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:
@@ -1,13 +1,12 @@
|
||||
// app/api/admin/meta/route.ts
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth-options';
|
||||
import { adminGuard } from '@/lib/admin-check';
|
||||
|
||||
export async function GET() {
|
||||
// Basic protection: ensure signed-in user is admin. Modify as needed.
|
||||
// If you want stricter, get session from context or check roles.
|
||||
// Here we skip session check to let dev access; but production should check.
|
||||
const denied = await adminGuard();
|
||||
if (denied) return denied;
|
||||
|
||||
try {
|
||||
const courses = await prisma.course.findMany({ orderBy: { title: 'asc' } });
|
||||
const playlists = await prisma.playlist.findMany({ orderBy: { title: 'asc' } });
|
||||
|
||||
@@ -1,122 +1,78 @@
|
||||
// app/api/enrollments/sync/route.ts
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth-options';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { normalizeEmail } from '@/lib/normalize-email';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] POST endpoint called');
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] Request method:', req.method);
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] Request URL:', req.url);
|
||||
|
||||
/**
|
||||
* Enrols the signed-in user in the courses listed for them on the allow-list.
|
||||
* The course codes are read from the database; anything the browser sends is
|
||||
* ignored, so a user cannot enrol themselves in a course they were not given.
|
||||
*/
|
||||
export async function POST() {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] Session check:', !!session?.user?.email);
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] Session user:', session?.user);
|
||||
|
||||
if (!session?.user?.email) {
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] No session or email, returning 401');
|
||||
const email = normalizeEmail(session?.user?.email);
|
||||
if (!email) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userEmail = session.user.email;
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] About to parse request body...');
|
||||
|
||||
const body = await req.json();
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] Raw body received:', JSON.stringify(body, null, 2));
|
||||
|
||||
const levels: string[] = body?.levels || [];
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] Extracted levels:', levels);
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] Levels type check - isArray:', Array.isArray(levels));
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] Levels length:', levels.length);
|
||||
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] User:', userEmail);
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] Received levels:', levels);
|
||||
|
||||
if (!Array.isArray(levels) || levels.length === 0) {
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] VALIDATION FAILED!');
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] levels isArray:', Array.isArray(levels));
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] levels length:', levels?.length);
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] levels value:', levels);
|
||||
console.log('🔥 [SYNC-ENROLLMENTS] Returning 400 - Levels array required');
|
||||
return NextResponse.json(
|
||||
{ error: 'Levels array required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get user
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: userEmail },
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { email: { equals: email, mode: 'insensitive' } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!user) {
|
||||
console.log('[SYNC-ENROLLMENTS] User not found in database:', userEmail);
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
console.log('[SYNC-ENROLLMENTS] Found user:', user.id, user.email);
|
||||
const allowedStudent = await prisma.allowedStudent.findFirst({
|
||||
where: { email: { equals: email, mode: 'insensitive' } },
|
||||
select: { levels: true, active: true },
|
||||
});
|
||||
|
||||
const createdEnrollments = [];
|
||||
const levels = allowedStudent?.active
|
||||
? allowedStudent.levels
|
||||
.split(',')
|
||||
.map((level) => level.trim())
|
||||
.filter((level) => level.length > 0)
|
||||
: [];
|
||||
|
||||
// For each course level, find the course and create enrollment
|
||||
for (const level of levels) {
|
||||
try {
|
||||
console.log('[SYNC-ENROLLMENTS] Looking for course with code:', level);
|
||||
|
||||
const course = await prisma.course.findUnique({
|
||||
where: { code: level },
|
||||
});
|
||||
|
||||
if (!course) {
|
||||
console.warn(`[SYNC-ENROLLMENTS] Course code not found: ${level}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log('[SYNC-ENROLLMENTS] Found course:', course.id, course.code, course.title);
|
||||
|
||||
// Check if enrollment already exists
|
||||
const existing = await prisma.enrollment.findFirst({
|
||||
where: { userId: user.id, courseId: course.id },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
console.log('[SYNC-ENROLLMENTS] Enrollment already exists:', existing.id);
|
||||
} else {
|
||||
const enrollment = await prisma.enrollment.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
courseId: course.id,
|
||||
},
|
||||
});
|
||||
console.log('[SYNC-ENROLLMENTS] Created new enrollment:', enrollment.id);
|
||||
createdEnrollments.push({
|
||||
courseCode: level,
|
||||
courseId: course.id,
|
||||
enrollmentId: enrollment.id,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[SYNC-ENROLLMENTS] Failed to create enrollment for level ${level}:`, err);
|
||||
}
|
||||
if (levels.length === 0) {
|
||||
return NextResponse.json({ message: 'Enrollments synced', created: [] });
|
||||
}
|
||||
|
||||
console.log('[SYNC-ENROLLMENTS] Summary - Created enrollments:', createdEnrollments.length);
|
||||
console.log('[SYNC-ENROLLMENTS] Details:', createdEnrollments);
|
||||
const courses = await prisma.course.findMany({
|
||||
where: { code: { in: levels } },
|
||||
select: { id: true, code: true },
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Enrollments synced',
|
||||
created: createdEnrollments,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
const missing = levels.filter((level) => !courses.some((c) => c.code === level));
|
||||
if (missing.length > 0) {
|
||||
console.warn(`[SYNC-ENROLLMENTS] Course codes not found: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
const created = [];
|
||||
for (const course of courses) {
|
||||
const existing = await prisma.enrollment.findUnique({
|
||||
where: { userId_courseId: { userId: user.id, courseId: course.id } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) continue;
|
||||
|
||||
const enrollment = await prisma.enrollment.create({
|
||||
data: { userId: user.id, courseId: course.id },
|
||||
});
|
||||
created.push({
|
||||
courseCode: course.code,
|
||||
courseId: course.id,
|
||||
enrollmentId: enrollment.id,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Enrollments synced', created });
|
||||
} catch (err) {
|
||||
console.error('🔥 [SYNC-ENROLLMENTS] FATAL ERROR:', err);
|
||||
console.error('🔥 [SYNC-ENROLLMENTS] Error stack:', err instanceof Error ? err.stack : 'No stack');
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to sync enrollments' },
|
||||
{ status: 500 }
|
||||
);
|
||||
console.error('[SYNC-ENROLLMENTS] error:', err);
|
||||
return NextResponse.json({ error: 'Failed to sync enrollments' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "../../../../lib/auth-options";
|
||||
import { filterAccessibleVideos } from "../../../../lib/watch-progress";
|
||||
|
||||
export async function GET(req: Request, context: any) {
|
||||
try {
|
||||
@@ -20,6 +21,7 @@ export async function GET(req: Request, context: any) {
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email },
|
||||
include: { enrollments: { select: { courseId: true } } },
|
||||
});
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
@@ -37,21 +39,31 @@ export async function GET(req: Request, context: any) {
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
videoCourses: { select: { courseId: true, exclusive: true } },
|
||||
},
|
||||
orderBy: { index: "asc" },
|
||||
},
|
||||
course: true,
|
||||
courses: { select: { courseId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!playlist) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const enrolled = await prisma.enrollment.findUnique({
|
||||
where: { userId_courseId: { userId: user.id, courseId: playlist.courseId } },
|
||||
});
|
||||
if (!enrolled) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
// Enrolment in the primary course or any course the playlist is shared with.
|
||||
const userCourseIds = user.enrollments.map((e) => e.courseId);
|
||||
const playlistCourseIds = [playlist.courseId, ...playlist.courses.map((c) => c.courseId)];
|
||||
if (!playlistCourseIds.some((courseId) => userCourseIds.includes(courseId))) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ playlist });
|
||||
// Hide videos reserved for courses this user is not in, as /api/playlists does.
|
||||
const visible = {
|
||||
...playlist,
|
||||
videos: filterAccessibleVideos(playlist.videos, userCourseIds),
|
||||
};
|
||||
|
||||
return NextResponse.json({ playlist: visible });
|
||||
} catch (err: any) {
|
||||
console.error("GET /api/playlists/[id] error:", err);
|
||||
return NextResponse.json({ error: "Server error" }, { status: 500 });
|
||||
|
||||
+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 }
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getViewer } from '@/lib/video-access';
|
||||
|
||||
// Path to thumbnails directory (should match UPLOADS_DIR/thumbnails)
|
||||
const THUMBNAILS_DIR = path.join(process.env.UPLOADS_DIR || '/uploads', 'thumbnails');
|
||||
@@ -23,6 +24,11 @@ export async function GET(
|
||||
);
|
||||
}
|
||||
|
||||
// Thumbnails show course content, so they need a signed-in user.
|
||||
if (!(await getViewer())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Construct the file path and validate it
|
||||
const requestedPath = path.join(THUMBNAILS_DIR, ...pathSegments);
|
||||
|
||||
@@ -44,7 +50,7 @@ export async function GET(
|
||||
}
|
||||
|
||||
// Read the file
|
||||
const fileContent = fs.readFileSync(requestedPath);
|
||||
const fileContent = await fs.promises.readFile(requestedPath);
|
||||
|
||||
// Determine content type based on file extension
|
||||
let contentType = 'application/octet-stream';
|
||||
@@ -64,8 +70,7 @@ export async function GET(
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=31536000, immutable', // Cache for 1 year (thumbnails don't change)
|
||||
'Access-Control-Allow-Origin': '*', // Allow CORS if needed
|
||||
'Cache-Control': 'private, max-age=31536000, immutable', // Cache for 1 year (thumbnails don't change)
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -90,6 +95,8 @@ export async function HEAD(
|
||||
return new NextResponse(null, { status: 400 });
|
||||
}
|
||||
|
||||
if (!(await getViewer())) return new NextResponse(null, { status: 401 });
|
||||
|
||||
const requestedPath = path.join(THUMBNAILS_DIR, ...pathSegments);
|
||||
|
||||
// Security: prevent directory traversal attacks
|
||||
@@ -107,7 +114,7 @@ export async function HEAD(
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/jpeg', // Default for HEAD requests
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
'Cache-Control': 'private, max-age=31536000, immutable',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth-options';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { checkUnlockEligibility } from '@/lib/watch-progress';
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
@@ -59,40 +60,31 @@ export async function POST(req: Request) {
|
||||
if (!user)
|
||||
return NextResponse.json({ error: 'user not found' }, { status: 404 });
|
||||
|
||||
// Verify video exists
|
||||
const video = await prisma.video.findUnique({
|
||||
where: { id: videoId },
|
||||
select: { id: true, locked: true, instantAccess: true },
|
||||
});
|
||||
|
||||
if (!video) {
|
||||
return NextResponse.json({ error: 'video not found' }, { status: 404 });
|
||||
// Admins can open anything; everyone else must have earned the unlock.
|
||||
const role = (session.user as any).role;
|
||||
const isAdmin = role === 'admin' || role === 'superadmin';
|
||||
if (!isAdmin) {
|
||||
const check = await checkUnlockEligibility(user.id, videoId);
|
||||
if (!check.ok) {
|
||||
return NextResponse.json({ error: check.error }, { status: check.status });
|
||||
}
|
||||
if (check.free) {
|
||||
return NextResponse.json({ message: 'Video does not need unlocking', unlock: null });
|
||||
}
|
||||
} else {
|
||||
const exists = await prisma.video.findUnique({ where: { id: videoId }, select: { id: true } });
|
||||
if (!exists) {
|
||||
return NextResponse.json({ error: 'video not found' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
// Check if already unlocked
|
||||
const existingUnlock = await prisma.videoUnlock.findUnique({
|
||||
const unlock = await prisma.videoUnlock.upsert({
|
||||
where: { userId_videoId: { userId: user.id, videoId } },
|
||||
update: {},
|
||||
create: { userId: user.id, videoId },
|
||||
});
|
||||
|
||||
if (existingUnlock) {
|
||||
return NextResponse.json({
|
||||
message: 'Video already unlocked',
|
||||
unlock: existingUnlock
|
||||
});
|
||||
}
|
||||
|
||||
// Create the unlock record
|
||||
const unlock = await prisma.videoUnlock.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
videoId: videoId,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Video unlocked successfully',
|
||||
unlock
|
||||
});
|
||||
return NextResponse.json({ message: 'Video unlocked successfully', unlock });
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('user unlocks POST error', err);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { prisma } from "../../../../lib/prisma";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "../../../../lib/auth-options";
|
||||
import { getVideoUrls } from "../../../../lib/video-urls";
|
||||
import { checkVideoAccess } from "../../../../lib/video-access";
|
||||
|
||||
export async function GET(req: Request, context: any) {
|
||||
try {
|
||||
@@ -74,6 +75,16 @@ export async function GET(req: Request, context: any) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Sequential locks apply to students even when they follow a direct link.
|
||||
const role = (session.user as any).role;
|
||||
const isAdmin = role === "admin" || role === "superadmin";
|
||||
if (!isAdmin) {
|
||||
const access = await checkVideoAccess({ id: user.id, isAdmin: false }, id);
|
||||
if (!access.ok) {
|
||||
return NextResponse.json({ error: access.error }, { status: access.status });
|
||||
}
|
||||
}
|
||||
|
||||
// Find the next video (index = current.index + 1)
|
||||
const next = await prisma.video.findFirst({
|
||||
where: {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { checkVideoAccess, getViewer } from '@/lib/video-access';
|
||||
|
||||
// Path to HLS directory (should match UPLOADS_DIR/hls)
|
||||
const HLS_DIR = path.join(process.env.UPLOADS_DIR || '/uploads', 'hls');
|
||||
@@ -23,6 +24,17 @@ export async function GET(
|
||||
);
|
||||
}
|
||||
|
||||
// Signed-in viewers only, and only for videos they may watch.
|
||||
// The first path segment is the video id.
|
||||
const viewer = await getViewer();
|
||||
if (!viewer) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const access = await checkVideoAccess(viewer, pathSegments[0]);
|
||||
if (!access.ok) {
|
||||
return NextResponse.json({ error: access.error }, { status: access.status });
|
||||
}
|
||||
|
||||
// Construct the file path and validate it
|
||||
const requestedPath = path.join(HLS_DIR, ...pathSegments);
|
||||
|
||||
@@ -44,7 +56,7 @@ export async function GET(
|
||||
}
|
||||
|
||||
// Read the file
|
||||
const fileContent = fs.readFileSync(requestedPath);
|
||||
const fileContent = await fs.promises.readFile(requestedPath);
|
||||
|
||||
// Determine content type based on file extension
|
||||
let contentType = 'application/octet-stream';
|
||||
@@ -62,8 +74,7 @@ export async function GET(
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=3600', // Cache for 1 hour
|
||||
'Access-Control-Allow-Origin': '*', // Allow CORS if needed
|
||||
'Cache-Control': 'private, max-age=3600', // Viewer's browser only, 1 hour
|
||||
'Accept-Ranges': 'bytes',
|
||||
},
|
||||
});
|
||||
@@ -89,6 +100,11 @@ export async function HEAD(
|
||||
return new NextResponse(null, { status: 400 });
|
||||
}
|
||||
|
||||
const viewer = await getViewer();
|
||||
if (!viewer) return new NextResponse(null, { status: 401 });
|
||||
const access = await checkVideoAccess(viewer, pathSegments[0]);
|
||||
if (!access.ok) return new NextResponse(null, { status: access.status });
|
||||
|
||||
const requestedPath = path.join(HLS_DIR, ...pathSegments);
|
||||
|
||||
// Security: prevent directory traversal attacks
|
||||
|
||||
@@ -400,7 +400,7 @@ export default function DashboardClient() {
|
||||
handleOpenVideo(pl.id, v.id);
|
||||
return;
|
||||
}
|
||||
if ((v as any).index === 0) {
|
||||
if (v.id === pl.videos?.[0]?.id) {
|
||||
handleOpenVideo(pl.id, v.id);
|
||||
return;
|
||||
}
|
||||
@@ -417,7 +417,7 @@ export default function DashboardClient() {
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{(v as any).locked || (!(v as any).instantAccess && (v as any).index !== 0 && !userUnlocks.has(v.id)) ? (
|
||||
{(v as any).locked || (!(v as any).instantAccess && v.id !== pl.videos?.[0]?.id && !userUnlocks.has(v.id)) ? (
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
|
||||
<Lock className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,8 @@ import { NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { Readable } from "stream";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { checkVideoAccess, getViewer } from "@/lib/video-access";
|
||||
|
||||
const VIDEOS_DIR = path.join(process.env.UPLOADS_DIR || "/uploads", "videos");
|
||||
|
||||
@@ -63,12 +65,52 @@ function parseRange(
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
// Maps a requested file to the Video row it belongs to, then applies the same
|
||||
// access rule as the video API. Files with no Video row are admin-only.
|
||||
const VIDEO_ID_TTL_MS = 5 * 60_000;
|
||||
const videoIdByPath = new Map<string, { expires: number; videoId: string | null }>();
|
||||
|
||||
async function findVideoId(segments: string[]): Promise<string | null> {
|
||||
const relative = segments.join("/");
|
||||
const hit = videoIdByPath.get(relative);
|
||||
if (hit && hit.expires > Date.now()) return hit.videoId;
|
||||
|
||||
const byUrl = await prisma.video.findFirst({
|
||||
where: { url: `/uploads/videos/${relative}` },
|
||||
select: { id: true },
|
||||
});
|
||||
// Uploads are normally stored as {videoId}.mp4
|
||||
const byName =
|
||||
byUrl ??
|
||||
(await prisma.video.findUnique({
|
||||
where: { id: path.basename(relative, path.extname(relative)) },
|
||||
select: { id: true },
|
||||
}));
|
||||
|
||||
const videoId = byName?.id ?? null;
|
||||
if (videoIdByPath.size >= 5000) videoIdByPath.clear();
|
||||
videoIdByPath.set(relative, { expires: Date.now() + VIDEO_ID_TTL_MS, videoId });
|
||||
return videoId;
|
||||
}
|
||||
|
||||
async function authorize(segments: string[]): Promise<{ status: number; error: string } | null> {
|
||||
const viewer = await getViewer();
|
||||
if (!viewer) return { status: 401, error: "Unauthorized" };
|
||||
if (viewer.isAdmin) return null;
|
||||
|
||||
const videoId = await findVideoId(segments);
|
||||
if (!videoId) return { status: 404, error: "Not found" };
|
||||
|
||||
const access = await checkVideoAccess(viewer, videoId);
|
||||
return access.ok ? null : { status: access.status, error: access.error };
|
||||
}
|
||||
|
||||
function baseHeaders(filePath: string, stats: fs.Stats): Record<string, string> {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
return {
|
||||
"Content-Type": CONTENT_TYPES[ext] ?? "application/octet-stream",
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
"Cache-Control": "private, max-age=3600",
|
||||
"Last-Modified": stats.mtime.toUTCString(),
|
||||
};
|
||||
}
|
||||
@@ -84,6 +126,11 @@ export async function GET(
|
||||
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
||||
}
|
||||
|
||||
const denied = await authorize(params?.path || []);
|
||||
if (denied) {
|
||||
return NextResponse.json({ error: denied.error }, { status: denied.status });
|
||||
}
|
||||
|
||||
const stats = await statFile(filePath);
|
||||
if (!stats) {
|
||||
console.log(`[Videos] File not found: ${filePath}`);
|
||||
@@ -132,6 +179,9 @@ export async function HEAD(
|
||||
const filePath = resolveVideoPath(params?.path || []);
|
||||
if (!filePath) return new NextResponse(null, { status: 400 });
|
||||
|
||||
const denied = await authorize(params?.path || []);
|
||||
if (denied) return new NextResponse(null, { status: denied.status });
|
||||
|
||||
const stats = await statFile(filePath);
|
||||
if (!stats) return new NextResponse(null, { status: 404 });
|
||||
|
||||
|
||||
+252
-350
@@ -27,6 +27,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { HlsPlayer } from '@/components/hls';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import Image from 'next/image';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type VideoFromApi = {
|
||||
id: string;
|
||||
@@ -50,261 +51,256 @@ type VideoFromApi = {
|
||||
} | null;
|
||||
};
|
||||
|
||||
type ProgressResult = {
|
||||
progress?: { percent?: number };
|
||||
segments?: WatchSegment[];
|
||||
unlockedNext?: { id: string; title: string } | null;
|
||||
};
|
||||
|
||||
type TrackedVideo = { videoId: string; durationSec: number | null };
|
||||
|
||||
/**
|
||||
* Records which parts of a video were actually played and reports them to
|
||||
* /api/progress.
|
||||
*
|
||||
* It samples the <video> element on a timer rather than relying on media
|
||||
* events, so it keeps working when the element is swapped (one per video) or
|
||||
* when playback starts before anything else on the page has settled. A sample
|
||||
* only counts as watched when the playhead moved forward by no more than the
|
||||
* wall-clock time that passed, which excludes seeks and scrubbing.
|
||||
*/
|
||||
export function useWatchTracker(
|
||||
videoRef: React.RefObject<HTMLVideoElement | null>,
|
||||
opts: { sendIntervalMs?: number; commitIntervalSec?: number; debug?: boolean } = {}
|
||||
opts: {
|
||||
sendIntervalMs?: number;
|
||||
sampleIntervalMs?: number;
|
||||
onResult?: (videoId: string, result: ProgressResult) => void;
|
||||
} = {}
|
||||
) {
|
||||
const sendIntervalMs = opts.sendIntervalMs ?? 5000;
|
||||
const commitIntervalSec = opts.commitIntervalSec ?? 3;
|
||||
const debug = !!opts.debug;
|
||||
const sampleIntervalMs = opts.sampleIntervalMs ?? 500;
|
||||
|
||||
const rangesRef = React.useRef<Array<[number, number]>>([]);
|
||||
const lastTimeRef = React.useRef<number | null>(null);
|
||||
const currentRangeStartRef = React.useRef<number | null>(null);
|
||||
const sendTimerRef = React.useRef<number | null>(null);
|
||||
const commitTimerRef = React.useRef<number | null>(null);
|
||||
|
||||
// merging helper
|
||||
function mergeRanges(ranges: Array<[number, number]>) {
|
||||
if (!ranges.length) return [];
|
||||
ranges.sort((a, b) => a[0] - b[0]);
|
||||
const merged: Array<[number, number]> = [];
|
||||
for (const [s, e] of ranges) {
|
||||
if (!merged.length) merged.push([s, e]);
|
||||
else {
|
||||
const last = merged[merged.length - 1];
|
||||
if (s <= last[1] + 0.5) last[1] = Math.max(last[1], e);
|
||||
else merged.push([s, e]);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function addRange(s: number, e: number) {
|
||||
if (e <= s) return;
|
||||
if (debug) console.debug("[tracker] addRange", s, e);
|
||||
rangesRef.current.push([s, e]);
|
||||
rangesRef.current = mergeRanges(rangesRef.current);
|
||||
}
|
||||
|
||||
function getUniqueWatchedSec() {
|
||||
let sum = 0;
|
||||
for (const [a, b] of rangesRef.current) sum += Math.max(0, b - a);
|
||||
return Math.round(sum);
|
||||
}
|
||||
|
||||
// commit the "current" playing segment to ranges (useful while playing)
|
||||
function commitCurrentRange() {
|
||||
const el = videoRef.current;
|
||||
if (!el) return;
|
||||
const start = currentRangeStartRef.current;
|
||||
const now = el.currentTime;
|
||||
if (start === null) return;
|
||||
// only commit if we've moved forward at least 0.5s to avoid noise
|
||||
if (now - start >= 0.5) {
|
||||
addRange(start, now);
|
||||
// keep currentRangeStart open at 'now' so we continue accumulating
|
||||
currentRangeStartRef.current = now;
|
||||
}
|
||||
}
|
||||
|
||||
// send progress to server (same signature you had)
|
||||
async function sendProgress(args: { videoId: string; playlistId?: string; duration: number; extra?: Record<string, any> }) {
|
||||
const { videoId, playlistId, duration, extra } = args;
|
||||
const watchedSec = getUniqueWatchedSec();
|
||||
const el = videoRef.current;
|
||||
const lastPos = Math.floor(el?.currentTime ?? 0);
|
||||
|
||||
if (!duration || duration <= 0) return null;
|
||||
if (watchedSec === 0 && (el?.paused ?? true)) return null;
|
||||
|
||||
const payload = { videoId, playlistId, watchedSec, lastPos, duration, ...extra };
|
||||
try {
|
||||
if (debug) console.debug("[tracker] sendProgress", payload);
|
||||
const res = await fetch("/api/progress", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
// If video not found, stop trying to track progress
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) {
|
||||
console.warn(`[tracker] Video ${videoId} not found, stopping progress tracking`);
|
||||
stopAutoSend(); // Stop automatic progress tracking for deleted video
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return res.ok ? await res.json().catch(() => null) : null;
|
||||
} catch (err) {
|
||||
if (debug) console.warn("[tracker] sendProgress error", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// start/stop auto-send
|
||||
function startAutoSend(args: { videoId: string; playlistId?: string; duration: number }) {
|
||||
stopAutoSend(); // clear existing
|
||||
|
||||
// immediate send (best-effort)
|
||||
sendProgress(args).catch(() => {});
|
||||
|
||||
// periodic send
|
||||
sendTimerRef.current = window.setInterval(() => {
|
||||
const watched = getUniqueWatchedSec();
|
||||
const el = videoRef.current;
|
||||
const isPlaying = !!(el && !el.paused && !el.ended);
|
||||
if (watched > 0 || isPlaying) {
|
||||
sendProgress(args).catch(() => {});
|
||||
}
|
||||
}, sendIntervalMs) as unknown as number;
|
||||
|
||||
// commit currently playing ranges periodically (so continuous play is captured)
|
||||
commitTimerRef.current = window.setInterval(() => {
|
||||
commitCurrentRange();
|
||||
}, commitIntervalSec * 1000) as unknown as number;
|
||||
|
||||
if (debug) console.debug("[tracker] startAutoSend", { sendIntervalMs, commitIntervalSec });
|
||||
}
|
||||
|
||||
function stopAutoSend(opts?: { sendFinal?: boolean; videoId?: string; playlistId?: string; duration?: number }) {
|
||||
if (sendTimerRef.current) {
|
||||
window.clearInterval(sendTimerRef.current);
|
||||
sendTimerRef.current = null;
|
||||
}
|
||||
if (commitTimerRef.current) {
|
||||
window.clearInterval(commitTimerRef.current);
|
||||
commitTimerRef.current = null;
|
||||
}
|
||||
if (opts?.sendFinal && opts.videoId && opts.duration) {
|
||||
sendProgress({ videoId: opts.videoId, playlistId: opts.playlistId, duration: opts.duration }).catch(() => {});
|
||||
}
|
||||
if (debug) console.debug("[tracker] stopAutoSend");
|
||||
}
|
||||
|
||||
// attach listeners to populate rangesRef
|
||||
const onResultRef = React.useRef(opts.onResult);
|
||||
React.useEffect(() => {
|
||||
onResultRef.current = opts.onResult;
|
||||
});
|
||||
|
||||
const activeRef = React.useRef<TrackedVideo | null>(null);
|
||||
const rangesRef = React.useRef<Array<[number, number]>>([]);
|
||||
const lastSampleRef = React.useRef<{ el: HTMLVideoElement; time: number; wall: number } | null>(null);
|
||||
const lastPosRef = React.useRef(0);
|
||||
const elementDurationRef = React.useRef(0);
|
||||
const dirtyRef = React.useRef(false);
|
||||
|
||||
const reset = React.useCallback((next: TrackedVideo | null) => {
|
||||
activeRef.current = next;
|
||||
rangesRef.current = [];
|
||||
lastSampleRef.current = null;
|
||||
lastPosRef.current = 0;
|
||||
elementDurationRef.current = 0;
|
||||
dirtyRef.current = false;
|
||||
}, []);
|
||||
|
||||
const sample = React.useCallback(() => {
|
||||
const el = videoRef.current;
|
||||
if (!el) {
|
||||
if (debug) console.debug("[tracker] no video element to attach");
|
||||
if (!activeRef.current || !el || typeof el.currentTime !== 'number') {
|
||||
lastSampleRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
currentRangeStartRef.current = null;
|
||||
lastTimeRef.current = el.currentTime ?? 0;
|
||||
const prev = lastSampleRef.current;
|
||||
const now = el.currentTime;
|
||||
const wall = performance.now();
|
||||
lastSampleRef.current = { el, time: now, wall };
|
||||
|
||||
const onPlay = () => {
|
||||
currentRangeStartRef.current = el.currentTime;
|
||||
lastTimeRef.current = el.currentTime;
|
||||
if (debug) console.debug("[tracker] play", currentRangeStartRef.current);
|
||||
};
|
||||
// First sample of a new element: nothing to compare against yet.
|
||||
if (!prev || prev.el !== el) return;
|
||||
|
||||
const onPause = () => {
|
||||
if (currentRangeStartRef.current !== null) {
|
||||
addRange(currentRangeStartRef.current, el.currentTime);
|
||||
currentRangeStartRef.current = null;
|
||||
}
|
||||
if (debug) console.debug("[tracker] pause, ranges:", rangesRef.current);
|
||||
};
|
||||
lastPosRef.current = now;
|
||||
if (Number.isFinite(el.duration) && el.duration > 0) {
|
||||
elementDurationRef.current = el.duration;
|
||||
}
|
||||
if (el.seeking) return;
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
const now = el.currentTime;
|
||||
const last = lastTimeRef.current ?? now;
|
||||
// detect seek (big jump)
|
||||
if (Math.abs(now - last) > 2.5) {
|
||||
if (currentRangeStartRef.current !== null) {
|
||||
addRange(currentRangeStartRef.current, last);
|
||||
const delta = now - prev.time;
|
||||
if (delta <= 0) return; // paused, or jumped backwards
|
||||
|
||||
// Real playback cannot outrun the clock (timers may fire late in
|
||||
// background tabs, hence wall-clock time rather than the interval).
|
||||
const elapsedSec = (wall - prev.wall) / 1000;
|
||||
const maxAdvance = elapsedSec * Math.max(1, el.playbackRate || 1) + 0.75;
|
||||
if (delta > maxAdvance) return; // jumped forwards
|
||||
|
||||
const ranges = rangesRef.current;
|
||||
const last = ranges[ranges.length - 1];
|
||||
if (last && Math.abs(last[1] - prev.time) < 0.01) {
|
||||
last[1] = now; // still in the same stretch of playback
|
||||
} else {
|
||||
ranges.push([prev.time, now]);
|
||||
}
|
||||
dirtyRef.current = true;
|
||||
}, [videoRef]);
|
||||
|
||||
const flush = React.useCallback(async (keepalive = false) => {
|
||||
const active = activeRef.current;
|
||||
if (!active || !dirtyRef.current || rangesRef.current.length === 0) return;
|
||||
|
||||
const duration = active.durationSec || Math.floor(elementDurationRef.current);
|
||||
if (!duration || duration <= 0) return; // metadata not loaded yet; try again next tick
|
||||
|
||||
// Everything played this session is resent each time; the server merges,
|
||||
// so a dropped request loses nothing.
|
||||
const ranges = rangesRef.current.map(([s, e]) => [s, e]);
|
||||
dirtyRef.current = false;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/progress', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
keepalive,
|
||||
body: JSON.stringify({
|
||||
videoId: active.videoId,
|
||||
ranges,
|
||||
lastPos: Math.floor(lastPosRef.current),
|
||||
duration,
|
||||
}),
|
||||
});
|
||||
|
||||
const stillActive = activeRef.current?.videoId === active.videoId;
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) {
|
||||
// Video was deleted: stop tracking it.
|
||||
if (stillActive) reset(null);
|
||||
} else if (stillActive) {
|
||||
dirtyRef.current = true;
|
||||
}
|
||||
currentRangeStartRef.current = now;
|
||||
if (debug) console.debug("[tracker] seek detected, start:", now);
|
||||
return;
|
||||
}
|
||||
lastTimeRef.current = now;
|
||||
// we do not addRange here to avoid flooding; commit timer handles mid-play commits
|
||||
};
|
||||
|
||||
const onEnded = () => {
|
||||
if (currentRangeStartRef.current !== null) {
|
||||
addRange(currentRangeStartRef.current, el.duration ?? lastTimeRef.current ?? 0);
|
||||
currentRangeStartRef.current = null;
|
||||
}
|
||||
if (debug) console.debug("[tracker] ended, ranges:", rangesRef.current);
|
||||
};
|
||||
const json = await res.json().catch(() => null);
|
||||
if (json) onResultRef.current?.(active.videoId, json);
|
||||
} catch {
|
||||
if (activeRef.current?.videoId === active.videoId) dirtyRef.current = true;
|
||||
}
|
||||
}, [reset]);
|
||||
|
||||
el.addEventListener("play", onPlay);
|
||||
el.addEventListener("pause", onPause);
|
||||
el.addEventListener("timeupdate", onTimeUpdate);
|
||||
el.addEventListener("ended", onEnded);
|
||||
/** Begin tracking a video. Anything recorded for a previous video is dropped. */
|
||||
const start = React.useCallback((video: TrackedVideo) => reset(video), [reset]);
|
||||
|
||||
/** Send what is left for the current video, then stop tracking it. */
|
||||
const stop = React.useCallback(() => {
|
||||
void flush(true);
|
||||
reset(null);
|
||||
}, [flush, reset]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const sampleTimer = window.setInterval(sample, sampleIntervalMs);
|
||||
const sendTimer = window.setInterval(() => void flush(), sendIntervalMs);
|
||||
|
||||
// Leaving or hiding the page: get the latest progress out while we can.
|
||||
const flushNow = () => {
|
||||
sample();
|
||||
void flush(true);
|
||||
};
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'hidden') flushNow();
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
window.addEventListener('pagehide', flushNow);
|
||||
|
||||
return () => {
|
||||
el.removeEventListener("play", onPlay);
|
||||
el.removeEventListener("pause", onPause);
|
||||
el.removeEventListener("timeupdate", onTimeUpdate);
|
||||
el.removeEventListener("ended", onEnded);
|
||||
stopAutoSend();
|
||||
window.clearInterval(sampleTimer);
|
||||
window.clearInterval(sendTimer);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
window.removeEventListener('pagehide', flushNow);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [videoRef.current]);
|
||||
}, [sample, flush, sampleIntervalMs, sendIntervalMs]);
|
||||
|
||||
return {
|
||||
getUniqueWatchedSec,
|
||||
addRange,
|
||||
sendProgress,
|
||||
startAutoSend,
|
||||
stopAutoSend,
|
||||
};
|
||||
return { start, stop };
|
||||
}
|
||||
|
||||
|
||||
export default function Page() {
|
||||
const search = useSearchParams();
|
||||
const router = useRouter();
|
||||
const playlistId = search.get('playlistId') ?? undefined;
|
||||
const playlistIdParam = search.get('playlistId') ?? undefined;
|
||||
const videoId = search.get('videoId') ?? undefined;
|
||||
|
||||
const { playlist, isLoading: playlistLoading } = usePlaylist(playlistId);
|
||||
const { video, next, isLoading: videoLoading } = useVideo(videoId);
|
||||
const { video, isLoading: videoLoading, error: videoError } = useVideo(videoId);
|
||||
// Links from watch history and liked videos carry no playlist; use the video's own.
|
||||
const { playlist, isLoading: playlistLoading } = usePlaylist(
|
||||
playlistIdParam ?? (video as any)?.playlistId ?? undefined
|
||||
);
|
||||
|
||||
// Segments state for current video and playlist videos
|
||||
const [currentSegments, setCurrentSegments] = React.useState<WatchSegment[]>([]);
|
||||
const [playlistSegments, setPlaylistSegments] = React.useState<Record<string, WatchSegment[]>>({});
|
||||
const [currentProgress, setCurrentProgress] = React.useState(0);
|
||||
|
||||
|
||||
// Per-user unlock state
|
||||
const [userUnlocks, setUserUnlocks] = React.useState<Set<string>>(new Set()); // Set of unlocked videoIds
|
||||
const [videoInstantAccess, setVideoInstantAccess] = React.useState<Record<string, boolean>>({}); // videoId -> instantAccess
|
||||
const [unlockingVideo, setUnlockingVideo] = React.useState<string | null>(null); // videoId being unlocked
|
||||
|
||||
// inside your videoplayer component
|
||||
const videoRef = React.useRef<HTMLVideoElement | null>(null);
|
||||
const tracker = useWatchTracker(videoRef, { sendIntervalMs: 5000 });
|
||||
const videoRef = React.useRef<HTMLVideoElement | null>(null);
|
||||
const trackedVideoIdRef = React.useRef<string | null>(null);
|
||||
const userUnlocksRef = React.useRef(userUnlocks);
|
||||
React.useEffect(() => {
|
||||
userUnlocksRef.current = userUnlocks;
|
||||
}, [userUnlocks]);
|
||||
|
||||
// Fetch segments for current video
|
||||
// Every progress update returns the server's view: percentage, merged
|
||||
// segments, and the next video if this one just unlocked it.
|
||||
const handleProgressResult = React.useCallback((id: string, result: ProgressResult) => {
|
||||
const segments = result.segments;
|
||||
if (segments) {
|
||||
setPlaylistSegments((prev) => ({ ...prev, [id]: segments }));
|
||||
}
|
||||
if (id === trackedVideoIdRef.current) {
|
||||
if (segments) setCurrentSegments(segments);
|
||||
if (typeof result.progress?.percent === 'number') setCurrentProgress(result.progress.percent);
|
||||
}
|
||||
const unlocked = result.unlockedNext;
|
||||
if (unlocked && !userUnlocksRef.current.has(unlocked.id)) {
|
||||
setUserUnlocks((prev) => new Set(prev).add(unlocked.id));
|
||||
toast.success(`Unlocked: ${unlocked.title}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const tracker = useWatchTracker(videoRef, { onResult: handleProgressResult });
|
||||
const { start: startTracking, stop: stopTracking } = tracker;
|
||||
|
||||
// Load saved progress for the current video. Live updates then arrive with
|
||||
// each progress save, so there is no need to poll.
|
||||
React.useEffect(() => {
|
||||
if (!videoId) return;
|
||||
let cancelled = false;
|
||||
|
||||
const fetchSegments = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/progress/segments?videoId=${videoId}`);
|
||||
const [res, progressRes] = await Promise.all([
|
||||
fetch(`/api/progress/segments?videoId=${videoId}`),
|
||||
fetch(`/api/progress?videoId=${videoId}`),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setCurrentSegments(data.segments ?? []);
|
||||
if (!cancelled) setCurrentSegments(data.segments ?? []);
|
||||
}
|
||||
const progressRes = await fetch(`/api/progress?videoId=${videoId}`);
|
||||
if (progressRes.ok) {
|
||||
const data = await progressRes.json();
|
||||
setCurrentProgress(data.percent ?? 0);
|
||||
if (!cancelled) setCurrentProgress(data.percent ?? 0);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch segments:', err);
|
||||
}
|
||||
};
|
||||
|
||||
setCurrentSegments([]);
|
||||
setCurrentProgress(0);
|
||||
fetchSegments();
|
||||
// Refresh segments every 5 seconds
|
||||
const interval = setInterval(fetchSegments, 5000);
|
||||
return () => clearInterval(interval);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [videoId]);
|
||||
|
||||
// Fetch segments for all playlist videos
|
||||
@@ -313,10 +309,8 @@ React.useEffect(() => {
|
||||
|
||||
const fetchPlaylistSegments = async () => {
|
||||
const segments: Record<string, WatchSegment[]> = {};
|
||||
const instantAccess: Record<string, boolean> = {};
|
||||
|
||||
|
||||
for (const video of playlist.videos) {
|
||||
// Fetch segments
|
||||
try {
|
||||
const res = await fetch(`/api/progress/segments?videoId=${video.id}`);
|
||||
if (res.ok) {
|
||||
@@ -326,15 +320,13 @@ React.useEffect(() => {
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch segments for video ${video.id}:`, err);
|
||||
}
|
||||
|
||||
// Track instantAccess status from video object
|
||||
instantAccess[video.id] = (video as any).instantAccess ?? false;
|
||||
}
|
||||
|
||||
setPlaylistSegments(segments);
|
||||
setVideoInstantAccess(instantAccess);
|
||||
|
||||
// Keep anything newer that arrived from a progress save in the meantime.
|
||||
setPlaylistSegments((prev) => ({ ...segments, ...prev }));
|
||||
};
|
||||
|
||||
setPlaylistSegments({});
|
||||
fetchPlaylistSegments();
|
||||
}, [playlist?.videos]);
|
||||
|
||||
@@ -345,8 +337,9 @@ React.useEffect(() => {
|
||||
const res = await fetch('/api/user/unlocks');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const unlockedVideoIds = new Set((data.unlocks?.map((u: any) => u.videoId) ?? []) as string[]);
|
||||
setUserUnlocks(unlockedVideoIds);
|
||||
const unlockedVideoIds = (data.unlocks?.map((u: any) => u.videoId) ?? []) as string[];
|
||||
// Merge, in case a progress save unlocked something while this loaded.
|
||||
setUserUnlocks((prev) => new Set([...prev, ...unlockedVideoIds]));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch user unlocks:', err);
|
||||
@@ -356,55 +349,20 @@ React.useEffect(() => {
|
||||
fetchUserUnlocks();
|
||||
}, []);
|
||||
|
||||
// Track whichever video is loaded; switching videos flushes the old one first.
|
||||
const loadedVideoId = video?.id ? String(video.id) : null;
|
||||
const loadedVideoDuration: number | null = video?.durationSec ?? null;
|
||||
React.useEffect(() => {
|
||||
if (!video) return;
|
||||
if (!loadedVideoId) return;
|
||||
|
||||
let didCancel = false;
|
||||
let poll: number | null = null;
|
||||
|
||||
const startWhenReady = () => {
|
||||
if (didCancel) return;
|
||||
const el = videoRef.current;
|
||||
const duration = (video.durationSec ?? Math.floor(el?.duration ?? 0)) || 0;
|
||||
|
||||
if (el) {
|
||||
tracker.startAutoSend({
|
||||
videoId: String(video.id),
|
||||
playlistId: playlist?.id,
|
||||
duration,
|
||||
});
|
||||
} else {
|
||||
// poll until the video element mounts (should be quick)
|
||||
poll = window.setInterval(() => {
|
||||
if (videoRef.current) {
|
||||
if (poll) {
|
||||
window.clearInterval(poll);
|
||||
poll = null;
|
||||
}
|
||||
tracker.startAutoSend({
|
||||
videoId: String(video.id),
|
||||
playlistId: playlist?.id,
|
||||
duration: (video.durationSec ?? Math.floor(videoRef.current?.duration ?? 0)) || 0,
|
||||
});
|
||||
}
|
||||
}, 150) as unknown as number;
|
||||
}
|
||||
};
|
||||
|
||||
startWhenReady();
|
||||
trackedVideoIdRef.current = loadedVideoId;
|
||||
startTracking({ videoId: loadedVideoId, durationSec: loadedVideoDuration });
|
||||
|
||||
return () => {
|
||||
didCancel = true;
|
||||
if (poll) {
|
||||
window.clearInterval(poll);
|
||||
poll = null;
|
||||
}
|
||||
const duration = video?.durationSec ?? Math.floor(videoRef.current?.duration ?? 0);
|
||||
// send final snapshot
|
||||
tracker.stopAutoSend({ sendFinal: true, videoId: String(video?.id ?? ''), playlistId: playlist?.id, duration });
|
||||
stopTracking();
|
||||
trackedVideoIdRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [video?.id, playlist?.id]);
|
||||
}, [loadedVideoId, loadedVideoDuration, startTracking, stopTracking]);
|
||||
|
||||
|
||||
const current: VideoFromApi = (video as VideoFromApi) ?? {
|
||||
@@ -425,84 +383,40 @@ React.useEffect(() => {
|
||||
return `${mins}:${secs}`;
|
||||
};
|
||||
|
||||
// A video is open when it is the first in the playlist, marked instant
|
||||
// access, or unlocked for this user. The lecturer's lock overrides all three.
|
||||
const firstVideoId: string | undefined = playlist?.videos?.[0]?.id;
|
||||
const isAccessible = (v: VideoFromApi) =>
|
||||
!v.locked &&
|
||||
(!!(v as any).instantAccess || v.id === firstVideoId || userUnlocks.has(v.id));
|
||||
|
||||
const openVideo = (v: VideoFromApi) => {
|
||||
router.push(`/videoplayer?playlistId=${playlist?.id}&videoId=${v.id}`);
|
||||
};
|
||||
|
||||
// The server decides whether the unlock is earned and explains when it is not.
|
||||
const handleUnlock = async (v: VideoFromApi) => {
|
||||
console.log('unlock request for', v.id);
|
||||
|
||||
// Prevent multiple unlock attempts
|
||||
if (unlockingVideo === v.id) return;
|
||||
|
||||
if (unlockingVideo === v.id || v.locked) return;
|
||||
|
||||
setUnlockingVideo(v.id);
|
||||
|
||||
try {
|
||||
// Can't unlock if globally locked
|
||||
if ((v as any).locked) {
|
||||
console.log('Video is globally locked, cannot unlock');
|
||||
return;
|
||||
}
|
||||
|
||||
// Can't unlock if already unlocked or has instant access
|
||||
if ((v as any).instantAccess || userUnlocks.has(v.id)) {
|
||||
console.log('Video already accessible');
|
||||
router.push(`/videoplayer?playlistId=${playlist?.id}&videoId=${v.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if previous video in sequence is completed
|
||||
if (!playlist?.videos) return;
|
||||
|
||||
const videoIndex = (v as any).index;
|
||||
if (videoIndex <= 0) {
|
||||
// First video should always be accessible
|
||||
router.push(`/videoplayer?playlistId=${playlist?.id}&videoId=${v.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find previous video
|
||||
const previousVideo = playlist.videos.find((pv: any) => pv.index === videoIndex - 1);
|
||||
if (!previousVideo) {
|
||||
console.log('Previous video not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if previous video is completed
|
||||
const progressRes = await fetch(`/api/progress?videoId=${previousVideo.id}`);
|
||||
if (!progressRes.ok) {
|
||||
console.error('Failed to fetch previous video progress');
|
||||
alert('Error checking unlock requirements. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
const progressData = await progressRes.json();
|
||||
const isCompleted = progressData.percent >= 90; // Consider 90% as completed
|
||||
|
||||
if (!isCompleted) {
|
||||
console.log('Previous video not completed yet - need', Math.ceil(90 - progressData.percent), '% more');
|
||||
// TODO: Show toast notification explaining unlock requirements
|
||||
alert(`You need to complete ${Math.ceil(90 - progressData.percent)}% more of the previous video to unlock this one.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Previous video is completed, unlock this video
|
||||
const unlockRes = await fetch('/api/user/unlocks', {
|
||||
const res = await fetch('/api/user/unlocks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ videoId: v.id }),
|
||||
});
|
||||
|
||||
if (unlockRes.ok) {
|
||||
// Update local state
|
||||
setUserUnlocks(prev => new Set(prev).add(v.id));
|
||||
console.log('Video unlocked successfully');
|
||||
// Redirect to the unlocked video
|
||||
router.push(`/videoplayer?playlistId=${playlist?.id}&videoId=${v.id}`);
|
||||
} else {
|
||||
const errorData = await unlockRes.json().catch(() => ({ error: 'Unknown error' }));
|
||||
console.error('Failed to unlock video:', errorData.error);
|
||||
alert(`Failed to unlock video: ${errorData.error}`);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
toast.error(data?.error ?? 'Could not unlock this video. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
setUserUnlocks((prev) => new Set(prev).add(v.id));
|
||||
openVideo(v);
|
||||
} catch (err) {
|
||||
console.error('Error checking unlock conditions:', err);
|
||||
alert('Error checking if video can be unlocked. Please try again.');
|
||||
console.error('Error unlocking video:', err);
|
||||
toast.error('Could not unlock this video. Please try again.');
|
||||
} finally {
|
||||
setUnlockingVideo(null);
|
||||
}
|
||||
@@ -585,7 +499,11 @@ React.useEffect(() => {
|
||||
ref={videoRef}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-80">Video not available</div>
|
||||
<div className="flex items-center justify-center h-80 px-6 text-center">
|
||||
{videoError?.message && !/^HTTP \d+$/.test(videoError.message)
|
||||
? videoError.message
|
||||
: 'Video not available'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -698,7 +616,7 @@ React.useEffect(() => {
|
||||
) : (
|
||||
playlist?.videos?.map((v: VideoFromApi) => {
|
||||
const active = String(v.id) === String(current.id);
|
||||
const watchedPercent = 0; // Replace with actual watched % from your state/API
|
||||
const accessible = isAccessible(v);
|
||||
return (
|
||||
<div
|
||||
key={v.id}
|
||||
@@ -707,29 +625,13 @@ React.useEffect(() => {
|
||||
active ? 'border-primary bg-muted' : 'border-transparent hover:border-border'
|
||||
}`}
|
||||
onClick={() => {
|
||||
// Check if video is accessible based on the logic:
|
||||
// 1. If locked at schema level, always deny
|
||||
if ((v as any).locked) return;
|
||||
// 2. If instantAccess, always allow
|
||||
if ((v as any).instantAccess) {
|
||||
router.push(`/videoplayer?playlistId=${playlist.id}&videoId=${v.id}`);
|
||||
return;
|
||||
}
|
||||
// 3. If first video (index 0), always allow
|
||||
if ((v as any).index === 0) {
|
||||
router.push(`/videoplayer?playlistId=${playlist.id}&videoId=${v.id}`);
|
||||
return;
|
||||
}
|
||||
// 4. Otherwise, check if user has unlocked it
|
||||
if (userUnlocks.has(v.id)) {
|
||||
router.push(`/videoplayer?playlistId=${playlist.id}&videoId=${v.id}`);
|
||||
}
|
||||
if (accessible) openVideo(v);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative w-20 h-12 shrink-0 overflow-hidden rounded">
|
||||
<img src={v.thumbnail ?? '/01.jpg'} alt={v.title} className="w-full h-full object-cover" loading="lazy" />
|
||||
{(v as any).locked || (!(v as any).instantAccess && (v as any).index !== 0 && !userUnlocks.has(v.id)) ? (
|
||||
{!accessible ? (
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
|
||||
<Lock className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
@@ -742,28 +644,28 @@ React.useEffect(() => {
|
||||
<Badge>{fmt(v)}</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate mt-1">
|
||||
{(v as any).locked ? 'Locked' : (v as any).instantAccess || (v as any).index === 0 ? 'Available' : userUnlocks.has(v.id) ? 'Available' : active ? 'Now playing' : 'Locked'}
|
||||
{active ? 'Now playing' : accessible ? 'Available' : 'Locked'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{(v as any).locked || (!(v as any).instantAccess && (v as any).index !== 0 && !userUnlocks.has(v.id)) ? (
|
||||
|
||||
{!accessible ? (
|
||||
<div className="ml-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={(v as any).locked ? "secondary" : "default"}
|
||||
disabled={(v as any).locked || unlockingVideo === v.id}
|
||||
variant={v.locked ? "secondary" : "default"}
|
||||
disabled={v.locked || unlockingVideo === v.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUnlock(v);
|
||||
}}
|
||||
>
|
||||
{unlockingVideo === v.id ? 'Unlocking...' : (v as any).locked ? 'Locked' : 'Unlock'}
|
||||
{unlockingVideo === v.id ? 'Unlocking...' : v.locked ? 'Locked' : 'Unlock'}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
|
||||
<SegmentedProgressBar
|
||||
segments={playlistSegments[v.id] ?? []}
|
||||
duration={v.durationSec ?? 1}
|
||||
|
||||
+11
-2
@@ -24,7 +24,11 @@ export function useVideo(id?: string) {
|
||||
|
||||
fetch(`/api/videos/${id}`)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
if (!res.ok) {
|
||||
// The server explains refusals (e.g. which video to finish first).
|
||||
const body = await res.json().catch(() => null);
|
||||
throw new Error(body?.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
const json = await res.json();
|
||||
if (!cancelled) {
|
||||
setVideo(json.video ?? null);
|
||||
@@ -32,7 +36,12 @@ export function useVideo(id?: string) {
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err);
|
||||
if (!cancelled) {
|
||||
// Never leave the previous video on screen under a new address.
|
||||
setVideo(null);
|
||||
setNext(null);
|
||||
setError(err);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// lib/account-status.ts
|
||||
// Whether an account may currently use the platform, checked against the
|
||||
// database rather than whatever was true when the user signed in.
|
||||
import { prisma } from './prisma';
|
||||
import { normalizeEmail } from './normalize-email';
|
||||
|
||||
export type AccountStatus = {
|
||||
/** False once a student is removed from, or deactivated on, the allow-list. */
|
||||
allowed: boolean;
|
||||
role: string;
|
||||
/** Comma-separated course codes from the allow-list. */
|
||||
levels: string;
|
||||
};
|
||||
|
||||
// Sessions are checked on every request, so results are held briefly. A change
|
||||
// to a student's access therefore takes effect within this window.
|
||||
const TTL_MS = 60_000;
|
||||
const MAX_ENTRIES = 5000;
|
||||
const cache = new Map<string, { expires: number; status: AccountStatus }>();
|
||||
|
||||
export async function getAccountStatus(
|
||||
userId: string,
|
||||
opts: { fresh?: boolean } = {}
|
||||
): Promise<AccountStatus> {
|
||||
const hit = cache.get(userId);
|
||||
if (!opts.fresh && hit && hit.expires > Date.now()) return hit.status;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { email: true, role: true },
|
||||
});
|
||||
|
||||
let status: AccountStatus = { allowed: false, role: 'user', levels: '' };
|
||||
if (user) {
|
||||
const role = user.role ?? 'user';
|
||||
const email = normalizeEmail(user.email);
|
||||
const student = email
|
||||
? await prisma.allowedStudent.findFirst({
|
||||
where: { email: { equals: email, mode: 'insensitive' } },
|
||||
select: { levels: true, active: true },
|
||||
})
|
||||
: null;
|
||||
|
||||
const isAdmin = role === 'admin' || role === 'superadmin';
|
||||
status = {
|
||||
allowed: isAdmin || !!student?.active,
|
||||
role,
|
||||
levels: student?.levels ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
if (cache.size >= MAX_ENTRIES) cache.clear();
|
||||
cache.set(userId, { expires: Date.now() + TTL_MS, status });
|
||||
return status;
|
||||
}
|
||||
+25
-37
@@ -4,6 +4,7 @@ import GoogleProvider from "next-auth/providers/google";
|
||||
import { PrismaAdapter } from "@next-auth/prisma-adapter";
|
||||
import { prisma } from "./prisma";
|
||||
import { normalizeEmail } from "./normalize-email";
|
||||
import { getAccountStatus } from "./account-status";
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
adapter: PrismaAdapter(prisma),
|
||||
@@ -125,48 +126,30 @@ export const authOptions: NextAuthOptions = {
|
||||
|
||||
/**
|
||||
* JWT CALLBACK
|
||||
* Token is the source of truth for session data.
|
||||
* Role must ALWAYS come from the database.
|
||||
* Runs on sign-in and again every time the session is read. Role, course
|
||||
* levels and whether the account is still allowed always come from the
|
||||
* database, so removing a student takes effect without waiting for their
|
||||
* 30-day session to expire.
|
||||
*/
|
||||
async jwt({ token, user }) {
|
||||
// On first login
|
||||
if (user?.id) {
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
if (user?.id) token.id = user.id;
|
||||
|
||||
if (dbUser) {
|
||||
token.id = dbUser.id;
|
||||
token.role = dbUser.role ?? "user";
|
||||
|
||||
// Attach student levels if applicable
|
||||
let levels = "";
|
||||
const normalizedDbEmail = normalizeEmail(dbUser.email);
|
||||
|
||||
if (normalizedDbEmail) {
|
||||
const allowedStudent = await prisma.allowedStudent.findFirst({
|
||||
where: {
|
||||
email: {
|
||||
equals: normalizedDbEmail,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
select: { levels: true },
|
||||
});
|
||||
|
||||
levels = allowedStudent?.levels ?? "";
|
||||
}
|
||||
|
||||
token.levels = levels;
|
||||
const userId = (token.id ?? token.sub) as string | undefined;
|
||||
if (userId) {
|
||||
try {
|
||||
// A fresh sign-in always re-reads the database.
|
||||
const status = await getAccountStatus(userId, { fresh: !!user });
|
||||
token.id = userId;
|
||||
token.role = status.role;
|
||||
token.levels = status.levels;
|
||||
token.disabled = !status.allowed;
|
||||
} catch (err) {
|
||||
// Database hiccup: keep what the token already says rather than
|
||||
// signing everyone out.
|
||||
console.error('[auth] account status check failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Safety fallback
|
||||
token.role = token.role ?? "user";
|
||||
|
||||
return token;
|
||||
@@ -174,9 +157,14 @@ export const authOptions: NextAuthOptions = {
|
||||
|
||||
/**
|
||||
* SESSION CALLBACK
|
||||
* Only exposes data already trusted in the token.
|
||||
* Only exposes data already trusted in the token. A disabled account gets
|
||||
* a session with no user, which every route treats as signed out.
|
||||
*/
|
||||
async session({ session, token }) {
|
||||
if (token.disabled) {
|
||||
return { expires: session.expires };
|
||||
}
|
||||
|
||||
if (session.user) {
|
||||
(session.user as any).id = token.id ?? null;
|
||||
(session.user as any).role = token.role ?? "user";
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// lib/video-access.ts
|
||||
// Who is asking, and may they watch this video? Shared by the video API and
|
||||
// the routes that serve the media files themselves.
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from './auth-options';
|
||||
import { prisma } from './prisma';
|
||||
import { checkUnlockEligibility } from './watch-progress';
|
||||
|
||||
export type Viewer = { id: string; isAdmin: boolean };
|
||||
|
||||
/** The signed-in user, or null. Does not touch the database. */
|
||||
export async function getViewer(): Promise<Viewer | null> {
|
||||
const session = await getServerSession(authOptions);
|
||||
const user = session?.user as { id?: string; email?: string; role?: string } | undefined;
|
||||
if (!user?.email || !user.id) return null;
|
||||
return { id: user.id, isAdmin: user.role === 'admin' || user.role === 'superadmin' };
|
||||
}
|
||||
|
||||
export type AccessResult = { ok: true } | { ok: false; status: number; error: string };
|
||||
|
||||
// A playing video requests a media chunk every few seconds, so a positive
|
||||
// answer is remembered briefly. Refusals are never cached: an unlock applies
|
||||
// straight away, while a newly applied lock can take up to this long to bite.
|
||||
const TTL_MS = 60_000;
|
||||
const MAX_ENTRIES = 20_000;
|
||||
const allowed = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* Admins may watch anything. Students need to be enrolled, and the video must
|
||||
* be open to them: first in its playlist, instant access, already unlocked, or
|
||||
* earned by completing the video before it.
|
||||
*/
|
||||
export async function checkVideoAccess(viewer: Viewer, videoId: string): Promise<AccessResult> {
|
||||
if (viewer.isAdmin) return { ok: true };
|
||||
|
||||
const key = `${viewer.id}:${videoId}`;
|
||||
const expires = allowed.get(key);
|
||||
if (expires && expires > Date.now()) return { ok: true };
|
||||
|
||||
const check = await checkUnlockEligibility(viewer.id, videoId);
|
||||
const where = { userId_videoId: { userId: viewer.id, videoId } };
|
||||
|
||||
if (check.ok) {
|
||||
if (!check.free) {
|
||||
// Earned but never recorded (e.g. completed before unlocks were tracked).
|
||||
await prisma.videoUnlock.upsert({
|
||||
where,
|
||||
update: {},
|
||||
create: { userId: viewer.id, videoId },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const unlocked =
|
||||
check.reason === 'not_earned' &&
|
||||
(await prisma.videoUnlock.findUnique({ where, select: { id: true } }));
|
||||
if (!unlocked) return { ok: false, status: check.status, error: check.error };
|
||||
}
|
||||
|
||||
if (allowed.size >= MAX_ENTRIES) allowed.clear();
|
||||
allowed.set(key, Date.now() + TTL_MS);
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
// 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}%).`,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user