refactor: isolate level persistence from HTTP routes
This commit is contained in:
+1
-1
@@ -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.
|
||||
|
||||
|
||||
+22
-217
@@ -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<string, string>; [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<CaseState | null> {
|
||||
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<WidgetRow>(`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<string, unknown> }>(
|
||||
`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<WidgetRow>(`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<string, unknown> }>(
|
||||
`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<string, unknown> }>(
|
||||
'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<string, unknown> }>(
|
||||
'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<string, string>,
|
||||
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<string, string[]>()
|
||||
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) => {
|
||||
try {
|
||||
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() }
|
||||
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) => {
|
||||
|
||||
@@ -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<unknown[]>
|
||||
createLevel(input: { id: string; title: string; subtitle: string }): Promise<CaseState>
|
||||
getLevel(levelId: string, authorMode?: boolean): Promise<CaseState | null>
|
||||
saveLevel(state: CaseState, authorMode: boolean): Promise<void>
|
||||
resetLevel(levelId: string): Promise<CaseState | null>
|
||||
getAsset(assetId: string): Promise<AssetRecord | null>
|
||||
uploadDocument(levelId: string, file: UploadedDocument): Promise<CaseDocument | null>
|
||||
}
|
||||
|
||||
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<string, string>; [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<CaseState | null> {
|
||||
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<WidgetRow>(`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<string, unknown> }>(
|
||||
`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<WidgetRow>(`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<string, unknown> }>(
|
||||
`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<string, unknown> }>(
|
||||
'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<string, unknown> }>(
|
||||
'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<string, string>,
|
||||
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<string, string[]>()
|
||||
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<AssetRecord>('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() }
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user