diff --git a/app/api/courses/route.ts b/app/api/courses/route.ts index 7313025..5a10268 100644 --- a/app/api/courses/route.ts +++ b/app/api/courses/route.ts @@ -1,38 +1,56 @@ +import { Prisma } from '@prisma/client' import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' import { getLocalThumbnailUrl } from '@/lib/thumbnail-index-server' export const dynamic = 'force-dynamic' +const DEFAULT_PAGE_SIZE = 10 +const MAX_PAGE_SIZE = 100 +const SORT_FIELDS = new Set(['updatedAt', 'createdAt', 'name', 'progress']) + +type SortOrder = 'asc' | 'desc' + +type CourseWithLibraryData = Prisma.CourseGetPayload<{ + include: { + _count: { select: { modules: true } } + progress: true + courseTags: { include: { tag: true } } + } +}> + +function parsePositiveInteger(value: string | null, fallback: number): number { + const parsed = Number.parseInt(value ?? '', 10) + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback +} + export async function GET(request: NextRequest) { + const startedAt = performance.now() + try { const { searchParams } = request.nextUrl - - // Pagination - const page = parseInt(searchParams.get('page') || '1') - const limit = Math.min(parseInt(searchParams.get('limit') || '10'), 100) + const page = parsePositiveInteger(searchParams.get('page'), 1) + const limit = Math.min( + parsePositiveInteger(searchParams.get('limit'), DEFAULT_PAGE_SIZE), + MAX_PAGE_SIZE + ) const skip = (page - 1) * limit - - // Search - const search = searchParams.get('search') || '' - - // Filters - const filter = searchParams.get('filter') || 'all' // all, in-progress, completed, not-started, favorites, tag: + const search = (searchParams.get('search') || '').trim() + const filter = searchParams.get('filter') || 'all' const tag = searchParams.get('tag') || '' const tagFilter = tag || (filter.startsWith('tag:') ? filter.slice(4) : '') const favoritesOnly = searchParams.get('favorites') === 'true' - const sortBy = searchParams.get('sortBy') || 'updatedAt' - const sortOrder = searchParams.get('sortOrder') || 'desc' - - // Build where clause - const where: any = { hidden: false } + const requestedSort = searchParams.get('sortBy') || 'updatedAt' + const sortBy = SORT_FIELDS.has(requestedSort) ? requestedSort : 'updatedAt' + const sortOrder: SortOrder = searchParams.get('sortOrder') === 'asc' ? 'asc' : 'desc' + + const where: Prisma.CourseWhereInput = { hidden: false } if (search) { - const term = search.trim() where.OR = [ - { name: { contains: term } }, - { displayName: { contains: term } }, - { slug: { contains: term } }, + { name: { contains: search } }, + { displayName: { contains: search } }, + { slug: { contains: search } }, ] } @@ -58,96 +76,120 @@ export async function GET(request: NextRequest) { } } - // Build orderBy. Favorites are pinned above non-favorites for library organization. - const secondaryOrderBy: any = {} - if (sortBy === 'name') { - secondaryOrderBy.name = sortOrder - } else if (sortBy === 'progress') { - secondaryOrderBy.updatedAt = sortOrder - } else { - secondaryOrderBy[sortBy] = sortOrder - } - const orderBy: any[] = [{ favorited: 'desc' }, secondaryOrderBy] + const secondaryOrderBy: Prisma.CourseOrderByWithRelationInput = + sortBy === 'name' + ? { name: sortOrder } + : { updatedAt: sortOrder } - const total = await prisma.course.count({ where }) + const orderBy: Prisma.CourseOrderByWithRelationInput[] = [ + { favorited: 'desc' }, + secondaryOrderBy, + ] - const courses = await prisma.course.findMany({ - where, - orderBy, - skip: Math.max(0, skip), - take: limit, - include: { - _count: { select: { modules: true } }, - progress: { - where: { userId: 'local-user', lessonId: null }, + const databaseStartedAt = performance.now() + const [total, courses, tags] = await Promise.all([ + prisma.course.count({ where }), + prisma.course.findMany({ + where, + orderBy, + skip: Math.max(0, skip), + take: limit, + include: { + _count: { select: { modules: true } }, + progress: { + where: { userId: 'local-user', lessonId: null, moduleId: null }, + orderBy: { lastWatched: 'desc' }, + take: 1, + }, + courseTags: { + include: { tag: true }, + orderBy: { tag: { name: 'asc' } }, + }, }, - courseTags: { - include: { tag: true }, - orderBy: { tag: { name: 'asc' } }, - }, - }, - }) + }), + prisma.tag.findMany({ + orderBy: { name: 'asc' }, + include: { _count: { select: { courseTags: true } } }, + }), + ]) + const initialDatabaseDuration = performance.now() - databaseStartedAt - const courseIds = courses.map(c => c.id) - const lessonCounts = await prisma.lesson.groupBy({ - by: ['moduleId'], - where: { module: { courseId: { in: courseIds } } }, - _count: true, - }) + const courseIds = courses.map((course) => course.id) + const enrichStartedAt = performance.now() - const moduleToCourse = new Map() - const modules = await prisma.module.findMany({ - where: { courseId: { in: courseIds } }, - select: { id: true, courseId: true }, - }) - modules.forEach(m => moduleToCourse.set(m.id, m.courseId)) + const [modulesWithCounts, completedProgress] = courseIds.length > 0 + ? await Promise.all([ + prisma.module.findMany({ + where: { courseId: { in: courseIds } }, + select: { + courseId: true, + _count: { select: { lessons: true } }, + }, + }), + prisma.progress.groupBy({ + by: ['courseId'], + where: { + userId: 'local-user', + courseId: { in: courseIds }, + lessonId: { not: null }, + completed: true, + }, + _count: { _all: true }, + }), + ]) + : [[], []] - const moduleLessonCounts = new Map() - lessonCounts.forEach(lc => { - const courseId = moduleToCourse.get(lc.moduleId as string) - if (courseId) { - moduleLessonCounts.set(courseId, (moduleLessonCounts.get(courseId) || 0) + lc._count) - } - }) - - const coursesWithProgress = await Promise.all(courses.map(async (course) => { - const courseProgress = course.progress[0] - const totalLessons = moduleLessonCounts.get(course.id) || 0 - let completedLessons = 0 - let percentage = 0 - let lastWatched = null - - if (courseProgress) { - completedLessons = courseProgress.completed ? totalLessons : 0 - percentage = courseProgress.completed ? 100 : 0 - lastWatched = courseProgress.lastWatched.toISOString() - } - - return { - ...course, - _count: { ...course._count, lessons: totalLessons }, - progress: { completedLessons, totalLessons, percentage, lastWatched }, - thumbnail: await getLocalThumbnailUrl(course.slug) ?? null, - description: course.description, - tags: course.courseTags.map((connection) => connection.tag), - } - })) - - if (sortBy === 'progress') { - coursesWithProgress.sort((a, b) => - sortOrder === 'asc' - ? a.progress.percentage - b.progress.percentage - : b.progress.percentage - a.progress.percentage + const lessonCountByCourse = new Map() + for (const module of modulesWithCounts) { + lessonCountByCourse.set( + module.courseId, + (lessonCountByCourse.get(module.courseId) || 0) + module._count.lessons ) } - const totalPages = Math.ceil(total / limit) - const tags = await prisma.tag.findMany({ - orderBy: { name: 'asc' }, - include: { _count: { select: { courseTags: true } } }, - }) + const completedCountByCourse = new Map( + completedProgress.map((entry) => [entry.courseId, Number(entry._count._all)]) + ) - return NextResponse.json({ + const coursesWithProgress = await Promise.all( + courses.map(async (course: CourseWithLibraryData) => { + const totalLessons = lessonCountByCourse.get(course.id) || 0 + const completedLessons = Math.min( + completedCountByCourse.get(course.id) || 0, + totalLessons + ) + const percentage = totalLessons > 0 + ? Math.round((completedLessons / totalLessons) * 1000) / 10 + : 0 + const summaryProgress = course.progress[0] + const thumbnail = course.thumbnail || await getLocalThumbnailUrl(course.slug) + const { courseTags, progress: _progressRows, ...courseData } = course + + return { + ...courseData, + _count: { ...course._count, lessons: totalLessons }, + progress: { + completedLessons, + totalLessons, + percentage, + lastWatched: summaryProgress?.lastWatched.toISOString() ?? null, + }, + thumbnail: thumbnail ?? null, + tags: courseTags.map((connection) => connection.tag), + } + }) + ) + + if (sortBy === 'progress') { + coursesWithProgress.sort((a, b) => { + const difference = a.progress.percentage - b.progress.percentage + return sortOrder === 'asc' ? difference : -difference + }) + } + + const enrichDuration = performance.now() - enrichStartedAt + const totalPages = Math.ceil(total / limit) + const response = NextResponse.json({ courses: coursesWithProgress, tags, pagination: { @@ -159,6 +201,18 @@ export async function GET(request: NextRequest) { hasPrev: page > 1, }, }) + + response.headers.set( + 'Server-Timing', + [ + `db;dur=${initialDatabaseDuration.toFixed(1)}`, + `enrich;dur=${enrichDuration.toFixed(1)}`, + `total;dur=${(performance.now() - startedAt).toFixed(1)}`, + ].join(', ') + ) + response.headers.set('Cache-Control', 'private, no-store') + + return response } catch (error) { console.error('Courses API error:', error) return NextResponse.json( @@ -166,4 +220,4 @@ export async function GET(request: NextRequest) { { status: 500 } ) } -} \ No newline at end of file +}