diff --git a/prisma/migrations/20240919203740_init/migration.sql b/prisma/migrations/20240921214957_init/migration.sql similarity index 90% rename from prisma/migrations/20240919203740_init/migration.sql rename to prisma/migrations/20240921214957_init/migration.sql index 921e469..13baa0a 100644 --- a/prisma/migrations/20240919203740_init/migration.sql +++ b/prisma/migrations/20240921214957_init/migration.sql @@ -178,6 +178,21 @@ CREATE TABLE "Purchase" ( CONSTRAINT "Purchase_pkey" PRIMARY KEY ("id") ); +-- CreateTable +CREATE TABLE "UserCourse" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "courseId" TEXT NOT NULL, + "started" BOOLEAN NOT NULL DEFAULT false, + "completed" BOOLEAN NOT NULL DEFAULT false, + "startedAt" TIMESTAMP(3), + "completedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UserCourse_pkey" PRIMARY KEY ("id") +); + -- CreateIndex CREATE UNIQUE INDEX "User_pubkey_key" ON "User"("pubkey"); @@ -211,6 +226,9 @@ CREATE UNIQUE INDEX "Course_noteId_key" ON "Course"("noteId"); -- CreateIndex CREATE UNIQUE INDEX "UserLesson_userId_lessonId_key" ON "UserLesson"("userId", "lessonId"); +-- CreateIndex +CREATE UNIQUE INDEX "UserCourse_userId_courseId_key" ON "UserCourse"("userId", "courseId"); + -- AddForeignKey ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; @@ -264,3 +282,9 @@ ALTER TABLE "Purchase" ADD CONSTRAINT "Purchase_courseId_fkey" FOREIGN KEY ("cou -- AddForeignKey ALTER TABLE "Purchase" ADD CONSTRAINT "Purchase_resourceId_fkey" FOREIGN KEY ("resourceId") REFERENCES "Resource"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserCourse" ADD CONSTRAINT "UserCourse_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserCourse" ADD CONSTRAINT "UserCourse_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fa521d2..1d0b241 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -28,6 +28,7 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt userLessons UserLesson[] + userCourses UserCourse[] } model Session { @@ -122,6 +123,7 @@ model Course { noteId String? @unique createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + userCourses UserCourse[] } model CourseDraft { @@ -192,4 +194,20 @@ model Purchase { amountPaid Int createdAt DateTime @default(now()) updatedAt DateTime @updatedAt +} + +model UserCourse { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id]) + courseId String + course Course @relation(fields: [courseId], references: [id]) + started Boolean @default(false) + completed Boolean @default(false) + startedAt DateTime? + completedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([userId, courseId]) } \ No newline at end of file diff --git a/src/components/content/courses/CourseDetailsNew.js b/src/components/content/courses/CourseDetailsNew.js index d7394c8..a112b48 100644 --- a/src/components/content/courses/CourseDetailsNew.js +++ b/src/components/content/courses/CourseDetailsNew.js @@ -16,6 +16,7 @@ import useWindowWidth from "@/hooks/useWindowWidth"; import { useNDKContext } from "@/context/NDKContext"; import { findKind0Fields } from '@/utils/nostr'; import appConfig from "@/config/appConfig"; +import useTrackCourse from '@/hooks/tracking/useTrackCourse'; import { ProgressSpinner } from 'primereact/progressspinner'; const lnAddress = process.env.NEXT_PUBLIC_LIGHTNING_ADDRESS; @@ -33,6 +34,8 @@ export default function CourseDetailsNew({ processedEvent, paidCourse, lessons, const isMobileView = windowWidth <= 768; const { ndk } = useNDKContext(); + const { isCompleted } = useTrackCourse({courseId: processedEvent?.d}); + const fetchAuthor = useCallback(async (pubkey) => { if (!pubkey) return; const author = await ndk.getUser({ pubkey }); @@ -126,6 +129,7 @@ export default function CourseDetailsNew({ processedEvent, paidCourse, lessons,
router.push('/')} />
+ {isCompleted && }

