mirror of
https://github.com/nicetry247/offlineacademy.git
synced 2026-08-30 12:19:06 +00:00
perf: streamline course library queries
This commit is contained in:
+152
-98
@@ -1,38 +1,56 @@
|
|||||||
|
import { Prisma } from '@prisma/client'
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { getLocalThumbnailUrl } from '@/lib/thumbnail-index-server'
|
import { getLocalThumbnailUrl } from '@/lib/thumbnail-index-server'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
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) {
|
export async function GET(request: NextRequest) {
|
||||||
|
const startedAt = performance.now()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { searchParams } = request.nextUrl
|
const { searchParams } = request.nextUrl
|
||||||
|
const page = parsePositiveInteger(searchParams.get('page'), 1)
|
||||||
// Pagination
|
const limit = Math.min(
|
||||||
const page = parseInt(searchParams.get('page') || '1')
|
parsePositiveInteger(searchParams.get('limit'), DEFAULT_PAGE_SIZE),
|
||||||
const limit = Math.min(parseInt(searchParams.get('limit') || '10'), 100)
|
MAX_PAGE_SIZE
|
||||||
|
)
|
||||||
const skip = (page - 1) * limit
|
const skip = (page - 1) * limit
|
||||||
|
const search = (searchParams.get('search') || '').trim()
|
||||||
// Search
|
const filter = searchParams.get('filter') || 'all'
|
||||||
const search = searchParams.get('search') || ''
|
|
||||||
|
|
||||||
// Filters
|
|
||||||
const filter = searchParams.get('filter') || 'all' // all, in-progress, completed, not-started, favorites, tag:<tagId>
|
|
||||||
const tag = searchParams.get('tag') || ''
|
const tag = searchParams.get('tag') || ''
|
||||||
const tagFilter = tag || (filter.startsWith('tag:') ? filter.slice(4) : '')
|
const tagFilter = tag || (filter.startsWith('tag:') ? filter.slice(4) : '')
|
||||||
const favoritesOnly = searchParams.get('favorites') === 'true'
|
const favoritesOnly = searchParams.get('favorites') === 'true'
|
||||||
const sortBy = searchParams.get('sortBy') || 'updatedAt'
|
const requestedSort = searchParams.get('sortBy') || 'updatedAt'
|
||||||
const sortOrder = searchParams.get('sortOrder') || 'desc'
|
const sortBy = SORT_FIELDS.has(requestedSort) ? requestedSort : 'updatedAt'
|
||||||
|
const sortOrder: SortOrder = searchParams.get('sortOrder') === 'asc' ? 'asc' : 'desc'
|
||||||
|
|
||||||
// Build where clause
|
const where: Prisma.CourseWhereInput = { hidden: false }
|
||||||
const where: any = { hidden: false }
|
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
const term = search.trim()
|
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ name: { contains: term } },
|
{ name: { contains: search } },
|
||||||
{ displayName: { contains: term } },
|
{ displayName: { contains: search } },
|
||||||
{ slug: { contains: term } },
|
{ 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: Prisma.CourseOrderByWithRelationInput =
|
||||||
const secondaryOrderBy: any = {}
|
sortBy === 'name'
|
||||||
if (sortBy === 'name') {
|
? { name: sortOrder }
|
||||||
secondaryOrderBy.name = sortOrder
|
: { updatedAt: sortOrder }
|
||||||
} else if (sortBy === 'progress') {
|
|
||||||
secondaryOrderBy.updatedAt = sortOrder
|
|
||||||
} else {
|
|
||||||
secondaryOrderBy[sortBy] = sortOrder
|
|
||||||
}
|
|
||||||
const orderBy: any[] = [{ favorited: 'desc' }, secondaryOrderBy]
|
|
||||||
|
|
||||||
const total = await prisma.course.count({ where })
|
const orderBy: Prisma.CourseOrderByWithRelationInput[] = [
|
||||||
|
{ favorited: 'desc' },
|
||||||
|
secondaryOrderBy,
|
||||||
|
]
|
||||||
|
|
||||||
const courses = await prisma.course.findMany({
|
const databaseStartedAt = performance.now()
|
||||||
where,
|
const [total, courses, tags] = await Promise.all([
|
||||||
orderBy,
|
prisma.course.count({ where }),
|
||||||
skip: Math.max(0, skip),
|
prisma.course.findMany({
|
||||||
take: limit,
|
where,
|
||||||
include: {
|
orderBy,
|
||||||
_count: { select: { modules: true } },
|
skip: Math.max(0, skip),
|
||||||
progress: {
|
take: limit,
|
||||||
where: { userId: 'local-user', lessonId: null },
|
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 },
|
prisma.tag.findMany({
|
||||||
orderBy: { tag: { name: 'asc' } },
|
orderBy: { name: 'asc' },
|
||||||
},
|
include: { _count: { select: { courseTags: true } } },
|
||||||
},
|
}),
|
||||||
})
|
])
|
||||||
|
const initialDatabaseDuration = performance.now() - databaseStartedAt
|
||||||
|
|
||||||
const courseIds = courses.map(c => c.id)
|
const courseIds = courses.map((course) => course.id)
|
||||||
const lessonCounts = await prisma.lesson.groupBy({
|
const enrichStartedAt = performance.now()
|
||||||
by: ['moduleId'],
|
|
||||||
where: { module: { courseId: { in: courseIds } } },
|
|
||||||
_count: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const moduleToCourse = new Map<string, string>()
|
const [modulesWithCounts, completedProgress] = courseIds.length > 0
|
||||||
const modules = await prisma.module.findMany({
|
? await Promise.all([
|
||||||
where: { courseId: { in: courseIds } },
|
prisma.module.findMany({
|
||||||
select: { id: true, courseId: true },
|
where: { courseId: { in: courseIds } },
|
||||||
})
|
select: {
|
||||||
modules.forEach(m => moduleToCourse.set(m.id, m.courseId))
|
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<string, number>()
|
const lessonCountByCourse = new Map<string, number>()
|
||||||
lessonCounts.forEach(lc => {
|
for (const module of modulesWithCounts) {
|
||||||
const courseId = moduleToCourse.get(lc.moduleId as string)
|
lessonCountByCourse.set(
|
||||||
if (courseId) {
|
module.courseId,
|
||||||
moduleLessonCounts.set(courseId, (moduleLessonCounts.get(courseId) || 0) + lc._count)
|
(lessonCountByCourse.get(module.courseId) || 0) + module._count.lessons
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
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 totalPages = Math.ceil(total / limit)
|
const completedCountByCourse = new Map<string, number>(
|
||||||
const tags = await prisma.tag.findMany({
|
completedProgress.map((entry) => [entry.courseId, Number(entry._count._all)])
|
||||||
orderBy: { name: 'asc' },
|
)
|
||||||
include: { _count: { select: { courseTags: true } } },
|
|
||||||
})
|
|
||||||
|
|
||||||
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,
|
courses: coursesWithProgress,
|
||||||
tags,
|
tags,
|
||||||
pagination: {
|
pagination: {
|
||||||
@@ -159,6 +201,18 @@ export async function GET(request: NextRequest) {
|
|||||||
hasPrev: page > 1,
|
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) {
|
} catch (error) {
|
||||||
console.error('Courses API error:', error)
|
console.error('Courses API error:', error)
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
Reference in New Issue
Block a user