Super commit - repo selection for course submission, course submission required field, course submission link field, badge issuance flow fixed

This commit is contained in:
austinkelsay 2025-01-01 14:24:58 -06:00
parent 12693c6637
commit 991a732f4e
No known key found for this signature in database
GPG Key ID: 44CB4EC6D9F2FA02
14 changed files with 230 additions and 51 deletions

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "UserCourse" ADD COLUMN "submittedRepoLink" TEXT;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Course" ADD COLUMN "submissionRequired" BOOLEAN NOT NULL DEFAULT false;

View File

@ -1,14 +1,14 @@
// datasource db {
// provider = "postgresql"
// url = env("DATABASE_URL")
// }
datasource db { datasource db {
provider = "postgresql" provider = "postgresql"
url = env("POSTGRES_PRISMA_URL") url = env("DATABASE_URL")
directUrl = env("POSTGRES_URL_NON_POOLING")
} }
// datasource db {
// provider = "postgresql"
// url = env("POSTGRES_PRISMA_URL")
// directUrl = env("POSTGRES_URL_NON_POOLING")
// }
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
} }
@ -130,6 +130,7 @@ model Course {
lessons Lesson[] lessons Lesson[]
purchases Purchase[] purchases Purchase[]
noteId String? @unique noteId String? @unique
submissionRequired Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
userCourses UserCourse[] userCourses UserCourse[]
@ -218,6 +219,7 @@ model UserCourse {
completed Boolean @default(false) completed Boolean @default(false)
startedAt DateTime? startedAt DateTime?
completedAt DateTime? completedAt DateTime?
submittedRepoLink String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View File

@ -53,7 +53,7 @@ const UserPurchaseTable = ({ session, windowWidth }) => {
emptyMessage="No purchases" emptyMessage="No purchases"
value={session.user?.purchased} value={session.user?.purchased}
header={purchasesHeader} header={purchasesHeader}
className="m-2 max-lap:m-0" className="m-2 max-lap:m-0 max-lap:mt-2"
style={{ width: "100%", borderRadius: "8px", border: "1px solid #333", boxShadow: "0 0 10px 0 rgba(0, 0, 0, 0.1)" }} style={{ width: "100%", borderRadius: "8px", border: "1px solid #333", boxShadow: "0 0 10px 0 rgba(0, 0, 0, 0.1)" }}
pt={{ pt={{
wrapper: { wrapper: {

View File

@ -0,0 +1,66 @@
import React, { useMemo } from 'react';
import { Dropdown } from 'primereact/dropdown';
import { useFetchGithubRepos } from '@/hooks/githubQueries/useFetchGithubRepos';
import { useSession } from 'next-auth/react';
import axios from 'axios';
import { useToast } from '@/hooks/useToast';
const RepoSelector = ({ courseId, onSubmit }) => {
const { data: session } = useSession();
const accessToken = session?.account?.access_token;
const { data: repos, isLoading } = useFetchGithubRepos(accessToken);
const { showToast } = useToast();
// Find the existing submission for this course
const existingSubmission = useMemo(() => {
return session?.user?.userCourses?.find(
course => course.courseId === courseId
)?.submittedRepoLink;
}, [session, courseId]);
const repoOptions = repos?.map(repo => ({
label: repo.name,
value: repo.html_url
})) || [];
const handleRepoSelect = async (repoLink) => {
try {
await axios.post(`/api/users/${session.user.id}/courses/${courseId}/submit-repo`, {
repoLink
});
onSubmit(repoLink);
showToast('success', 'Success', 'Repository submitted successfully');
} catch (error) {
console.error('Error submitting repo:', error);
showToast('error', 'Error', 'Failed to submit repository');
}
};
if (!accessToken) {
return (
<div className="pl-[28px] mt-2 text-gray-400">
GitHub connection required
</div>
);
}
return (
<div className="pl-[28px] mt-2">
<Dropdown
value={existingSubmission}
options={repoOptions}
onChange={(e) => handleRepoSelect(e.value)}
placeholder={isLoading ? "Loading repositories..." : "Select a repository"}
className="w-full max-w-[300px]"
loading={isLoading}
/>
{existingSubmission && (
<div className="text-sm text-gray-400 mt-1">
Repository submitted
</div>
)}
</div>
);
};
export default RepoSelector;

View File

@ -61,6 +61,8 @@ const UserBadges = ({ visible, onHide }) => {
}) })
}; };
console.log("Current Badge: ", currentBadge);
// Only update if this is the first instance or if it's newer than the existing one // Only update if this is the first instance or if it's newer than the existing one
if (!latestBadgeMap.has(defId) || if (!latestBadgeMap.has(defId) ||
new Date(currentBadge.awardedOn) > new Date(latestBadgeMap.get(defId).awardedOn)) { new Date(currentBadge.awardedOn) > new Date(latestBadgeMap.get(defId).awardedOn)) {
@ -118,7 +120,7 @@ const UserBadges = ({ visible, onHide }) => {
> >
<div className="relative w-32 h-32 mb-4"> <div className="relative w-32 h-32 mb-4">
<Image <Image
src={badge.thumbnail} src={badge.thumbnail || badge.image}
alt={badge.name} alt={badge.name}
layout="fill" layout="fill"
objectFit="contain" objectFit="contain"

View File

@ -7,6 +7,7 @@ import { useBadge } from '@/hooks/badges/useBadge';
import GenericButton from '@/components/buttons/GenericButton'; import GenericButton from '@/components/buttons/GenericButton';
import UserProgressFlow from './UserProgressFlow'; import UserProgressFlow from './UserProgressFlow';
import { Tooltip } from 'primereact/tooltip'; import { Tooltip } from 'primereact/tooltip';
import RepoSelector from '@/components/profile/RepoSelector';
const allTasks = [ const allTasks = [
{ {
@ -32,10 +33,11 @@ const allTasks = [
status: 'Frontend Course', status: 'Frontend Course',
completed: false, completed: false,
tier: 'Frontend Dev', tier: 'Frontend Dev',
courseId: 'f73c37f4-df2e-4f7d-a838-dce568c76136', // courseId: 'f73c37f4-df2e-4f7d-a838-dce568c76136',
courseId: 'af98e096-3136-4d8b-aafe-17677f0266d0',
subTasks: [ subTasks: [
{ status: 'Complete the course', completed: false }, { status: 'Complete the course', completed: false },
// { status: 'Select your completed project', completed: false }, { status: 'Submit your project repository', completed: false },
] ]
}, },
{ {
@ -45,7 +47,7 @@ const allTasks = [
courseId: 'f6825391-831c-44da-904a-9ac3d149b7be', courseId: 'f6825391-831c-44da-904a-9ac3d149b7be',
subTasks: [ subTasks: [
{ status: 'Complete the course', completed: false }, { status: 'Complete the course', completed: false },
// { status: 'Select your completed project', completed: false }, { status: 'Submit your project repository', completed: false },
] ]
}, },
]; ];
@ -87,20 +89,28 @@ const UserProgress = () => {
return { return {
...task, ...task,
completed: session?.account?.provider === 'github' ? true : false, completed: session?.account?.provider === 'github' ? true : false,
subTasks: task.subTasks ? task.subTasks.map(subTask => ({ subTasks: task.subTasks.map(subTask => ({
...subTask, ...subTask,
completed: session?.account?.provider === 'github' ? true : false completed: session?.account?.provider === 'github' ? true : false
})) : undefined }))
}; };
} }
const userCourse = session?.user?.userCourses?.find(uc => uc.courseId === task.courseId);
const courseCompleted = completedCourseIds.includes(task.courseId);
const repoSubmitted = userCourse?.submittedRepoLink ? true : false;
return { return {
...task, ...task,
completed: task.courseId === null || completedCourseIds.includes(task.courseId), completed: courseCompleted && (task.courseId === null || repoSubmitted),
subTasks: task.subTasks ? task.subTasks.map(subTask => ({ subTasks: task.subTasks.map(subTask => ({
...subTask, ...subTask,
completed: completedCourseIds.includes(task.courseId) completed: subTask.status.includes('Complete')
})) : undefined ? courseCompleted
: subTask.status.includes('repository')
? repoSubmitted
: false
}))
}; };
}); });
@ -164,7 +174,7 @@ const UserProgress = () => {
await new Promise(resolve => setTimeout(resolve, 1000)); await new Promise(resolve => setTimeout(resolve, 1000));
const updatedSession = await getSession(); const updatedSession = await getSession();
if (updatedSession?.account?.provider === 'github') { if (updatedSession?.account?.provider === 'github') {
router.push('/'); // Accounts linked successfully router.push('/profile'); // Accounts linked successfully
} }
} }
} else { } else {
@ -253,19 +263,42 @@ const UserProgress = () => {
{task.subTasks && ( {task.subTasks && (
<ul className="space-y-2"> <ul className="space-y-2">
{task.subTasks.map((subTask, subIndex) => ( {task.subTasks.map((subTask, subIndex) => (
<li key={subIndex} className="flex items-center pl-[28px]"> <li key={subIndex}>
{subTask.completed ? ( <div className="flex items-center pl-[28px]">
<div className="w-4 h-4 bg-green-500 rounded-full flex items-center justify-center mr-3"> {subTask.completed ? (
<i className="pi pi-check text-white text-sm"></i> <div className="w-4 h-4 bg-green-500 rounded-full flex items-center justify-center mr-3">
</div> <i className="pi pi-check text-white text-sm"></i>
) : ( </div>
<div className="w-4 h-4 bg-gray-700 rounded-full flex items-center justify-center mr-3"> ) : (
<i className="pi pi-info-circle text-white text-sm"></i> <div className="w-4 h-4 bg-gray-700 rounded-full flex items-center justify-center mr-3">
</div> <i className="pi pi-info-circle text-white text-sm"></i>
</div>
)}
<span className={`${subTask.completed ? 'text-white' : 'text-gray-400'}`}>
{subTask.status}
</span>
</div>
{subTask.status.includes('repository') && !subTask.completed && (
<RepoSelector
courseId={task.courseId}
onSubmit={() => {
const updatedTasks = tasks.map(t =>
t.courseId === task.courseId
? {
...t,
subTasks: t.subTasks.map(st =>
st.status === subTask.status
? { ...st, completed: true }
: st
)
}
: t
);
setTasks(updatedTasks);
router.push('/profile');
}}
/>
)} )}
<span className={`${subTask.completed ? 'text-white' : 'text-gray-400'}`}>
{subTask.status}
</span>
</li> </li>
))} ))}
</ul> </ul>

View File

@ -44,6 +44,7 @@ export const createCourse = async (data) => {
id: courseData.id, id: courseData.id,
noteId: courseData.noteId, noteId: courseData.noteId,
price: courseData.price, price: courseData.price,
submissionRequired: courseData.submissionRequired || false,
user: { connect: { id: courseData.user.connect.id } }, user: { connect: { id: courseData.user.connect.id } },
lessons: { lessons: {
connect: courseData.lessons.connect connect: courseData.lessons.connect
@ -71,6 +72,7 @@ export const updateCourse = async (id, data) => {
where: { id }, where: { id },
data: { data: {
...otherData, ...otherData,
submissionRequired: otherData.submissionRequired || false,
lessons: { lessons: {
deleteMany: {}, deleteMany: {},
create: lessons.map((lesson, index) => ({ create: lessons.map((lesson, index) => ({

View File

@ -65,6 +65,20 @@ export const deleteUserCourse = async (userId, courseId) => {
}); });
}; };
export const submitCourseRepo = async (userId, courseSlug, repoLink) => {
return await prisma.userCourse.update({
where: {
userId_courseId: {
userId,
courseId: courseSlug
}
},
data: {
submittedRepoLink: repoLink
}
});
};
export const checkCourseCompletion = async (userId, courseId) => { export const checkCourseCompletion = async (userId, courseId) => {
const course = await prisma.course.findUnique({ const course = await prisma.course.findUnique({
where: { id: courseId }, where: { id: courseId },

View File

@ -25,13 +25,13 @@ export const useBadge = () => {
// Check for GitHub connection badge // Check for GitHub connection badge
if (session?.account?.provider === 'github') { if (session?.account?.provider === 'github') {
const hasPlebBadge = userBadges?.some( const hasPlebBadge = userBadges?.some(
userBadge => userBadge.badge?.id === '3664e78f-b618-420d-a7cc-f3393b0211df' userBadge => userBadge.badge?.id === '4664e73f-c618-41dd-a7cc-f3393b031fdf'
); );
if (!hasPlebBadge) { if (!hasPlebBadge) {
try { try {
const response = await axios.post('/api/badges/issue', { const response = await axios.post('/api/badges/issue', {
badgeId: '3664e78f-b618-420d-a7cc-f3393b0211df', badgeId: '4664e73f-c618-41dd-a7cc-f3393b031fdf',
userId: session.user.id, userId: session.user.id,
}); });
@ -51,8 +51,12 @@ export const useBadge = () => {
userBadge => userBadge.badge?.courseId === userCourse.courseId userBadge => userBadge.badge?.courseId === userCourse.courseId
); );
const hasBadgeDefined = !!userCourse.course?.badge; const hasBadgeDefined = !!userCourse.course?.badge;
// Check if course requires repo submission
const requiresRepo = userCourse.course?.submissionRequired ?? false;
const hasRepoIfRequired = requiresRepo ? !!userCourse.submittedRepoLink : true;
return isCompleted && hasNoBadge && hasBadgeDefined; return isCompleted && hasNoBadge && hasBadgeDefined && hasRepoIfRequired;
}); });
for (const course of eligibleCourses || []) { for (const course of eligibleCourses || []) {

View File

@ -8,26 +8,46 @@ export function useFetchGithubRepos(accessToken) {
return useQuery({ return useQuery({
queryKey: ['githubRepos', accessToken], queryKey: ['githubRepos', accessToken],
queryFn: async () => { queryFn: async () => {
if (!accessToken) return []; if (!accessToken) {
console.log('No access token provided');
return [];
}
const octokit = new ThrottledOctokit({ try {
auth: accessToken, const octokit = new ThrottledOctokit({
throttle: { auth: accessToken,
onRateLimit: (retryAfter, options, octokit, retryCount) => { throttle: {
if (retryCount < 2) return true; onRateLimit: (retryAfter, options, octokit, retryCount) => {
console.log(`Rate limit exceeded, retrying after ${retryAfter} seconds`);
if (retryCount < 2) return true;
return false;
},
onSecondaryRateLimit: (retryAfter, options, octokit) => {
console.log(`Secondary rate limit hit, retrying after ${retryAfter} seconds`);
return true;
},
}, },
onSecondaryRateLimit: (retryAfter, options, octokit) => true, });
},
});
const { data } = await octokit.repos.listForAuthenticatedUser({ console.log('Fetching repositories...');
sort: 'updated', const { data } = await octokit.repos.listForAuthenticatedUser({
per_page: 100 sort: 'updated',
}); per_page: 100
});
console.log(`Found ${data.length} repositories`);
return data; return data.map(repo => ({
id: repo.id,
name: repo.name,
html_url: repo.html_url
}));
} catch (error) {
console.error('Error fetching GitHub repos:', error);
throw error;
}
}, },
staleTime: 1000 * 60 * 5, // 5 minutes staleTime: 1000 * 60 * 5, // 5 minutes
enabled: !!accessToken enabled: !!accessToken,
retry: 3,
}); });
} }

View File

@ -51,6 +51,14 @@ export default async function handler(req, res) {
return res.status(400).json({ error: 'Course not completed' }); return res.status(400).json({ error: 'Course not completed' });
} }
// Check if course requires repo submission
if (userCourse.course.submissionRequired && !userCourse.submittedRepoLink) {
return res.status(400).json({
error: 'Repository submission required',
message: 'You must submit a project repository to earn this badge'
});
}
badge = userCourse.course.badge; badge = userCourse.course.badge;
} else if (badgeId) { } else if (badgeId) {
// Direct badge lookup for non-course badges // Direct badge lookup for non-course badges

View File

@ -0,0 +1,24 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/pages/api/auth/[...nextauth].js";
import { submitCourseRepo } from "@/db/models/userCourseModels";
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const session = await getServerSession(req, res, authOptions);
if (!session) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { courseSlug } = req.query;
const { repoLink } = req.body;
try {
await submitCourseRepo(session.user.id, courseSlug, repoLink);
return res.status(200).json({ success: true });
} catch (error) {
return res.status(500).json({ error: 'Failed to submit repo' });
}
}