From 88c74d646eb3bcf794d4dd3d4fd3b6872193940a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Sat, 14 Feb 2026 15:25:32 +0300 Subject: [PATCH] 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 --- app/api/projects/[projectId]/route.ts | 15 ++ .../[projectId]/videos/[videoId]/route.ts | 24 ++- app/api/projects/[projectId]/videos/route.ts | 8 +- app/api/projects/route.ts | 19 ++- app/api/watch/[videoId]/progress/route.ts | 138 +++++++++++++++ app/api/watch/[videoId]/route.ts | 39 +++-- app/api/workspaces/route.ts | 18 +- components/video-page-content.tsx | 158 ++++++++++++++++++ lib/db.ts | 65 ++++++- prisma/schema.prisma | 38 +++++ 10 files changed, 484 insertions(+), 38 deletions(-) create mode 100644 app/api/watch/[videoId]/progress/route.ts diff --git a/app/api/projects/[projectId]/route.ts b/app/api/projects/[projectId]/route.ts index 6978572..ee4857f 100644 --- a/app/api/projects/[projectId]/route.ts +++ b/app/api/projects/[projectId]/route.ts @@ -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 } }, }, diff --git a/app/api/projects/[projectId]/videos/[videoId]/route.ts b/app/api/projects/[projectId]/videos/[videoId]/route.ts index 33a63a7..4e24e90 100644 --- a/app/api/projects/[projectId]/videos/[videoId]/route.ts +++ b/app/api/projects/[projectId]/videos/[videoId]/route.ts @@ -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 }, diff --git a/app/api/projects/[projectId]/videos/route.ts b/app/api/projects/[projectId]/videos/route.ts index 444b401..b60905f 100644 --- a/app/api/projects/[projectId]/videos/route.ts +++ b/app/api/projects/[projectId]/videos/route.ts @@ -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 } }, }, }, diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index 605d801..ee91f44 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -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 diff --git a/app/api/watch/[videoId]/progress/route.ts b/app/api/watch/[videoId]/progress/route.ts new file mode 100644 index 0000000..8bbbd39 --- /dev/null +++ b/app/api/watch/[videoId]/progress/route.ts @@ -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'); + } +} diff --git a/app/api/watch/[videoId]/route.ts b/app/api/watch/[videoId]/route.ts index 952dcd1..10cda16 100644 --- a/app/api/watch/[videoId]/route.ts +++ b/app/api/watch/[videoId]/route.ts @@ -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 } }, + }, + }), }, }, }); diff --git a/app/api/workspaces/route.ts b/app/api/workspaces/route.ts index 9705c6e..2216236 100644 --- a/app/api/workspaces/route.ts +++ b/app/api/workspaces/route.ts @@ -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({ diff --git a/components/video-page-content.tsx b/components/video-page-content.tsx index 3e5b634..89d3f84 100644 --- a/components/video-page-content.tsx +++ b/components/video-page-content.tsx @@ -2,6 +2,7 @@ import { useState, useRef, useCallback, useEffect } from 'react'; import Link from 'next/link'; +import { usePathname } from 'next/navigation'; import { toast } from 'sonner'; import { ArrowLeft, @@ -155,6 +156,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi const playerRef = useRef(null); const timelineRef = useRef(null); const videoContainerRef = useRef(null); + const pathname = usePathname(); const [video, setVideo] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); @@ -168,6 +170,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi const [playbackSpeed, setPlaybackSpeed] = useState(1); const [cursorIdle, setCursorIdle] = useState(false); const cursorIdleTimerRef = useRef | null>(null); + const lastPathnameRef = useRef(pathname); const [commentText, setCommentText] = useState(''); const [isSubmittingComment, setIsSubmittingComment] = useState(false); @@ -188,6 +191,13 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi const [selectedTimestamp, setSelectedTimestamp] = useState(null); const [showResolved, setShowResolved] = useState(false); + // Watch progress state + const [savedProgress, setSavedProgress] = useState(null); + const [showResumePrompt, setShowResumePrompt] = useState(false); + const progressSaveTimerRef = useRef | null>(null); + const lastSavedProgressRef = useRef(0); + const [progressFetchKey, setProgressFetchKey] = useState(0); + const [replyingTo, setReplyingTo] = useState(null); const [replyText, setReplyText] = useState(''); const [isSubmittingReply, setIsSubmittingReply] = useState(false); @@ -392,6 +402,116 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi }); }, [videoDuration, activeVersion?.id, activeVersion?.duration, propProjectId, videoId]); + // Load watch progress when video is loaded (authenticated users only) + const loadWatchProgress = useCallback(async (showPrompt = true) => { + if (!video?.isAuthenticated || !activeVersionId) return; + + // Reset state + setSavedProgress(null); + setShowResumePrompt(false); + + try { + // Use cache: 'no-store' to always fetch fresh data + const res = await fetch(`/api/watch/${videoId}/progress`, { cache: 'no-store' }); + if (res.ok) { + const response = await res.json(); + const progress = response.data?.progress || 0; + const percentage = response.data?.percentage || 0; + + // Only show resume prompt if progress is between 5% and 95% + if (showPrompt && percentage > 5 && percentage < 95) { + setSavedProgress(progress); + setShowResumePrompt(true); + } + } + } catch (err) { + console.error('Error loading watch progress:', err); + } + }, [video?.isAuthenticated, activeVersionId, videoId]); + + // Load progress on mount and when dependencies change + useEffect(() => { + loadWatchProgress(); + }, [loadWatchProgress, progressFetchKey]); + + // Refetch progress when pathname changes (user navigates back to this page) + useEffect(() => { + if (lastPathnameRef.current !== pathname) { + const previousPath = lastPathnameRef.current; + lastPathnameRef.current = pathname; + + // If we navigated away and came back to this video page, refetch progress + if (previousPath !== pathname) { + setProgressFetchKey(k => k + 1); + } + } + }, [pathname]); + + // Save watch progress periodically while playing (authenticated users only) + useEffect(() => { + if (!video?.isAuthenticated || !isReady || !activeVersionId) return; + + // Save progress every 5 seconds while playing + progressSaveTimerRef.current = setInterval(() => { + if (currentTime > 0 && Math.abs(currentTime - lastSavedProgressRef.current) >= 2) { + // Save to API + fetch(`/api/watch/${videoId}/progress`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + progress: currentTime, + duration: videoDuration, + versionId: activeVersionId, + }), + }).catch((err) => console.error('Error saving watch progress:', err)); + + lastSavedProgressRef.current = currentTime; + } + }, 5000); + + return () => { + if (progressSaveTimerRef.current) { + clearInterval(progressSaveTimerRef.current); + } + }; + }, [video?.isAuthenticated, isReady, currentTime, videoDuration, activeVersionId, videoId]); + + // Save progress when user leaves the page + useEffect(() => { + if (!video?.isAuthenticated) return; + + const saveProgressOnLeave = () => { + if (currentTime > 0 && navigator.sendBeacon) { + // Use sendBeacon for reliable save on page unload + const data = new Blob([JSON.stringify({ + progress: currentTime, + duration: videoDuration, + versionId: activeVersionId, + })], { type: 'application/json' }); + navigator.sendBeacon(`/api/watch/${videoId}/progress`, data); + } + }; + + window.addEventListener('beforeunload', saveProgressOnLeave); + return () => window.removeEventListener('beforeunload', saveProgressOnLeave); + }, [video?.isAuthenticated, currentTime, videoDuration, activeVersionId, videoId]); + + // Resume playback from saved position + const handleResumeFromSaved = useCallback(() => { + if (savedProgress !== null && playerRef.current?.seekTo) { + playerRef.current.seekTo(savedProgress, true); + setCurrentTime(savedProgress); + setShowResumePrompt(false); + setSavedProgress(null); + } + }, [savedProgress]); + + // Dismiss resume prompt + const handleDismissResume = useCallback(() => { + setShowResumePrompt(false); + setSavedProgress(null); + }, []); + useEffect(() => { if (!isReady || !playerRef.current) return; @@ -1720,6 +1840,44 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi )} + + {/* Resume playback prompt */} + {showResumePrompt && savedProgress !== null && ( +
+
+
+
+ +
+
+

