// lib/watch-progress.ts // Single source of truth for watch-progress maths and the sequential unlock rule. import { prisma } from './prisma'; /** A video counts as completed (and unlocks the next one) at this percentage. */ export const UNLOCK_THRESHOLD_PERCENT = 80; export type SecRange = { startSec: number; endSec: number }; /** * Merges overlapping or touching ranges. Returns a new array sorted by start. * When ranges carry a `watchedAt`, the merged range keeps the most recent one. */ export function mergeRanges( ranges: T[] ): Array { const sorted = ranges .filter((r) => r.endSec > r.startSec) .sort((a, b) => a.startSec - b.startSec); const merged: Array = []; for (const r of sorted) { const last = merged[merged.length - 1]; if (last && r.startSec <= last.endSec) { last.endSec = Math.max(last.endSec, r.endSec); if (r.watchedAt && (!last.watchedAt || r.watchedAt > last.watchedAt)) { last.watchedAt = r.watchedAt; } } else { merged.push({ startSec: r.startSec, endSec: r.endSec, watchedAt: r.watchedAt }); } } return merged; } export function totalSeconds(ranges: SecRange[]): number { return ranges.reduce((sum, r) => sum + Math.max(0, r.endSec - r.startSec), 0); } /** * Validates client-reported [start, end] pairs and snaps them to whole seconds * inside the video. Anything malformed is dropped. */ export function sanitizeRanges(input: unknown, durationSec: number): SecRange[] { if (!Array.isArray(input)) return []; const max = Math.ceil(durationSec); const out: SecRange[] = []; for (const item of input.slice(0, 500)) { if (!Array.isArray(item) || item.length < 2) continue; const s = Number(item[0]); const e = Number(item[1]); if (!Number.isFinite(s) || !Number.isFinite(e) || e <= s) continue; const startSec = Math.min(max, Math.max(0, Math.floor(s))); const endSec = Math.min(max, Math.max(0, Math.ceil(e))); if (endSec > startSec) out.push({ startSec, endSec }); } return out; } type RestrictableVideo = { videoCourses?: Array<{ courseId: string; exclusive: boolean }>; }; /** Drops videos that are exclusive to courses the user is not enrolled in. */ export function filterAccessibleVideos( videos: T[], userCourseIds: string[] ): T[] { return videos.filter((video) => { const restricted = (video.videoCourses ?? []) .filter((a) => a.exclusive) .map((a) => a.courseId); if (restricted.length === 0) return true; return restricted.some((courseId) => userCourseIds.includes(courseId)); }); } type PlaylistVideo = { id: string; title: string; index: number; locked: boolean; instantAccess: boolean; }; /** * The playlist's videos, in order, as this user sees them. Returns null when * the user is not enrolled in any course the playlist belongs to. */ export async function getPlaylistVideosForUser( userId: string, playlistId: string ): Promise { const [enrollments, playlist] = await Promise.all([ prisma.enrollment.findMany({ where: { userId }, select: { courseId: true } }), prisma.playlist.findUnique({ where: { id: playlistId }, select: { courseId: true, courses: { select: { courseId: true } }, videos: { orderBy: { index: 'asc' }, select: { id: true, title: true, index: true, locked: true, instantAccess: true, videoCourses: { select: { courseId: true, exclusive: true } }, }, }, }, }), ]); if (!playlist) return null; const userCourseIds = enrollments.map((e) => e.courseId); const playlistCourseIds = [playlist.courseId, ...playlist.courses.map((c) => c.courseId)]; if (!playlistCourseIds.some((id) => userCourseIds.includes(id))) return null; return filterAccessibleVideos(playlist.videos, userCourseIds).map( ({ id, title, index, locked, instantAccess }) => ({ id, title, index, locked, instantAccess }) ); } /** * Called when a user completes `video`: unlocks the next video in the playlist * that needs unlocking (skipping lecturer-locked and instant-access videos). */ export async function unlockNextVideo( userId: string, video: { id: string; playlistId: string } ): Promise<{ id: string; title: string } | null> { const videos = await getPlaylistVideosForUser(userId, video.playlistId); if (!videos) return null; const pos = videos.findIndex((v) => v.id === video.id); if (pos === -1) return null; const next = videos.slice(pos + 1).find((v) => !v.locked && !v.instantAccess); if (!next) return null; await prisma.videoUnlock.upsert({ where: { userId_videoId: { userId, videoId: next.id } }, update: {}, create: { userId, videoId: next.id }, }); return { id: next.id, title: next.title }; } export type UnlockCheck = | { ok: true; free: boolean } | { ok: false; status: number; reason: 'not_found' | 'locked' | 'no_access' | 'not_earned'; error: string; }; /** * Decides whether a user may unlock a video by hand. Mirrors unlockNextVideo: * a video opens once any watchable video between it and the previous * unlock-gated video (inclusive) has been completed. * `free` means the video never needed an unlock record. */ export async function checkUnlockEligibility( userId: string, videoId: string ): Promise { const video = await prisma.video.findUnique({ where: { id: videoId }, select: { id: true, playlistId: true, locked: true }, }); if (!video) return { ok: false, status: 404, reason: 'not_found', error: 'Video not found' }; if (video.locked) { return { ok: false, status: 403, reason: 'locked', error: 'This video is locked by your lecturer.', }; } const videos = await getPlaylistVideosForUser(userId, video.playlistId); const pos = videos ? videos.findIndex((v) => v.id === videoId) : -1; if (!videos || pos === -1) { return { ok: false, status: 403, reason: 'no_access', error: 'You do not have access to this video.', }; } if (videos[pos].instantAccess || pos === 0) return { ok: true, free: true }; // Walk back to the previous video that itself needs unlocking. let gateStart = 0; for (let i = pos - 1; i >= 0; i--) { if (!videos[i].locked && !videos[i].instantAccess) { gateStart = i; break; } } const gate = videos.slice(gateStart, pos).filter((v) => !v.locked); if (gate.length === 0) return { ok: true, free: true }; const progress = await prisma.videoProgress.findMany({ where: { userId, videoId: { in: gate.map((v) => v.id) } }, select: { videoId: true, percent: true, completed: true }, }); const done = progress.some( (p) => p.completed || p.percent >= UNLOCK_THRESHOLD_PERCENT ); if (done) return { ok: true, free: false }; const previous = gate[gate.length - 1]; const watched = progress.find((p) => p.videoId === previous.id)?.percent ?? 0; return { ok: false, status: 403, reason: 'not_earned', error: `Watch at least ${UNLOCK_THRESHOLD_PERCENT}% of "${previous.title}" to unlock this video (you are at ${watched}%).`, }; }