|
@@ -293,13 +293,14 @@ app.get('/api/files/:stored', { preHandler: accountAuth }, async (req, reply) =>
|
|
|
const digitalFile = await pool.query(`SELECT f.stored_name,d.organization_id,d.personal_user_id FROM digital_instructor_files f JOIN digital_instructors d ON d.id=f.instructor_id WHERE f.stored_name=$1`, [stored])
|
|
const digitalFile = await pool.query(`SELECT f.stored_name,d.organization_id,d.personal_user_id FROM digital_instructor_files f JOIN digital_instructors d ON d.id=f.instructor_id WHERE f.stored_name=$1`, [stored])
|
|
|
if (digitalFile.rows[0]) {
|
|
if (digitalFile.rows[0]) {
|
|
|
const file = digitalFile.rows[0]
|
|
const file = digitalFile.rows[0]
|
|
|
- const permitted = file.personal_user_id === userId(req) || (file.organization_id && ((req as any).user.role === 'ADMIN' || (await pool.query('SELECT 1 FROM organization_members WHERE organization_id=$1 AND user_id=$2', [file.organization_id,userId(req)])).rows[0]))
|
|
|
|
|
|
|
+ const permitted = (req as any).user.role === 'ADMIN' || file.personal_user_id === userId(req) || (file.organization_id && (await pool.query('SELECT 1 FROM organization_members WHERE organization_id=$1 AND user_id=$2', [file.organization_id,userId(req)])).rows[0])
|
|
|
if (!permitted) return reply.code(403).send({ message: '无权访问该文件' })
|
|
if (!permitted) return reply.code(403).send({ message: '无权访问该文件' })
|
|
|
return reply.sendFile(stored, uploadDir)
|
|
return reply.sendFile(stored, uploadDir)
|
|
|
}
|
|
}
|
|
|
const { rows } = await pool.query(`SELECT c.user_id,c.organization_id FROM courses c WHERE
|
|
const { rows } = await pool.query(`SELECT c.user_id,c.organization_id FROM courses c WHERE
|
|
|
EXISTS(SELECT 1 FROM course_assets a WHERE a.course_id=c.id AND a.stored_name=$1)
|
|
EXISTS(SELECT 1 FROM course_assets a WHERE a.course_id=c.id AND a.stored_name=$1)
|
|
|
OR EXISTS(SELECT 1 FROM course_deliverables d WHERE d.course_id=c.id AND d.stored_name=$1)
|
|
OR EXISTS(SELECT 1 FROM course_deliverables d WHERE d.course_id=c.id AND d.stored_name=$1)
|
|
|
|
|
+ OR EXISTS(SELECT 1 FROM course_instructor_files f WHERE f.course_id=c.id AND f.stored_name=$1)
|
|
|
OR EXISTS(SELECT 1 FROM instructors i WHERE i.course_id=c.id AND i.image_stored_name=$1)
|
|
OR EXISTS(SELECT 1 FROM instructors i WHERE i.course_id=c.id AND i.image_stored_name=$1)
|
|
|
OR EXISTS(SELECT 1 FROM instructor_images ii JOIN instructors i ON i.id=ii.instructor_id WHERE i.course_id=c.id AND ii.stored_name=$1)
|
|
OR EXISTS(SELECT 1 FROM instructor_images ii JOIN instructors i ON i.id=ii.instructor_id WHERE i.course_id=c.id AND ii.stored_name=$1)
|
|
|
LIMIT 1`, [stored])
|
|
LIMIT 1`, [stored])
|
|
@@ -1084,6 +1085,208 @@ app.get('/api/admin/courses/:id', { preHandler: courseOperator }, async (req, re
|
|
|
return { course }
|
|
return { course }
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
|
|
+// 制作中的课程由平台管理员维护;成员端仍读取同一份课程内容。
|
|
|
|
|
+async function productionCourse(id: string, reply: FastifyReply) {
|
|
|
|
|
+ const { rows } = await pool.query('SELECT * FROM courses WHERE id=$1', [id])
|
|
|
|
|
+ if (!rows[0]) { reply.code(404).send({ message: '课程不存在' }); return null }
|
|
|
|
|
+ if (rows[0].status !== 'IN_PRODUCTION') { reply.code(409).send({ message: '只有制作中的课程可以编辑素材' }); return null }
|
|
|
|
|
+ return rows[0]
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function pruneCourseInstructorFiles(id: string) {
|
|
|
|
|
+ const latest = await pool.query('SELECT digital_instructor_snapshot FROM courses WHERE id=$1', [id])
|
|
|
|
|
+ const snapshot = latest.rows[0]?.digital_instructor_snapshot || {}
|
|
|
|
|
+ const referenced = new Set([...(snapshot.images || []), ...(snapshot.audio || [])].map((file: any) => file.id))
|
|
|
|
|
+ const files = await pool.query('SELECT id,stored_name FROM course_instructor_files WHERE course_id=$1', [id])
|
|
|
|
|
+ for (const file of files.rows) if (!referenced.has(file.id)) {
|
|
|
|
|
+ await pool.query('DELETE FROM course_instructor_files WHERE id=$1 AND course_id=$2', [file.id,id])
|
|
|
|
|
+ await unlink(resolve(uploadDir,file.stored_name)).catch(() => {})
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+app.patch('/api/admin/courses/:id/content', { preHandler: adminAuth }, async (req, reply) => {
|
|
|
|
|
+ const { id } = req.params as { id: string }
|
|
|
|
|
+ const row = await productionCourse(id, reply)
|
|
|
|
|
+ if (!row) return
|
|
|
|
|
+ const p = z.object({ name: z.string().trim().min(1).max(100), description: z.string().max(500), assetNotes: z.string().max(5000) }).safeParse(req.body)
|
|
|
|
|
+ if (!p.success) return reply.code(400).send({ message: p.error.issues[0]?.message || '请检查课程信息' })
|
|
|
|
|
+ await pool.query('UPDATE courses SET name=$1,description=$2,asset_notes=$3,updated_at=NOW() WHERE id=$4', [p.data.name,p.data.description,p.data.assetNotes,id])
|
|
|
|
|
+ await addProgressEvent(id, 'ADMIN', 'CONTENT_UPDATED', '平台更新课程资料', '课程名称、需求说明或通用备注已更新。')
|
|
|
|
|
+ return { course: await fullCourse(row, true) }
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+app.post('/api/admin/courses/:id/instructor-sync', { preHandler: adminAuth }, async (req, reply) => {
|
|
|
|
|
+ const { id } = req.params as { id: string }
|
|
|
|
|
+ const row = await productionCourse(id, reply)
|
|
|
|
|
+ if (!row) return
|
|
|
|
|
+ if (!row.digital_instructor_id) return reply.code(409).send({ message: '课程尚未选择数字讲师' })
|
|
|
|
|
+ const instructor = await digitalInstructors.get(row.digital_instructor_id)
|
|
|
|
|
+ if (!instructor || (row.organization_id ? instructor.organization_id !== row.organization_id : instructor.personal_user_id !== row.user_id)) {
|
|
|
|
|
+ return reply.code(409).send({ message: '课程讲师与所属工作区不一致' })
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!await readyDigitalInstructor(instructor.id)) return reply.code(409).send({ message: '讲师照片和录音尚未齐备' })
|
|
|
|
|
+ const snapshot = await digitalInstructors.dto(instructor)
|
|
|
|
|
+ await pool.query('UPDATE courses SET digital_instructor_snapshot=$1,updated_at=NOW() WHERE id=$2', [JSON.stringify(snapshot),id])
|
|
|
|
|
+ await pruneCourseInstructorFiles(id)
|
|
|
|
|
+ await addProgressEvent(id, 'ADMIN', 'CONTENT_UPDATED', '平台同步数字讲师资料', '本课程的数字讲师快照已更新。')
|
|
|
|
|
+ return { course: await fullCourse(row, true) }
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+app.post('/api/admin/courses/:id/instructor', { preHandler: adminAuth }, async (req, reply) => {
|
|
|
|
|
+ const { id } = req.params as { id: string }
|
|
|
|
|
+ const row = await productionCourse(id, reply)
|
|
|
|
|
+ if (!row) return
|
|
|
|
|
+ if (!row.digital_instructor_snapshot) return reply.code(409).send({ message: '课程没有可编辑的数字讲师快照' })
|
|
|
|
|
+ const fields: Record<string,string> = {}
|
|
|
|
|
+ const pending: Array<{ id: string; kind: 'IMAGE' | 'AUDIO'; original: string; stored: string; size: number; mime: string }> = []
|
|
|
|
|
+ let committed = false
|
|
|
|
|
+ try {
|
|
|
|
|
+ for await (const part of req.parts()) {
|
|
|
|
|
+ if (part.type === 'field') { fields[part.fieldname] = String(part.value); continue }
|
|
|
|
|
+ const kind = part.fieldname === 'images' ? 'IMAGE' : part.fieldname === 'audio' ? 'AUDIO' : null
|
|
|
|
|
+ const mime = part.mimetype.toLowerCase()
|
|
|
|
|
+ if (!kind || !(kind === 'IMAGE' ? ['image/jpeg','image/png','image/webp'].includes(mime) : ['audio/wav','audio/x-wav','audio/mpeg','audio/mp4','audio/x-m4a','audio/webm','audio/ogg'].includes(mime))) throw new Error('照片或录音格式不支持')
|
|
|
|
|
+ if (kind === 'AUDIO' && pending.some((file) => file.kind === 'AUDIO')) throw new Error('一次只能上传一份录音')
|
|
|
|
|
+ const buffer = await part.toBuffer()
|
|
|
|
|
+ if (!buffer.length || buffer.length > (kind === 'IMAGE' ? 20 : 50) * 1024 * 1024) throw new Error('照片或录音为空或超过大小限制')
|
|
|
|
|
+ const stored = `course-instructor-${id}-${randomUUID()}${extname(part.filename).slice(0,10).toLowerCase()}`
|
|
|
|
|
+ await writeFile(resolve(uploadDir,stored),buffer)
|
|
|
|
|
+ pending.push({ id:randomUUID(),kind,original:part.filename.slice(0,255),stored,size:buffer.length,mime })
|
|
|
|
|
+ }
|
|
|
|
|
+ const parsed = z.object({ name: z.string().trim().min(1).max(80), note: z.string().max(1000), removeImageIds: z.string().default('[]') }).safeParse(fields)
|
|
|
|
|
+ if (!parsed.success) throw new Error(parsed.error.issues[0]?.message || '请检查讲师资料')
|
|
|
|
|
+ const removeIds: unknown = JSON.parse(parsed.data.removeImageIds)
|
|
|
|
|
+ if (!Array.isArray(removeIds) || removeIds.some((value) => typeof value !== 'string')) throw new Error('照片移除参数不正确')
|
|
|
|
|
+ const client = await pool.connect()
|
|
|
|
|
+ let snapshot: any
|
|
|
|
|
+ try {
|
|
|
|
|
+ await client.query('BEGIN')
|
|
|
|
|
+ const current = await client.query('SELECT status,digital_instructor_snapshot FROM courses WHERE id=$1 FOR UPDATE', [id])
|
|
|
|
|
+ if (current.rows[0]?.status !== 'IN_PRODUCTION') throw new Error('只有制作中的课程可以编辑讲师')
|
|
|
|
|
+ snapshot = current.rows[0].digital_instructor_snapshot
|
|
|
|
|
+ if (!snapshot) throw new Error('课程没有可编辑的数字讲师快照')
|
|
|
|
|
+ const originalImages = snapshot.images || []
|
|
|
|
|
+ if (removeIds.some((value) => !originalImages.some((image: any) => image.id === value))) throw new Error('要移除的照片不存在')
|
|
|
|
|
+ const images = [...originalImages.filter((image: any) => !removeIds.includes(image.id)), ...pending.filter((file) => file.kind === 'IMAGE').map((file) => ({ id:file.id,name:file.original,url:`/uploads/${file.stored}` }))]
|
|
|
|
|
+ const newAudio = pending.find((file) => file.kind === 'AUDIO')
|
|
|
|
|
+ const audio = newAudio ? [{ id:newAudio.id,name:newAudio.original,url:`/uploads/${newAudio.stored}` }] : (snapshot.audio || [])
|
|
|
|
|
+ if (!images.length || !audio.length || images.length > 19) throw new Error('请保留 1 至 19 张照片和一份录音')
|
|
|
|
|
+ snapshot = { ...snapshot, name:parsed.data.name, note:parsed.data.note, images, audio, ready:true }
|
|
|
|
|
+ for (const file of pending) await client.query('INSERT INTO course_instructor_files(id,course_id,kind,original_name,stored_name,size,mime_type) VALUES($1,$2,$3,$4,$5,$6,$7)', [file.id,id,file.kind,file.original,file.stored,file.size,file.mime])
|
|
|
|
|
+ await client.query('UPDATE courses SET digital_instructor_snapshot=$1,updated_at=NOW() WHERE id=$2', [JSON.stringify(snapshot),id])
|
|
|
|
|
+ await client.query('COMMIT')
|
|
|
|
|
+ committed = true
|
|
|
|
|
+ } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
|
|
|
|
+ await pruneCourseInstructorFiles(id)
|
|
|
|
|
+ await addProgressEvent(id, 'ADMIN', 'CONTENT_UPDATED', '平台修改本课数字讲师', '本课程的数字讲师资料或素材已更新。')
|
|
|
|
|
+ return { course: await fullCourse(row,true) }
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ return reply.code(400).send({ message:(error as Error).message || '讲师资料保存失败' })
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ if (!committed) for (const file of pending) await unlink(resolve(uploadDir,file.stored)).catch(() => {})
|
|
|
|
|
+ }
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+app.post('/api/admin/courses/:id/episodes', { preHandler: adminAuth }, async (req, reply) => {
|
|
|
|
|
+ const { id } = req.params as { id: string }
|
|
|
|
|
+ const row = await productionCourse(id, reply)
|
|
|
|
|
+ if (!row) return
|
|
|
|
|
+ const p = z.object({ title: z.string().trim().min(1).max(200), summary: z.string().max(500).default(''), lectureNotes: z.string().default('') }).safeParse(req.body)
|
|
|
|
|
+ if (!p.success) return reply.code(400).send({ message: p.error.issues[0]?.message || '请检查分集信息' })
|
|
|
|
|
+ const max = await pool.query('SELECT COALESCE(MAX(episode_number),0)::int AS value FROM episodes WHERE course_id=$1', [id])
|
|
|
|
|
+ await pool.query('INSERT INTO episodes(id,course_id,episode_number,title,summary,lecture_notes) VALUES($1,$2,$3,$4,$5,$6)', [randomUUID(),id,max.rows[0].value+1,p.data.title,p.data.summary,p.data.lectureNotes])
|
|
|
|
|
+ await refreshCoursePoints(id)
|
|
|
|
|
+ await addProgressEvent(id, 'ADMIN', 'CONTENT_UPDATED', '平台新增课程分集', '制作中的课程增加了分集。')
|
|
|
|
|
+ return reply.code(201).send({ course: await fullCourse(row, true) })
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+app.patch('/api/admin/courses/:id/episodes/:episodeId', { preHandler: adminAuth }, async (req, reply) => {
|
|
|
|
|
+ const { id, episodeId } = req.params as { id: string; episodeId: string }
|
|
|
|
|
+ const row = await productionCourse(id, reply)
|
|
|
|
|
+ if (!row) return
|
|
|
|
|
+ const p = z.object({ title: z.string().trim().min(1).max(200), summary: z.string().max(500), lectureNotes: z.string() }).safeParse(req.body)
|
|
|
|
|
+ if (!p.success) return reply.code(400).send({ message: p.error.issues[0]?.message || '请检查分集信息' })
|
|
|
|
|
+ const richPrefix = '<!--xinghen-richtext-->'
|
|
|
|
|
+ const notes = p.data.lectureNotes.startsWith(richPrefix) ? richPrefix + sanitizeHtml(p.data.lectureNotes.slice(richPrefix.length), {
|
|
|
|
|
+ allowedTags: ['p','div','br','strong','b','em','i','ul','ol','li'], allowedAttributes: {}
|
|
|
|
|
+ }) : p.data.lectureNotes
|
|
|
|
|
+ const updated = await pool.query('UPDATE episodes SET title=$1,summary=$2,lecture_notes=$3,updated_at=NOW() WHERE id=$4 AND course_id=$5 RETURNING id', [p.data.title,p.data.summary,notes,episodeId,id])
|
|
|
|
|
+ if (!updated.rows[0]) return reply.code(404).send({ message: '分集不存在' })
|
|
|
|
|
+ await pool.query('UPDATE courses SET updated_at=NOW() WHERE id=$1', [id])
|
|
|
|
|
+ await addProgressEvent(id, 'ADMIN', 'CONTENT_UPDATED', '平台修改课程分集', '制作中的分集标题、简介或讲稿已更新。')
|
|
|
|
|
+ return { course: await fullCourse(row, true) }
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+app.delete('/api/admin/courses/:id/episodes/:episodeId', { preHandler: adminAuth }, async (req, reply) => {
|
|
|
|
|
+ const { id, episodeId } = req.params as { id: string; episodeId: string }
|
|
|
|
|
+ const row = await productionCourse(id, reply)
|
|
|
|
|
+ if (!row) return
|
|
|
|
|
+ const existing = await pool.query('SELECT id FROM episodes WHERE id=$1 AND course_id=$2', [episodeId,id])
|
|
|
|
|
+ if (!existing.rows[0]) return reply.code(404).send({ message: '分集不存在' })
|
|
|
|
|
+ const count = await pool.query('SELECT COUNT(*)::int AS value FROM episodes WHERE course_id=$1', [id])
|
|
|
|
|
+ if (count.rows[0].value <= 1) return reply.code(409).send({ message: '课程至少保留一个分集' })
|
|
|
|
|
+ const delivered = await pool.query('SELECT 1 FROM course_deliverables WHERE course_id=$1 AND episode_id=$2 LIMIT 1', [id,episodeId])
|
|
|
|
|
+ if (delivered.rows[0]) return reply.code(409).send({ message: '该集已有交付文件,请先处理交付文件' })
|
|
|
|
|
+ const assets = await pool.query('SELECT stored_name FROM course_assets WHERE course_id=$1 AND episode_id=$2', [id,episodeId])
|
|
|
|
|
+ await pool.query('DELETE FROM episodes WHERE id=$1 AND course_id=$2', [episodeId,id])
|
|
|
|
|
+ for (const asset of assets.rows) {
|
|
|
|
|
+ await unlink(resolve(uploadDir,asset.stored_name)).catch(() => {})
|
|
|
|
|
+ await unlink(resolve(uploadDir,'.previews',asset.stored_name+'.pdf')).catch(() => {})
|
|
|
|
|
+ await unlink(resolve(uploadDir,'.previews',`${asset.stored_name}.${previewCacheVersion}.pdf`)).catch(() => {})
|
|
|
|
|
+ }
|
|
|
|
|
+ await refreshCoursePoints(id)
|
|
|
|
|
+ await addProgressEvent(id, 'ADMIN', 'CONTENT_UPDATED', '平台删除课程分集', '制作中的课程删除了一个分集及其素材。')
|
|
|
|
|
+ return { course: await fullCourse(row, true) }
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+app.post('/api/admin/courses/:id/assets', { preHandler: adminAuth }, async (req, reply) => {
|
|
|
|
|
+ const { id } = req.params as { id: string }
|
|
|
|
|
+ const row = await productionCourse(id, reply)
|
|
|
|
|
+ if (!row) return
|
|
|
|
|
+ const pending: Array<{ original: string; stored: string; size: number; mime: string }> = []
|
|
|
|
|
+ let episodeId: string | null = null
|
|
|
|
|
+ let committed = false
|
|
|
|
|
+ try {
|
|
|
|
|
+ for await (const part of req.parts()) {
|
|
|
|
|
+ if (part.type === 'file') {
|
|
|
|
|
+ const stored = `${id}-${randomUUID()}${extname(part.filename).slice(0,16)}`
|
|
|
|
|
+ await pipeline(part.file,createWriteStream(resolve(uploadDir,stored)))
|
|
|
|
|
+ if (part.file.truncated) { await unlink(resolve(uploadDir,stored)).catch(() => {}); return reply.code(413).send({ message: '文件超过大小限制' }) }
|
|
|
|
|
+ pending.push({ original: part.filename, stored, size: part.file.bytesRead, mime: part.mimetype })
|
|
|
|
|
+ } else if (part.fieldname === 'episodeId' && part.value) episodeId = String(part.value)
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!pending.length) return reply.code(400).send({ message: '请选择课程素材文件' })
|
|
|
|
|
+ if (episodeId && !(await pool.query('SELECT 1 FROM episodes WHERE id=$1 AND course_id=$2', [episodeId,id])).rows[0]) return reply.code(400).send({ message: '分集不存在' })
|
|
|
|
|
+ const client = await pool.connect()
|
|
|
|
|
+ try {
|
|
|
|
|
+ await client.query('BEGIN')
|
|
|
|
|
+ for (const file of pending) await client.query('INSERT INTO course_assets(id,course_id,episode_id,original_name,stored_name,size,mime_type) VALUES($1,$2,$3,$4,$5,$6,$7)', [randomUUID(),id,episodeId,file.original,file.stored,file.size,file.mime])
|
|
|
|
|
+ await client.query('COMMIT')
|
|
|
|
|
+ committed = true
|
|
|
|
|
+ } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
|
|
|
|
+ await pool.query('UPDATE courses SET updated_at=NOW() WHERE id=$1', [id])
|
|
|
|
|
+ await addProgressEvent(id, 'ADMIN', 'CONTENT_UPDATED', '平台上传课程素材', `新增 ${pending.length} 份课程素材。`)
|
|
|
|
|
+ return { course: await fullCourse(row, true) }
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ if (!committed) for (const file of pending) await unlink(resolve(uploadDir,file.stored)).catch(() => {})
|
|
|
|
|
+ }
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+app.delete('/api/admin/courses/:id/assets/:assetId', { preHandler: adminAuth }, async (req, reply) => {
|
|
|
|
|
+ const { id, assetId } = req.params as { id: string; assetId: string }
|
|
|
|
|
+ const row = await productionCourse(id, reply)
|
|
|
|
|
+ if (!row) return
|
|
|
|
|
+ const deleted = await pool.query('DELETE FROM course_assets WHERE id=$1 AND course_id=$2 RETURNING stored_name', [assetId,id])
|
|
|
|
|
+ if (!deleted.rows[0]) return reply.code(404).send({ message: '素材不存在' })
|
|
|
|
|
+ const stored = deleted.rows[0].stored_name
|
|
|
|
|
+ await unlink(resolve(uploadDir,stored)).catch(() => {})
|
|
|
|
|
+ await unlink(resolve(uploadDir,'.previews',stored+'.pdf')).catch(() => {})
|
|
|
|
|
+ await unlink(resolve(uploadDir,'.previews',`${stored}.${previewCacheVersion}.pdf`)).catch(() => {})
|
|
|
|
|
+ await pool.query('UPDATE courses SET updated_at=NOW() WHERE id=$1', [id])
|
|
|
|
|
+ await addProgressEvent(id, 'ADMIN', 'CONTENT_UPDATED', '平台删除课程素材', '制作中的课程移除了一份素材。')
|
|
|
|
|
+ return { course: await fullCourse(row, true) }
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
// 4. 修改课程状态(例如接单转为 IN_PRODUCTION 或其他流转)
|
|
// 4. 修改课程状态(例如接单转为 IN_PRODUCTION 或其他流转)
|
|
|
app.post('/api/admin/courses/:id/status', { preHandler: courseOperator }, async (req, reply) => {
|
|
app.post('/api/admin/courses/:id/status', { preHandler: courseOperator }, async (req, reply) => {
|
|
|
const { id } = req.params as any
|
|
const { id } = req.params as any
|