Initial commit
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
// 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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 for segments: ${videoId} (likely deleted video with cached browser reference)`);
|
||||
return NextResponse.json({ error: 'video not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Fetch all watch segments for this user+video
|
||||
const segments = await prisma.videoWatchSegment.findMany({
|
||||
where: { userId: user.id, videoId },
|
||||
select: { startSec: true, endSec: true, watchedAt: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
return NextResponse.json({ segments });
|
||||
} catch (err: any) {
|
||||
console.error('segments GET error', err);
|
||||
return NextResponse.json(
|
||||
{ error: err?.message ?? 'server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user