// 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'; 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 } ); } } // 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); if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const userEmail = session.user.email; const body = await req.json().catch(() => ({})); const { videoId, playlistId, watchedSec, lastPos, duration } = body ?? {}; if (!videoId) 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 } }); 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) { // 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 } }, }); if (!videoProgress) { videoProgress = await prisma.videoProgress.create({ data: { userId: user.id, videoId, watchedSec: 0, lastPos: 0, percent: 0, durationSec: 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, }, }); } // 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, }); 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 }); } 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 } ); } }