From 991a732f4e7dd8cc2917beb0a315844fe888e71f Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Wed, 1 Jan 2025 14:24:58 -0600 Subject: [PATCH] Super commit - repo selection for course submission, course submission required field, course submission link field, badge issuance flow fixed --- .../migration.sql | 2 + .../migration.sql | 2 + prisma/schema.prisma | 18 +++-- .../profile/DataTables/UserPurchaseTable.js | 2 +- src/components/profile/RepoSelector.js | 66 ++++++++++++++++ src/components/profile/UserBadges.js | 4 +- .../profile/progress/UserProgress.js | 79 +++++++++++++------ src/db/models/courseModels.js | 2 + src/db/models/userCourseModels.js | 14 ++++ src/hooks/badges/useBadge.js | 10 ++- .../githubQueries/useFetchGithubRepos.js | 50 ++++++++---- src/pages/api/badges/issue.js | 8 ++ .../index.js} | 0 .../courses/[courseSlug]/submit-repo.js | 24 ++++++ 14 files changed, 230 insertions(+), 51 deletions(-) create mode 100644 prisma/migrations/20241231220017_user_course_submissions/migration.sql create mode 100644 prisma/migrations/20250101195457_submission_required_field/migration.sql create mode 100644 src/components/profile/RepoSelector.js rename src/pages/api/users/[slug]/courses/{[courseSlug].js => [courseSlug]/index.js} (100%) create mode 100644 src/pages/api/users/[slug]/courses/[courseSlug]/submit-repo.js diff --git a/prisma/migrations/20241231220017_user_course_submissions/migration.sql b/prisma/migrations/20241231220017_user_course_submissions/migration.sql new file mode 100644 index 0000000..5f4db29 --- /dev/null +++ b/prisma/migrations/20241231220017_user_course_submissions/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "UserCourse" ADD COLUMN "submittedRepoLink" TEXT; diff --git a/prisma/migrations/20250101195457_submission_required_field/migration.sql b/prisma/migrations/20250101195457_submission_required_field/migration.sql new file mode 100644 index 0000000..edd3331 --- /dev/null +++ b/prisma/migrations/20250101195457_submission_required_field/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Course" ADD COLUMN "submissionRequired" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 74bb7bd..a5a1fb2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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" } @@ -130,6 +130,7 @@ model Course { lessons Lesson[] purchases Purchase[] noteId String? @unique + submissionRequired Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt userCourses UserCourse[] @@ -218,6 +219,7 @@ model UserCourse { completed Boolean @default(false) startedAt DateTime? completedAt DateTime? + submittedRepoLink String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/components/profile/DataTables/UserPurchaseTable.js b/src/components/profile/DataTables/UserPurchaseTable.js index 2624ada..f4fb7ed 100644 --- a/src/components/profile/DataTables/UserPurchaseTable.js +++ b/src/components/profile/DataTables/UserPurchaseTable.js @@ -53,7 +53,7 @@ const UserPurchaseTable = ({ session, windowWidth }) => { emptyMessage="No purchases" value={session.user?.purchased} 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)" }} pt={{ wrapper: { diff --git a/src/components/profile/RepoSelector.js b/src/components/profile/RepoSelector.js new file mode 100644 index 0000000..bb7a0a7 --- /dev/null +++ b/src/components/profile/RepoSelector.js @@ -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 ( +
+ GitHub connection required +
+ ); + } + + return ( +
+ handleRepoSelect(e.value)} + placeholder={isLoading ? "Loading repositories..." : "Select a repository"} + className="w-full max-w-[300px]" + loading={isLoading} + /> + {existingSubmission && ( +
+ ✓ Repository submitted +
+ )} +
+ ); +}; + +export default RepoSelector; \ No newline at end of file diff --git a/src/components/profile/UserBadges.js b/src/components/profile/UserBadges.js index 29b35b4..2f9cb82 100644 --- a/src/components/profile/UserBadges.js +++ b/src/components/profile/UserBadges.js @@ -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 if (!latestBadgeMap.has(defId) || new Date(currentBadge.awardedOn) > new Date(latestBadgeMap.get(defId).awardedOn)) { @@ -118,7 +120,7 @@ const UserBadges = ({ visible, onHide }) => { >
{badge.name} { return { ...task, completed: session?.account?.provider === 'github' ? true : false, - subTasks: task.subTasks ? task.subTasks.map(subTask => ({ + subTasks: task.subTasks.map(subTask => ({ ...subTask, 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 { ...task, - completed: task.courseId === null || completedCourseIds.includes(task.courseId), - subTasks: task.subTasks ? task.subTasks.map(subTask => ({ + completed: courseCompleted && (task.courseId === null || repoSubmitted), + subTasks: task.subTasks.map(subTask => ({ ...subTask, - completed: completedCourseIds.includes(task.courseId) - })) : undefined + completed: subTask.status.includes('Complete') + ? courseCompleted + : subTask.status.includes('repository') + ? repoSubmitted + : false + })) }; }); @@ -164,7 +174,7 @@ const UserProgress = () => { await new Promise(resolve => setTimeout(resolve, 1000)); const updatedSession = await getSession(); if (updatedSession?.account?.provider === 'github') { - router.push('/'); // Accounts linked successfully + router.push('/profile'); // Accounts linked successfully } } } else { @@ -253,19 +263,42 @@ const UserProgress = () => { {task.subTasks && ( diff --git a/src/db/models/courseModels.js b/src/db/models/courseModels.js index 822846f..ad5c880 100644 --- a/src/db/models/courseModels.js +++ b/src/db/models/courseModels.js @@ -44,6 +44,7 @@ export const createCourse = async (data) => { id: courseData.id, noteId: courseData.noteId, price: courseData.price, + submissionRequired: courseData.submissionRequired || false, user: { connect: { id: courseData.user.connect.id } }, lessons: { connect: courseData.lessons.connect @@ -71,6 +72,7 @@ export const updateCourse = async (id, data) => { where: { id }, data: { ...otherData, + submissionRequired: otherData.submissionRequired || false, lessons: { deleteMany: {}, create: lessons.map((lesson, index) => ({ diff --git a/src/db/models/userCourseModels.js b/src/db/models/userCourseModels.js index 1872e4c..ab8bdf7 100644 --- a/src/db/models/userCourseModels.js +++ b/src/db/models/userCourseModels.js @@ -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) => { const course = await prisma.course.findUnique({ where: { id: courseId }, diff --git a/src/hooks/badges/useBadge.js b/src/hooks/badges/useBadge.js index 13906a1..5967fc3 100644 --- a/src/hooks/badges/useBadge.js +++ b/src/hooks/badges/useBadge.js @@ -25,13 +25,13 @@ export const useBadge = () => { // Check for GitHub connection badge if (session?.account?.provider === 'github') { const hasPlebBadge = userBadges?.some( - userBadge => userBadge.badge?.id === '3664e78f-b618-420d-a7cc-f3393b0211df' + userBadge => userBadge.badge?.id === '4664e73f-c618-41dd-a7cc-f3393b031fdf' ); if (!hasPlebBadge) { try { const response = await axios.post('/api/badges/issue', { - badgeId: '3664e78f-b618-420d-a7cc-f3393b0211df', + badgeId: '4664e73f-c618-41dd-a7cc-f3393b031fdf', userId: session.user.id, }); @@ -51,8 +51,12 @@ export const useBadge = () => { userBadge => userBadge.badge?.courseId === userCourse.courseId ); 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 || []) { diff --git a/src/hooks/githubQueries/useFetchGithubRepos.js b/src/hooks/githubQueries/useFetchGithubRepos.js index 1e34d0b..9e48f46 100644 --- a/src/hooks/githubQueries/useFetchGithubRepos.js +++ b/src/hooks/githubQueries/useFetchGithubRepos.js @@ -8,26 +8,46 @@ export function useFetchGithubRepos(accessToken) { return useQuery({ queryKey: ['githubRepos', accessToken], queryFn: async () => { - if (!accessToken) return []; + if (!accessToken) { + console.log('No access token provided'); + return []; + } - const octokit = new ThrottledOctokit({ - auth: accessToken, - throttle: { - onRateLimit: (retryAfter, options, octokit, retryCount) => { - if (retryCount < 2) return true; + try { + const octokit = new ThrottledOctokit({ + auth: accessToken, + throttle: { + 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({ - sort: 'updated', - per_page: 100 - }); + console.log('Fetching repositories...'); + const { data } = await octokit.repos.listForAuthenticatedUser({ + 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 - enabled: !!accessToken + enabled: !!accessToken, + retry: 3, }); } \ No newline at end of file diff --git a/src/pages/api/badges/issue.js b/src/pages/api/badges/issue.js index 69088b7..77ff30b 100644 --- a/src/pages/api/badges/issue.js +++ b/src/pages/api/badges/issue.js @@ -51,6 +51,14 @@ export default async function handler(req, res) { 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; } else if (badgeId) { // Direct badge lookup for non-course badges diff --git a/src/pages/api/users/[slug]/courses/[courseSlug].js b/src/pages/api/users/[slug]/courses/[courseSlug]/index.js similarity index 100% rename from src/pages/api/users/[slug]/courses/[courseSlug].js rename to src/pages/api/users/[slug]/courses/[courseSlug]/index.js diff --git a/src/pages/api/users/[slug]/courses/[courseSlug]/submit-repo.js b/src/pages/api/users/[slug]/courses/[courseSlug]/submit-repo.js new file mode 100644 index 0000000..5fcef6e --- /dev/null +++ b/src/pages/api/users/[slug]/courses/[courseSlug]/submit-repo.js @@ -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' }); + } +} \ No newline at end of file