From 9702b9c3d9d54867b87dade557c843cde8908b05 Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Fri, 14 Aug 2026 13:57:51 +0200 Subject: [PATCH] refactor: isolate level persistence from HTTP routes --- docs/TODO.md | 2 +- server/index.ts | 241 ++++------------------------------ server/levelRepository.ts | 267 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 291 insertions(+), 219 deletions(-) create mode 100644 server/levelRepository.ts diff --git a/docs/TODO.md b/docs/TODO.md index 698c088..880269d 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -31,7 +31,7 @@ This is the ordered implementation roadmap following the accepted exhibit model. - [ ] Add boards, exhibits, exhibit types, subtype tables, immutable template versions, and cloned mutable levels. - [ ] Migrate transitional `widgets`, `widget_relations`, and `playthrough_*` data with equivalence checks. - [ ] Implement template instantiation and “save level as template” as transactional clone operations. -- [ ] Introduce a server-side repository/service boundary so SQL and cloning transactions do not live in Express route handlers. +- [x] Introduce a server-side repository/service boundary so SQL and cloning transactions do not live in Express route handlers. - [ ] Move the frontend to an exhibit/widget registry backed by the normalized API. - [ ] Remove transitional tables only after automated data-equivalence and behavior checks pass. diff --git a/server/index.ts b/server/index.ts index 0b7fa2b..2c14c51 100644 --- a/server/index.ts +++ b/server/index.ts @@ -2,12 +2,12 @@ import 'dotenv/config' import cors from 'cors' import express from 'express' import fs from 'node:fs' -import { createHash, randomUUID } from 'node:crypto' import path from 'node:path' import { fileURLToPath } from 'node:url' import multer from 'multer' -import pg, { type PoolClient } from 'pg' -import type { CaseDocument, CaseState, Connection, Evidence, WidgetRelation } from '../src/types.js' +import pg from 'pg' +import type { CaseState } from '../src/types.js' +import { createLegacyLevelRepository } from './levelRepository.js' const { Pool } = pg const databaseUrl = process.env.DATABASE_URL @@ -18,182 +18,12 @@ if (!databaseUrl) { export const pool = new Pool({ connectionString: databaseUrl }) const editingEnabled = process.env.LEVEL_EDITING_ENABLED === 'true' - -type WidgetRow = { - id: string; widget_type: 'document' | Evidence['type']; title: string; content: string - config: { kind?: string; date?: string; body?: string[]; fileType?: CaseDocument['fileType']; metadata?: Record; [key: string]: unknown }; source_widget_id?: string; source_region_key?: string - event_date?: string; published_at?: string; x?: number; y?: number; width?: number; sort_order: number; asset_id?: string - original_name?: string; mime_type?: string; byte_size?: number -} +const levels = createLegacyLevelRepository(pool, editingEnabled) function wantsEdit(req: express.Request) { return editingEnabled && req.query.edit === '1' } -function isoTimestamp(value: unknown) { - if (!value) return undefined - const parsed = new Date(String(value)) - return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : undefined -} - -async function assembleLevel(levelId: string, playthroughId = `default:${levelId}`, authorMode = false): Promise { - const levelResult = await pool.query<{ id: string; title: string; subtitle: string; status: string }>( - 'SELECT id, title, subtitle, status FROM osint.levels WHERE id = $1', [levelId], - ) - const level = levelResult.rows[0] - if (!level) return null - - await pool.query( - `INSERT INTO osint.playthroughs (id, level_id) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING`, - [playthroughId, levelId], - ) - const [widgetsResult, regionsResult, authoredConnections, authoredRelations, playthroughResult, generatedResult, playerConnections, playerRelations, stateResult, relationStateResult] = await Promise.all([ - pool.query(`SELECT w.id, w.widget_type, w.title, w.content, w.config, w.source_widget_id, w.source_region_key, - w.event_date::text, w.published_at::text, w.x, w.y, w.width, w.sort_order, w.asset_id, a.original_name, a.mime_type, a.byte_size - FROM osint.widgets w LEFT JOIN osint.assets a ON a.id = w.asset_id - WHERE w.level_id = $1 ORDER BY w.sort_order, w.id`, [levelId]), - pool.query<{ document_widget_id: string; region_key: string; label: string; excerpt: string; event_date?: string }>( - `SELECT r.document_widget_id, r.region_key, r.label, r.excerpt, r.event_date::text - FROM osint.widget_regions r JOIN osint.widgets w ON w.id = r.document_widget_id - WHERE w.level_id = $1 ORDER BY r.sort_order, r.id`, [levelId]), - pool.query<{ id: string; from_widget_id: string; to_widget_id: string }>( - 'SELECT id, from_widget_id, to_widget_id FROM osint.level_connections WHERE level_id = $1', [levelId]), - pool.query<{ id: string; from_widget_id: string; to_widget_id: string; relation_type: string; sort_order: number; config: Record }>( - `SELECT id, from_widget_id, to_widget_id, relation_type, sort_order, config FROM osint.widget_relations - WHERE level_id = $1 ORDER BY sort_order, id`, [levelId]), - pool.query<{ viewport: CaseState['viewport']; updated_at: Date }>( - 'SELECT viewport, updated_at FROM osint.playthroughs WHERE id = $1', [playthroughId]), - pool.query(`SELECT id, widget_type, title, content, config, source_widget_id, - source_region_key, event_date::text, x, y, width, 0 AS sort_order - FROM osint.playthrough_widgets WHERE playthrough_id = $1 ORDER BY created_at, id`, [playthroughId]), - pool.query<{ id: string; from_widget_id: string; to_widget_id: string }>( - 'SELECT id, from_widget_id, to_widget_id FROM osint.playthrough_connections WHERE playthrough_id = $1', [playthroughId]), - pool.query<{ id: string; from_widget_id: string; to_widget_id: string; relation_type: string; sort_order: number; config: Record }>( - `SELECT id, from_widget_id, to_widget_id, relation_type, sort_order, config FROM osint.playthrough_widget_relations - WHERE playthrough_id = $1 ORDER BY sort_order, id`, [playthroughId]), - pool.query<{ widget_id: string; x: number; y: number; width: number; hidden: boolean; config: Record }>( - 'SELECT widget_id, x, y, width, hidden, config FROM osint.playthrough_widget_state WHERE playthrough_id = $1', [playthroughId]), - pool.query<{ relation_id: string; config: Record }>( - 'SELECT relation_id, config FROM osint.playthrough_widget_relation_state WHERE playthrough_id = $1', [playthroughId]), - ]) - - const stateByWidget = new Map(authorMode ? [] : stateResult.rows.map(row => [row.widget_id, row])) - const documents: CaseDocument[] = widgetsResult.rows.filter(w => w.widget_type === 'document').map(w => { const override = stateByWidget.get(w.id)?.config || {}; const publishedAt = isoTimestamp(override.publishedAt || w.published_at); return ({ - id: w.id, title: String(override.title || w.title), kind: w.config.kind || 'DOCUMENT', date: publishedAt?.slice(0, 10) || w.config.date || '', publishedAt, - body: w.config.body || [], fileType: (override.fileType || w.config.fileType || (w.mime_type?.startsWith('image/') ? 'image' : 'file')) as CaseDocument['fileType'], metadata: (override.metadata || w.config.metadata || {}) as Record, - assetId: w.asset_id, fileName: w.original_name, mimeType: w.mime_type, fileSize: w.byte_size, - regions: regionsResult.rows.filter(r => r.document_widget_id === w.id).map(r => ({ id: r.region_key, label: r.label, excerpt: r.excerpt, date: r.event_date })), - }) }) - const relationState = new Map(authorMode ? [] : relationStateResult.rows.map(row => [row.relation_id, row.config])) - const containedByFolder = new Map() - for (const relation of [...authoredRelations.rows, ...(authorMode ? [] : playerRelations.rows)]) { - if (relation.relation_type !== 'contains') continue - containedByFolder.set(relation.from_widget_id, [...(containedByFolder.get(relation.from_widget_id) || []), relation.to_widget_id]) - } - const toEvidence = (w: WidgetRow): Evidence => { - const override = stateByWidget.get(w.id) - const runtimeConfig = { ...w.config, ...(override?.config || {}) } - return { id: w.id, type: w.widget_type as Evidence['type'], title: String(override?.config?.title || w.title), content: String(override?.config?.content ?? w.content), config: runtimeConfig, - sourceDocumentId: w.source_widget_id, sourceRegionId: w.source_region_key, eventDate: w.widget_type === 'event' ? w.event_date : undefined, - containedDocumentIds: containedByFolder.get(w.id) || (w.source_widget_id ? [w.source_widget_id] : []), - x: override?.x ?? w.x ?? 100, y: override?.y ?? w.y ?? 100, width: override?.width ?? w.width ?? 240 } - } - const authoredEvidence = widgetsResult.rows.filter(w => w.widget_type !== 'document' && !stateByWidget.get(w.id)?.hidden).map(toEvidence) - const evidence = [...authoredEvidence, ...(authorMode ? [] : generatedResult.rows.map(toEvidence))] - const connections: Connection[] = [...authoredConnections.rows, ...(authorMode ? [] : playerConnections.rows)].map(c => ({ - id: c.id, fromEvidenceId: c.from_widget_id, toEvidenceId: c.to_widget_id, - })) - const relations: WidgetRelation[] = [...authoredRelations.rows, ...(authorMode ? [] : playerRelations.rows)].map(relation => ({ - id: relation.id, fromWidgetId: relation.from_widget_id, toWidgetId: relation.to_widget_id, type: relation.relation_type, - sortOrder: relation.sort_order, config: { ...relation.config, ...(relationState.get(relation.id) || {}) }, - })) - const playthrough = playthroughResult.rows[0] - return { id: level.id, title: level.title, subtitle: level.subtitle, documents, evidence, relations, connections, - viewport: playthrough.viewport, updatedAt: playthrough.updated_at.toISOString(), levelStatus: level.status, editingAllowed: editingEnabled } -} - -async function savePlaythrough(client: PoolClient, state: CaseState) { - const playthroughId = `default:${state.id}` - const authored = await client.query<{ id: string }>('SELECT id FROM osint.widgets WHERE level_id = $1', [state.id]) - const authoredIds = new Set(authored.rows.map(row => row.id)) - const authoredRelations = await client.query<{ id: string }>('SELECT id FROM osint.widget_relations WHERE level_id = $1', [state.id]) - const authoredRelationIds = new Set(authoredRelations.rows.map(row => row.id)) - await client.query('UPDATE osint.playthroughs SET viewport = $2::jsonb, updated_at = NOW() WHERE id = $1', [playthroughId, JSON.stringify(state.viewport)]) - await client.query('DELETE FROM osint.playthrough_widget_state WHERE playthrough_id = $1', [playthroughId]) - await client.query('DELETE FROM osint.playthrough_widget_relation_state WHERE playthrough_id = $1', [playthroughId]) - await client.query('DELETE FROM osint.playthrough_widget_relations WHERE playthrough_id = $1', [playthroughId]) - await client.query('DELETE FROM osint.playthrough_widgets WHERE playthrough_id = $1', [playthroughId]) - for (const document of state.documents) { - await client.query(`INSERT INTO osint.playthrough_widget_state (playthrough_id, widget_id, x, y, width, config) - VALUES ($1,$2,0,0,0,$3::jsonb)`, [playthroughId, document.id, JSON.stringify({ title: document.title, publishedAt: document.publishedAt || null, fileType: document.fileType, metadata: document.metadata })]) - } - for (const widget of state.evidence) { - if (authoredIds.has(widget.id)) { - await client.query(`INSERT INTO osint.playthrough_widget_state (playthrough_id, widget_id, x, y, width, config) - VALUES ($1, $2, $3, $4, $5, $6::jsonb)`, [playthroughId, widget.id, widget.x, widget.y, widget.width, JSON.stringify({ ...(widget.config || {}), title: widget.title, content: widget.content })]) - } else { - await client.query(`INSERT INTO osint.playthrough_widgets - (id, playthrough_id, widget_type, title, content, config, source_widget_id, source_region_key, event_date, x, y, width) - VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9,$10,$11,$12)`, [widget.id, playthroughId, widget.type, widget.title, widget.content, JSON.stringify(widget.config || {}), - widget.sourceDocumentId || null, widget.sourceRegionId || null, widget.eventDate || null, widget.x, widget.y, widget.width]) - } - } - const fallbackRelations: WidgetRelation[] = state.evidence.flatMap(widget => (widget.containedDocumentIds || []).map((documentId, index) => ({ id: `contains:${widget.id}:${documentId}`, fromWidgetId: widget.id, toWidgetId: documentId, type: 'contains', sortOrder: index }))) - for (const relation of (state.relations || fallbackRelations).filter(relation => authoredRelationIds.has(relation.id))) { - await client.query(`INSERT INTO osint.playthrough_widget_relation_state (playthrough_id, relation_id, config) - VALUES ($1,$2,$3::jsonb)`, [playthroughId, relation.id, JSON.stringify(relation.config || {})]) - } - for (const relation of (state.relations || fallbackRelations).filter(relation => !authoredRelationIds.has(relation.id))) { - await client.query(`INSERT INTO osint.playthrough_widget_relations - (id, playthrough_id, from_widget_id, to_widget_id, relation_type, sort_order, config) - VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb)`, - [relation.id, playthroughId, relation.fromWidgetId, relation.toWidgetId, relation.type, relation.sortOrder || 0, JSON.stringify(relation.config || {})]) - } - await client.query('DELETE FROM osint.playthrough_connections WHERE playthrough_id = $1', [playthroughId]) - const authoredConnections = await client.query<{ id: string }>('SELECT id FROM osint.level_connections WHERE level_id = $1', [state.id]) - const authoredConnectionIds = new Set(authoredConnections.rows.map(row => row.id)) - for (const connection of state.connections.filter(c => !authoredConnectionIds.has(c.id))) { - await client.query(`INSERT INTO osint.playthrough_connections (id, playthrough_id, from_widget_id, to_widget_id) - VALUES ($1, $2, $3, $4)`, [connection.id, playthroughId, connection.fromEvidenceId, connection.toEvidenceId]) - } -} - -async function saveAuthoredLevel(client: PoolClient, state: CaseState) { - await client.query('UPDATE osint.levels SET title = $2, subtitle = $3, updated_at = NOW() WHERE id = $1', [state.id, state.title, state.subtitle]) - await client.query(`INSERT INTO osint.playthroughs (id, level_id, viewport, updated_at) - VALUES ($1, $2, $3::jsonb, NOW()) - ON CONFLICT (id) DO UPDATE SET viewport = EXCLUDED.viewport, updated_at = NOW()`, - [`default:${state.id}`, state.id, JSON.stringify(state.viewport)]) - await client.query('DELETE FROM osint.level_connections WHERE level_id = $1', [state.id]) - await client.query('DELETE FROM osint.widget_relations WHERE level_id = $1', [state.id]) - await client.query('DELETE FROM osint.widgets WHERE level_id = $1', [state.id]) - for (const [index, doc] of state.documents.entries()) { - await client.query(`INSERT INTO osint.widgets (id, level_id, widget_type, title, config, published_at, asset_id, sort_order) - VALUES ($1,$2,'document',$3,$4::jsonb,$5,$6,$7)`, [doc.id, state.id, doc.title, JSON.stringify({ kind: doc.kind, body: doc.body, fileType: doc.fileType, metadata: doc.metadata }), doc.publishedAt || doc.date || null, doc.assetId || null, index]) - for (const [regionIndex, region] of doc.regions.entries()) { - await client.query(`INSERT INTO osint.widget_regions (id, document_widget_id, region_key, label, excerpt, event_date, sort_order) - VALUES ($1,$2,$3,$4,$5,$6,$7)`, [`${doc.id}:${region.id}`, doc.id, region.id, region.label, region.excerpt, region.date || null, regionIndex]) - } - } - for (const [index, widget] of state.evidence.entries()) { - await client.query(`INSERT INTO osint.widgets - (id, level_id, widget_type, title, content, config, source_widget_id, source_region_key, event_date, x, y, width, sort_order) - VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9,$10,$11,$12,$13)`, [widget.id, state.id, widget.type, widget.title, widget.content, JSON.stringify(widget.config || {}), - widget.sourceDocumentId || null, widget.sourceRegionId || null, widget.eventDate || null, widget.x, widget.y, widget.width, index]) - } - const fallbackRelations: WidgetRelation[] = state.evidence.flatMap(widget => (widget.containedDocumentIds || []).map((documentId, index) => ({ id: `contains:${widget.id}:${documentId}`, fromWidgetId: widget.id, toWidgetId: documentId, type: 'contains', sortOrder: index }))) - for (const relation of state.relations || fallbackRelations) { - await client.query(`INSERT INTO osint.widget_relations - (id, level_id, from_widget_id, to_widget_id, relation_type, sort_order, config) - VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb)`, - [relation.id, state.id, relation.fromWidgetId, relation.toWidgetId, relation.type, relation.sortOrder || 0, JSON.stringify(relation.config || {})]) - } - for (const connection of state.connections) { - await client.query(`INSERT INTO osint.level_connections (id, level_id, from_widget_id, to_widget_id) - VALUES ($1,$2,$3,$4)`, [connection.id, state.id, connection.fromEvidenceId, connection.toEvidenceId]) - } -} - export const app = express() app.disable('x-powered-by') app.use(cors({ origin: process.env.CORS_ORIGIN || true })) @@ -208,7 +38,7 @@ app.get('/api/health', async (_req, res) => { catch { res.status(503).json({ ok: false, database: 'unavailable' }) } }) app.get('/api/levels', async (_req, res, next) => { - try { const result = await pool.query('SELECT id, title, subtitle, status, updated_at AS "updatedAt" FROM osint.levels ORDER BY updated_at DESC'); res.json(result.rows) } + try { res.json(await levels.listLevels()) } catch (error) { next(error) } }) app.post('/api/levels', async (req, res, next) => { @@ -216,17 +46,12 @@ app.post('/api/levels', async (req, res, next) => { if (!editingEnabled) return res.status(403).json({ error: 'Level editing is disabled' }) const title = String(req.body?.title || 'Untitled Investigation').trim() const id = String(req.body?.id || `level-${Date.now()}`).trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-') - await pool.query('INSERT INTO osint.levels (id, title, subtitle) VALUES ($1, $2, $3)', [id, title, String(req.body?.subtitle || '')]) - const level = await assembleLevel(id, `default:${id}`, true) - res.status(201).json(level) + res.status(201).json(await levels.createLevel({ id, title, subtitle: String(req.body?.subtitle || '') })) } catch (error) { next(error) } }) app.get('/api/assets/:id', async (req, res, next) => { try { - const result = await pool.query<{ original_name: string; mime_type: string; byte_size: string; content: Buffer }>( - 'SELECT original_name, mime_type, byte_size, content FROM osint.assets WHERE id = $1', [req.params.id], - ) - const asset = result.rows[0] + const asset = await levels.getAsset(req.params.id) if (!asset) return res.status(404).json({ error: 'Asset not found' }) const inline = asset.mime_type === 'application/pdf' || asset.mime_type.startsWith('image/') || asset.mime_type.startsWith('text/') res.setHeader('Content-Type', asset.mime_type || 'application/octet-stream') @@ -237,53 +62,33 @@ app.get('/api/assets/:id', async (req, res, next) => { } catch (error) { next(error) } }) app.post('/api/levels/:id/documents', upload.single('file'), async (req, res, next) => { - if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' }) - if (!req.file) return res.status(400).json({ error: 'A file is required' }) - const client = await pool.connect() try { - const level = await client.query('SELECT id FROM osint.levels WHERE id = $1', [req.params.id]) - if (!level.rows[0]) return res.status(404).json({ error: 'Level not found' }) - const assetId = `asset-${randomUUID()}` - const widgetId = `document-${randomUUID()}` - const checksum = createHash('sha256').update(req.file.buffer).digest('hex') - const kind = req.file.mimetype === 'application/pdf' ? 'PDF' : req.file.mimetype.startsWith('image/') ? 'IMAGE' : 'FILE' - const fileType: CaseDocument['fileType'] = req.file.mimetype.startsWith('image/') ? 'image' : req.file.mimetype === 'application/pdf' ? 'pdf' : 'file' - await client.query('BEGIN') - await client.query(`INSERT INTO osint.assets (id, level_id, original_name, mime_type, byte_size, content, checksum_sha256) - VALUES ($1,$2,$3,$4,$5,$6,$7)`, [assetId, req.params.id, req.file.originalname, req.file.mimetype || 'application/octet-stream', req.file.size, req.file.buffer, checksum]) - await client.query(`INSERT INTO osint.widgets (id, level_id, widget_type, title, config, asset_id, sort_order) - VALUES ($1,$2,'document',$3,$4::jsonb,$5,(SELECT COALESCE(MAX(sort_order),-1)+1 FROM osint.widgets WHERE level_id=$2 AND widget_type='document'))`, - [widgetId, req.params.id, req.file.originalname, JSON.stringify({ kind, body: [], fileType, metadata: {} }), assetId]) - await client.query('UPDATE osint.levels SET updated_at = NOW() WHERE id = $1', [req.params.id]) - await client.query('COMMIT') - res.status(201).json({ id: widgetId, title: req.file.originalname, kind, fileType, metadata: {}, date: '', body: [], regions: [], assetId, - fileName: req.file.originalname, mimeType: req.file.mimetype, fileSize: req.file.size }) - } catch (error) { await client.query('ROLLBACK'); next(error) } finally { client.release() } + if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' }) + if (!req.file) return res.status(400).json({ error: 'A file is required' }) + const document = await levels.uploadDocument(String(req.params.id), req.file) + document ? res.status(201).json(document) : res.status(404).json({ error: 'Level not found' }) + } catch (error) { next(error) } }) app.get('/api/levels/:id', async (req, res, next) => { - try { const level = await assembleLevel(req.params.id, `default:${req.params.id}`, wantsEdit(req)); level ? res.json(level) : res.status(404).json({ error: 'Level not found' }) } - catch (error) { next(error) } + try { + const level = await levels.getLevel(req.params.id, wantsEdit(req)) + level ? res.json(level) : res.status(404).json({ error: 'Level not found' }) + } catch (error) { next(error) } }) app.put('/api/levels/:id', async (req, res, next) => { const state = req.body as CaseState if (!state || state.id !== req.params.id || !Array.isArray(state.evidence) || !Array.isArray(state.connections)) return res.status(400).json({ error: 'Invalid level state' }) - const client = await pool.connect() try { - await client.query('BEGIN') - if (wantsEdit(req)) await saveAuthoredLevel(client, state) - else await savePlaythrough(client, state) - await client.query('COMMIT'); res.json({ ok: true, mode: wantsEdit(req) ? 'author' : 'play' }) - } catch (error) { await client.query('ROLLBACK'); next(error) } finally { client.release() } + const authorMode = wantsEdit(req) + await levels.saveLevel(state, authorMode) + res.json({ ok: true, mode: authorMode ? 'author' : 'play' }) + } catch (error) { next(error) } }) app.post('/api/levels/:id/reset', async (req, res, next) => { - const client = await pool.connect() try { - const playthroughId = `default:${req.params.id}` - await client.query('BEGIN') - await client.query('DELETE FROM osint.playthroughs WHERE id = $1', [playthroughId]) - await client.query('COMMIT') - const level = await assembleLevel(req.params.id); level ? res.json(level) : res.status(404).json({ error: 'Level not found' }) - } catch (error) { await client.query('ROLLBACK'); next(error) } finally { client.release() } + const level = await levels.resetLevel(req.params.id) + level ? res.json(level) : res.status(404).json({ error: 'Level not found' }) + } catch (error) { next(error) } }) app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { diff --git a/server/levelRepository.ts b/server/levelRepository.ts new file mode 100644 index 0000000..bf040cd --- /dev/null +++ b/server/levelRepository.ts @@ -0,0 +1,267 @@ +import { createHash, randomUUID } from 'node:crypto' +import type { Pool, PoolClient } from 'pg' +import type { CaseDocument, CaseState, Connection, Evidence, WidgetRelation } from '../src/types.js' + +export type UploadedDocument = { + buffer: Buffer + originalname: string + mimetype: string + size: number +} + +export type AssetRecord = { + original_name: string + mime_type: string + byte_size: string + content: Buffer +} + +export interface LevelRepository { + listLevels(): Promise + createLevel(input: { id: string; title: string; subtitle: string }): Promise + getLevel(levelId: string, authorMode?: boolean): Promise + saveLevel(state: CaseState, authorMode: boolean): Promise + resetLevel(levelId: string): Promise + getAsset(assetId: string): Promise + uploadDocument(levelId: string, file: UploadedDocument): Promise +} + +type WidgetRow = { + id: string; widget_type: 'document' | Evidence['type']; title: string; content: string + config: { kind?: string; date?: string; body?: string[]; fileType?: CaseDocument['fileType']; metadata?: Record; [key: string]: unknown }; source_widget_id?: string; source_region_key?: string + event_date?: string; published_at?: string; x?: number; y?: number; width?: number; sort_order: number; asset_id?: string + original_name?: string; mime_type?: string; byte_size?: number +} + +function isoTimestamp(value: unknown) { + if (!value) return undefined + const parsed = new Date(String(value)) + return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : undefined +} + +export function createLegacyLevelRepository(pool: Pool, editingEnabled: boolean): LevelRepository { + async function assembleLevel(levelId: string, playthroughId = `default:${levelId}`, authorMode = false): Promise { + const levelResult = await pool.query<{ id: string; title: string; subtitle: string; status: string }>( + 'SELECT id, title, subtitle, status FROM osint.levels WHERE id = $1', [levelId], + ) + const level = levelResult.rows[0] + if (!level) return null + + await pool.query( + `INSERT INTO osint.playthroughs (id, level_id) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING`, + [playthroughId, levelId], + ) + const [widgetsResult, regionsResult, authoredConnections, authoredRelations, playthroughResult, generatedResult, playerConnections, playerRelations, stateResult, relationStateResult] = await Promise.all([ + pool.query(`SELECT w.id, w.widget_type, w.title, w.content, w.config, w.source_widget_id, w.source_region_key, + w.event_date::text, w.published_at::text, w.x, w.y, w.width, w.sort_order, w.asset_id, a.original_name, a.mime_type, a.byte_size + FROM osint.widgets w LEFT JOIN osint.assets a ON a.id = w.asset_id + WHERE w.level_id = $1 ORDER BY w.sort_order, w.id`, [levelId]), + pool.query<{ document_widget_id: string; region_key: string; label: string; excerpt: string; event_date?: string }>( + `SELECT r.document_widget_id, r.region_key, r.label, r.excerpt, r.event_date::text + FROM osint.widget_regions r JOIN osint.widgets w ON w.id = r.document_widget_id + WHERE w.level_id = $1 ORDER BY r.sort_order, r.id`, [levelId]), + pool.query<{ id: string; from_widget_id: string; to_widget_id: string }>( + 'SELECT id, from_widget_id, to_widget_id FROM osint.level_connections WHERE level_id = $1', [levelId]), + pool.query<{ id: string; from_widget_id: string; to_widget_id: string; relation_type: string; sort_order: number; config: Record }>( + `SELECT id, from_widget_id, to_widget_id, relation_type, sort_order, config FROM osint.widget_relations + WHERE level_id = $1 ORDER BY sort_order, id`, [levelId]), + pool.query<{ viewport: CaseState['viewport']; updated_at: Date }>( + 'SELECT viewport, updated_at FROM osint.playthroughs WHERE id = $1', [playthroughId]), + pool.query(`SELECT id, widget_type, title, content, config, source_widget_id, + source_region_key, event_date::text, x, y, width, 0 AS sort_order + FROM osint.playthrough_widgets WHERE playthrough_id = $1 ORDER BY created_at, id`, [playthroughId]), + pool.query<{ id: string; from_widget_id: string; to_widget_id: string }>( + 'SELECT id, from_widget_id, to_widget_id FROM osint.playthrough_connections WHERE playthrough_id = $1', [playthroughId]), + pool.query<{ id: string; from_widget_id: string; to_widget_id: string; relation_type: string; sort_order: number; config: Record }>( + `SELECT id, from_widget_id, to_widget_id, relation_type, sort_order, config FROM osint.playthrough_widget_relations + WHERE playthrough_id = $1 ORDER BY sort_order, id`, [playthroughId]), + pool.query<{ widget_id: string; x: number; y: number; width: number; hidden: boolean; config: Record }>( + 'SELECT widget_id, x, y, width, hidden, config FROM osint.playthrough_widget_state WHERE playthrough_id = $1', [playthroughId]), + pool.query<{ relation_id: string; config: Record }>( + 'SELECT relation_id, config FROM osint.playthrough_widget_relation_state WHERE playthrough_id = $1', [playthroughId]), + ]) + + const stateByWidget = new Map(authorMode ? [] : stateResult.rows.map(row => [row.widget_id, row])) + const documents: CaseDocument[] = widgetsResult.rows.filter(w => w.widget_type === 'document').map(w => { const override = stateByWidget.get(w.id)?.config || {}; const publishedAt = isoTimestamp(override.publishedAt || w.published_at); return ({ + id: w.id, title: String(override.title || w.title), kind: w.config.kind || 'DOCUMENT', date: publishedAt?.slice(0, 10) || w.config.date || '', publishedAt, + body: w.config.body || [], fileType: (override.fileType || w.config.fileType || (w.mime_type?.startsWith('image/') ? 'image' : 'file')) as CaseDocument['fileType'], metadata: (override.metadata || w.config.metadata || {}) as Record, + assetId: w.asset_id, fileName: w.original_name, mimeType: w.mime_type, fileSize: w.byte_size, + regions: regionsResult.rows.filter(r => r.document_widget_id === w.id).map(r => ({ id: r.region_key, label: r.label, excerpt: r.excerpt, date: r.event_date })), + }) }) + const relationState = new Map(authorMode ? [] : relationStateResult.rows.map(row => [row.relation_id, row.config])) + const containedByFolder = new Map() + for (const relation of [...authoredRelations.rows, ...(authorMode ? [] : playerRelations.rows)]) { + if (relation.relation_type !== 'contains') continue + containedByFolder.set(relation.from_widget_id, [...(containedByFolder.get(relation.from_widget_id) || []), relation.to_widget_id]) + } + const toEvidence = (w: WidgetRow): Evidence => { + const override = stateByWidget.get(w.id) + const runtimeConfig = { ...w.config, ...(override?.config || {}) } + return { id: w.id, type: w.widget_type as Evidence['type'], title: String(override?.config?.title || w.title), content: String(override?.config?.content ?? w.content), config: runtimeConfig, + sourceDocumentId: w.source_widget_id, sourceRegionId: w.source_region_key, eventDate: w.widget_type === 'event' ? w.event_date : undefined, + containedDocumentIds: containedByFolder.get(w.id) || (w.source_widget_id ? [w.source_widget_id] : []), + x: override?.x ?? w.x ?? 100, y: override?.y ?? w.y ?? 100, width: override?.width ?? w.width ?? 240 } + } + const authoredEvidence = widgetsResult.rows.filter(w => w.widget_type !== 'document' && !stateByWidget.get(w.id)?.hidden).map(toEvidence) + const evidence = [...authoredEvidence, ...(authorMode ? [] : generatedResult.rows.map(toEvidence))] + const connections: Connection[] = [...authoredConnections.rows, ...(authorMode ? [] : playerConnections.rows)].map(c => ({ + id: c.id, fromEvidenceId: c.from_widget_id, toEvidenceId: c.to_widget_id, + })) + const relations: WidgetRelation[] = [...authoredRelations.rows, ...(authorMode ? [] : playerRelations.rows)].map(relation => ({ + id: relation.id, fromWidgetId: relation.from_widget_id, toWidgetId: relation.to_widget_id, type: relation.relation_type, + sortOrder: relation.sort_order, config: { ...relation.config, ...(relationState.get(relation.id) || {}) }, + })) + const playthrough = playthroughResult.rows[0] + return { id: level.id, title: level.title, subtitle: level.subtitle, documents, evidence, relations, connections, + viewport: playthrough.viewport, updatedAt: playthrough.updated_at.toISOString(), levelStatus: level.status, editingAllowed: editingEnabled } + } + + async function savePlaythrough(client: PoolClient, state: CaseState) { + const playthroughId = `default:${state.id}` + const authored = await client.query<{ id: string }>('SELECT id FROM osint.widgets WHERE level_id = $1', [state.id]) + const authoredIds = new Set(authored.rows.map(row => row.id)) + const authoredRelations = await client.query<{ id: string }>('SELECT id FROM osint.widget_relations WHERE level_id = $1', [state.id]) + const authoredRelationIds = new Set(authoredRelations.rows.map(row => row.id)) + await client.query('UPDATE osint.playthroughs SET viewport = $2::jsonb, updated_at = NOW() WHERE id = $1', [playthroughId, JSON.stringify(state.viewport)]) + await client.query('DELETE FROM osint.playthrough_widget_state WHERE playthrough_id = $1', [playthroughId]) + await client.query('DELETE FROM osint.playthrough_widget_relation_state WHERE playthrough_id = $1', [playthroughId]) + await client.query('DELETE FROM osint.playthrough_widget_relations WHERE playthrough_id = $1', [playthroughId]) + await client.query('DELETE FROM osint.playthrough_widgets WHERE playthrough_id = $1', [playthroughId]) + for (const document of state.documents) { + await client.query(`INSERT INTO osint.playthrough_widget_state (playthrough_id, widget_id, x, y, width, config) + VALUES ($1,$2,0,0,0,$3::jsonb)`, [playthroughId, document.id, JSON.stringify({ title: document.title, publishedAt: document.publishedAt || null, fileType: document.fileType, metadata: document.metadata })]) + } + for (const widget of state.evidence) { + if (authoredIds.has(widget.id)) { + await client.query(`INSERT INTO osint.playthrough_widget_state (playthrough_id, widget_id, x, y, width, config) + VALUES ($1, $2, $3, $4, $5, $6::jsonb)`, [playthroughId, widget.id, widget.x, widget.y, widget.width, JSON.stringify({ ...(widget.config || {}), title: widget.title, content: widget.content })]) + } else { + await client.query(`INSERT INTO osint.playthrough_widgets + (id, playthrough_id, widget_type, title, content, config, source_widget_id, source_region_key, event_date, x, y, width) + VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9,$10,$11,$12)`, [widget.id, playthroughId, widget.type, widget.title, widget.content, JSON.stringify(widget.config || {}), + widget.sourceDocumentId || null, widget.sourceRegionId || null, widget.eventDate || null, widget.x, widget.y, widget.width]) + } + } + const fallbackRelations: WidgetRelation[] = state.evidence.flatMap(widget => (widget.containedDocumentIds || []).map((documentId, index) => ({ id: `contains:${widget.id}:${documentId}`, fromWidgetId: widget.id, toWidgetId: documentId, type: 'contains', sortOrder: index }))) + for (const relation of (state.relations || fallbackRelations).filter(relation => authoredRelationIds.has(relation.id))) { + await client.query(`INSERT INTO osint.playthrough_widget_relation_state (playthrough_id, relation_id, config) + VALUES ($1,$2,$3::jsonb)`, [playthroughId, relation.id, JSON.stringify(relation.config || {})]) + } + for (const relation of (state.relations || fallbackRelations).filter(relation => !authoredRelationIds.has(relation.id))) { + await client.query(`INSERT INTO osint.playthrough_widget_relations + (id, playthrough_id, from_widget_id, to_widget_id, relation_type, sort_order, config) + VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb)`, + [relation.id, playthroughId, relation.fromWidgetId, relation.toWidgetId, relation.type, relation.sortOrder || 0, JSON.stringify(relation.config || {})]) + } + await client.query('DELETE FROM osint.playthrough_connections WHERE playthrough_id = $1', [playthroughId]) + const authoredConnections = await client.query<{ id: string }>('SELECT id FROM osint.level_connections WHERE level_id = $1', [state.id]) + const authoredConnectionIds = new Set(authoredConnections.rows.map(row => row.id)) + for (const connection of state.connections.filter(c => !authoredConnectionIds.has(c.id))) { + await client.query(`INSERT INTO osint.playthrough_connections (id, playthrough_id, from_widget_id, to_widget_id) + VALUES ($1, $2, $3, $4)`, [connection.id, playthroughId, connection.fromEvidenceId, connection.toEvidenceId]) + } + } + + async function saveAuthoredLevel(client: PoolClient, state: CaseState) { + await client.query('UPDATE osint.levels SET title = $2, subtitle = $3, updated_at = NOW() WHERE id = $1', [state.id, state.title, state.subtitle]) + await client.query(`INSERT INTO osint.playthroughs (id, level_id, viewport, updated_at) + VALUES ($1, $2, $3::jsonb, NOW()) + ON CONFLICT (id) DO UPDATE SET viewport = EXCLUDED.viewport, updated_at = NOW()`, + [`default:${state.id}`, state.id, JSON.stringify(state.viewport)]) + await client.query('DELETE FROM osint.level_connections WHERE level_id = $1', [state.id]) + await client.query('DELETE FROM osint.widget_relations WHERE level_id = $1', [state.id]) + await client.query('DELETE FROM osint.widgets WHERE level_id = $1', [state.id]) + for (const [index, doc] of state.documents.entries()) { + await client.query(`INSERT INTO osint.widgets (id, level_id, widget_type, title, config, published_at, asset_id, sort_order) + VALUES ($1,$2,'document',$3,$4::jsonb,$5,$6,$7)`, [doc.id, state.id, doc.title, JSON.stringify({ kind: doc.kind, body: doc.body, fileType: doc.fileType, metadata: doc.metadata }), doc.publishedAt || doc.date || null, doc.assetId || null, index]) + for (const [regionIndex, region] of doc.regions.entries()) { + await client.query(`INSERT INTO osint.widget_regions (id, document_widget_id, region_key, label, excerpt, event_date, sort_order) + VALUES ($1,$2,$3,$4,$5,$6,$7)`, [`${doc.id}:${region.id}`, doc.id, region.id, region.label, region.excerpt, region.date || null, regionIndex]) + } + } + for (const [index, widget] of state.evidence.entries()) { + await client.query(`INSERT INTO osint.widgets + (id, level_id, widget_type, title, content, config, source_widget_id, source_region_key, event_date, x, y, width, sort_order) + VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9,$10,$11,$12,$13)`, [widget.id, state.id, widget.type, widget.title, widget.content, JSON.stringify(widget.config || {}), + widget.sourceDocumentId || null, widget.sourceRegionId || null, widget.eventDate || null, widget.x, widget.y, widget.width, index]) + } + const fallbackRelations: WidgetRelation[] = state.evidence.flatMap(widget => (widget.containedDocumentIds || []).map((documentId, index) => ({ id: `contains:${widget.id}:${documentId}`, fromWidgetId: widget.id, toWidgetId: documentId, type: 'contains', sortOrder: index }))) + for (const relation of state.relations || fallbackRelations) { + await client.query(`INSERT INTO osint.widget_relations + (id, level_id, from_widget_id, to_widget_id, relation_type, sort_order, config) + VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb)`, + [relation.id, state.id, relation.fromWidgetId, relation.toWidgetId, relation.type, relation.sortOrder || 0, JSON.stringify(relation.config || {})]) + } + for (const connection of state.connections) { + await client.query(`INSERT INTO osint.level_connections (id, level_id, from_widget_id, to_widget_id) + VALUES ($1,$2,$3,$4)`, [connection.id, state.id, connection.fromEvidenceId, connection.toEvidenceId]) + } + } + + return { + async listLevels() { + const result = await pool.query('SELECT id, title, subtitle, status, updated_at AS "updatedAt" FROM osint.levels ORDER BY updated_at DESC') + return result.rows + }, + async createLevel(input) { + await pool.query('INSERT INTO osint.levels (id, title, subtitle) VALUES ($1, $2, $3)', [input.id, input.title, input.subtitle]) + return (await assembleLevel(input.id, `default:${input.id}`, true))! + }, + getLevel(levelId, authorMode = false) { + return assembleLevel(levelId, `default:${levelId}`, authorMode) + }, + async saveLevel(state, authorMode) { + const client = await pool.connect() + try { + await client.query('BEGIN') + if (authorMode) await saveAuthoredLevel(client, state) + else await savePlaythrough(client, state) + await client.query('COMMIT') + } catch (error) { + await client.query('ROLLBACK') + throw error + } finally { client.release() } + }, + async resetLevel(levelId) { + const client = await pool.connect() + try { + await client.query('BEGIN') + await client.query('DELETE FROM osint.playthroughs WHERE id = $1', [`default:${levelId}`]) + await client.query('COMMIT') + } catch (error) { + await client.query('ROLLBACK') + throw error + } finally { client.release() } + return assembleLevel(levelId) + }, + async getAsset(assetId) { + const result = await pool.query('SELECT original_name, mime_type, byte_size, content FROM osint.assets WHERE id = $1', [assetId]) + return result.rows[0] || null + }, + async uploadDocument(levelId, file) { + const client = await pool.connect() + try { + const level = await client.query('SELECT id FROM osint.levels WHERE id = $1', [levelId]) + if (!level.rows[0]) return null + const assetId = `asset-${randomUUID()}` + const widgetId = `document-${randomUUID()}` + const checksum = createHash('sha256').update(file.buffer).digest('hex') + const kind = file.mimetype === 'application/pdf' ? 'PDF' : file.mimetype.startsWith('image/') ? 'IMAGE' : 'FILE' + const fileType: CaseDocument['fileType'] = file.mimetype.startsWith('image/') ? 'image' : file.mimetype === 'application/pdf' ? 'pdf' : 'file' + await client.query('BEGIN') + await client.query(`INSERT INTO osint.assets (id, level_id, original_name, mime_type, byte_size, content, checksum_sha256) + VALUES ($1,$2,$3,$4,$5,$6,$7)`, [assetId, levelId, file.originalname, file.mimetype || 'application/octet-stream', file.size, file.buffer, checksum]) + await client.query(`INSERT INTO osint.widgets (id, level_id, widget_type, title, config, asset_id, sort_order) + VALUES ($1,$2,'document',$3,$4::jsonb,$5,(SELECT COALESCE(MAX(sort_order),-1)+1 FROM osint.widgets WHERE level_id=$2 AND widget_type='document'))`, + [widgetId, levelId, file.originalname, JSON.stringify({ kind, body: [], fileType, metadata: {} }), assetId]) + await client.query('UPDATE osint.levels SET updated_at = NOW() WHERE id = $1', [levelId]) + await client.query('COMMIT') + return { id: widgetId, title: file.originalname, kind, fileType, metadata: {}, date: '', body: [], regions: [], assetId, + fileName: file.originalname, mimeType: file.mimetype, fileSize: file.size } + } catch (error) { + await client.query('ROLLBACK') + throw error + } finally { client.release() } + }, + } +}