diff --git a/src/components/charts/CombinedContributionChart.js b/src/components/charts/CombinedContributionChart.js index 76675b7..682921b 100644 --- a/src/components/charts/CombinedContributionChart.js +++ b/src/components/charts/CombinedContributionChart.js @@ -67,15 +67,11 @@ const CombinedContributionChart = ({ session }) => { } }); - console.log('All Learning Activities:', allActivities); - console.log('Activities by Date:', activityData); - return activityData; }, [session]); const handleNewCommit = useCallback(({ contributionData, totalCommits }) => { const activityData = prepareProgressData(); - console.log("GitHub Contribution Data:", contributionData); // Create a new object with GitHub commits const combinedData = { ...contributionData }; @@ -85,7 +81,6 @@ const CombinedContributionChart = ({ session }) => { combinedData[date] = (combinedData[date] || 0) + count; }); - console.log("Combined Data:", combinedData); setContributionData(combinedData); setTotalContributions(totalCommits + Object.values(activityData).reduce((a, b) => a + b, 0)); }, [prepareProgressData]); diff --git a/src/components/profile/UserBadges.js b/src/components/profile/UserBadges.js index d0b0f0c..b32d4b3 100644 --- a/src/components/profile/UserBadges.js +++ b/src/components/profile/UserBadges.js @@ -1,43 +1,89 @@ -import React from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { Dialog } from 'primereact/dialog'; import Image from 'next/image'; +import { useNDKContext } from '@/context/NDKContext'; +import { useSession } from 'next-auth/react'; +import { ProgressSpinner } from 'primereact/progressspinner'; +import { nip19 } from 'nostr-tools'; 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 [badges, setBadges] = useState([]); + const [loading, setLoading] = useState(true); + const { ndk } = useNDKContext(); + const { data: session } = useSession(); + + // Define fetchBadges as a useCallback to prevent unnecessary recreations + const fetchBadges = useCallback(async () => { + if (!ndk || !session?.user?.pubkey) return; + + setLoading(true); + try { + // Fetch badge definitions (kind 30009) + const badgeDefinitions = await ndk.fetchEvents({ + // todo: add the plebdevs hardcoded badge ids (probably in config?) + ids: ["97777aaccfb409ab973d30fc3a27de5ca64080c13a0bca6c2c261105ae545118"] + }); + + console.log("Badge Definitions: ", badgeDefinitions); + + // Fetch badge awards (kind 8) using fetchEvents instead of subscribe + const badgeAwards = await ndk.fetchEvents({ + kinds: [8], + // todo: add the plebdevs author pubkey + authors: ["62bad2c804210b9ccd8b3d6b49da7333185bae17c12b6d7a8ed5865642e82b1e"], + "#p": [session.user.pubkey] + }); + + // Create a map to store the latest badge for each definition + const latestBadgeMap = new Map(); + + // Process all awards + for (const award of badgeAwards) { + const definition = Array.from(badgeDefinitions).find(def => { + const defDTag = def.tags.find(t => t[0] === 'd')?.[1]; + const awardATag = award.tags.find(t => t[0] === 'a')?.[1]; + return awardATag?.includes(defDTag); + }); + + if (definition) { + const defId = definition.id; + const currentBadge = { + name: definition.tags.find(t => t[0] === 'name')?.[1] || 'Unknown Badge', + description: definition.tags.find(t => t[0] === 'description')?.[1] || '', + image: definition.tags.find(t => t[0] === 'image')?.[1] || '', + thumbnail: definition.tags.find(t => t[0] === 'thumb')?.[1] || '', + awardedOn: new Date(award.created_at * 1000).toISOString(), + nostrId: award.id, + naddr: nip19.naddrEncode({ + pubkey: definition.pubkey, + kind: definition.kind, + identifier: definition.tags.find(t => t[0] === 'd')?.[1] + }) + }; + + // Only update if this is the first instance or if it's newer than the existing one + if (!latestBadgeMap.has(defId) || + new Date(currentBadge.awardedOn) > new Date(latestBadgeMap.get(defId).awardedOn)) { + latestBadgeMap.set(defId, currentBadge); + } + } + } + + // Convert map values to array for state update + setBadges(Array.from(latestBadgeMap.values())); + } catch (error) { + console.error('Error fetching badges:', error); + } finally { + setLoading(false); } - ]; + }, [ndk, session?.user?.pubkey]); + + // Initial fetch effect + useEffect(() => { + if (visible) { + fetchBadges(); + } + }, [visible, fetchBadges]); const formatDate = (dateString) => { return new Date(dateString).toLocaleDateString('en-US', { @@ -49,55 +95,57 @@ const UserBadges = ({ visible, onHide }) => { 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" + className="w-full max-w-3xl" > -
-

Showcase your achievements and progress through your dev journey

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

{badge.name}

-

{badge.description}

- -
-
- Earned on {formatDate(badge.awardedOn)} +
+ {loading ? ( +
+ +
+ ) : badges.length === 0 ? ( +
+ No badges earned yet. Complete courses to earn badges! +
+ ) : ( +
+ {badges.map((badge, index) => ( +
+
+ {badge.name}
+

{badge.name}

+

{badge.description}

- - - View on Nostr - +
+
+ Earned on {formatDate(badge.awardedOn)} +
+ + + + View on Nostr + +
-
- ))} -
+ ))} +
+ )}
); diff --git a/src/components/profile/progress/UserProgress.js b/src/components/profile/progress/UserProgress.js index dfb5d65..6942a16 100644 --- a/src/components/profile/progress/UserProgress.js +++ b/src/components/profile/progress/UserProgress.js @@ -3,6 +3,7 @@ import { ProgressBar } from 'primereact/progressbar'; import { Accordion, AccordionTab } from 'primereact/accordion'; import { useSession, signIn, getSession } from 'next-auth/react'; import { useRouter } from 'next/router'; +import { useBadge } from '@/hooks/badges/useBadge'; import GenericButton from '@/components/buttons/GenericButton'; import UserProgressFlow from './UserProgressFlow'; import { Tooltip } from 'primereact/tooltip'; @@ -59,7 +60,8 @@ const UserProgress = () => { const router = useRouter(); const { data: session, update } = useSession(); - + useBadge(); + useEffect(() => { if (session?.user) { setIsLoading(true); diff --git a/src/db/models/badgeModels.js b/src/db/models/badgeModels.js index 1d77c8d..721807e 100644 --- a/src/db/models/badgeModels.js +++ b/src/db/models/badgeModels.js @@ -46,12 +46,17 @@ export const createBadge = async (data) => { data: { name: data.name, noteId: data.noteId, - course: { + course: data.courseId ? { connect: { id: data.courseId } - } + } : undefined }, include: { - course: true + course: true, + userBadges: { + include: { + user: true + } + } } }); }; diff --git a/src/hooks/apiQueries/useCompletedCoursesQuery.js b/src/hooks/apiQueries/useCompletedCoursesQuery.js new file mode 100644 index 0000000..7549f52 --- /dev/null +++ b/src/hooks/apiQueries/useCompletedCoursesQuery.js @@ -0,0 +1,33 @@ +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; +import { useSession } from 'next-auth/react'; + +export function useCompletedCoursesQuery() { + const { data: session } = useSession(); + + const fetchCompletedCourses = async () => { + if (!session?.user?.id) return []; + + try { + const response = await axios.get('/api/courses/completed', { + params: { + userId: session.user.id + } + }); + return response.data; + } catch (error) { + console.error('Error fetching completed courses:', error); + return []; + } + }; + + return useQuery({ + queryKey: ['completedCourses', session?.user?.id], + queryFn: fetchCompletedCourses, + enabled: !!session?.user?.id, + staleTime: 1000 * 60 * 5, // 5 minutes + cacheTime: 1000 * 60 * 30, // 30 minutes + refetchOnWindowFocus: false, + refetchOnMount: false, + }); +} \ No newline at end of file diff --git a/src/hooks/badges/useBadge.js b/src/hooks/badges/useBadge.js new file mode 100644 index 0000000..93b691d --- /dev/null +++ b/src/hooks/badges/useBadge.js @@ -0,0 +1,74 @@ +import { useEffect, useState } from 'react'; +import { useSession } from 'next-auth/react'; +import axios from 'axios'; +import { useCompletedCoursesQuery } from '../apiQueries/useCompletedCoursesQuery'; +import { useQueryClient } from '@tanstack/react-query'; + +export const useBadge = () => { + const { data: session, update } = useSession(); + const [isProcessing, setIsProcessing] = useState(false); + const [error, setError] = useState(null); + const { data: completedCourses } = useCompletedCoursesQuery(); + const queryClient = useQueryClient(); + + useEffect(() => { + if (!session?.user || isProcessing || !completedCourses) return; + + const checkForBadgeEligibility = async () => { + setIsProcessing(true); + setError(null); + + try { + const { userBadges } = session.user; + let badgesAwarded = false; + + const eligibleCourses = completedCourses?.filter(userCourse => { + const isCompleted = userCourse.completed; + const hasNoBadge = !userBadges?.some( + userBadge => userBadge.badge?.courseId === userCourse.courseId + ); + const hasBadgeDefined = !!userCourse.course?.badge; + + return isCompleted && hasNoBadge && hasBadgeDefined; + }); + + for (const course of eligibleCourses || []) { + try { + const response = await axios.post('/api/badges/issue', { + courseId: course.courseId, + userId: session.user.id, + }); + + if (response.data.success) { + badgesAwarded = true; + } + } catch (error) { + console.error('Error issuing badge:', error); + } + } + + if (badgesAwarded) { + // Update session silently + await update({ revalidate: false }); + // Invalidate the completed courses query to trigger a clean refetch + await queryClient.invalidateQueries(['completedCourses']); + } + } catch (error) { + console.error('Error checking badge eligibility:', error); + setError(error.message); + } finally { + setIsProcessing(false); + } + }; + + const timeoutId = setTimeout(checkForBadgeEligibility, 0); + const interval = setInterval(checkForBadgeEligibility, 300000); + + return () => { + clearTimeout(timeoutId); + clearInterval(interval); + }; + }, [session?.user?.id, completedCourses]); + + return { isProcessing, error }; +}; \ No newline at end of file diff --git a/src/pages/api/auth/[...nextauth].js b/src/pages/api/auth/[...nextauth].js index 3b5c0b0..19e87c5 100644 --- a/src/pages/api/auth/[...nextauth].js +++ b/src/pages/api/auth/[...nextauth].js @@ -232,7 +232,8 @@ export const authOptions = { nip05: fullUser.nip05, lightningAddress: fullUser.lightningAddress, githubUsername: token.githubUsername, - createdAt: fullUser.createdAt + createdAt: fullUser.createdAt, + userBadges: fullUser.userBadges }; // Add GitHub account info to session if it exists diff --git a/src/pages/api/badges/issue.js b/src/pages/api/badges/issue.js new file mode 100644 index 0000000..e3e3bd5 --- /dev/null +++ b/src/pages/api/badges/issue.js @@ -0,0 +1,160 @@ +import { getServerSession } from "next-auth/next"; +import { authOptions } from "../auth/[...nextauth]"; +import prisma from "@/db/prisma"; +import { finalizeEvent, verifyEvent } from 'nostr-tools/pure'; +import { SimplePool } from 'nostr-tools/pool'; +import { nip19 } from 'nostr-tools'; +import { Buffer } from 'buffer'; +import appConfig from "@/config/appConfig"; + +const hexToBytes = (hex) => { + return Buffer.from(hex, 'hex'); +}; + +const BADGE_AWARD_KIND = 8; +const BADGE_DEFINITION_KIND = 30009; + +export default async function handler(req, res) { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + try { + // Verify authentication + const session = await getServerSession(req, res, authOptions); + if (!session) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const { courseId, userId } = req.body; + + // Verify course completion and get badge details + const userCourse = await prisma.userCourse.findFirst({ + where: { + userId, + courseId, + completed: true, + }, + include: { + course: { + include: { + badge: true, + }, + }, + user: true, // Include user to get pubkey + }, + }); + + if (!userCourse) { + return res.status(400).json({ error: 'Course not completed' }); + } + + if (!userCourse.course.badge) { + return res.status(400).json({ error: 'No badge defined for this course' }); + } + + let noteId = userCourse.course.badge.noteId; + + if (noteId && noteId.startsWith("naddr")) { + const naddr = nip19.decode(noteId); + noteId = `${naddr.data.kind}:${naddr.data.pubkey}:${naddr.data.identifier}`; + } + + // Check if badge already exists + const existingBadge = await prisma.userBadge.findFirst({ + where: { + userId, + badgeId: userCourse.course.badge.id, + }, + }); + + if (existingBadge) { + return res.status(400).json({ error: 'Badge already awarded' }); + } + + // Get the signing key from environment and convert to bytes + const signingKey = process.env.BADGE_SIGNING_KEY; + if (!signingKey) { + throw new Error('Signing key not configured'); + } + const signingKeyBytes = hexToBytes(signingKey); + + // Create event template + const eventTemplate = { + kind: BADGE_AWARD_KIND, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['p', userCourse.user.pubkey], + ['a', noteId], + ['d', `course-completion-${userCourse.course.id}`], + ], + content: JSON.stringify({ + name: userCourse.course.badge.name, + description: `Completed ${userCourse.course.id}`, + image: userCourse.course.badge.noteId, + course: courseId, + awardedAt: new Date().toISOString(), + }) + }; + + // Add validation for required fields + if (!userCourse.user.pubkey || !noteId) { + return res.status(400).json({ + error: 'Missing required fields', + message: 'Pubkey and noteId are required' + }); + } + + // Finalize (sign) the event + const signedEvent = finalizeEvent(eventTemplate, signingKeyBytes); + + // Verify the event + const isValid = verifyEvent(signedEvent); + if (!isValid) { + throw new Error('Event validation failed'); + } + + // Initialize pool and publish to relays + const pool = new SimplePool(); + + let published = false; + try { + await Promise.any(pool.publish(appConfig.defaultRelayUrls, signedEvent)); + published = true; + console.log('Event published to at least one relay'); + } catch (error) { + throw new Error('Failed to publish to any relay'); + } finally { + // Add a small delay before closing the pool + if (published) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + await pool.close(appConfig.defaultRelayUrls); // Pass the relays array to close() + } + + // Store badge in database + const userBadge = await prisma.userBadge.create({ + data: { + userId, + badgeId: userCourse.course.badge.id, + awardedAt: new Date(), + }, + include: { + badge: true, + }, + }); + + return res.status(200).json({ + success: true, + userBadge, + event: signedEvent + }); + + } catch (error) { + console.error('Error issuing badge:', error); + return res.status(500).json({ + error: 'Failed to issue badge', + message: error.message + }); + } +} \ No newline at end of file diff --git a/src/pages/api/courses/completed.js b/src/pages/api/courses/completed.js new file mode 100644 index 0000000..e45f3ca --- /dev/null +++ b/src/pages/api/courses/completed.js @@ -0,0 +1,37 @@ +import { getServerSession } from "next-auth/next"; +import { authOptions } from "../auth/[...nextauth]"; +import prisma from "@/db/prisma"; + +export default async function handler(req, res) { + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + try { + const session = await getServerSession(req, res, authOptions); + if (!session) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const { userId } = req.query; + + const completedCourses = await prisma.userCourse.findMany({ + where: { + userId: userId, + completed: true, + }, + include: { + course: { + include: { + badge: true, + }, + }, + }, + }); + + return res.status(200).json(completedCourses); + } catch (error) { + console.error('Error fetching completed courses:', error); + return res.status(500).json({ error: 'Failed to fetch completed courses' }); + } +} \ No newline at end of file