Files
Vault/lib/watch-progress.ts
T
twotalesanimation a834caa511
Deploy Vault CMS / deploy (push) Failing after 38s
Fix watch tracking, video unlocking and access-control gaps
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>
2026-11-11 16:58:07 +02:00

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}%).`,
};
}