a834caa511
Deploy Vault CMS / deploy (push) Failing after 38s
Watch progress and unlocking: - Player samples the playhead and reports the ranges actually played, so skipped sections earn no credit and early plays are not lost - Server merges ranges into compact segment rows; progress never regresses - Single 80% unlock rule enforced server-side (lib/watch-progress.ts); the Unlock button and automatic unlock now agree - Lock state and progress bar update live from each progress save - Playlist is derived from the video, so unlocks work for shared playlists and for links from watch history and liked videos - First/previous video decided by playlist position, not index numbers Access control: - Video API and HLS/MP4 routes require a signed-in viewer who is enrolled and has the video unlocked; thumbnails require sign-in - Enrolment sync reads course codes from the allow-list, not the request - /api/admin/meta is admin-only - Account status, role and levels are re-checked from the database during a session, so deactivated students lose access within a minute Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
225 lines
7.2 KiB
TypeScript
225 lines
7.2 KiB
TypeScript
// 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<T extends SecRange & { watchedAt?: Date }>(
|
|
ranges: T[]
|
|
): Array<SecRange & { watchedAt?: Date }> {
|
|
const sorted = ranges
|
|
.filter((r) => r.endSec > r.startSec)
|
|
.sort((a, b) => a.startSec - b.startSec);
|
|
|
|
const merged: Array<SecRange & { watchedAt?: Date }> = [];
|
|
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<T extends RestrictableVideo>(
|
|
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<PlaylistVideo[] | null> {
|
|
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<UnlockCheck> {
|
|
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}%).`,
|
|
};
|
|
}
|