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>
198 lines
6.5 KiB
TypeScript
198 lines
6.5 KiB
TypeScript
// app/api/progress/route.ts
|
|
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 {
|
|
const session = await getServerSession(authOptions);
|
|
if (!session?.user?.email)
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
|
|
const userEmail = session.user.email;
|
|
const { searchParams } = new URL(req.url);
|
|
const videoId = searchParams.get('videoId');
|
|
|
|
if (!videoId)
|
|
return NextResponse.json({ error: 'videoId required' }, { status: 400 });
|
|
|
|
const user = await prisma.user.findUnique({ where: { email: userEmail } });
|
|
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 } });
|
|
if (!video) {
|
|
console.debug(`[PROGRESS] Video not found: ${videoId} (likely deleted video with cached browser reference)`);
|
|
return NextResponse.json({ error: 'video not found' }, { status: 404 });
|
|
}
|
|
|
|
const progress = await prisma.videoProgress.findUnique({
|
|
where: { userId_videoId: { userId: user.id, videoId } },
|
|
select: { percent: true, lastPos: true, watchedSec: true },
|
|
});
|
|
|
|
return NextResponse.json({
|
|
percent: progress?.percent ?? 0,
|
|
lastPos: progress?.lastPos ?? 0,
|
|
watchedSec: progress?.watchedSec ?? 0,
|
|
});
|
|
} catch (err: any) {
|
|
console.error('progress GET error', err);
|
|
return NextResponse.json(
|
|
{ error: err?.message ?? 'server error' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function POST(req: Request) {
|
|
try {
|
|
const session = await getServerSession(authOptions);
|
|
if (!session?.user?.email)
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
|
|
const userEmail = session.user.email;
|
|
|
|
const body = await req.json().catch(() => ({}));
|
|
const { videoId, watchedSec, lastPos, duration } = body ?? {};
|
|
|
|
if (!videoId || typeof videoId !== 'string')
|
|
return NextResponse.json({ error: 'videoId required' }, { status: 400 });
|
|
|
|
// look up user id
|
|
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 },
|
|
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 });
|
|
}
|
|
|
|
// 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 });
|
|
|
|
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: Math.round(dur),
|
|
},
|
|
});
|
|
|
|
const existing = await tx.videoWatchSegment.findMany({
|
|
where: { userId: user.id, videoId },
|
|
select: { startSec: true, endSec: true, watchedAt: true },
|
|
orderBy: { startSec: 'asc' },
|
|
});
|
|
|
|
// 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,
|
|
})),
|
|
});
|
|
}
|
|
|
|
// 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);
|
|
|
|
return NextResponse.json(
|
|
{ error: err?.message ?? 'server error' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|