mirror of
https://github.com/AustinKelsay/plebdevs.git
synced 2025-06-05 00:32:03 +00:00
badge rewards end to end flow works for single hardcoded badge issuance
This commit is contained in:
parent
e92a7fa73c
commit
4437f7f929
@ -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]);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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);
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
33
src/hooks/apiQueries/useCompletedCoursesQuery.js
Normal file
33
src/hooks/apiQueries/useCompletedCoursesQuery.js
Normal 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,
|
||||
});
|
||||
}
|
74
src/hooks/badges/useBadge.js
Normal file
74
src/hooks/badges/useBadge.js
Normal 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 };
|
||||
};
|
@ -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
|
||||
|
160
src/pages/api/badges/issue.js
Normal file
160
src/pages/api/badges/issue.js
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
37
src/pages/api/courses/completed.js
Normal file
37
src/pages/api/courses/completed.js
Normal 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' });
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user