feat: add video watch progress tracking with resume functionality

Implements a complete watch progress system that allows users to:
- Save playback position automatically every 5 seconds while watching
- Resume from last position when returning to a video
- Save progress on page leave using sendBeacon for reliability

Also includes:
- Optimized slug generation in projects and workspaces APIs (single query vs loop)
- Added isActive filter to version queries across all video endpoints
- Added pagination support for video and comment queries
- Enhanced database pool management with connection limits and graceful shutdown
- New WatchProgress Prisma model with user-version relations
This commit is contained in:
Yusuf İpek
2026-02-14 15:25:32 +03:00
parent 2888f7de98
commit 88c74d646e
10 changed files with 484 additions and 38 deletions
+15
View File
@@ -52,6 +52,11 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
try {
const session = await auth();
const { projectId } = await params;
// Parse pagination params
const searchParams = request.nextUrl.searchParams;
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 100);
const offset = parseInt(searchParams.get('offset') || '0');
const project = await db.project.findUnique({
where: { id: projectId },
@@ -64,10 +69,20 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
},
videos: {
orderBy: { position: 'asc' },
skip: offset,
take: limit,
include: {
versions: {
where: { isActive: true },
orderBy: { versionNumber: 'desc' },
take: 1,
select: {
id: true,
thumbnailUrl: true,
duration: true,
versionNumber: true,
_count: { select: { comments: true } },
},
},
_count: { select: { versions: true } },
},
@@ -15,25 +15,37 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
const session = await auth();
const { projectId, videoId } = await params;
// Parse query params for pagination and options
const searchParams = request.nextUrl.searchParams;
const commentLimit = Math.min(parseInt(searchParams.get('commentLimit') || '50'), 100);
const commentOffset = parseInt(searchParams.get('commentOffset') || '0');
const includeReplies = searchParams.get('includeReplies') === 'true';
const video = await db.video.findFirst({
where: { id: videoId, projectId },
include: {
project: true,
versions: {
where: { isActive: true },
orderBy: { versionNumber: 'desc' },
take: 1,
include: {
comments: {
orderBy: { timestamp: 'asc' },
skip: commentOffset,
take: commentLimit,
include: {
author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } },
replies: {
orderBy: { createdAt: 'asc' },
include: {
author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } },
...(includeReplies ? {
replies: {
orderBy: { createdAt: 'asc' },
include: {
author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } },
},
},
},
} : {}),
},
where: { parentId: null }, // Only top-level comments
},
+7 -1
View File
@@ -38,8 +38,14 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
orderBy: { position: 'asc' },
include: {
versions: {
where: { isActive: true },
orderBy: { versionNumber: 'desc' },
include: {
take: 1,
select: {
id: true,
thumbnailUrl: true,
duration: true,
versionNumber: true,
_count: { select: { comments: true } },
},
},
+12 -7
View File
@@ -105,14 +105,19 @@ export async function POST(request: NextRequest) {
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
// Ensure uniqueness by appending random suffix if needed
// Find all existing slugs with the same prefix in a single query
const existingProjects = await db.project.findMany({
where: { slug: { startsWith: baseSlug } },
select: { slug: true },
});
// Generate unique slug from the results
const usedSlugs = new Set(existingProjects.map(p => p.slug));
let slug = baseSlug;
let attempts = 0;
while (attempts < 10) {
const existing = await db.project.findUnique({ where: { slug } });
if (!existing) break;
slug = `${baseSlug}-${Math.random().toString(36).substring(2, 6)}`;
attempts++;
let counter = 1;
while (usedSlugs.has(slug)) {
slug = `${baseSlug}-${counter}`;
counter++;
}
// Verify user has access to the workspace
+138
View File
@@ -0,0 +1,138 @@
import { NextRequest } from 'next/server';
import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
import { apiErrors, successResponse } from '@/lib/api-response';
type RouteParams = { params: Promise<{ videoId: string }> };
// GET /api/watch/[videoId]/progress - Get watch progress for the current user
export async function GET(request: NextRequest, { params }: RouteParams) {
try {
const session = await auth();
if (!session?.user?.id) {
return apiErrors.unauthorized('Authentication required');
}
const { videoId } = await params;
// Get the video and its active version
const video = await db.video.findUnique({
where: { id: videoId },
include: {
versions: {
where: { isActive: true },
take: 1,
},
},
});
if (!video) {
return apiErrors.notFound('Video');
}
const activeVersion = video.versions[0];
if (!activeVersion) {
return apiErrors.notFound('Video version');
}
// Get watch progress for this user and version
const progress = await db.watchProgress.findUnique({
where: {
userId_versionId: {
userId: session.user.id,
versionId: activeVersion.id,
},
},
});
return successResponse({
progress: progress ? progress.progress : 0,
duration: progress?.duration || activeVersion.duration || 0,
percentage: progress?.percentage || 0,
updatedAt: progress?.updatedAt || null,
});
} catch (error) {
console.error('Error fetching watch progress:', error);
return apiErrors.internalError('Failed to fetch watch progress');
}
}
// POST /api/watch/[videoId]/progress - Save watch progress for the current user
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
const session = await auth();
if (!session?.user?.id) {
return apiErrors.unauthorized('Authentication required');
}
const { videoId } = await params;
const body = await request.json();
const { progress, duration, versionId } = body;
if (typeof progress !== 'number' || progress < 0) {
return apiErrors.badRequest('Invalid progress value');
}
// Get the video version
let targetVersionId = versionId;
if (!targetVersionId) {
const video = await db.video.findUnique({
where: { id: videoId },
include: {
versions: {
where: { isActive: true },
take: 1,
},
},
});
if (!video) {
return apiErrors.notFound('Video');
}
const activeVersion = video.versions[0];
if (!activeVersion) {
return apiErrors.notFound('Video version');
}
targetVersionId = activeVersion.id;
}
// Calculate percentage
const safeDuration = duration || 0;
const percentage = safeDuration > 0 ? Math.min(100, (progress / safeDuration) * 100) : 0;
// Upsert watch progress
const watchProgress = await db.watchProgress.upsert({
where: {
userId_versionId: {
userId: session.user.id,
versionId: targetVersionId,
},
},
update: {
progress,
duration: safeDuration,
percentage,
},
create: {
userId: session.user.id,
versionId: targetVersionId,
progress,
duration: safeDuration,
percentage,
},
});
return successResponse({
success: true,
progress: watchProgress.progress,
percentage: watchProgress.percentage,
});
} catch (error) {
console.error('Error saving watch progress:', error);
return apiErrors.internalError('Failed to save watch progress');
}
}
+24 -15
View File
@@ -11,30 +11,39 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
const session = await auth();
const { videoId } = await params;
// Parse query params
const searchParams = request.nextUrl.searchParams;
const includeComments = searchParams.get('includeComments') === 'true';
const video = await db.video.findUnique({
where: { id: videoId },
include: {
project: true,
versions: {
where: { isActive: true },
orderBy: { versionNumber: 'desc' },
include: {
comments: {
orderBy: { timestamp: 'asc' },
where: { parentId: null },
include: {
author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } },
replies: {
orderBy: { createdAt: 'asc' },
include: {
author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } },
},
take: 1,
...(includeComments ? {
include: {
comments: {
orderBy: { timestamp: 'asc' },
where: { parentId: null },
include: {
author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } },
},
},
_count: { select: { comments: true } },
},
_count: { select: { comments: true } },
},
} : {
select: {
id: true,
thumbnailUrl: true,
duration: true,
versionNumber: true,
_count: { select: { comments: true } },
},
}),
},
},
});
+12 -6
View File
@@ -63,13 +63,19 @@ export async function POST(request: NextRequest) {
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
// Find all existing slugs with the same prefix in a single query
const existingWorkspaces = await db.workspace.findMany({
where: { slug: { startsWith: baseSlug } },
select: { slug: true },
});
// Generate unique slug from the results
const usedSlugs = new Set(existingWorkspaces.map(w => w.slug));
let slug = baseSlug;
let attempts = 0;
while (attempts < 10) {
const existing = await db.workspace.findUnique({ where: { slug } });
if (!existing) break;
slug = `${baseSlug}-${Math.random().toString(36).substring(2, 6)}`;
attempts++;
let counter = 1;
while (usedSlugs.has(slug)) {
slug = `${baseSlug}-${counter}`;
counter++;
}
const workspace = await db.workspace.create({