badge rewards end to end flow works for single hardcoded badge issuance

This commit is contained in:
austinkelsay 2024-12-29 16:28:57 -06:00
parent e92a7fa73c
commit 4437f7f929
No known key found for this signature in database
GPG Key ID: 44CB4EC6D9F2FA02
9 changed files with 443 additions and 88 deletions

View File

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

View File

@ -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 (
<Dialog
header="Your Badges"
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"
className="w-full max-w-3xl"
>
<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 className="p-4">
{loading ? (
<div className="flex justify-center items-center h-40">
<ProgressSpinner />
</div>
) : badges.length === 0 ? (
<div className="text-center text-gray-400">
No badges earned yet. Complete courses to earn badges!
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{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>
<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 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://badges.page/a/${badge.naddr}`}
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>
))}
</div>
)}
</div>
</Dialog>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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