Created userCourse model, added endpoints, added basic useTrackcourse hook

This commit is contained in:
austinkelsay 2024-09-21 16:50:59 -05:00
parent 1f8b69fb22
commit 96a6a29936
12 changed files with 233 additions and 25 deletions
prisma
migrations/20240921214957_init
schema.prisma
src

@ -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;

@ -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])
}

@ -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,
<div className="w-full mx-auto px-4 py-8 -mt-32 relative z-10 max-mob:px-0 max-tab:px-0">
<i className={`pi pi-arrow-left cursor-pointer hover:opacity-75 absolute top-0 left-4`} onClick={() => router.push('/')} />
<div className="mb-8 bg-gray-800/70 rounded-lg p-4 max-mob:rounded-t-none max-tab:rounded-t-none">
{isCompleted && <Tag severity="success" value="Completed" />}
<div className="flex flex-row items-center justify-between w-full">
<h1 className='text-4xl font-bold text-white'>{processedEvent.name}</h1>
<div className="flex flex-wrap gap-2">

@ -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) {

@ -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);
}

@ -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;
};

@ -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: {

@ -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;

@ -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();
}

@ -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`);
}
}

@ -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`);
}
}

@ -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 = () => {
<div className="w-full py-4 rounded-b-lg">
{lesson.type === 'video' ?
<VideoLesson lesson={lesson} course={course} decryptionPerformed={decryptionPerformed} isPaid={paidCourse} setCompleted={setCompleted} /> :
<DocumentLesson lesson={lesson} course={course} decryptionPerformed={decryptionPerformed} isPaid={paidCourse} />
<DocumentLesson lesson={lesson} course={course} decryptionPerformed={decryptionPerformed} isPaid={paidCourse} setCompleted={setCompleted} />
}
</div>
</AccordionTab>