mirror of
https://github.com/AustinKelsay/plebdevs.git
synced 2025-06-22 15:35:05 +00:00
Created userCourse model, added endpoints, added basic useTrackcourse hook
This commit is contained in:
parent
1f8b69fb22
commit
96a6a29936
@ -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 {
|
||||
@ -193,3 +195,19 @@ model Purchase {
|
||||
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);
|
||||
}
|
||||
|
84
src/db/models/userCourseModels.js
Normal file
84
src/db/models/userCourseModels.js
Normal file
@ -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: {
|
||||
|
45
src/hooks/tracking/useTrackCourse.js
Normal file
45
src/hooks/tracking/useTrackCourse.js
Normal file
@ -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();
|
||||
}
|
||||
|
25
src/pages/api/users/[slug]/courses/[courseSlug].js
Normal file
25
src/pages/api/users/[slug]/courses/[courseSlug].js
Normal file
@ -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`);
|
||||
}
|
||||
}
|
21
src/pages/api/users/[slug]/courses/index.js
Normal file
21
src/pages/api/users/[slug]/courses/index.js
Normal file
@ -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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user