From 12defee4514d08c053bbb44cbf81593a6dc4aeec Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Tue, 10 Dec 2024 16:44:34 -0600 Subject: [PATCH] Added badges to schema, added badges models, same for userbadges, added basic hardcoded ui for badges --- next.config.js | 2 +- .../20241210224336_add_badges/migration.sql | 39 +++++++ prisma/schema.prisma | 40 +++++-- src/components/profile/UserBadges.js | 106 ++++++++++++++++++ .../profile/progress/UserProgress.js | 31 +++-- src/components/profile/progress/badges.md | 26 +++-- src/db/models/badgeModels.js | 77 +++++++++++++ src/db/models/courseModels.js | 51 +++++++-- src/db/models/userBadgeModels.js | 64 +++++++++++ src/db/models/userModels.js | 20 ++++ src/pages/api/auth/auth.md | 13 --- 11 files changed, 417 insertions(+), 52 deletions(-) create mode 100644 prisma/migrations/20241210224336_add_badges/migration.sql create mode 100644 src/components/profile/UserBadges.js create mode 100644 src/db/models/badgeModels.js create mode 100644 src/db/models/userBadgeModels.js delete mode 100644 src/pages/api/auth/auth.md diff --git a/next.config.js b/next.config.js index cb08b1e..a76754b 100644 --- a/next.config.js +++ b/next.config.js @@ -3,7 +3,7 @@ const removeImports = require("next-remove-imports")(); module.exports = removeImports({ reactStrictMode: true, images: { - domains: ['localhost', 'secure.gravatar.com', 'plebdevs-three.vercel.app', 'plebdevs.com'], + domains: ['localhost', 'secure.gravatar.com', 'plebdevs-three.vercel.app', 'plebdevs.com', 'plebdevs-bucket.nyc3.cdn.digitaloceanspaces.com'], }, webpack(config, options) { return config; diff --git a/prisma/migrations/20241210224336_add_badges/migration.sql b/prisma/migrations/20241210224336_add_badges/migration.sql new file mode 100644 index 0000000..50fa657 --- /dev/null +++ b/prisma/migrations/20241210224336_add_badges/migration.sql @@ -0,0 +1,39 @@ +-- CreateTable +CREATE TABLE "Badge" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "noteId" TEXT NOT NULL, + "courseId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Badge_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "UserBadge" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "badgeId" TEXT NOT NULL, + "awardedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "UserBadge_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Badge_noteId_key" ON "Badge"("noteId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Badge_courseId_key" ON "Badge"("courseId"); + +-- CreateIndex +CREATE UNIQUE INDEX "UserBadge_userId_badgeId_key" ON "UserBadge"("userId", "badgeId"); + +-- AddForeignKey +ALTER TABLE "Badge" ADD CONSTRAINT "Badge_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserBadge" ADD CONSTRAINT "UserBadge_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserBadge" ADD CONSTRAINT "UserBadge_badgeId_fkey" FOREIGN KEY ("badgeId") REFERENCES "Badge"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 887a8ff..9bf55c0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,14 +1,14 @@ -// datasource db { -// provider = "postgresql" -// url = env("DATABASE_URL") -// } - datasource db { - provider = "postgresql" - url = env("POSTGRES_PRISMA_URL") - directUrl = env("POSTGRES_URL_NON_POOLING") + provider = "postgresql" + url = env("DATABASE_URL") } +// datasource db { +// provider = "postgresql" +// url = env("POSTGRES_PRISMA_URL") +// directUrl = env("POSTGRES_URL_NON_POOLING") +// } + generator client { provider = "prisma-client-js" } @@ -38,6 +38,7 @@ model User { userCourses UserCourse[] nip05 Nip05? lightningAddress LightningAddress? + userBadges UserBadge[] } model Session { @@ -132,6 +133,7 @@ model Course { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt userCourses UserCourse[] + badge Badge? } model CourseDraft { @@ -247,3 +249,25 @@ model LightningAddress { lndHost String lndPort String @default("8080") } + +model Badge { + id String @id @default(uuid()) + name String + noteId String @unique + courseId String @unique // One badge per course + course Course @relation(fields: [courseId], references: [id]) + userBadges UserBadge[] // Many users can have this badge + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model UserBadge { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id]) + badgeId String + badge Badge @relation(fields: [badgeId], references: [id]) + awardedAt DateTime @default(now()) + + @@unique([userId, badgeId]) // Each user can only have one of each badge +} diff --git a/src/components/profile/UserBadges.js b/src/components/profile/UserBadges.js new file mode 100644 index 0000000..d0b0f0c --- /dev/null +++ b/src/components/profile/UserBadges.js @@ -0,0 +1,106 @@ +import React from 'react'; +import { Dialog } from 'primereact/dialog'; +import Image from 'next/image'; + +const UserBadges = ({ visible, onHide }) => { + // Hardcoded badges for now - later we'll fetch from nostr + const badges = [ + { + name: "Pleb", + description: "You are signed up and ready to start your Dev Journey, onwards!", + image: "https://plebdevs-bucket.nyc3.cdn.digitaloceanspaces.com/images/badges/pleb/lg.png", + thumbnail: "https://plebdevs-bucket.nyc3.cdn.digitaloceanspaces.com/images/badges/pleb/sm.png", + awardedOn: "2024-03-15", + nostrId: "naddr1qq98getnw3e8getnw3eqzqqzyp3t45kgqsssh8xd3v7kkjw6wve3skawzlqjkmt63m2cv4jzaq43uqcyqqq82wgcvg0zv" + }, + { + name: "Plebdev", + description: "You have completed the PlebDevs Starter and taken the first important step on your Dev Journey, congrats!", + image: "https://plebdevs-bucket.nyc3.cdn.digitaloceanspaces.com/images/badges/plebdev/1012.png", + thumbnail: "https://plebdevs-bucket.nyc3.cdn.digitaloceanspaces.com/images/badges/plebdev/256.png", + awardedOn: "2024-03-15", + nostrId: "naddr1qq98getnw3e8getnw3eqzqqzyp3t45kgqsssh8xd3v7kkjw6wve3skawzlqjkmt63m2cv4jzaq43uqcyqqq82wgcvg0zv" + }, + { + name: "Frontend Dev", + description: "You have completed the Frontend Course and proven your proficiency at writing web apps and deploying Web Apps.", + image: "https://plebdevs-bucket.nyc3.cdn.digitaloceanspaces.com/images/badges/frontend/lg.png", + thumbnail: "https://plebdevs-bucket.nyc3.cdn.digitaloceanspaces.com/images/badges/frontend/sm.png", + awardedOn: "2024-03-15", + nostrId: "naddr1qq98getnw3e8getnw3eqzqqzyp3t45kgqsssh8xd3v7kkjw6wve3skawzlqjkmt63m2cv4jzaq43uqcyqqq82wgcvg0zv" + }, + { + name: "Backend Dev", + description: "You have completed the Backend Course and demonstrated the ability to build and deploy Servers, API's, and Databases for Application Development.", + image: "https://plebdevs-bucket.nyc3.cdn.digitaloceanspaces.com/images/badges/backend/lg.png", + thumbnail: "https://plebdevs-bucket.nyc3.cdn.digitaloceanspaces.com/images/badges/backend/sm.png", + awardedOn: "2024-03-15", + nostrId: "naddr1qq98getnw3e8getnw3eqzqqzyp3t45kgqsssh8xd3v7kkjw6wve3skawzlqjkmt63m2cv4jzaq43uqcyqqq82wgcvg0zv" + } + ]; + + const formatDate = (dateString) => { + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + }; + + return ( + + Your Badges Collection + + } + className="w-[90vw] md:w-[70vw] lg:w-[50vw]" + contentClassName="bg-gray-900" + headerClassName="bg-gray-900 border-b border-gray-700" + > +
+

Showcase your achievements and progress through your dev journey

+ +
+ {badges.map((badge, index) => ( +
+
+ {badge.name} +
+

{badge.name}

+

{badge.description}

+ +
+
+ Earned on {formatDate(badge.awardedOn)} +
+ + + + View on Nostr + +
+
+ ))} +
+
+
+ ); +}; + +export default UserBadges; diff --git a/src/components/profile/progress/UserProgress.js b/src/components/profile/progress/UserProgress.js index 6ea37e7..df21181 100644 --- a/src/components/profile/progress/UserProgress.js +++ b/src/components/profile/progress/UserProgress.js @@ -4,6 +4,7 @@ import { Accordion, AccordionTab } from 'primereact/accordion'; import { useSession, signIn, getSession } from 'next-auth/react'; import { useRouter } from 'next/router'; import GenericButton from '@/components/buttons/GenericButton'; +import UserBadges from '@/components/profile/UserBadges'; const allTasks = [ { @@ -12,14 +13,13 @@ const allTasks = [ tier: 'Pleb', courseId: null, subTasks: [ - { status: 'Create First GitHub Repo', completed: false }, - { status: 'Push Commit', completed: false } + { status: 'Create Your First GitHub Repo', completed: false }, ] }, { status: 'PlebDevs Starter', completed: false, - tier: 'New Dev', + tier: 'Plebdev', // courseId: "f538f5c5-1a72-4804-8eb1-3f05cea64874", courseId: "f6daa88a-53d6-4901-8dbd-d2203a05b7ab", subTasks: [ @@ -29,7 +29,7 @@ const allTasks = [ { status: 'Frontend Course', completed: false, - tier: 'Junior Dev', + tier: 'Frontend Dev', courseId: 'f73c37f4-df2e-4f7d-a838-dce568c76136', subTasks: [ { status: 'Complete the course', completed: false }, @@ -39,7 +39,7 @@ const allTasks = [ { status: 'Backend Course', completed: false, - tier: 'Plebdev', + tier: 'Backend Dev', courseId: 'f6825391-831c-44da-904a-9ac3d149b7be', subTasks: [ { status: 'Complete the course', completed: false }, @@ -54,6 +54,7 @@ const UserProgress = () => { const [expandedItems, setExpandedItems] = useState({}); const [completedCourses, setCompletedCourses] = useState([]); const [tasks, setTasks] = useState([]); + const [showBadges, setShowBadges] = useState(false); const router = useRouter(); const { data: session, update } = useSession(); @@ -122,11 +123,11 @@ const UserProgress = () => { let tier = null; if (completedCourseIds.includes("f6825391-831c-44da-904a-9ac3d149b7be")) { - tier = 'Plebdev'; + tier = 'Backend Dev'; } else if (completedCourseIds.includes("f73c37f4-df2e-4f7d-a838-dce568c76136")) { - tier = 'Junior Dev'; + tier = 'Frontend Dev'; } else if (completedCourseIds.includes("f6daa88a-53d6-4901-8dbd-d2203a05b7ab")) { - tier = 'New Dev'; + tier = 'Plebdev'; } else if (session?.account?.provider === 'github') { tier = 'Pleb'; } @@ -220,7 +221,7 @@ const UserProgress = () => { )} {task.status} - + {task.tier} @@ -277,9 +278,17 @@ const UserProgress = () => { ))} - + + setShowBadges(false)} + /> ); }; diff --git a/src/components/profile/progress/badges.md b/src/components/profile/progress/badges.md index 25ba227..efe5c51 100644 --- a/src/components/profile/progress/badges.md +++ b/src/components/profile/progress/badges.md @@ -1,13 +1,21 @@ { - "kind": 30009, - "tags": [ - ["d", "junior_dev_2024"], - ["name", "Junior Developer 2024"], - ["description", "Awarded upon completion of the Junior Developer course track"], - ["image", "https://yourplatform.com/badges/junior-dev.png", "1024x1024"], - ["thumb", "https://yourplatform.com/badges/junior-dev_256x256.png", "256x256"], - ["thumb", "https://yourplatform.com/badges/junior-dev_64x64.png", "64x64"] - ] + "content":"", + "created_at":1733852920, + "id":"b0a72bef2d167359e46f29371c6fab353364aded30dd04778e9c66b3e58def46", + "kind":30009, + "pubkey":"62bad2c804210b9ccd8b3d6b49da7333185bae17c12b6d7a8ed5865642e82b1e", + "sig":"6b481176a7208b6f8edc76de1bf90859d3fe97b8894f49ee1fd2471ccf3584fb990e7e8a2bba075e6c9867e351c092d262c3fb67997c8c983c4deaef82adba8e", + "tags":[ + ["d","testr42069"], + ["name","mario-test"], + ["description","A test for mario, it's a' me."], + ["image","https://plebdevs-bucket.nyc3.cdn.digitaloceanspaces.com/voltage-tipper.png","1024x1024"], + ["thumb","https://plebdevs-bucket.nyc3.cdn.digitaloceanspaces.com/voltage-tipper.png","512x512"], + ["thumb","https://plebdevs-bucket.nyc3.cdn.digitaloceanspaces.com/voltage-tipper.png","256x256"], + ["thumb","https://plebdevs-bucket.nyc3.cdn.digitaloceanspaces.com/voltage-tipper.png","64x64"], + ["thumb","https://plebdevs-bucket.nyc3.cdn.digitaloceanspaces.com/voltage-tipper.png","32x32"], + ["thumb","https://plebdevs-bucket.nyc3.cdn.digitaloceanspaces.com/voltage-tipper.png","16x16"] + ] } Key points for implementation: diff --git a/src/db/models/badgeModels.js b/src/db/models/badgeModels.js new file mode 100644 index 0000000..1d77c8d --- /dev/null +++ b/src/db/models/badgeModels.js @@ -0,0 +1,77 @@ +import prisma from "@/db/prisma"; + +export const getAllBadges = async () => { + return await prisma.badge.findMany({ + include: { + course: true, + userBadges: { + include: { + user: true + } + } + } + }); +}; + +export const getBadgeById = async (id) => { + return await prisma.badge.findUnique({ + where: { id }, + include: { + course: true, + userBadges: { + include: { + user: true + } + } + } + }); +}; + +export const getBadgeByCourseId = async (courseId) => { + return await prisma.badge.findUnique({ + where: { courseId }, + include: { + course: true, + userBadges: { + include: { + user: true + } + } + } + }); +}; + +export const createBadge = async (data) => { + return await prisma.badge.create({ + data: { + name: data.name, + noteId: data.noteId, + course: { + connect: { id: data.courseId } + } + }, + include: { + course: true + } + }); +}; + +export const updateBadge = async (id, data) => { + return await prisma.badge.update({ + where: { id }, + data: { + name: data.name, + noteId: data.noteId + }, + include: { + course: true, + userBadges: true + } + }); +}; + +export const deleteBadge = async (id) => { + return await prisma.badge.delete({ + where: { id } + }); +}; diff --git a/src/db/models/courseModels.js b/src/db/models/courseModels.js index 9953ae4..822846f 100644 --- a/src/db/models/courseModels.js +++ b/src/db/models/courseModels.js @@ -13,6 +13,7 @@ export const getAllCourses = async () => { } }, purchases: true, + badge: true }, }); }; @@ -31,30 +32,41 @@ export const getCourseById = async (id) => { } }, purchases: true, + badge: true }, }); }; export const createCourse = async (data) => { + const { badge, ...courseData } = data; return await prisma.course.create({ data: { - id: data.id, - noteId: data.noteId, - price: data.price, - user: { connect: { id: data.user.connect.id } }, + id: courseData.id, + noteId: courseData.noteId, + price: courseData.price, + user: { connect: { id: courseData.user.connect.id } }, lessons: { - connect: data.lessons.connect - } + connect: courseData.lessons.connect + }, + ...(badge && { + badge: { + create: { + name: badge.name, + noteId: badge.noteId + } + } + }) }, include: { lessons: true, - user: true + user: true, + badge: true } }); }; export const updateCourse = async (id, data) => { - const { lessons, ...otherData } = data; + const { lessons, badge, ...otherData } = data; return await prisma.course.update({ where: { id }, data: { @@ -66,7 +78,21 @@ export const updateCourse = async (id, data) => { draftId: lesson.draftId || null, index: index })) - } + }, + ...(badge && { + badge: { + upsert: { + create: { + name: badge.name, + noteId: badge.noteId + }, + update: { + name: badge.name, + noteId: badge.noteId + } + } + } + }) }, include: { lessons: { @@ -77,12 +103,17 @@ export const updateCourse = async (id, data) => { orderBy: { index: 'asc' } - } + }, + badge: true } }); }; export const deleteCourse = async (id) => { + await prisma.badge.deleteMany({ + where: { courseId: id } + }); + return await prisma.course.delete({ where: { id }, }); diff --git a/src/db/models/userBadgeModels.js b/src/db/models/userBadgeModels.js new file mode 100644 index 0000000..1a10a78 --- /dev/null +++ b/src/db/models/userBadgeModels.js @@ -0,0 +1,64 @@ +import prisma from "@/db/prisma"; + +export const getUserBadges = async (userId) => { + return await prisma.userBadge.findMany({ + where: { userId }, + include: { + badge: true, + user: true + } + }); +}; + +export const getUserBadge = async (userId, badgeId) => { + return await prisma.userBadge.findUnique({ + where: { + userId_badgeId: { + userId, + badgeId + } + }, + include: { + badge: true, + user: true + } + }); +}; + +export const awardBadgeToUser = async (userId, badgeId) => { + return await prisma.userBadge.create({ + data: { + user: { + connect: { id: userId } + }, + badge: { + connect: { id: badgeId } + } + }, + include: { + badge: true, + user: true + } + }); +}; + +export const removeUserBadge = async (userId, badgeId) => { + return await prisma.userBadge.delete({ + where: { + userId_badgeId: { + userId, + badgeId + } + } + }); +}; + +export const getUsersWithBadge = async (badgeId) => { + return await prisma.userBadge.findMany({ + where: { badgeId }, + include: { + user: true, + badge: true + } + }); +}; diff --git a/src/db/models/userModels.js b/src/db/models/userModels.js index 0d82f1d..7ec5328 100644 --- a/src/db/models/userModels.js +++ b/src/db/models/userModels.js @@ -20,6 +20,11 @@ export const getAllUsers = async () => { lesson: true, }, }, + userBadges: { + include: { + badge: true + } + } }, }); }; @@ -47,6 +52,11 @@ export const getUserById = async (id) => { }, nip05: true, lightningAddress: true, + userBadges: { + include: { + badge: true + } + } }, }); }; @@ -74,6 +84,11 @@ export const getUserByPubkey = async (pubkey) => { }, nip05: true, lightningAddress: true, + userBadges: { + include: { + badge: true + } + } }, }); } @@ -265,6 +280,11 @@ export const getUserByEmail = async (email) => { }, nip05: true, lightningAddress: true, + userBadges: { + include: { + badge: true + } + } }, }); } catch (error) { diff --git a/src/pages/api/auth/auth.md b/src/pages/api/auth/auth.md deleted file mode 100644 index 9b31731..0000000 --- a/src/pages/api/auth/auth.md +++ /dev/null @@ -1,13 +0,0 @@ -Any account type is backed by a nostr keypair: -- email (ephemeral keypair + email address) -- Github (ephemeral keypair + basic github account info and permissions to read data from API) -- anon (is only ephemeral keypair) -- Login with nostr (not ephemeral keypair, this is the users keypair, we only have access to private key through web extension interface) - -Any time a user signs in, we try to pull the acount from the db, and add all of the data we can from the users record into their session. -If the user does not have an account in the db we create one for them and return it in a signed in state. -If the users does not have an account and they are signing up anon/github/email we must generate an ephemeral keypair for them and save it to the db (otherwise for nostr login user is bringing their keypair in whcih case we only need to save the pubkey) - -Here is another consideration, when a user is signing in via nostr, we want to pull their latest kind0 info and treat that as the latest and greatest. If they have a record in the db we want to update it if the name or image has changed. If they do not have a record we create one with their nostr image and username (or first 8 chars of pubkey if there is no name) - -Finally. It is possible to link github to an existing account in whcih case the user can sign in with either github or anon and it will pull the correct recrod. \ No newline at end of file