{processedEvent.name}

diff --git a/src/components/content/courses/DocumentLesson.js b/src/components/content/courses/DocumentLesson.js index 5aa0379..f1982aa 100644 --- a/src/components/content/courses/DocumentLesson.js +++ b/src/components/content/courses/DocumentLesson.js @@ -20,10 +20,9 @@ const MDDisplay = dynamic( } ); -const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid }) => { +const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted }) => { const [zapAmount, setZapAmount] = useState(0); const [nAddress, setNAddress] = useState(null); - const [completed, setCompleted] = useState(false); const { zaps, zapsLoading, zapsError } = useZapsQuery({ event: lesson, type: "lesson" }); const { returnImageProxy } = useImageProxy(); const windowWidth = useWindowWidth(); @@ -56,10 +55,10 @@ const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid }) => { }, [lesson]); useEffect(() => { - if (isCompleted) { + if (isCompleted && !isTracking) { setCompleted(lesson.id); } - }, [isCompleted, lesson.id, setCompleted]); + }, [isCompleted, lesson.id, setCompleted, isTracking]); const renderContent = () => { if (isPaid && decryptionPerformed) { diff --git a/src/components/content/courses/VideoLesson.js b/src/components/content/courses/VideoLesson.js index d776044..770f081 100644 --- a/src/components/content/courses/VideoLesson.js +++ b/src/components/content/courses/VideoLesson.js @@ -89,10 +89,10 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted }, []); useEffect(() => { - if (isCompleted) { + if (isCompleted && !isTracking) { setCompleted(lesson.id); } - }, [isCompleted, lesson.id]); // Remove setCompleted from dependencies + }, [isCompleted, lesson.id, setCompleted, isTracking]); useEffect(() => { if (!zaps || zapsLoading || zapsError) return; @@ -115,7 +115,6 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted const timer = setTimeout(checkDuration, 500); return () => clearTimeout(timer); } else { - // For non-paid content, start checking after 3 seconds const timer = setTimeout(checkDuration, 3000); return () => clearTimeout(timer); } diff --git a/src/db/models/userCourseModels.js b/src/db/models/userCourseModels.js new file mode 100644 index 0000000..6c0c418 --- /dev/null +++ b/src/db/models/userCourseModels.js @@ -0,0 +1,84 @@ +import prisma from "@/db/prisma"; + +export const getUserCourses = async (userId) => { + return await prisma.userCourse.findMany({ + where: { userId }, + include: { course: true }, + }); +}; + +export const getUserCourse = async (userId, courseId) => { + return await prisma.userCourse.findUnique({ + where: { + userId_courseId: { + userId, + courseId, + }, + }, + include: { course: true }, + }); +}; + +export const createOrUpdateUserCourse = async (userId, courseId, data) => { + return await prisma.userCourse.upsert({ + where: { + userId_courseId: { + userId, + courseId, + }, + }, + update: { + ...data, + updatedAt: new Date(), + }, + create: { + userId, + courseId, + ...data, + }, + }); +}; + +export const deleteUserCourse = async (userId, courseId) => { + return await prisma.userCourse.delete({ + where: { + userId_courseId: { + userId, + courseId, + }, + }, + }); +}; + +export const checkCourseCompletion = async (userId, courseId) => { + const course = await prisma.course.findUnique({ + where: { id: courseId }, + include: { + lessons: { + include: { + userLessons: { + where: { userId: userId } + } + } + } + } + }); + + if (!course) { + throw new Error("Course not found"); + } + + const allLessonsCompleted = course.lessons.every(lesson => + lesson.userLessons.length > 0 && lesson.userLessons[0].completed + ); + + if (allLessonsCompleted) { + await createOrUpdateUserCourse(userId, courseId, { + completed: true, + completedAt: new Date() + }); + return true; + } + + return false; +}; \ No newline at end of file diff --git a/src/db/models/userLessonModels.js b/src/db/models/userLessonModels.js index 6613744..e96a2dc 100644 --- a/src/db/models/userLessonModels.js +++ b/src/db/models/userLessonModels.js @@ -20,7 +20,6 @@ export const getUserLesson = async (userId, lessonId) => { }; export const createOrUpdateUserLesson = async (userId, lessonId, data) => { - console.log(`Creating or updating user lesson for user ${userId} and lesson ${lessonId} with data:`, data); return await prisma.userLesson.upsert({ where: { userId_lessonId: { diff --git a/src/hooks/tracking/useTrackCourse.js b/src/hooks/tracking/useTrackCourse.js new file mode 100644 index 0000000..0595a07 --- /dev/null +++ b/src/hooks/tracking/useTrackCourse.js @@ -0,0 +1,45 @@ +import React, {useState, useEffect, useRef, useCallback} from 'react'; +import {useSession} from 'next-auth/react'; +import axios from 'axios'; + +const useTrackCourse = ({courseId}) => { + const [isCompleted, setIsCompleted] = useState(false); + const {data: session} = useSession(); + const completedRef = useRef(false); + + const checkOrCreateUserCourse = useCallback(async () => { + if (!session?.user) return false; + try { + const response = await axios.get(`/api/users/${session.user.id}/courses/${courseId}`); + if (response.status === 200 && response?.data) { + setIsCompleted(true); + completedRef.current = true; + } else if (response.status === 204) { + await axios.post(`/api/users/${session.user.id}/courses?courseSlug=${courseId}`, { + completed: false, + started: true, + startedAt: new Date().toISOString(), + }); + + setIsCompleted(false); + return false; + } else { + console.error('Error checking or creating UserCourse:', response.statusText); + return false; + } + } catch (error) { + console.error('Error checking or creating UserCourse:', error); + return false; + } + }, [session, courseId]); + + useEffect(() => { + if (!completedRef.current && courseId) { + checkOrCreateUserCourse(); + } + }, [checkOrCreateUserCourse, courseId]); + + return {isCompleted}; +}; + +export default useTrackCourse; \ No newline at end of file diff --git a/src/hooks/tracking/useTrackVideoLesson.js b/src/hooks/tracking/useTrackVideoLesson.js index bbfc8fe..4d3ee50 100644 --- a/src/hooks/tracking/useTrackVideoLesson.js +++ b/src/hooks/tracking/useTrackVideoLesson.js @@ -18,26 +18,20 @@ const useTrackVideoLesson = ({lessonId, videoDuration, courseId, videoPlayed}) = } }, [session]); - // Check if the lesson is already completed or create a new UserLesson record const checkOrCreateUserLesson = useCallback(async () => { if (!session?.user) return false; try { const response = await axios.get(`/api/users/${session.user.id}/lessons/${lessonId}?courseId=${courseId}`); if (response.status === 200 && response?.data) { - // Case 1: UserLesson record exists if (response?.data?.completed) { - // Lesson is already completed setIsCompleted(true); completedRef.current = true; return true; } else { - // Lesson exists but is not completed return false; } } else if (response.status === 204) { - // Case 2: UserLesson record doesn't exist, create a new one await axios.post(`/api/users/${session.user.id}/lessons?courseId=${courseId}`, { - // currently the only id we get is the resource id which associates to the lesson resourceId: lessonId, opened: true, openedAt: new Date().toISOString(), @@ -51,7 +45,7 @@ const useTrackVideoLesson = ({lessonId, videoDuration, courseId, videoPlayed}) = console.error('Error checking or creating UserLesson:', error); return false; } - }, [session, lessonId]); + }, [session, lessonId, courseId]); const markLessonAsCompleted = useCallback(async () => { if (!session?.user || completedRef.current) return; @@ -72,15 +66,13 @@ const useTrackVideoLesson = ({lessonId, videoDuration, courseId, videoPlayed}) = } catch (error) { console.error('Error marking lesson as completed:', error); } - }, [lessonId, session]); + }, [lessonId, courseId, session]); useEffect(() => { const initializeTracking = async () => { - console.log('initializeTracking', videoDuration, !completedRef.current, videoPlayed); - if (isAdmin) return; // Skip tracking for admin users + if (isAdmin) return; const alreadyCompleted = await checkOrCreateUserLesson(); - // Case 3: Start tracking if the lesson is not completed, video duration is available, and video has been played if (!alreadyCompleted && videoDuration && !completedRef.current && videoPlayed) { console.log(`Tracking started for lesson ${lessonId}, video duration: ${videoDuration} seconds, video played: ${videoPlayed}`); setIsTracking(true); @@ -92,7 +84,6 @@ const useTrackVideoLesson = ({lessonId, videoDuration, courseId, videoPlayed}) = initializeTracking(); - // Cleanup function to clear the interval when the component unmounts return () => { if (timerRef.current) { clearInterval(timerRef.current); @@ -101,9 +92,8 @@ const useTrackVideoLesson = ({lessonId, videoDuration, courseId, videoPlayed}) = }, [lessonId, videoDuration, checkOrCreateUserLesson, videoPlayed, isAdmin]); useEffect(() => { - if (isAdmin) return; // Skip tracking for admin users + if (isAdmin) return; - // Case 4: Mark lesson as completed when 90% of the video is watched if (videoDuration && timeSpent >= Math.round(videoDuration * 0.9) && !completedRef.current) { markLessonAsCompleted(); } diff --git a/src/pages/api/users/[slug]/courses/[courseSlug].js b/src/pages/api/users/[slug]/courses/[courseSlug].js new file mode 100644 index 0000000..6ec5572 --- /dev/null +++ b/src/pages/api/users/[slug]/courses/[courseSlug].js @@ -0,0 +1,25 @@ +import { checkCourseCompletion } from "@/db/models/userCourseModels"; + +// todo somehow make it to where we can get lesson slug in this endpoint +export default async function handler(req, res) { + const { method } = req; + const { slug, courseSlug } = req.query; + switch (method) { + case "GET": + try { + const courseCompletion = await checkCourseCompletion(slug, courseSlug); + if (courseCompletion) { + res.status(200).json(courseCompletion); + } else { + res.status(204).end(); + } + } catch (error) { + res.status(500).json({ error: error.message }); + } + break; + + default: + res.setHeader("Allow", ["GET", "PUT", "DELETE"]); + res.status(405).end(`Method ${method} Not Allowed`); + } +} \ No newline at end of file diff --git a/src/pages/api/users/[slug]/courses/index.js b/src/pages/api/users/[slug]/courses/index.js new file mode 100644 index 0000000..086a2d9 --- /dev/null +++ b/src/pages/api/users/[slug]/courses/index.js @@ -0,0 +1,21 @@ +import { createOrUpdateUserCourse } from "@/db/models/userCourseModels"; + +export default async function handler(req, res) { + const { method } = req; + const { slug, courseSlug } = req.query; + const userId = slug; + switch (method) { + case "POST": + try { + const userCourse = await createOrUpdateUserCourse(userId, courseSlug, req.body); + res.status(201).json(userCourse); + } catch (error) { + res.status(400).json({ error: error.message }); + } + break; + + default: + res.setHeader("Allow", ["GET", "POST"]); + res.status(405).end(`Method ${method} Not Allowed`); + } +} \ No newline at end of file diff --git a/src/pages/course/[slug]/index.js b/src/pages/course/[slug]/index.js index 170a118..3d9eb17 100644 --- a/src/pages/course/[slug]/index.js +++ b/src/pages/course/[slug]/index.js @@ -143,10 +143,10 @@ const Course = () => { const [expandedIndex, setExpandedIndex] = useState(null); const [completedLessons, setCompletedLessons] = useState([]); - const setCompleted = (lessonId) => { + const setCompleted = useCallback((lessonId) => { console.log('setting completed', lessonId); setCompletedLessons(prev => [...prev, lessonId]); - } + }, []); const fetchAuthor = useCallback(async (pubkey) => { const author = await ndk.getUser({ pubkey }); @@ -247,7 +247,7 @@ const Course = () => {
{lesson.type === 'video' ? : - + }