Admin: create/delete mysteries, asset library, cutscene component picker

- Mysteries tab: "+ New" (slug + title) and per-row delete; opens the graph editor.
- Assets tab: upload images/audio/PDFs to the shared osint.assets store, grid with
  thumbnails/icons, copy id/url, delete (blocked with 409 when the asset is in use).
- Cutscene component_key is now a datalist of registered keys with a
  "not registered" warning, instead of free text.

Backend: DELETE /api/admin/mysteries/:id and POST/GET/DELETE /api/admin/assets
(reusing the deduplicated, MinIO-backed asset store).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 19:05:29 +02:00
co-authored by Claude Opus 4.8
parent 3b72f38d00
commit 7fe8fadd8a
6 changed files with 179 additions and 11 deletions
+35
View File
@@ -4,6 +4,7 @@ import { cloneBoard } from './boardClone.js'
import type { ObjectStorage } from './objectStorage.js'
export type UploadedFile = { buffer: Buffer; originalname: string; mimetype: string; size: number }
export type AssetDto = { id: string; originalName: string; mimeType: string; byteSize: number; url: string }
export type PoseDto = { poseKey: string; assetId: string; url: string }
export type NpcDto = { id: string; key: string; name: string; role: string; defaultPose: string | null; poses: PoseDto[]; inUse: boolean }
export type MysterySummary = { id: string; slug: string; title: string; nodes: number }
@@ -46,6 +47,10 @@ export interface NarrativeRepository {
getCurrentPlaythrough(userId: string): Promise<PlaythroughState | null>
advancePlaythrough(userId: string, playthroughId: string, terminalKey?: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }>
listMysteries(): Promise<MysterySummary[]>
deleteMystery(id: string): Promise<boolean>
uploadAsset(file: UploadedFile): Promise<AssetDto>
listAssets(): Promise<AssetDto[]>
deleteAsset(id: string): Promise<'deleted' | 'in_use' | 'not_found'>
listNpcs(): Promise<NpcDto[]>
createNpc(input: { key: string; name: string; role?: string; defaultPose?: string | null }): Promise<NpcDto>
updateNpc(id: string, input: { name?: string; role?: string; defaultPose?: string | null }): Promise<NpcDto | null>
@@ -259,6 +264,36 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
return result.rows.map(row => ({ id: row.id, slug: row.slug, title: row.title, nodes: Number(row.nodes) }))
},
async deleteMystery(id) {
const result = await pool.query('DELETE FROM osint.mysteries WHERE id=$1', [id])
return (result.rowCount ?? 0) > 0
},
async uploadAsset(file) {
const id = await storeAsset(file)
const row = (await pool.query<{ original_name: string; mime_type: string; byte_size: string }>(
'SELECT original_name,mime_type,byte_size FROM osint.assets WHERE id=$1', [id])).rows[0]
return { id, originalName: row.original_name, mimeType: row.mime_type, byteSize: Number(row.byte_size), url: `/api/assets/${id}` }
},
async listAssets() {
const result = await pool.query<{ id: string; original_name: string; mime_type: string; byte_size: string }>(
'SELECT id,original_name,mime_type,byte_size FROM osint.assets ORDER BY created_at DESC')
return result.rows.map(row => ({ id: row.id, originalName: row.original_name, mimeType: row.mime_type, byteSize: Number(row.byte_size), url: `/api/assets/${row.id}` }))
},
async deleteAsset(id) {
// document_exhibits.asset_id is ON DELETE RESTRICT, so an in-use asset raises a
// foreign-key violation (23503) rather than deleting.
try {
const result = await pool.query('DELETE FROM osint.assets WHERE id=$1', [id])
return (result.rowCount ?? 0) > 0 ? 'deleted' : 'not_found'
} catch (error) {
if ((error as { code?: string }).code === '23503') return 'in_use'
throw error
}
},
async listNpcs() {
const npcs = await pool.query<{ id: string }>('SELECT id FROM osint.npcs WHERE mystery_id IS NULL ORDER BY name')
return (await Promise.all(npcs.rows.map(row => loadNpc(row.id)))).filter((npc): npc is NpcDto => npc !== null)