mirror of
https://github.com/nicetry247/offlineacademy.git
synced 2026-08-11 19:36:53 +00:00
initial commit
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const userId = 'local-user'
|
||||
|
||||
// 1. Total completed lessons and total watch time (from completed lessons)
|
||||
const completedLessons = await prisma.progress.findMany({
|
||||
where: {
|
||||
userId,
|
||||
lessonId: { not: null },
|
||||
completed: true,
|
||||
},
|
||||
include: {
|
||||
lesson: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 2. In-progress lessons (position watch time)
|
||||
const inProgressLessons = await prisma.progress.findMany({
|
||||
where: {
|
||||
userId,
|
||||
lessonId: { not: null },
|
||||
completed: false,
|
||||
position: { gt: 0 },
|
||||
},
|
||||
include: {
|
||||
lesson: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 3. All lessons for completion stats
|
||||
const allLessons = await prisma.lesson.count({
|
||||
where: {
|
||||
module: { course: { hidden: false } },
|
||||
},
|
||||
})
|
||||
|
||||
// 4. Completed courses
|
||||
const completedCourses = await prisma.progress.findMany({
|
||||
where: {
|
||||
userId,
|
||||
lessonId: null,
|
||||
moduleId: null,
|
||||
completed: true,
|
||||
course: { hidden: false },
|
||||
},
|
||||
include: { course: true },
|
||||
})
|
||||
|
||||
// 5. In-progress courses (have lesson progress but course not completed)
|
||||
const inProgressCourses = await prisma.progress.findMany({
|
||||
where: {
|
||||
userId,
|
||||
lessonId: { not: null },
|
||||
lesson: { module: { course: { hidden: false } } },
|
||||
},
|
||||
include: {
|
||||
lesson: {
|
||||
include: {
|
||||
module: { include: { course: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { lastWatched: 'desc' },
|
||||
})
|
||||
|
||||
// 6. Bookmarks count
|
||||
const totalBookmarks = await prisma.bookmark.count({
|
||||
where: { userId },
|
||||
})
|
||||
|
||||
// 7. Weekly progress - lessons completed in the last 8 weeks
|
||||
const eightWeeksAgo = new Date()
|
||||
eightWeeksAgo.setDate(eightWeeksAgo.getDate() - 56)
|
||||
|
||||
const weeklyCompleted = await prisma.progress.groupBy({
|
||||
by: ['lastWatched'],
|
||||
where: {
|
||||
userId,
|
||||
lessonId: { not: null },
|
||||
completed: true,
|
||||
lastWatched: { gte: eightWeeksAgo },
|
||||
},
|
||||
_count: { id: true },
|
||||
})
|
||||
|
||||
// Aggregate by week
|
||||
const weeklyData = new Map<string, number>()
|
||||
for (const wc of weeklyCompleted) {
|
||||
const weekStart = new Date(wc.lastWatched)
|
||||
weekStart.setDate(weekStart.getDate() - weekStart.getDay()) // Start of week (Sunday)
|
||||
const weekKey = weekStart.toISOString().split('T')[0]
|
||||
weeklyData.set(weekKey, (weeklyData.get(weekKey) || 0) + wc._count.id)
|
||||
}
|
||||
|
||||
// Ensure last 8 weeks have entries (even if 0)
|
||||
const weeklyProgress: Array<{ week: string; count: number }> = []
|
||||
for (let i = 7; i >= 0; i--) {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() - date.getDay() - i * 7)
|
||||
const weekKey = date.toISOString().split('T')[0]
|
||||
weeklyProgress.push({
|
||||
week: weekKey,
|
||||
count: weeklyData.get(weekKey) || 0,
|
||||
})
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
const completedLessonCount = completedLessons.length
|
||||
const totalWatchTimeSeconds = completedLessons.reduce((sum, p) => {
|
||||
return sum + (p.lesson?.duration || 0)
|
||||
}, 0) + inProgressLessons.reduce((sum, p) => {
|
||||
return sum + (p.position || 0)
|
||||
}, 0)
|
||||
|
||||
// Unique courses with progress
|
||||
const courseIds = new Set<string>()
|
||||
for (const p of inProgressCourses) {
|
||||
if (p.lesson?.module?.courseId) {
|
||||
courseIds.add(p.lesson.module.courseId)
|
||||
}
|
||||
}
|
||||
for (const c of completedCourses) {
|
||||
if (c.courseId) courseIds.add(c.courseId)
|
||||
}
|
||||
|
||||
// In-progress courses with their progress
|
||||
const inProgressCourseMap = new Map<string, {
|
||||
courseId: string
|
||||
courseName: string
|
||||
courseSlug: string
|
||||
completedLessons: number
|
||||
totalLessons: number
|
||||
percentage: number
|
||||
lastWatched: Date
|
||||
}>()
|
||||
|
||||
for (const p of inProgressCourses) {
|
||||
const course = p.lesson?.module?.course
|
||||
if (!course) continue
|
||||
const existing = inProgressCourseMap.get(course.id)
|
||||
if (!existing || p.lastWatched > existing.lastWatched) {
|
||||
inProgressCourseMap.set(course.id, {
|
||||
courseId: course.id,
|
||||
courseName: course.displayName || course.name,
|
||||
courseSlug: course.slug,
|
||||
completedLessons: 0, // will compute below
|
||||
totalLessons: 0,
|
||||
percentage: 0,
|
||||
lastWatched: p.lastWatched,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Compute progress for each in-progress course
|
||||
for (const [courseId, data] of Array.from(inProgressCourseMap.entries())) {
|
||||
const lessons = await prisma.lesson.findMany({
|
||||
where: { module: { courseId } },
|
||||
select: { id: true },
|
||||
})
|
||||
const completed = await prisma.progress.count({
|
||||
where: {
|
||||
userId,
|
||||
lessonId: { in: lessons.map(l => l.id) },
|
||||
completed: true,
|
||||
},
|
||||
})
|
||||
data.totalLessons = lessons.length
|
||||
data.completedLessons = completed
|
||||
data.percentage = lessons.length > 0 ? Math.round((completed / lessons.length) * 100) : 0
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
summary: {
|
||||
totalWatchTimeHours: Math.round(totalWatchTimeSeconds / 3600 * 10) / 10,
|
||||
totalWatchTimeMinutes: Math.round(totalWatchTimeSeconds / 60),
|
||||
completedLessons: completedLessonCount,
|
||||
totalLessons: allLessons,
|
||||
overallCompletionRate: allLessons > 0 ? Math.round((completedLessonCount / allLessons) * 100) : 0,
|
||||
completedCourses: completedCourses.length,
|
||||
inProgressCourses: inProgressCourseMap.size,
|
||||
totalBookmarks,
|
||||
},
|
||||
weeklyProgress,
|
||||
inProgressCourses: Array.from(inProgressCourseMap.values()),
|
||||
completedCourses: completedCourses.map(c => ({
|
||||
courseId: c.courseId,
|
||||
courseName: c.course.displayName || c.course.name,
|
||||
courseSlug: c.course.slug,
|
||||
completedAt: c.lastWatched,
|
||||
})),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Analytics fetch error:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch analytics' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { z } from 'zod'
|
||||
|
||||
const updateSchema = z.object({
|
||||
note: z.string().trim().max(1000).optional().nullable(),
|
||||
})
|
||||
|
||||
function normalizeNote(note?: string | null) {
|
||||
const trimmed = (note || '').trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ bookmarkId: string }> }) {
|
||||
try {
|
||||
const { bookmarkId } = await params
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const parsed = updateSchema.safeParse(body)
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request', details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const bookmark = await prisma.bookmark.update({
|
||||
where: { id: bookmarkId },
|
||||
data: { note: normalizeNote(parsed.data.note) },
|
||||
include: {
|
||||
lesson: {
|
||||
select: { id: true, title: true, slug: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
bookmark: {
|
||||
...bookmark,
|
||||
note: bookmark.note ?? '',
|
||||
createdAt: bookmark.createdAt.toISOString(),
|
||||
updatedAt: bookmark.updatedAt.toISOString(),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Bookmark update error:', error)
|
||||
return NextResponse.json({ error: 'Failed to update bookmark' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ bookmarkId: string }> }) {
|
||||
try {
|
||||
const { bookmarkId } = await params
|
||||
|
||||
await prisma.bookmark.delete({ where: { id: bookmarkId } })
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Bookmark delete error:', error)
|
||||
return NextResponse.json({ error: 'Failed to delete bookmark' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { z } from 'zod'
|
||||
|
||||
const bookmarkSchema = z.object({
|
||||
lessonId: z.string().min(1),
|
||||
courseId: z.string().min(1),
|
||||
moduleId: z.string().min(1),
|
||||
position: z.number().int().min(0),
|
||||
note: z.string().trim().max(1000).optional().nullable(),
|
||||
})
|
||||
|
||||
function normalizeNote(note?: string | null) {
|
||||
const trimmed = (note || '').trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const lessonId = searchParams.get('lessonId')
|
||||
const courseId = searchParams.get('courseId')
|
||||
|
||||
if (!lessonId && !courseId) {
|
||||
return NextResponse.json({ error: 'lessonId or courseId required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const bookmarks = await prisma.bookmark.findMany({
|
||||
where: {
|
||||
userId: 'local-user',
|
||||
...(lessonId ? { lessonId } : { courseId: courseId! }),
|
||||
lesson: {
|
||||
module: {
|
||||
course: { hidden: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ position: 'asc' }, { createdAt: 'asc' }],
|
||||
include: {
|
||||
lesson: {
|
||||
select: { id: true, title: true, slug: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
items: bookmarks.map(bookmark => ({
|
||||
...bookmark,
|
||||
note: bookmark.note ?? '',
|
||||
createdAt: bookmark.createdAt.toISOString(),
|
||||
updatedAt: bookmark.updatedAt.toISOString(),
|
||||
})),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Bookmark fetch error:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch bookmarks' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const parsed = bookmarkSchema.safeParse(body)
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request', details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const bookmark = await prisma.bookmark.create({
|
||||
data: {
|
||||
userId: 'local-user',
|
||||
lessonId: parsed.data.lessonId,
|
||||
courseId: parsed.data.courseId,
|
||||
moduleId: parsed.data.moduleId,
|
||||
position: parsed.data.position,
|
||||
note: normalizeNote(parsed.data.note),
|
||||
},
|
||||
include: {
|
||||
lesson: {
|
||||
select: { id: true, title: true, slug: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
bookmark: {
|
||||
...bookmark,
|
||||
note: bookmark.note ?? '',
|
||||
createdAt: bookmark.createdAt.toISOString(),
|
||||
updatedAt: bookmark.updatedAt.toISOString(),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Bookmark create error:', error)
|
||||
return NextResponse.json({ error: 'Failed to create bookmark' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { ensureModuleQuizCache, syncQuizLessonFromCache, writeQuizCache } from '@/lib/quiz'
|
||||
import { join, dirname } from 'path'
|
||||
const QUIZ_CACHE_FILE = 'quiz_cache.json'
|
||||
|
||||
async function getCourseWithModules(slug: string) {
|
||||
return prisma.course.findFirst({
|
||||
where: { slug, hidden: false },
|
||||
include: {
|
||||
modules: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: { lessons: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ slug: string }> }) {
|
||||
try {
|
||||
const { slug } = await params
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const action = body?.action
|
||||
if (action === 'regenerate') {
|
||||
const course = await getCourseWithModules(slug)
|
||||
if (!course) {
|
||||
return NextResponse.json({ error: 'Course not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const sourceValue = body?.source
|
||||
const difficulty = typeof body?.difficulty === 'string' ? body.difficulty.toLowerCase() : 'medium'
|
||||
const results: Array<{ module: string; status: 'ok' | 'skipped'; error?: string }> = []
|
||||
|
||||
for (const module of course.modules) {
|
||||
const modulePath = join(course.path, module.name)
|
||||
try {
|
||||
const generated = await ensureModuleQuizCache({
|
||||
modulePath,
|
||||
topic: module.name,
|
||||
source: sourceValue === 'the-trivia-api' ? 'the-trivia-api' : 'quizapi',
|
||||
force: true,
|
||||
})
|
||||
|
||||
if (!generated) {
|
||||
results.push({ module: module.name, status: 'skipped', error: 'Intro/local-only quiz generation skipped' })
|
||||
continue
|
||||
}
|
||||
|
||||
await syncQuizLessonFromCache({
|
||||
moduleId: module.id,
|
||||
modulePath,
|
||||
courseRoot: dirname(course.path),
|
||||
topic: module.name,
|
||||
source: sourceValue === 'the-trivia-api' ? 'the-trivia-api' : 'quizapi',
|
||||
lessonOrder: module.lessons.length,
|
||||
autoFetch: false,
|
||||
force: true,
|
||||
})
|
||||
|
||||
results.push({ module: module.name, status: 'ok' })
|
||||
} catch (error) {
|
||||
results.push({ module: module.name, status: 'skipped', error: String(error) })
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, action: 'regenerate', difficulty, results })
|
||||
}
|
||||
|
||||
if (action === 'import') {
|
||||
const course = await getCourseWithModules(slug)
|
||||
if (!course) {
|
||||
return NextResponse.json({ error: 'Course not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const moduleName = typeof body?.module === 'string' ? body.module.trim() : ''
|
||||
const targetModule = course.modules.find((item) => item.name === moduleName)
|
||||
if (!targetModule) {
|
||||
return NextResponse.json({ error: 'Module not found', availableModules: course.modules.map((item) => item.name) }, { status: 404 })
|
||||
}
|
||||
|
||||
const quiz = typeof body?.quiz === 'object' && body.quiz !== null ? (body.quiz as Record<string, unknown>) : null
|
||||
if (!quiz) {
|
||||
return NextResponse.json({ error: 'Missing quiz JSON' }, { status: 400 })
|
||||
}
|
||||
|
||||
const modulePath = join(course.path, targetModule.name)
|
||||
const cachePath = join(modulePath, QUIZ_CACHE_FILE)
|
||||
await writeQuizCache(cachePath, quiz as never)
|
||||
|
||||
const synced = await syncQuizLessonFromCache({
|
||||
moduleId: targetModule.id,
|
||||
modulePath,
|
||||
courseRoot: dirname(course.path),
|
||||
topic: targetModule.name,
|
||||
source: 'the-trivia-api',
|
||||
lessonOrder: targetModule.lessons.length,
|
||||
autoFetch: false,
|
||||
force: true,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
action: 'import',
|
||||
module: targetModule.name,
|
||||
cachePath,
|
||||
lesson: synced?.lesson || null,
|
||||
})
|
||||
}
|
||||
|
||||
if (action === 'clear') {
|
||||
const course = await getCourseWithModules(slug)
|
||||
if (!course) {
|
||||
return NextResponse.json({ error: 'Course not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const moduleName = typeof body?.module === 'string' ? body.module.trim() : ''
|
||||
|
||||
if (moduleName === '__all__') {
|
||||
const moduleIds = course.modules.map((module) => module.id)
|
||||
const modulePaths = course.modules.map((module) => join(course.path, module.name, QUIZ_CACHE_FILE))
|
||||
|
||||
try {
|
||||
await prisma.lesson.deleteMany({ where: { moduleId: { in: moduleIds }, slug: 'quiz' } })
|
||||
} catch {
|
||||
// ignore if no quiz lessons exist
|
||||
}
|
||||
|
||||
try {
|
||||
const { unlink } = await import('fs/promises')
|
||||
await Promise.allSettled(modulePaths.map((cachePath) => unlink(cachePath).catch(() => {})))
|
||||
} catch {
|
||||
// ignore missing cache files
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, action: 'clear', module: '__all__' })
|
||||
}
|
||||
|
||||
if (!moduleName) {
|
||||
return NextResponse.json({ error: 'Missing module name' }, { status: 400 })
|
||||
}
|
||||
|
||||
const targetModule = course.modules.find((item) => item.name === moduleName)
|
||||
if (!targetModule) {
|
||||
return NextResponse.json({ error: 'Module not found', availableModules: course.modules.map((item) => item.name) }, { status: 404 })
|
||||
}
|
||||
|
||||
const modulePath = join(course.path, targetModule.name)
|
||||
const cachePath = join(modulePath, QUIZ_CACHE_FILE)
|
||||
|
||||
try {
|
||||
await prisma.lesson.deleteMany({ where: { moduleId: targetModule.id, slug: 'quiz' } })
|
||||
} catch {
|
||||
// ignore if no quiz lesson exists
|
||||
}
|
||||
|
||||
try {
|
||||
const { unlink } = await import('fs/promises')
|
||||
await unlink(cachePath).catch(() => {})
|
||||
} catch {
|
||||
// ignore missing cache file
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, action: 'clear', module: targetModule.name })
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Unsupported action' }, { status: 400 })
|
||||
} catch (error) {
|
||||
console.error('Course quiz batch error:', error)
|
||||
return NextResponse.json({ error: 'Failed to process course quiz action' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { rm } from 'fs/promises'
|
||||
import { resolve, sep } from 'path'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { z } from 'zod'
|
||||
|
||||
const renameSchema = z.object({ displayName: z.string().trim().min(1).max(200) })
|
||||
const tagSchema = z.object({ tagId: z.string().min(1) })
|
||||
const favoriteSchema = z.object({ favorited: z.boolean() })
|
||||
|
||||
async function getCoursesRootPath() {
|
||||
const setting = await prisma.setting.findUnique({ where: { key: 'coursesRoot' } })
|
||||
return setting?.value || './My_Courses'
|
||||
}
|
||||
|
||||
async function findCourse(slug: string, includeHidden = false) {
|
||||
return prisma.course.findFirst({
|
||||
where: includeHidden ? { slug } : { slug, hidden: false },
|
||||
select: { id: true, slug: true, name: true, displayName: true, hidden: true, path: true },
|
||||
})
|
||||
}
|
||||
|
||||
function isPathWithinRoot(candidatePath: string, rootPath: string) {
|
||||
const normalizedCandidate = resolve(candidatePath)
|
||||
const normalizedRoot = resolve(rootPath)
|
||||
return normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(normalizedRoot + sep)
|
||||
}
|
||||
|
||||
async function enrichCourseTags(course: { id: string }) {
|
||||
const connections = await prisma.courseTag.findMany({
|
||||
where: { courseId: course.id },
|
||||
include: { tag: true },
|
||||
})
|
||||
return connections.map(cn => cn.tag)
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
|
||||
try {
|
||||
const { slug } = await params
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const rename = renameSchema.safeParse(body)
|
||||
const tag = body?.tagId ? tagSchema.safeParse(body) : null
|
||||
const favorite = body?.favorited !== undefined ? favoriteSchema.safeParse(body) : null
|
||||
|
||||
if (!rename.success && !tag?.success && !favorite?.success) {
|
||||
return NextResponse.json({ error: 'Invalid request', details: rename.error?.flatten() ?? {} }, { status: 400 })
|
||||
}
|
||||
|
||||
const existing = await findCourse(slug)
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Course not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {}
|
||||
|
||||
if (rename.success) {
|
||||
data.displayName = rename.data.displayName
|
||||
}
|
||||
|
||||
if (favorite?.success) {
|
||||
data.favorited = favorite.data.favorited
|
||||
}
|
||||
|
||||
const tagRecord = tag?.success
|
||||
? await prisma.tag.upsert({
|
||||
where: { name: tag.data.tagId },
|
||||
update: {},
|
||||
create: { name: tag.data.tagId },
|
||||
})
|
||||
: null
|
||||
|
||||
const course = await prisma.course.update({
|
||||
where: { id: existing.id },
|
||||
data,
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
displayName: true,
|
||||
hidden: true,
|
||||
path: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (tagRecord) {
|
||||
await prisma.courseTag.upsert({
|
||||
where: {
|
||||
courseId_tagId: {
|
||||
courseId: existing.id,
|
||||
tagId: tagRecord.id,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
courseId: existing.id,
|
||||
tagId: tagRecord.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const tags = await enrichCourseTags(course)
|
||||
|
||||
return NextResponse.json({ success: true, course: { ...course, tags } })
|
||||
} catch (error) {
|
||||
console.error('Course update error:', error)
|
||||
return NextResponse.json({ error: 'Failed to update course' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
|
||||
try {
|
||||
const { slug } = await params
|
||||
const scope = (request.nextUrl.searchParams.get('scope') || 'library').toLowerCase()
|
||||
|
||||
if (scope !== 'library' && scope !== 'disk' && scope !== 'tag') {
|
||||
return NextResponse.json({ error: 'Invalid delete scope. Use library, disk, or tag.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const existing = await findCourse(slug, scope === 'disk')
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Course not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (scope === 'disk') {
|
||||
const coursesRoot = await getCoursesRootPath()
|
||||
const absoluteCoursePath = resolve(existing.path)
|
||||
const absoluteCoursesRoot = resolve(coursesRoot)
|
||||
|
||||
if (!isPathWithinRoot(absoluteCoursePath, absoluteCoursesRoot)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Course path is outside the configured courses root',
|
||||
details: { coursePath: absoluteCoursePath, coursesRoot: absoluteCoursesRoot },
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
await rm(absoluteCoursePath, { recursive: true, force: true })
|
||||
await prisma.course.delete({ where: { id: existing.id } })
|
||||
|
||||
return NextResponse.json({ success: true, deletedFromDisk: true, path: absoluteCoursePath })
|
||||
}
|
||||
|
||||
if (scope === 'tag') {
|
||||
const { tagId } = await request.json().catch(() => ({ tagId: '' }))
|
||||
|
||||
if (!tagId) {
|
||||
return NextResponse.json({ error: 'tagId is required when removing a tag' }, { status: 400 })
|
||||
}
|
||||
|
||||
await prisma.courseTag.deleteMany({
|
||||
where: { courseId: existing.id, tagId },
|
||||
})
|
||||
|
||||
const course = await prisma.course.findUnique({
|
||||
where: { id: existing.id },
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
displayName: true,
|
||||
hidden: true,
|
||||
path: true,
|
||||
},
|
||||
})
|
||||
|
||||
const tags = course ? await enrichCourseTags(course) : []
|
||||
return NextResponse.json({ success: true, course: course ? { ...course, tags } : null })
|
||||
}
|
||||
|
||||
await prisma.course.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
hidden: true,
|
||||
displayName: null,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, deletedFromLibrary: true })
|
||||
} catch (error) {
|
||||
console.error('Course delete error:', error)
|
||||
return NextResponse.json({ error: 'Failed to delete course' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getLocalThumbnailUrl } from '@/lib/thumbnail-index-server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = request.nextUrl
|
||||
|
||||
// Pagination
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '10'), 100)
|
||||
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:<tagId>
|
||||
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 }
|
||||
|
||||
if (search) {
|
||||
const term = search.trim()
|
||||
where.OR = [
|
||||
{ name: { contains: term } },
|
||||
{ displayName: { contains: term } },
|
||||
{ slug: { contains: term } },
|
||||
]
|
||||
}
|
||||
|
||||
if (favoritesOnly || filter === 'favorites') {
|
||||
where.favorited = true
|
||||
}
|
||||
|
||||
if (tagFilter) {
|
||||
where.courseTags = { some: { tagId: tagFilter } }
|
||||
}
|
||||
|
||||
if (filter === 'in-progress') {
|
||||
where.progress = {
|
||||
some: { userId: 'local-user', completed: false, lessonId: null },
|
||||
}
|
||||
} else if (filter === 'completed') {
|
||||
where.progress = {
|
||||
some: { userId: 'local-user', completed: true, lessonId: null },
|
||||
}
|
||||
} else if (filter === 'not-started') {
|
||||
where.NOT = {
|
||||
progress: { some: { userId: 'local-user', lessonId: null } },
|
||||
}
|
||||
}
|
||||
|
||||
// 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 total = await prisma.course.count({ where })
|
||||
|
||||
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 },
|
||||
},
|
||||
courseTags: {
|
||||
include: { tag: true },
|
||||
orderBy: { tag: { name: 'asc' } },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const courseIds = courses.map(c => c.id)
|
||||
const lessonCounts = await prisma.lesson.groupBy({
|
||||
by: ['moduleId'],
|
||||
where: { module: { courseId: { in: courseIds } } },
|
||||
_count: true,
|
||||
})
|
||||
|
||||
const moduleToCourse = new Map<string, string>()
|
||||
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 moduleLessonCounts = new Map<string, number>()
|
||||
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 totalPages = Math.ceil(total / limit)
|
||||
const tags = await prisma.tag.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
include: { _count: { select: { courseTags: true } } },
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
courses: coursesWithProgress,
|
||||
tags,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages,
|
||||
hasNext: page < totalPages,
|
||||
hasPrev: page > 1,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Courses API error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch courses', details: String(error) },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
Executable
+141
@@ -0,0 +1,141 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { existsSync, statSync, createReadStream } from 'fs'
|
||||
import { join, resolve } from 'path'
|
||||
import { getCoursesRootPath } from '@/lib/scanner'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
try {
|
||||
const { path: pathSegments } = await params
|
||||
const filePath = pathSegments.join('/')
|
||||
|
||||
// Security: resolve and validate path is within COURSES_ROOT
|
||||
const coursesRoot = resolve(await getCoursesRootPath())
|
||||
const requestedPath = resolve(join(coursesRoot, filePath))
|
||||
|
||||
// Prevent directory traversal
|
||||
if (!requestedPath.startsWith(coursesRoot)) {
|
||||
return new NextResponse('Forbidden', { status: 403 })
|
||||
}
|
||||
|
||||
// Helper: try to find file with alternative extensions (e.g., .svg vs .png)
|
||||
const findActualFile = async (basePath: string): Promise<string | null> => {
|
||||
const extensions = ['.svg', '.png', '.jpg', '.jpeg', '.gif', '.webp']
|
||||
for (const ext of extensions) {
|
||||
const tryPath = basePath.replace(/\.[^.]+$/, '') + ext
|
||||
if (existsSync(tryPath)) return tryPath
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
let actualPath = requestedPath
|
||||
if (!existsSync(actualPath)) {
|
||||
// Try alternative extensions (for thumbnails with wrong extension requests)
|
||||
const foundPath = await findActualFile(requestedPath)
|
||||
if (foundPath) {
|
||||
actualPath = foundPath
|
||||
} else {
|
||||
return new NextResponse('Not Found', { status: 404 })
|
||||
}
|
||||
}
|
||||
|
||||
const stats = statSync(actualPath)
|
||||
|
||||
// Don't serve directories
|
||||
if (stats.isDirectory()) {
|
||||
return new NextResponse('Forbidden', { status: 403 })
|
||||
}
|
||||
|
||||
const fileSize = stats.size
|
||||
const range = request.headers.get('range')
|
||||
|
||||
// Determine content type from ACTUAL file extension
|
||||
const actualExt = actualPath.split('.').pop()?.toLowerCase()
|
||||
const mimeTypes: Record<string, string> = {
|
||||
mp4: 'video/mp4',
|
||||
mkv: 'video/x-matroska',
|
||||
webm: 'video/webm',
|
||||
mov: 'video/quicktime',
|
||||
avi: 'video/x-msvideo',
|
||||
mp3: 'audio/mpeg',
|
||||
wav: 'audio/wav',
|
||||
m4a: 'audio/mp4',
|
||||
pdf: 'application/pdf',
|
||||
md: 'text/markdown; charset=utf-8',
|
||||
html: 'text/html; charset=utf-8',
|
||||
htm: 'text/html; charset=utf-8',
|
||||
json: 'application/json; charset=utf-8',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
srt: 'text/plain; charset=utf-8',
|
||||
vtt: 'text/vtt; charset=utf-8',
|
||||
txt: 'text/plain; charset=utf-8',
|
||||
}
|
||||
const contentType = mimeTypes[actualExt || ''] || 'application/octet-stream'
|
||||
|
||||
// Handle range requests (video seeking)
|
||||
if (range) {
|
||||
const parts = range.replace(/bytes=/, '').split('-')
|
||||
const start = parseInt(parts[0], 10)
|
||||
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1
|
||||
const chunkSize = end - start + 1
|
||||
|
||||
if (start >= fileSize || end >= fileSize) {
|
||||
return new NextResponse(null, {
|
||||
status: 416,
|
||||
headers: { 'Content-Range': `bytes */${fileSize}` },
|
||||
})
|
||||
}
|
||||
|
||||
const stream = createReadStream(actualPath, { start, end })
|
||||
|
||||
// Set Content-Disposition to inline for previewable file types
|
||||
const previewableTypes = ['pdf', 'markdown', 'html', 'plain', 'json', 'vtt', 'srt']
|
||||
const isPreviewable = previewableTypes.some(t => contentType.includes(t))
|
||||
const contentDisposition = isPreviewable ? 'inline' : 'attachment'
|
||||
|
||||
return new NextResponse(stream as any, {
|
||||
status: 206,
|
||||
headers: {
|
||||
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': chunkSize.toString(),
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
'Content-Disposition': `${contentDisposition}; filename="${actualPath.split('/').pop()}"`,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Full file response
|
||||
const stream = createReadStream(actualPath)
|
||||
|
||||
// Set Content-Disposition to inline for previewable file types
|
||||
const previewableTypes = ['pdf', 'markdown', 'html', 'plain', 'json', 'vtt', 'srt']
|
||||
const isPreviewable = previewableTypes.some(t => contentType.includes(t))
|
||||
const contentDisposition = isPreviewable ? 'inline' : 'attachment'
|
||||
|
||||
return new NextResponse(stream as any, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Length': fileSize.toString(),
|
||||
'Content-Type': contentType,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
'Content-Disposition': `${contentDisposition}; filename="${actualPath.split('/').pop()}"`,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('File serve error:', error)
|
||||
return new NextResponse('Internal Server Error', { status: 500 })
|
||||
}
|
||||
}
|
||||
Executable
+127
@@ -0,0 +1,127 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ lessonId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { lessonId } = await params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const courseSlug = searchParams.get('course')
|
||||
|
||||
// Find the lesson with its module and progress
|
||||
const lesson = await prisma.lesson.findUnique({
|
||||
where: { id: lessonId },
|
||||
include: {
|
||||
module: true,
|
||||
progress: {
|
||||
where: { userId: 'local-user' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!lesson) {
|
||||
return NextResponse.json({ error: 'Lesson not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Fetch the course
|
||||
const course = await prisma.course.findFirst({
|
||||
where: { id: lesson.module.courseId, hidden: false },
|
||||
})
|
||||
|
||||
if (!course) {
|
||||
return NextResponse.json({ error: 'Course not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Fetch modules with lessons and progress
|
||||
const modules = await prisma.module.findMany({
|
||||
where: { courseId: course.id },
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
lessons: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
progress: {
|
||||
where: { userId: 'local-user' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Fetch course-level progress, with a fallback to the latest watched lesson.
|
||||
const courseProgress = await prisma.progress.findFirst({
|
||||
where: { userId: 'local-user', courseId: course.id, lessonId: null, moduleId: null },
|
||||
})
|
||||
const latestLessonProgress = courseProgress
|
||||
? null
|
||||
: await prisma.progress.findFirst({
|
||||
where: {
|
||||
userId: 'local-user',
|
||||
courseId: course.id,
|
||||
lessonId: { not: null },
|
||||
},
|
||||
orderBy: { lastWatched: 'desc' },
|
||||
})
|
||||
|
||||
// Calculate all lessons
|
||||
const allLessons = modules.flatMap(m => m.lessons)
|
||||
const currentIndex = allLessons.findIndex(l => l.id === lessonId)
|
||||
const prevLesson = currentIndex > 0 ? allLessons[currentIndex - 1] : null
|
||||
const nextLesson = currentIndex < allLessons.length - 1 ? allLessons[currentIndex + 1] : null
|
||||
|
||||
const totalLessons = allLessons.length
|
||||
const completedLessons = allLessons.filter(l => l.progress[0]?.completed).length
|
||||
const percentage = totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0
|
||||
|
||||
// Build response data
|
||||
const data = {
|
||||
lesson: {
|
||||
...lesson,
|
||||
progress: lesson.progress[0] ? {
|
||||
...lesson.progress[0],
|
||||
lastWatched: lesson.progress[0].lastWatched.toISOString()
|
||||
} : null,
|
||||
},
|
||||
course: {
|
||||
...course,
|
||||
progress: courseProgress
|
||||
? {
|
||||
...courseProgress,
|
||||
lastWatched: courseProgress.lastWatched.toISOString(),
|
||||
}
|
||||
: latestLessonProgress
|
||||
? {
|
||||
...latestLessonProgress,
|
||||
lastWatched: latestLessonProgress.lastWatched.toISOString(),
|
||||
}
|
||||
: null,
|
||||
modules: modules.map(m => ({
|
||||
...m,
|
||||
lessons: m.lessons.map(l => ({
|
||||
...l,
|
||||
progress: l.progress[0] ? {
|
||||
...l.progress[0],
|
||||
lastWatched: l.progress[0].lastWatched.toISOString()
|
||||
} : null,
|
||||
})),
|
||||
})),
|
||||
stats: {
|
||||
totalLessons,
|
||||
completedLessons,
|
||||
percentage,
|
||||
},
|
||||
},
|
||||
prevLesson,
|
||||
nextLesson,
|
||||
currentIndex,
|
||||
totalLessons: allLessons.length,
|
||||
}
|
||||
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Lesson fetch error:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch lesson' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Executable
+320
@@ -0,0 +1,320 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { z } from 'zod'
|
||||
|
||||
const progressSchema = z.object({
|
||||
lessonId: z.string(),
|
||||
courseId: z.string(),
|
||||
moduleId: z.string(),
|
||||
position: z.number().min(0),
|
||||
completed: z.boolean().default(false),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const parsed = progressSchema.safeParse(body)
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Invalid payload', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
|
||||
const { lessonId, courseId, moduleId, position, completed } = parsed.data
|
||||
|
||||
const progress = await prisma.progress.upsert({
|
||||
where: {
|
||||
userId_lessonId: {
|
||||
userId: 'local-user',
|
||||
lessonId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
position,
|
||||
completed,
|
||||
lastWatched: new Date(),
|
||||
courseId,
|
||||
moduleId,
|
||||
},
|
||||
create: {
|
||||
userId: 'local-user',
|
||||
lessonId,
|
||||
courseId,
|
||||
moduleId,
|
||||
position,
|
||||
completed,
|
||||
lastWatched: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
// Also maintain a course-level summary row so course pages can resume correctly.
|
||||
// This is stored separately from lesson progress using null lessonId/moduleId.
|
||||
const totalLessons = await prisma.lesson.count({
|
||||
where: { module: { courseId } },
|
||||
})
|
||||
const completedLessons = await prisma.lesson.count({
|
||||
where: {
|
||||
module: { courseId },
|
||||
progress: { some: { userId: 'local-user', completed: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (totalLessons > 0) {
|
||||
const courseCompleted = completedLessons === totalLessons
|
||||
const courseProgressData = {
|
||||
userId: 'local-user',
|
||||
courseId,
|
||||
lessonId: null,
|
||||
moduleId: null,
|
||||
position,
|
||||
completed: courseCompleted,
|
||||
lastWatched: new Date(),
|
||||
}
|
||||
|
||||
const existingCourseProgress = await prisma.progress.findFirst({
|
||||
where: {
|
||||
userId: 'local-user',
|
||||
courseId,
|
||||
lessonId: null,
|
||||
moduleId: null,
|
||||
},
|
||||
})
|
||||
|
||||
if (existingCourseProgress) {
|
||||
await prisma.progress.update({
|
||||
where: { id: existingCourseProgress.id },
|
||||
data: courseProgressData,
|
||||
})
|
||||
} else {
|
||||
await prisma.progress.create({
|
||||
data: courseProgressData,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, progress })
|
||||
} catch (error) {
|
||||
console.error('Progress save error:', error)
|
||||
return NextResponse.json({ error: 'Failed to save progress' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const lessonId = searchParams.get('lessonId')
|
||||
const courseId = searchParams.get('courseId')
|
||||
const type = searchParams.get('type') // 'continue' or 'completed'
|
||||
|
||||
if (type === 'continue') {
|
||||
// Get in-progress lessons
|
||||
const progressRecords = await prisma.progress.findMany({
|
||||
where: {
|
||||
userId: 'local-user',
|
||||
lessonId: { not: null },
|
||||
completed: false,
|
||||
lesson: { module: { course: { hidden: false } } },
|
||||
},
|
||||
orderBy: { lastWatched: 'desc' },
|
||||
take: 20,
|
||||
include: {
|
||||
lesson: {
|
||||
include: {
|
||||
module: {
|
||||
include: {
|
||||
course: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const items = progressRecords
|
||||
.filter(p => p.lesson)
|
||||
.map(p => ({
|
||||
progress: {
|
||||
...p,
|
||||
lastWatched: p.lastWatched.toISOString(),
|
||||
},
|
||||
lesson: p.lesson!,
|
||||
course: p.lesson!.module.course,
|
||||
module: p.lesson!.module,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ items })
|
||||
}
|
||||
|
||||
if (type === 'completed') {
|
||||
// Get completed courses
|
||||
const completedProgress = await prisma.progress.findMany({
|
||||
where: {
|
||||
userId: 'local-user',
|
||||
lessonId: null,
|
||||
moduleId: null,
|
||||
completed: true,
|
||||
course: { hidden: false },
|
||||
},
|
||||
include: {
|
||||
course: true,
|
||||
},
|
||||
})
|
||||
|
||||
const items = completedProgress.map(p => p.course)
|
||||
|
||||
return NextResponse.json({ items })
|
||||
}
|
||||
|
||||
if (type === 'analytics') {
|
||||
// Get analytics data for dashboard
|
||||
const allProgress = await prisma.progress.findMany({
|
||||
where: {
|
||||
userId: 'local-user',
|
||||
course: { hidden: false },
|
||||
},
|
||||
})
|
||||
|
||||
const completedLessons = allProgress.filter(p => p.lessonId && p.completed)
|
||||
const inProgressLessons = allProgress.filter(p => p.lessonId && !p.completed && p.position > 0)
|
||||
const notStartedCount = await prisma.lesson.count({
|
||||
where: {
|
||||
module: { course: { hidden: false } },
|
||||
progress: { none: { userId: 'local-user' } },
|
||||
},
|
||||
})
|
||||
|
||||
const totalCourses = await prisma.course.count({ where: { hidden: false } })
|
||||
const completedCourses = await prisma.progress.count({
|
||||
where: {
|
||||
userId: 'local-user',
|
||||
lessonId: null,
|
||||
moduleId: null,
|
||||
completed: true,
|
||||
course: { hidden: false },
|
||||
},
|
||||
})
|
||||
const inProgressCourses = await prisma.progress.count({
|
||||
where: {
|
||||
userId: 'local-user',
|
||||
lessonId: null,
|
||||
moduleId: null,
|
||||
completed: false,
|
||||
course: { hidden: false },
|
||||
},
|
||||
})
|
||||
|
||||
// Calculate total watched time (in seconds)
|
||||
const totalWatchedSeconds = allProgress.reduce((sum, p) => sum + (p.position || 0), 0)
|
||||
|
||||
// Get weekly activity (last 7 days)
|
||||
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
||||
const recentProgress = allProgress.filter(p => new Date(p.lastWatched) >= sevenDaysAgo)
|
||||
const weeklyWatchedSeconds = recentProgress.reduce((sum, p) => sum + (p.position || 0), 0)
|
||||
const weeklyCompleted = recentProgress.filter(p => p.lessonId && p.completed).length
|
||||
|
||||
// Lessons by type
|
||||
const lessonsByType = await prisma.lesson.groupBy({
|
||||
by: ['type'],
|
||||
_count: true,
|
||||
where: { module: { course: { hidden: false } } },
|
||||
})
|
||||
|
||||
// Completed lessons by type
|
||||
const completedByType: Record<string, number> = {}
|
||||
for (const p of completedLessons) {
|
||||
if (!p.lessonId) continue
|
||||
const lesson = await prisma.lesson.findUnique({ where: { id: p.lessonId }, select: { type: true } })
|
||||
if (lesson) {
|
||||
completedByType[lesson.type] = (completedByType[lesson.type] || 0) + 1
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
totalCourses,
|
||||
completedCourses,
|
||||
inProgressCourses,
|
||||
totalLessons: completedLessons.length + inProgressLessons.length + notStartedCount,
|
||||
completedLessons: completedLessons.length,
|
||||
inProgressLessons: inProgressLessons.length,
|
||||
notStartedLessons: notStartedCount,
|
||||
totalWatchedHours: Math.round(totalWatchedSeconds / 3600 * 10) / 10,
|
||||
totalWatchedMinutes: Math.round(totalWatchedSeconds / 60),
|
||||
weeklyWatchedHours: Math.round(weeklyWatchedSeconds / 3600 * 10) / 10,
|
||||
weeklyWatchedMinutes: Math.round(weeklyWatchedSeconds / 60),
|
||||
weeklyCompletedLessons: weeklyCompleted,
|
||||
lessonsByType,
|
||||
completedByType,
|
||||
completionRate: totalCourses > 0 ? Math.round((completedCourses / totalCourses) * 100) : 0,
|
||||
lessonCompletionRate: (completedLessons.length + inProgressLessons.length + notStartedCount) > 0
|
||||
? Math.round((completedLessons.length / (completedLessons.length + inProgressLessons.length + notStartedCount)) * 100)
|
||||
: 0,
|
||||
})
|
||||
}
|
||||
|
||||
if (lessonId) {
|
||||
const progress = await prisma.progress.findUnique({
|
||||
where: {
|
||||
userId_lessonId: {
|
||||
userId: 'local-user',
|
||||
lessonId,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (progress) {
|
||||
return NextResponse.json({
|
||||
progress: {
|
||||
...progress,
|
||||
lastWatched: progress.lastWatched.toISOString()
|
||||
}
|
||||
})
|
||||
}
|
||||
return NextResponse.json({ progress: null })
|
||||
}
|
||||
|
||||
if (courseId) {
|
||||
const progress = await prisma.progress.findFirst({
|
||||
where: {
|
||||
userId: 'local-user',
|
||||
courseId,
|
||||
lessonId: null,
|
||||
moduleId: null,
|
||||
course: { hidden: false },
|
||||
},
|
||||
})
|
||||
|
||||
if (progress) {
|
||||
return NextResponse.json({
|
||||
progress: {
|
||||
...progress,
|
||||
lastWatched: progress.lastWatched.toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const latestLessonProgress = await prisma.progress.findFirst({
|
||||
where: {
|
||||
userId: 'local-user',
|
||||
courseId,
|
||||
lessonId: { not: null },
|
||||
course: { hidden: false },
|
||||
},
|
||||
orderBy: { lastWatched: 'desc' },
|
||||
})
|
||||
|
||||
if (latestLessonProgress) {
|
||||
return NextResponse.json({
|
||||
progress: {
|
||||
...latestLessonProgress,
|
||||
lastWatched: latestLessonProgress.lastWatched.toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ progress: null })
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'lessonId, courseId or type required' }, { status: 400 })
|
||||
} catch (error) {
|
||||
console.error('Progress fetch error:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch progress' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { z } from 'zod'
|
||||
|
||||
const requestSchema = z.object({
|
||||
lessonId: z.string().min(1),
|
||||
courseId: z.string().min(1),
|
||||
moduleId: z.string().min(1),
|
||||
score: z.number().int().min(0),
|
||||
totalQuestions: z.number().int().positive(),
|
||||
passed: z.boolean(),
|
||||
})
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const parsed = requestSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request', details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { lessonId, courseId, moduleId, score, totalQuestions, passed } = parsed.data
|
||||
|
||||
const quizAttempt = await prisma.quizAttempt.upsert({
|
||||
where: {
|
||||
userId_lessonId: {
|
||||
userId: 'local-user',
|
||||
lessonId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
score,
|
||||
passed,
|
||||
completed: passed,
|
||||
},
|
||||
create: {
|
||||
userId: 'local-user',
|
||||
lessonId,
|
||||
score,
|
||||
passed,
|
||||
completed: passed,
|
||||
},
|
||||
})
|
||||
|
||||
const progress = await prisma.progress.upsert({
|
||||
where: {
|
||||
userId_lessonId: {
|
||||
userId: 'local-user',
|
||||
lessonId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
courseId,
|
||||
moduleId,
|
||||
completed: passed,
|
||||
position: passed ? totalQuestions : score,
|
||||
lastWatched: new Date(),
|
||||
},
|
||||
create: {
|
||||
userId: 'local-user',
|
||||
lessonId,
|
||||
courseId,
|
||||
moduleId,
|
||||
completed: passed,
|
||||
position: passed ? totalQuestions : score,
|
||||
lastWatched: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, quizAttempt, progress })
|
||||
} catch (error) {
|
||||
console.error('Quiz attempt error:', error)
|
||||
return NextResponse.json({ error: 'Failed to save quiz attempt' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { ensureModuleQuizCache, syncQuizLessonFromCache, shouldSkipQuizGeneration } from '@/lib/quiz'
|
||||
import type { QuizSource } from '@/lib/quiz-types'
|
||||
import { z } from 'zod'
|
||||
import { join, dirname } from 'path'
|
||||
|
||||
const requestSchema = z.object({
|
||||
lessonId: z.string().min(1),
|
||||
topic: z.string().min(1).optional(),
|
||||
source: z.enum(['quizapi', 'the-trivia-api']).optional(),
|
||||
force: z.boolean().optional(),
|
||||
})
|
||||
|
||||
async function getQuizSource(): Promise<QuizSource> {
|
||||
const setting = await prisma.setting.findUnique({ where: { key: 'quizApiSource' } })
|
||||
return setting?.value === 'the-trivia-api' ? 'the-trivia-api' : 'quizapi'
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const parsed = requestSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request', details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const lesson = await prisma.lesson.findUnique({
|
||||
where: { id: parsed.data.lessonId },
|
||||
include: {
|
||||
module: {
|
||||
include: {
|
||||
course: true,
|
||||
lessons: {
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!lesson) {
|
||||
return NextResponse.json({ error: 'Lesson not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!lesson.module.course.path) {
|
||||
return NextResponse.json({ error: 'Course path missing' }, { status: 400 })
|
||||
}
|
||||
|
||||
const source = parsed.data.source || await getQuizSource()
|
||||
const topic = parsed.data.topic?.trim() || lesson.module.name
|
||||
const modulePath = join(lesson.module.course.path, lesson.module.name)
|
||||
const moduleShouldSkip = await shouldSkipQuizGeneration(modulePath, topic)
|
||||
if (moduleShouldSkip) {
|
||||
return NextResponse.json({ error: 'Quiz generation skipped for this module' }, { status: 400 })
|
||||
}
|
||||
|
||||
const cache = await ensureModuleQuizCache({
|
||||
modulePath,
|
||||
topic,
|
||||
source,
|
||||
force: parsed.data.force ?? Boolean(parsed.data.topic),
|
||||
})
|
||||
|
||||
if (!cache) {
|
||||
return NextResponse.json({ error: 'Failed to generate quiz cache' }, { status: 500 })
|
||||
}
|
||||
|
||||
const synced = await syncQuizLessonFromCache({
|
||||
moduleId: lesson.moduleId,
|
||||
modulePath,
|
||||
courseRoot: dirname(lesson.module.course.path),
|
||||
topic,
|
||||
source,
|
||||
autoFetch: false,
|
||||
force: false,
|
||||
})
|
||||
|
||||
return NextResponse.json({ ...cache, lesson: synced?.lesson || null })
|
||||
} catch (error) {
|
||||
console.error('Quiz API error:', error)
|
||||
return NextResponse.json({ error: 'Failed to generate quiz' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { scanCoursesDirectory, scanCoursesFull } from '@/lib/scanner'
|
||||
|
||||
async function getQuizScanSettings() {
|
||||
const settings = await prisma.setting.findMany({
|
||||
where: { key: { in: ['autoFetchQuizzes', 'quizApiSource'] } },
|
||||
})
|
||||
|
||||
const settingsMap = Object.fromEntries(settings.map((setting) => [setting.key, setting.value]))
|
||||
return {
|
||||
autoFetchQuizzes: settingsMap.autoFetchQuizzes === 'true',
|
||||
quizApiSource: settingsMap.quizApiSource === 'the-trivia-api'
|
||||
? 'the-trivia-api'
|
||||
: 'quizapi',
|
||||
} as const
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const isFullScan = body.fullScan === true
|
||||
const quizSettings = await getQuizScanSettings()
|
||||
|
||||
const result = isFullScan
|
||||
? await scanCoursesFull(quizSettings)
|
||||
: await scanCoursesDirectory(quizSettings)
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
return NextResponse.json({
|
||||
...result,
|
||||
duration,
|
||||
success: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Scan API error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Scan failed', details: String(error), success: false },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(
|
||||
{ error: 'Method not allowed. Use POST to trigger scan.' },
|
||||
{ status: 405 }
|
||||
)
|
||||
}
|
||||
Executable
+103
@@ -0,0 +1,103 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { z } from 'zod'
|
||||
|
||||
const SETTINGS_KEYS = ['coursesRoot', 'autoFetchQuizzes', 'quizApiSource', 'quizApiKey'] as const
|
||||
const SETTINGS_KEYS_ARRAY: string[] = [...SETTINGS_KEYS]
|
||||
|
||||
const singleSettingSchema = z.object({
|
||||
key: z.enum(['coursesRoot', 'autoFetchQuizzes', 'quizApiSource', 'quizApiKey']),
|
||||
value: z.union([z.string(), z.boolean()]),
|
||||
})
|
||||
|
||||
const bulkSettingsSchema = z.object({
|
||||
coursesRoot: z.string().min(1).optional(),
|
||||
autoFetchQuizzes: z.union([z.boolean(), z.string()]).optional(),
|
||||
quizApiSource: z.enum(['the-trivia-api', 'quizapi']).optional(),
|
||||
quizApiKey: z.string().optional(),
|
||||
})
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const settings = await prisma.setting.findMany({
|
||||
where: { key: { in: SETTINGS_KEYS_ARRAY } },
|
||||
})
|
||||
|
||||
const settingsMap: Record<string, string> = {}
|
||||
for (const s of settings) {
|
||||
settingsMap[s.key] = s.value
|
||||
}
|
||||
|
||||
// Default coursesRoot if not set
|
||||
if (!settingsMap.coursesRoot) {
|
||||
settingsMap.coursesRoot = './My_Courses'
|
||||
}
|
||||
|
||||
if (!settingsMap.autoFetchQuizzes) {
|
||||
settingsMap.autoFetchQuizzes = 'false'
|
||||
}
|
||||
|
||||
if (!settingsMap.quizApiSource) {
|
||||
settingsMap.quizApiSource = 'quizapi'
|
||||
}
|
||||
|
||||
// Always include quizApiKey in response (empty string if not set)
|
||||
settingsMap.quizApiKey = settingsMap.quizApiKey || ''
|
||||
console.log('[DEBUG] Settings response:', JSON.stringify(settingsMap))
|
||||
|
||||
return NextResponse.json(settingsMap)
|
||||
} catch (error) {
|
||||
console.error('Settings fetch error:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch settings' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
const singleParsed = singleSettingSchema.safeParse(body)
|
||||
if (singleParsed.success) {
|
||||
const { key, value } = singleParsed.data
|
||||
const normalizedValue = typeof value === 'boolean' ? String(value) : value
|
||||
|
||||
const setting = await prisma.setting.upsert({
|
||||
where: { key },
|
||||
update: { value: normalizedValue },
|
||||
create: { key, value: normalizedValue },
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, setting })
|
||||
}
|
||||
|
||||
const bulkParsed = bulkSettingsSchema.safeParse(body)
|
||||
if (!bulkParsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request', details: bulkParsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const entries = Object.entries(bulkParsed.data).filter(([, value]) => value !== undefined)
|
||||
|
||||
if (entries.length === 0) {
|
||||
return NextResponse.json({ error: 'No settings provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
const savedSettings = []
|
||||
for (const [key, value] of entries) {
|
||||
const normalizedValue = typeof value === 'boolean' ? String(value) : value
|
||||
const setting = await prisma.setting.upsert({
|
||||
where: { key },
|
||||
update: { value: normalizedValue },
|
||||
create: { key, value: normalizedValue },
|
||||
})
|
||||
savedSettings.push(setting)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, settings: savedSettings })
|
||||
} catch (error) {
|
||||
console.error('Settings update error:', error)
|
||||
return NextResponse.json({ error: 'Failed to update setting' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user