From 215a00e593a058db5ee4a6cb3c850fad18fad62f Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Sun, 22 Sep 2024 17:08:26 -0500 Subject: [PATCH] fix course and lesson tracking for paid courses, add usercourses and userlessons into session --- .../content/courses/CourseDetailsNew.js | 6 ++- .../content/courses/DocumentLesson.js | 2 + src/components/content/courses/VideoLesson.js | 4 +- src/db/models/userModels.js | 48 +++++++++++++++---- src/hooks/tracking/useTrackCourse.js | 28 ++++++----- src/hooks/tracking/useTrackDocumentLesson.js | 29 ++++++----- src/hooks/tracking/useTrackVideoLesson.js | 29 ++++++----- src/pages/api/get-video-url.js | 14 +++++- src/pages/api/users/[slug]/index.js | 3 +- src/pages/course/[slug]/index.js | 2 +- 10 files changed, 117 insertions(+), 48 deletions(-) diff --git a/src/components/content/courses/CourseDetailsNew.js b/src/components/content/courses/CourseDetailsNew.js index a112b48..5239d97 100644 --- a/src/components/content/courses/CourseDetailsNew.js +++ b/src/components/content/courses/CourseDetailsNew.js @@ -34,7 +34,11 @@ export default function CourseDetailsNew({ processedEvent, paidCourse, lessons, const isMobileView = windowWidth <= 768; const { ndk } = useNDKContext(); - const { isCompleted } = useTrackCourse({courseId: processedEvent?.d}); + const { isCompleted } = useTrackCourse({ + courseId: processedEvent?.d, + paidCourse, + decryptionPerformed + }); const fetchAuthor = useCallback(async (pubkey) => { if (!pubkey) return; diff --git a/src/components/content/courses/DocumentLesson.js b/src/components/content/courses/DocumentLesson.js index f1982aa..f87c540 100644 --- a/src/components/content/courses/DocumentLesson.js +++ b/src/components/content/courses/DocumentLesson.js @@ -34,6 +34,8 @@ const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple lessonId: lesson?.d, courseId: course?.d, readTime: readTime, + paidCourse: isPaid, + decryptionPerformed: decryptionPerformed, }); useEffect(() => { diff --git a/src/components/content/courses/VideoLesson.js b/src/components/content/courses/VideoLesson.js index 770f081..b6a8cb0 100644 --- a/src/components/content/courses/VideoLesson.js +++ b/src/components/content/courses/VideoLesson.js @@ -35,7 +35,9 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted lessonId: lesson?.d, videoDuration, courseId: course?.d, - videoPlayed + videoPlayed, + paidCourse: isPaid, + decryptionPerformed }); useEffect(() => { diff --git a/src/db/models/userModels.js b/src/db/models/userModels.js index b612904..e29e9ea 100644 --- a/src/db/models/userModels.js +++ b/src/db/models/userModels.js @@ -7,11 +7,21 @@ const lnAddress = process.env.NEXT_PUBLIC_LIGHTNING_ADDRESS; export const getAllUsers = async () => { return await prisma.user.findMany({ include: { - role: true, // Include related role + role: true, purchased: { include: { - course: true, // Include course details in purchases - resource: true, // Include resource details in purchases + course: true, + resource: true, + }, + }, + userCourses: { + include: { + course: true, + }, + }, + userLessons: { + include: { + lesson: true, }, }, }, @@ -22,11 +32,21 @@ export const getUserById = async (id) => { return await prisma.user.findUnique({ where: { id }, include: { - role: true, // Include related role + role: true, purchased: { include: { - course: true, // Include course details in purchases - resource: true, // Include resource details in purchases + course: true, + resource: true, + }, + }, + userCourses: { + include: { + course: true, + }, + }, + userLessons: { + include: { + lesson: true, }, }, }, @@ -37,11 +57,21 @@ export const getUserByPubkey = async (pubkey) => { return await prisma.user.findUnique({ where: { pubkey }, include: { - role: true, // Include related role + role: true, purchased: { include: { - course: true, // Include course details in purchases - resource: true, // Include resource details in purchases + course: true, + resource: true, + }, + }, + userCourses: { + include: { + course: true, + }, + }, + userLessons: { + include: { + lesson: true, }, }, }, diff --git a/src/hooks/tracking/useTrackCourse.js b/src/hooks/tracking/useTrackCourse.js index 0595a07..fb71d96 100644 --- a/src/hooks/tracking/useTrackCourse.js +++ b/src/hooks/tracking/useTrackCourse.js @@ -2,24 +2,30 @@ import React, {useState, useEffect, useRef, useCallback} from 'react'; import {useSession} from 'next-auth/react'; import axios from 'axios'; -const useTrackCourse = ({courseId}) => { +const useTrackCourse = ({courseId, paidCourse, decryptionPerformed}) => { const [isCompleted, setIsCompleted] = useState(false); - const {data: session} = useSession(); + const {data: session, update} = useSession(); const completedRef = useRef(false); const checkOrCreateUserCourse = useCallback(async () => { - if (!session?.user) return false; + if (!session?.user || !courseId) 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(), - }); + // Only create a new UserCourse entry if it's a free course or if decryption has been performed for a paid course + if (paidCourse === false || (paidCourse && decryptionPerformed)) { + console.log("creating new UserCourse entry"); + await axios.post(`/api/users/${session.user.id}/courses?courseSlug=${courseId}`, { + completed: false, + started: true, + startedAt: new Date().toISOString(), + }); + // Call session update after creating a new UserCourse entry + await update(); + } setIsCompleted(false); return false; @@ -31,13 +37,13 @@ const useTrackCourse = ({courseId}) => { console.error('Error checking or creating UserCourse:', error); return false; } - }, [session, courseId]); + }, [courseId, paidCourse, decryptionPerformed]); useEffect(() => { - if (!completedRef.current && courseId) { + if (!completedRef.current && courseId && session?.user) { checkOrCreateUserCourse(); } - }, [checkOrCreateUserCourse, courseId]); + }, [courseId]); return {isCompleted}; }; diff --git a/src/hooks/tracking/useTrackDocumentLesson.js b/src/hooks/tracking/useTrackDocumentLesson.js index 1e47dd3..51d0cd4 100644 --- a/src/hooks/tracking/useTrackDocumentLesson.js +++ b/src/hooks/tracking/useTrackDocumentLesson.js @@ -2,13 +2,13 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { useSession } from 'next-auth/react'; import axios from 'axios'; -const useTrackDocumentLesson = ({ lessonId, courseId, readTime }) => { +const useTrackDocumentLesson = ({ lessonId, courseId, readTime, paidCourse, decryptionPerformed }) => { const [isCompleted, setIsCompleted] = useState(false); const [timeSpent, setTimeSpent] = useState(0); const [isTracking, setIsTracking] = useState(false); const [isAdmin, setIsAdmin] = useState(false); const timerRef = useRef(null); - const { data: session } = useSession(); + const { data: session, update } = useSession(); const completedRef = useRef(false); useEffect(() => { @@ -31,11 +31,16 @@ const useTrackDocumentLesson = ({ lessonId, courseId, readTime }) => { return false; } } else if (response.status === 204) { - await axios.post(`/api/users/${session.user.id}/lessons?courseId=${courseId}`, { - resourceId: lessonId, - opened: true, - openedAt: new Date().toISOString(), - }); + // Only create a new UserLesson entry if it's a free course or if decryption has been performed for a paid course + if (paidCourse === false || (paidCourse && decryptionPerformed)) { + await axios.post(`/api/users/${session.user.id}/lessons?courseId=${courseId}`, { + resourceId: lessonId, + opened: true, + openedAt: new Date().toISOString(), + }); + // Call session update after creating a new UserLesson entry + await update(); + } return false; } else { console.error('Error checking or creating UserLesson:', response.statusText); @@ -45,7 +50,7 @@ const useTrackDocumentLesson = ({ lessonId, courseId, readTime }) => { console.error('Error checking or creating UserLesson:', error); return false; } - }, [session, lessonId, courseId]); + }, [session, lessonId, courseId, update, paidCourse, decryptionPerformed]); const markLessonAsCompleted = useCallback(async () => { if (!session?.user || completedRef.current) return; @@ -60,20 +65,22 @@ const useTrackDocumentLesson = ({ lessonId, courseId, readTime }) => { if (response.status === 200) { setIsCompleted(true); setIsTracking(false); + // Call session update after marking the lesson as completed + await update(); } else { console.error('Failed to mark lesson as completed:', response.statusText); } } catch (error) { console.error('Error marking lesson as completed:', error); } - }, [lessonId, courseId, session]); + }, [lessonId, courseId, session, update]); useEffect(() => { const initializeTracking = async () => { if (isAdmin) return; // Skip tracking for admin users const alreadyCompleted = await checkOrCreateUserLesson(); - if (!alreadyCompleted && !completedRef.current) { + if (!alreadyCompleted && !completedRef.current && (!paidCourse || (paidCourse && decryptionPerformed))) { setIsTracking(true); timerRef.current = setInterval(() => { setTimeSpent(prevTime => prevTime + 1); @@ -88,7 +95,7 @@ const useTrackDocumentLesson = ({ lessonId, courseId, readTime }) => { clearInterval(timerRef.current); } }; - }, [lessonId, checkOrCreateUserLesson, isAdmin]); + }, [lessonId, checkOrCreateUserLesson, isAdmin, paidCourse, decryptionPerformed]); useEffect(() => { if (isAdmin) return; // Skip tracking for admin users diff --git a/src/hooks/tracking/useTrackVideoLesson.js b/src/hooks/tracking/useTrackVideoLesson.js index 4d3ee50..2de2742 100644 --- a/src/hooks/tracking/useTrackVideoLesson.js +++ b/src/hooks/tracking/useTrackVideoLesson.js @@ -2,13 +2,13 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { useSession } from 'next-auth/react'; import axios from 'axios'; -const useTrackVideoLesson = ({lessonId, videoDuration, courseId, videoPlayed}) => { +const useTrackVideoLesson = ({lessonId, videoDuration, courseId, videoPlayed, paidCourse, decryptionPerformed}) => { const [isCompleted, setIsCompleted] = useState(false); const [timeSpent, setTimeSpent] = useState(0); const [isTracking, setIsTracking] = useState(false); const [isAdmin, setIsAdmin] = useState(false); const timerRef = useRef(null); - const { data: session } = useSession(); + const { data: session, update } = useSession(); const completedRef = useRef(false); useEffect(() => { @@ -31,11 +31,16 @@ const useTrackVideoLesson = ({lessonId, videoDuration, courseId, videoPlayed}) = return false; } } else if (response.status === 204) { - await axios.post(`/api/users/${session.user.id}/lessons?courseId=${courseId}`, { - resourceId: lessonId, - opened: true, - openedAt: new Date().toISOString(), - }); + // Only create a new UserLesson entry if it's a free course or if decryption has been performed for a paid course + if (paidCourse === false || (paidCourse && decryptionPerformed)) { + await axios.post(`/api/users/${session.user.id}/lessons?courseId=${courseId}`, { + resourceId: lessonId, + opened: true, + openedAt: new Date().toISOString(), + }); + // Call session update after creating a new UserLesson entry + await update(); + } return false; } else { console.error('Error checking or creating UserLesson:', response.statusText); @@ -45,7 +50,7 @@ const useTrackVideoLesson = ({lessonId, videoDuration, courseId, videoPlayed}) = console.error('Error checking or creating UserLesson:', error); return false; } - }, [session, lessonId, courseId]); + }, [session, lessonId, courseId, update, paidCourse, decryptionPerformed]); const markLessonAsCompleted = useCallback(async () => { if (!session?.user || completedRef.current) return; @@ -60,20 +65,22 @@ const useTrackVideoLesson = ({lessonId, videoDuration, courseId, videoPlayed}) = if (response.status === 200) { setIsCompleted(true); setIsTracking(false); + // Call session update after marking the lesson as completed + await update(); } else { console.error('Failed to mark lesson as completed:', response.statusText); } } catch (error) { console.error('Error marking lesson as completed:', error); } - }, [lessonId, courseId, session]); + }, [lessonId, courseId, session, update]); useEffect(() => { const initializeTracking = async () => { if (isAdmin) return; const alreadyCompleted = await checkOrCreateUserLesson(); - if (!alreadyCompleted && videoDuration && !completedRef.current && videoPlayed) { + if (!alreadyCompleted && videoDuration && !completedRef.current && videoPlayed && (paidCourse === false || (paidCourse && decryptionPerformed))) { console.log(`Tracking started for lesson ${lessonId}, video duration: ${videoDuration} seconds, video played: ${videoPlayed}`); setIsTracking(true); timerRef.current = setInterval(() => { @@ -89,7 +96,7 @@ const useTrackVideoLesson = ({lessonId, videoDuration, courseId, videoPlayed}) = clearInterval(timerRef.current); } }; - }, [lessonId, videoDuration, checkOrCreateUserLesson, videoPlayed, isAdmin]); + }, [lessonId, videoDuration, checkOrCreateUserLesson, videoPlayed, isAdmin, paidCourse, decryptionPerformed]); useEffect(() => { if (isAdmin) return; diff --git a/src/pages/api/get-video-url.js b/src/pages/api/get-video-url.js index 248f8c0..77c6406 100644 --- a/src/pages/api/get-video-url.js +++ b/src/pages/api/get-video-url.js @@ -1,5 +1,6 @@ import { getServerSession } from "next-auth/next" import { authOptions } from "./auth/[...nextauth]" +import { getLessonsByCourseId } from "@/db/models/lessonModels" import { getSignedUrl } from "@aws-sdk/s3-request-presigner" import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3" import appConfig from "@/config/appConfig"; @@ -35,9 +36,18 @@ export default async function handler(req, res) { // Check if the user is authorized to access the video if (!session.user.role?.subscribed && !appConfig.authorPubkeys.includes(session.user.pubkey)) { const purchasedVideo = session.user.purchased?.find(purchase => purchase?.resource?.videoId === videoKey) - console.log("purchasedVideo", purchasedVideo) + // first check if it is individual video if (!purchasedVideo) { - return res.status(403).json({ error: "Forbidden: You don't have access to this video" }) + // next we have to check if it is in a course the user has purchased + const allPurchasedCourses = session?.user?.purchased?.filter(purchase => purchase?.courseId) || [] + + const allPurchasedLessons = await Promise.all( + allPurchasedCourses.map(course => getLessonsByCourseId(course.courseId)) + ).then(lessonsArrays => lessonsArrays.flat()) + + if (!allPurchasedLessons.some(lesson => lesson?.resource?.videoId === videoKey)) { + return res.status(403).json({ error: "Forbidden: You don't have access to this video" }) + } } } diff --git a/src/pages/api/users/[slug]/index.js b/src/pages/api/users/[slug]/index.js index 05dfc32..0e8477c 100644 --- a/src/pages/api/users/[slug]/index.js +++ b/src/pages/api/users/[slug]/index.js @@ -12,8 +12,9 @@ export default async function handler(req, res) { // If slug is a pubkey user = await getUserByPubkey(slug); } else if (isEmail) { + // todo // If slug is an email - user = await getUserByEmail(slug); + // user = await getUserByEmail(slug); } else { // Assume slug is an ID const id = parseInt(slug); diff --git a/src/pages/course/[slug]/index.js b/src/pages/course/[slug]/index.js index 3d9eb17..690d5ef 100644 --- a/src/pages/course/[slug]/index.js +++ b/src/pages/course/[slug]/index.js @@ -139,7 +139,7 @@ const Course = () => { const { ndk, addSigner } = useNDKContext(); const { data: session, update } = useSession(); const { showToast } = useToast(); - const [paidCourse, setPaidCourse] = useState(false); + const [paidCourse, setPaidCourse] = useState(null); const [expandedIndex, setExpandedIndex] = useState(null); const [completedLessons, setCompletedLessons] = useState([]);