Fix watch tracking, video unlocking and access-control gaps
Deploy Vault CMS / deploy (push) Failing after 38s
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>
This commit is contained in:
+103
-131
@@ -3,6 +3,14 @@ import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth-options';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import {
|
||||
UNLOCK_THRESHOLD_PERCENT,
|
||||
mergeRanges,
|
||||
sanitizeRanges,
|
||||
totalSeconds,
|
||||
unlockNextVideo,
|
||||
type SecRange,
|
||||
} from '@/lib/watch-progress';
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
@@ -47,36 +55,6 @@ export async function GET(req: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to calculate unique watched seconds from segments
|
||||
function calculateWatchedSeconds(segments: Array<{ startSec: number; endSec: number }>): number {
|
||||
if (segments.length === 0) return 0;
|
||||
|
||||
// Sort and merge overlapping ranges
|
||||
const sorted = segments.sort((a, b) => a.startSec - b.startSec);
|
||||
const merged: Array<[number, number]> = [];
|
||||
|
||||
for (const seg of sorted) {
|
||||
if (merged.length === 0) {
|
||||
merged.push([seg.startSec, seg.endSec]);
|
||||
} else {
|
||||
const last = merged[merged.length - 1];
|
||||
if (seg.startSec <= last[1] + 0.5) {
|
||||
// Overlapping or adjacent, merge
|
||||
last[1] = Math.max(last[1], seg.endSec);
|
||||
} else {
|
||||
// Gap, new range
|
||||
merged.push([seg.startSec, seg.endSec]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
for (const [start, end] of merged) {
|
||||
total += Math.max(0, end - start);
|
||||
}
|
||||
return Math.round(total);
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
@@ -86,137 +64,131 @@ export async function POST(req: Request) {
|
||||
const userEmail = session.user.email;
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const { videoId, playlistId, watchedSec, lastPos, duration } = body ?? {};
|
||||
const { videoId, watchedSec, lastPos, duration } = body ?? {};
|
||||
|
||||
if (!videoId)
|
||||
if (!videoId || typeof videoId !== 'string')
|
||||
return NextResponse.json({ error: 'videoId required' }, { status: 400 });
|
||||
if (!duration || Number(duration) <= 0)
|
||||
return NextResponse.json({ error: 'duration required' }, { status: 400 });
|
||||
|
||||
const watched = Number(watchedSec ?? 0);
|
||||
const lastPosition = Number(lastPos ?? 0);
|
||||
const dur = Number(duration);
|
||||
|
||||
// look up user id
|
||||
const user = await prisma.user.findUnique({ where: { email: userEmail } });
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: userEmail },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!user)
|
||||
return NextResponse.json({ error: 'user not found' }, { status: 404 });
|
||||
|
||||
// Validate that the video exists
|
||||
const video = await prisma.video.findUnique({ where: { id: videoId } });
|
||||
const video = await prisma.video.findUnique({
|
||||
where: { id: videoId },
|
||||
select: { id: true, playlistId: true, durationSec: true },
|
||||
});
|
||||
if (!video) {
|
||||
// Log at debug level since this is expected when users have old cached references
|
||||
console.debug(`[PROGRESS] Video not found: ${videoId} (likely deleted video with cached browser reference)`);
|
||||
return NextResponse.json({ error: 'video not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Ensure VideoProgress exists or create it
|
||||
let videoProgress = await prisma.videoProgress.findUnique({
|
||||
where: { userId_videoId: { userId: user.id, videoId } },
|
||||
});
|
||||
// The stored duration wins; the player's value is only a fallback for
|
||||
// videos uploaded without one.
|
||||
const dur =
|
||||
video.durationSec && video.durationSec > 0 ? video.durationSec : Number(duration);
|
||||
if (!Number.isFinite(dur) || dur <= 0)
|
||||
return NextResponse.json({ error: 'duration required' }, { status: 400 });
|
||||
|
||||
if (!videoProgress) {
|
||||
videoProgress = await prisma.videoProgress.create({
|
||||
data: {
|
||||
const lastPosition = Math.max(0, Math.floor(Number(lastPos ?? 0)) || 0);
|
||||
|
||||
// Current players send the ranges actually played. Tabs still running the
|
||||
// previous player only send a total, from which one range is inferred.
|
||||
let incoming: SecRange[];
|
||||
if (Array.isArray(body?.ranges)) {
|
||||
incoming = sanitizeRanges(body.ranges, dur);
|
||||
} else {
|
||||
const watched = Number(watchedSec ?? 0);
|
||||
incoming = sanitizeRanges(
|
||||
watched > 0 ? [[lastPosition - watched, lastPosition]] : [],
|
||||
dur
|
||||
);
|
||||
}
|
||||
|
||||
const where = { userId_videoId: { userId: user.id, videoId } };
|
||||
|
||||
const { progress, segments } = await prisma.$transaction(async (tx) => {
|
||||
const existingProgress = await tx.videoProgress.upsert({
|
||||
where,
|
||||
update: {},
|
||||
create: {
|
||||
userId: user.id,
|
||||
videoId,
|
||||
watchedSec: 0,
|
||||
lastPos: 0,
|
||||
percent: 0,
|
||||
durationSec: dur,
|
||||
durationSec: Math.round(dur),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Record the watch segment if watched seconds > 0
|
||||
let newSegment = null;
|
||||
if (watched > 0) {
|
||||
newSegment = await prisma.videoWatchSegment.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
videoId,
|
||||
startSec: Math.max(0, lastPosition - watched),
|
||||
endSec: lastPosition,
|
||||
},
|
||||
const existing = await tx.videoWatchSegment.findMany({
|
||||
where: { userId: user.id, videoId },
|
||||
select: { startSec: true, endSec: true, watchedAt: true },
|
||||
orderBy: { startSec: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch all segments for this user+video to recalculate totals
|
||||
const allSegments = await prisma.videoWatchSegment.findMany({
|
||||
where: { userId: user.id, videoId },
|
||||
select: { startSec: true, endSec: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
// Calculate total unique watched seconds
|
||||
const totalWatchedSec = calculateWatchedSeconds(allSegments);
|
||||
|
||||
// Calculate completion percentage
|
||||
const ratio = dur > 0 ? totalWatchedSec / dur : 0;
|
||||
const percentInt = Math.min(100, Math.round(ratio * 100));
|
||||
const completed = percentInt >= 80; // Changed from 90 to 80 for unlock threshold
|
||||
|
||||
// Update VideoProgress with recalculated values
|
||||
const upserted = await prisma.videoProgress.update({
|
||||
where: { userId_videoId: { userId: user.id, videoId } },
|
||||
data: {
|
||||
watchedSec: totalWatchedSec,
|
||||
lastPos: Math.max(videoProgress.lastPos ?? 0, lastPosition),
|
||||
percent: percentInt,
|
||||
durationSec: dur,
|
||||
completed,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// If 80% watched, unlock next video in playlist for this user
|
||||
let unlockedNext = null;
|
||||
if (completed && playlistId) {
|
||||
// find current video
|
||||
const current = await prisma.video.findUnique({ where: { id: videoId } });
|
||||
if (current && current.playlistId === playlistId) {
|
||||
// Find next video that is NOT instant access and NOT globally locked
|
||||
// Skip any instant access videos in the sequence
|
||||
const nextVideos = await prisma.video.findMany({
|
||||
where: {
|
||||
playlistId,
|
||||
index: { gt: current.index },
|
||||
locked: false,
|
||||
instantAccess: false,
|
||||
},
|
||||
orderBy: { index: 'asc' },
|
||||
take: 1,
|
||||
// Keep one row per distinct watched stretch instead of one per update.
|
||||
const now = new Date();
|
||||
const merged = mergeRanges([
|
||||
...existing,
|
||||
...incoming.map((r) => ({ ...r, watchedAt: now })),
|
||||
]);
|
||||
const unchanged =
|
||||
merged.length === existing.length &&
|
||||
merged.every(
|
||||
(m, i) => m.startSec === existing[i].startSec && m.endSec === existing[i].endSec
|
||||
);
|
||||
if (!unchanged) {
|
||||
await tx.videoWatchSegment.deleteMany({ where: { userId: user.id, videoId } });
|
||||
await tx.videoWatchSegment.createMany({
|
||||
data: merged.map((m) => ({
|
||||
userId: user.id,
|
||||
videoId,
|
||||
startSec: m.startSec,
|
||||
endSec: m.endSec,
|
||||
watchedAt: m.watchedAt ?? now,
|
||||
})),
|
||||
});
|
||||
|
||||
if (nextVideos.length > 0) {
|
||||
const next = nextVideos[0];
|
||||
// Create a VideoUnlock record for this user (per-user unlock tracking)
|
||||
const unlock = await prisma.videoUnlock.upsert({
|
||||
where: { userId_videoId: { userId: user.id, videoId: next.id } },
|
||||
update: {}, // if already exists, do nothing
|
||||
create: { userId: user.id, videoId: next.id },
|
||||
});
|
||||
unlockedNext = { id: next.id, title: next.title };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, progress: upserted, unlockedNext, segment: newSegment });
|
||||
// Progress never goes backwards, even if segment rows were pruned.
|
||||
const mergedSec = totalSeconds(merged);
|
||||
const percent = Math.max(
|
||||
existingProgress.percent,
|
||||
Math.min(100, Math.round((mergedSec / dur) * 100))
|
||||
);
|
||||
const completed =
|
||||
existingProgress.completed || percent >= UNLOCK_THRESHOLD_PERCENT;
|
||||
|
||||
const updated = await tx.videoProgress.update({
|
||||
where,
|
||||
data: {
|
||||
watchedSec: Math.max(existingProgress.watchedSec, mergedSec),
|
||||
lastPos: Math.max(existingProgress.lastPos ?? 0, lastPosition),
|
||||
percent,
|
||||
durationSec: Math.round(dur),
|
||||
completed,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
return { progress: updated, segments: merged };
|
||||
});
|
||||
|
||||
// Completing a video unlocks the next one in its playlist for this user.
|
||||
const unlockedNext = progress.completed
|
||||
? await unlockNextVideo(user.id, video)
|
||||
: null;
|
||||
|
||||
return NextResponse.json({ ok: true, progress, unlockedNext, segments });
|
||||
} catch (err: any) {
|
||||
console.error('progress error', err);
|
||||
|
||||
// Handle foreign key constraint violations
|
||||
if (err.code === 'P2003') {
|
||||
const constraint = err.meta?.constraint_name;
|
||||
if (constraint?.includes('videoId')) {
|
||||
console.error(`[PROGRESS] Foreign key violation - invalid videoId: ${err.meta}`);
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid video reference' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: err?.message ?? 'server error' },
|
||||
{ status: 500 }
|
||||
|
||||
Reference in New Issue
Block a user