Continue watching?

+

+ Resume from {formatTime(savedProgress)} +

+
+
+
+ + +
+
+
+ )} diff --git a/lib/db.ts b/lib/db.ts index bf77023..b76649a 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -1,11 +1,52 @@ import { PrismaClient } from '@prisma/client'; import { PrismaPg } from '@prisma/adapter-pg'; -import { Pool } from 'pg'; +import { Pool, PoolConfig } from 'pg'; const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined; }; +const globalForPool = globalThis as unknown as { + pgPool: Pool | undefined; +}; + +function createPool(connectionString: string): Pool { + // Prevent multiple pools from being created during development (Next.js hot reload) + if (globalForPool.pgPool) { + return globalForPool.pgPool; + } + + const poolConfig: PoolConfig = { + connectionString, + max: 20, // Maximum number of connections + idleTimeoutMillis: 30000, // Close idle connections after 30 seconds + connectionTimeoutMillis: 5000, // Return error after 5 seconds if can't connect + }; + + const pool = new Pool(poolConfig); + + // Add error handling for pool errors + pool.on('error', (err, client) => { + console.error('Unexpected database pool error:', err.message); + // Don't crash the app on unexpected pool errors + }); + + pool.on('connect', () => { + console.debug('New database connection established'); + }); + + pool.on('acquire', () => { + console.debug('Connection acquired from pool'); + }); + + // Store pool globally to prevent multiple instances during development + if (process.env.NODE_ENV !== 'production') { + globalForPool.pgPool = pool; + } + + return pool; +} + function createPrismaClient() { // In development without a database, we'll create a mock-friendly client // For production or when DATABASE_URL is set, use the real adapter @@ -14,13 +55,14 @@ function createPrismaClient() { if (!connectionString) { console.warn('DATABASE_URL not set - database features will not work'); // Return a client that will throw clear errors when used + const pool = createPool('postgresql://localhost:5432/dummy'); return new PrismaClient({ // This will fail on actual DB operations but allows imports to work - adapter: new PrismaPg(new Pool({ connectionString: 'postgresql://localhost:5432/dummy' })), + adapter: new PrismaPg(pool), }); } - const pool = new Pool({ connectionString }); + const pool = createPool(connectionString); const adapter = new PrismaPg(pool); return new PrismaClient({ @@ -35,4 +77,21 @@ if (process.env.NODE_ENV !== 'production') { globalForPrisma.prisma = db; } +// Graceful shutdown handler +async function shutdown() { + console.log('Shutting down database connections...'); + + if (globalForPool.pgPool) { + await globalForPool.pgPool.end(); + console.log('Database pool closed'); + } + + await db.$disconnect(); + console.log('Prisma client disconnected'); +} + +// Register shutdown handlers +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); + export default db; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4909068..3f229c3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -31,6 +31,7 @@ model User { comments Comment[] projectMemberships ProjectMember[] notificationSetting NotificationSetting? + watchProgress WatchProgress[] @@map("users") } @@ -158,6 +159,7 @@ model Project { @@index([ownerId]) @@index([slug]) @@index([workspaceId]) + @@index([workspaceId, updatedAt(sort: Desc)]) @@map("projects") } @@ -239,9 +241,11 @@ model VideoVersion { // Relations comments Comment[] + watchProgress WatchProgress[] @@unique([videoParentId, versionNumber]) @@index([videoParentId]) + @@index([videoParentId, isActive]) @@map("video_versions") } @@ -293,6 +297,8 @@ model Comment { @@index([authorId]) @@index([timestamp]) @@index([tagId]) + @@index([versionId, isResolved, timestamp]) + @@index([versionId, parentId, createdAt]) @@map("comments") } @@ -345,6 +351,7 @@ model ShareLink { @@index([projectId]) @@index([token]) + @@index([token, expiresAt]) @@map("share_links") } @@ -386,6 +393,37 @@ model NotificationSetting { @@map("notification_settings") } +// ============================================ +// WATCH PROGRESS +// ============================================ + +model WatchProgress { + id String @id @default(cuid()) + + // User relation (optional for guest progress, though typically requires auth) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + // Video version relation + versionId String + version VideoVersion @relation(fields: [versionId], references: [id], onDelete: Cascade) + + // Progress data + progress Float // Current playback position in seconds + duration Float // Total video duration at time of save + percentage Float // Progress as percentage (0-100) + + // Timestamps + updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + + // One progress record per user per version + @@unique([userId, versionId]) + @@index([userId]) + @@index([versionId]) + @@map("watch_progress") +} + // Rate limiting table (created as UNLOGGED via raw SQL migration) // Defined here so `prisma db push` doesn't drop it model RateLimit {