Files
offlineacademy/app/api/courses/[slug]/route.ts
T
2026-06-26 02:51:08 +00:00

186 lines
5.7 KiB
TypeScript

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 })
}
}