Added badges to schema, added badges models, same for userbadges, added basic hardcoded ui for badges

This commit is contained in:
austinkelsay 2024-12-10 16:44:34 -06:00
parent d5a05da1f7
commit 12defee451
No known key found for this signature in database
GPG Key ID: 44CB4EC6D9F2FA02
11 changed files with 417 additions and 52 deletions

View File

@ -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;

View File

@ -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;

View File

@ -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
}

View File

@ -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 (
<Dialog
visible={visible}
onHide={onHide}
header={
<div className="text-2xl font-bold text-white">
Your Badges Collection
</div>
}
className="w-[90vw] md:w-[70vw] lg:w-[50vw]"
contentClassName="bg-gray-900"
headerClassName="bg-gray-900 border-b border-gray-700"
>
<div className="p-6 bg-gray-900">
<p className="text-gray-400 mb-6">Showcase your achievements and progress through your dev journey</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{badges.map((badge, index) => (
<div
key={index}
className="bg-gray-800 rounded-xl p-6 flex flex-col items-center transform transition-all duration-200 hover:scale-[1.02] hover:shadow-lg"
>
<div className="relative w-32 h-32 mb-4">
<Image
src={badge.thumbnail}
alt={badge.name}
layout="fill"
objectFit="contain"
/>
</div>
<h3 className="text-white font-semibold text-xl mb-2">{badge.name}</h3>
<p className="text-gray-400 text-center text-sm">{badge.description}</p>
<div className="mt-4 flex flex-col items-center gap-2 w-full">
<div className="bg-blue-500/10 text-blue-400 px-3 py-1 rounded-full text-sm">
Earned on {formatDate(badge.awardedOn)}
</div>
<a
href={`https://nostrudel.ninja/#/badges/${badge.nostrId}`}
target="_blank"
rel="noopener noreferrer"
className="text-purple-400 hover:text-purple-300 text-sm flex items-center gap-1 transition-colors"
>
<i className="pi pi-external-link" />
View on Nostr
</a>
</div>
</div>
))}
</div>
</div>
</Dialog>
);
};
export default UserBadges;

View File

@ -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 = () => {
)}
<span className={`text-lg ${task.completed ? 'text-white' : 'text-gray-400'}`}>{task.status}</span>
</div>
<span className="bg-blue-500 text-white text-xs px-2 py-1 rounded-full w-20 text-center">
<span className="bg-blue-500 text-white text-sm px-2 py-1 rounded-full w-24 text-center">
{task.tier}
</span>
</div>
@ -277,9 +278,17 @@ const UserProgress = () => {
))}
</ul>
<button className="w-full py-3 bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-full font-semibold">
View Badges (Coming Soon)
<button
className="w-full py-3 bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-full font-semibold"
onClick={() => setShowBadges(true)}
>
View Badges
</button>
<UserBadges
visible={showBadges}
onHide={() => setShowBadges(false)}
/>
</div>
);
};

View File

@ -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:

View File

@ -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 }
});
};

View File

@ -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 },
});

View File

@ -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
}
});
};

View File

@ -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) {

View File

@ -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.