refactor: cut over to normalized exhibit schema

This commit is contained in:
2026-08-14 14:09:24 +02:00
parent 9702b9c3d9
commit 5599d330d8
10 changed files with 538 additions and 238 deletions
+230 -211
View File
@@ -1,20 +1,9 @@
import { createHash, randomUUID } from 'node:crypto'
import type { Pool, PoolClient } from 'pg'
import type { CaseDocument, CaseState, Connection, Evidence, WidgetRelation } from '../src/types.js'
import type { CaseDocument, CaseState, Evidence, SourceFileType, 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 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[]>
@@ -26,242 +15,272 @@ export interface LevelRepository {
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
type LevelRow = {
id: string; slug: string; board_id: string; title: string; subtitle: string; status: string
viewport_x: number; viewport_y: number; viewport_zoom: number; updated_at: Date
source_template_version_id: string | null
}
type ExhibitRow = {
id: string; exhibit_type_id: 'folder' | 'document' | 'note' | 'event'; xpos: number; ypos: number; width: number; hidden: boolean
title: string; content: string; is_open: boolean | null; document_type_id: SourceFileType | null
asset_id: string | null; published_at: Date | null; occurred_at: Date | null
original_name: string | null; mime_type: string | null; byte_size: string | null
source_document_id: string | null; source_region_key: string | null
}
function isoTimestamp(value: unknown) {
if (!value) return undefined
const parsed = new Date(String(value))
return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : undefined
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
function requireUuid(value: string, label: string) {
if (!uuidPattern.test(value)) throw new Error(`${label} must be a UUID`)
return value
}
function timestamp(value: string | undefined) {
if (!value) return null
const date = new Date(value)
return Number.isFinite(date.getTime()) ? date.toISOString() : null
}
function documentType(document: CaseDocument): SourceFileType {
const allowed: SourceFileType[] = ['image', 'pdf', 'web_capture', 'email', 'article', 'filing', 'price_list', 'text', 'file']
return allowed.includes(document.fileType) ? document.fileType : 'file'
}
function documentKind(type: SourceFileType) {
return type === 'web_capture' ? 'WEB CAPTURE' : type.toUpperCase()
}
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]
export function createLevelRepository(pool: Pool, editingEnabled: boolean): LevelRepository {
async function findLevel(client: Pool | PoolClient, slug: string, lock = false) {
const result = await client.query<LevelRow>(`SELECT id, slug, board_id, title, subtitle, status,
viewport_x, viewport_y, viewport_zoom, updated_at, source_template_version_id
FROM osint.levels WHERE slug = $1${lock ? ' FOR UPDATE' : ''}`, [slug])
return result.rows[0] || null
}
async function assembleLevel(slug: string): Promise<CaseState | null> {
const level = await findLevel(pool, slug)
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 [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult] = await Promise.all([
pool.query<ExhibitRow>(`SELECT e.id, e.exhibit_type_id, e.xpos, e.ypos, e.width, e.hidden,
COALESCE(f.title, d.title, n.title, ev.title, '') AS title,
COALESCE(f.label_text, n.note_text, ev.narrative_text, '') AS content,
f.is_open, d.document_type_id, d.asset_id, d.published_at, ev.occurred_at,
a.original_name, a.mime_type, a.byte_size,
s.source_document_exhibit_id AS source_document_id, sr.region_key AS source_region_key
FROM osint.exhibits e
LEFT JOIN osint.folder_exhibits f ON f.exhibit_id = e.id
LEFT JOIN osint.document_exhibits d ON d.exhibit_id = e.id
LEFT JOIN osint.note_exhibits n ON n.exhibit_id = e.id
LEFT JOIN osint.event_exhibits ev ON ev.exhibit_id = e.id
LEFT JOIN osint.assets a ON a.id = d.asset_id
LEFT JOIN osint.exhibit_sources s ON s.exhibit_id = e.id
LEFT JOIN osint.document_regions sr ON sr.id = s.source_region_id
WHERE e.board_id = $1 ORDER BY e.z_index, e.created_at, e.id`, [level.board_id]),
pool.query<{ document_exhibit_id: string; content: string }>(
`SELECT b.document_exhibit_id, b.content FROM osint.document_content_blocks b
JOIN osint.exhibits e ON e.id = b.document_exhibit_id WHERE e.board_id = $1 ORDER BY b.document_exhibit_id, b.sort_order`, [level.board_id]),
pool.query<{ document_exhibit_id: string; region_key: string; label: string; excerpt: string; occurred_at: Date | null }>(
`SELECT r.document_exhibit_id, r.region_key, r.label, r.excerpt, r.occurred_at FROM osint.document_regions r
JOIN osint.exhibits e ON e.id = r.document_exhibit_id WHERE e.board_id = $1 ORDER BY r.document_exhibit_id, r.sort_order`, [level.board_id]),
pool.query<{ folder_exhibit_id: string; child_exhibit_id: string; sort_order: number; xpos: number; ypos: number }>(
`SELECT m.folder_exhibit_id, m.child_exhibit_id, m.sort_order, child.xpos, child.ypos
FROM osint.folder_memberships m JOIN osint.exhibits child ON child.id = m.child_exhibit_id
WHERE m.board_id = $1 ORDER BY m.sort_order, m.child_exhibit_id`, [level.board_id]),
pool.query<{ id: string; from_exhibit_id: string; to_exhibit_id: string }>(
`SELECT id, from_exhibit_id, to_exhibit_id FROM osint.exhibit_connections WHERE board_id = $1 ORDER BY created_at, id`, [level.board_id]),
pool.query<{ exhibit_id: string; field_key: string; value: string }>(
`SELECT v.exhibit_id, f.field_key, v.value FROM osint.exhibit_metadata_text_values v
JOIN osint.metadata_fields f ON f.id = v.field_id WHERE f.board_id = $1 ORDER BY f.field_key`, [level.board_id]),
])
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 blocks = new Map<string, string[]>()
for (const row of blocksResult.rows) blocks.set(row.document_exhibit_id, [...(blocks.get(row.document_exhibit_id) || []), row.content])
const regions = new Map<string, CaseDocument['regions']>()
for (const row of regionsResult.rows) regions.set(row.document_exhibit_id, [...(regions.get(row.document_exhibit_id) || []), {
id: row.region_key, label: row.label, excerpt: row.excerpt, date: row.occurred_at?.toISOString(),
}])
const metadata = new Map<string, Record<string, string>>()
for (const row of metadataResult.rows) metadata.set(row.exhibit_id, { ...(metadata.get(row.exhibit_id) || {}), [row.field_key]: row.value })
const contained = new Map<string, string[]>()
const relations: WidgetRelation[] = membershipsResult.rows.map(row => {
contained.set(row.folder_exhibit_id, [...(contained.get(row.folder_exhibit_id) || []), row.child_exhibit_id])
return { id: `contains:${row.folder_exhibit_id}:${row.child_exhibit_id}`, fromWidgetId: row.folder_exhibit_id,
toWidgetId: row.child_exhibit_id, type: 'contains', sortOrder: row.sort_order, config: { x: row.xpos, y: row.ypos } }
})
const documents: CaseDocument[] = exhibitsResult.rows.filter(row => row.exhibit_type_id === 'document').map(row => {
const type = row.document_type_id || 'file'
const publishedAt = row.published_at?.toISOString()
return { id: row.id, title: row.title, kind: documentKind(type), date: publishedAt?.slice(0, 10) || '', publishedAt,
body: blocks.get(row.id) || [], regions: regions.get(row.id) || [], assetId: row.asset_id || undefined,
fileName: row.original_name || undefined, mimeType: row.mime_type || undefined,
fileSize: row.byte_size === null ? undefined : Number(row.byte_size), fileType: type, metadata: metadata.get(row.id) || {} }
})
const evidence: Evidence[] = exhibitsResult.rows.filter(row => row.exhibit_type_id !== 'document' && !row.hidden).map(row => ({
id: row.id, type: row.exhibit_type_id as Evidence['type'], title: row.title, content: row.content,
sourceDocumentId: row.source_document_id || undefined, sourceRegionId: row.source_region_key || undefined,
eventDate: row.occurred_at?.toISOString(), x: row.xpos, y: row.ypos, width: row.width,
config: row.exhibit_type_id === 'folder' ? { open: Boolean(row.is_open) } : {}, containedDocumentIds: contained.get(row.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 }
return { id: level.slug, title: level.title, subtitle: level.subtitle, documents, evidence, relations,
connections: connectionsResult.rows.map(row => ({ id: row.id, fromEvidenceId: row.from_exhibit_id, toEvidenceId: row.to_exhibit_id })),
viewport: { x: level.viewport_x, y: level.viewport_y, zoom: level.viewport_zoom }, updatedAt: level.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 replaceBoard(client: PoolClient, level: LevelRow, state: CaseState) {
const documentIds = new Set(state.documents.map(document => requireUuid(document.id, 'Document id')))
const evidenceIds = new Set(state.evidence.map(exhibit => requireUuid(exhibit.id, 'Exhibit id')))
const allIds = [...documentIds, ...evidenceIds]
if (new Set(allIds).size !== allIds.length) throw new Error('An id cannot identify both a document and another exhibit')
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])
}
const relationList = state.relations || state.evidence.flatMap(exhibit => (exhibit.containedDocumentIds || []).map((documentId, index) => ({
id: `contains:${exhibit.id}:${documentId}`, fromWidgetId: exhibit.id, toWidgetId: documentId, type: 'contains', sortOrder: index,
})))
const positions = new Map<string, { x: number; y: number }>()
for (const relation of relationList.filter(item => item.type === 'contains')) {
positions.set(relation.toWidgetId, { x: Number(relation.config?.x ?? 100), y: Number(relation.config?.y ?? 100) })
}
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 existing = await client.query<{ id: string; xpos: number; ypos: number }>('SELECT id, xpos, ypos FROM osint.exhibits WHERE board_id = $1', [level.board_id])
for (const row of existing.rows) if (!positions.has(row.id)) positions.set(row.id, { x: row.xpos, y: row.ypos })
await client.query(`UPDATE osint.levels SET title=$2, subtitle=$3, viewport_x=$4, viewport_y=$5, viewport_zoom=$6,
updated_at=NOW() WHERE id=$1`, [level.id, state.title, state.subtitle, state.viewport.x, state.viewport.y, state.viewport.zoom])
await client.query('UPDATE osint.boards SET revision=revision+1, updated_at=NOW() WHERE id=$1', [level.board_id])
await client.query('DELETE FROM osint.exhibit_connections WHERE board_id=$1', [level.board_id])
await client.query('DELETE FROM osint.folder_memberships WHERE board_id=$1', [level.board_id])
await client.query('DELETE FROM osint.event_evidence WHERE board_id=$1', [level.board_id])
await client.query('DELETE FROM osint.exhibit_sources WHERE exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)', [level.board_id])
await client.query('DELETE FROM osint.metadata_fields WHERE board_id=$1', [level.board_id])
for (const table of ['folder_exhibits', 'image_documents', 'note_exhibits', 'event_exhibits', 'document_exhibits']) {
await client.query(`DELETE FROM osint.${table} WHERE exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)`, [level.board_id])
}
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 || {})])
if (allIds.length) await client.query('DELETE FROM osint.exhibits WHERE board_id=$1 AND NOT (id = ANY($2::uuid[]))', [level.board_id, allIds])
else await client.query('DELETE FROM osint.exhibits WHERE board_id=$1', [level.board_id])
for (const [index, document] of state.documents.entries()) {
const position = positions.get(document.id) || { x: 100, y: 100 }
await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,z_index,hidden)
VALUES ($1,$2,'document',$3,$4,174,145,$5,FALSE)
ON CONFLICT (id) DO UPDATE SET exhibit_type_id='document',xpos=$3,ypos=$4,width=174,height=145,z_index=$5,hidden=FALSE,updated_at=NOW()`,
[document.id, level.board_id, position.x, position.y, index])
await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title,published_at)
VALUES ($1,$2,$3,$4,$5)`, [document.id, documentType(document), document.assetId || null, document.title, timestamp(document.publishedAt || document.date)])
if (document.fileType === 'image') await client.query('INSERT INTO osint.image_documents (exhibit_id) VALUES ($1)', [document.id])
for (const [sortOrder, content] of document.body.entries()) await client.query(
'INSERT INTO osint.document_content_blocks (id,document_exhibit_id,sort_order,content) VALUES ($1,$2,$3,$4)', [randomUUID(), document.id, sortOrder, content])
for (const [sortOrder, region] of document.regions.entries()) await client.query(
`INSERT INTO osint.document_regions (id,document_exhibit_id,region_key,label,excerpt,occurred_at,sort_order)
VALUES ($1,$2,$3,$4,$5,$6,$7)`, [randomUUID(), document.id, region.id, region.label, region.excerpt, timestamp(region.date), sortOrder])
}
for (const [index, exhibit] of state.evidence.entries()) {
const type = exhibit.type === 'evidence' ? 'folder' : exhibit.type
const canonicalType = type === 'folder' || type === 'note' || type === 'event' ? type : 'note'
await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,z_index,hidden)
VALUES ($1,$2,$3,$4,$5,$6,160,$7,FALSE)
ON CONFLICT (id) DO UPDATE SET exhibit_type_id=$3,xpos=$4,ypos=$5,width=$6,height=160,z_index=$7,hidden=FALSE,updated_at=NOW()`,
[exhibit.id, level.board_id, canonicalType, exhibit.x, exhibit.y, exhibit.width, state.documents.length + index])
if (canonicalType === 'folder') await client.query(
'INSERT INTO osint.folder_exhibits (exhibit_id,title,label_text,is_open) VALUES ($1,$2,$3,$4)',
[exhibit.id, exhibit.title, exhibit.content, Boolean(exhibit.config?.open)])
if (canonicalType === 'note') await client.query('INSERT INTO osint.note_exhibits (exhibit_id,title,note_text) VALUES ($1,$2,$3)', [exhibit.id, exhibit.title, exhibit.content])
if (canonicalType === 'event') await client.query(
'INSERT INTO osint.event_exhibits (exhibit_id,title,narrative_text,occurred_at) VALUES ($1,$2,$3,$4)',
[exhibit.id, exhibit.title, exhibit.content, timestamp(exhibit.eventDate) || new Date().toISOString()])
}
for (const relation of relationList.filter(item => item.type === 'contains')) {
if (!evidenceIds.has(relation.fromWidgetId) || !allIds.includes(relation.toWidgetId)) throw new Error('Folder membership references an unknown exhibit')
await client.query(`INSERT INTO osint.folder_memberships (board_id,folder_exhibit_id,child_exhibit_id,sort_order)
VALUES ($1,$2,$3,$4)`, [level.board_id, relation.fromWidgetId, relation.toWidgetId, relation.sortOrder || 0])
}
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])
requireUuid(connection.id, 'Connection id')
if (!evidenceIds.has(connection.fromEvidenceId) || !evidenceIds.has(connection.toEvidenceId)) throw new Error('Connection references an unknown exhibit')
await client.query(`INSERT INTO osint.exhibit_connections (id,board_id,connection_type_id,from_exhibit_id,to_exhibit_id)
VALUES ($1,$2,'thread',$3,$4)`, [connection.id, level.board_id, connection.fromEvidenceId, connection.toEvidenceId])
}
for (const exhibit of state.evidence.filter(item => item.sourceDocumentId)) {
if (!documentIds.has(exhibit.sourceDocumentId!)) throw new Error('Exhibit source references an unknown document')
let regionId: string | null = null
if (exhibit.sourceRegionId) {
const region = await client.query<{ id: string }>(
'SELECT id FROM osint.document_regions WHERE document_exhibit_id=$1 AND region_key=$2', [exhibit.sourceDocumentId, exhibit.sourceRegionId])
regionId = region.rows[0]?.id || null
}
await client.query('INSERT INTO osint.exhibit_sources (exhibit_id,source_document_exhibit_id,source_region_id) VALUES ($1,$2,$3)',
[exhibit.id, exhibit.sourceDocumentId, regionId])
}
const fields = new Map<string, string>()
for (const document of state.documents) for (const key of Object.keys(document.metadata || {})) {
if (!fields.has(key)) {
const fieldId = randomUUID(); fields.set(key, fieldId)
await client.query(`INSERT INTO osint.metadata_fields (id,board_id,field_key,label,value_type) VALUES ($1,$2,$3,$3,'text')`, [fieldId, level.board_id, key])
}
await client.query('INSERT INTO osint.exhibit_metadata_text_values (exhibit_id,field_id,value) VALUES ($1,$2,$3)',
[document.id, fields.get(key), document.metadata[key]])
}
}
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')
const result = await pool.query(`SELECT slug AS 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)
const boardId = randomUUID(); const levelId = randomUUID()
await client.query(`INSERT INTO osint.boards (id,board_kind) VALUES ($1,'level')`, [boardId])
await client.query(`INSERT INTO osint.levels (id,slug,board_id,title,subtitle) VALUES ($1,$2,$3,$4,$5)`,
[levelId, input.id, boardId, input.title, input.subtitle])
await client.query('COMMIT')
} catch (error) {
await client.query('ROLLBACK')
throw error
} finally { client.release() }
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
return (await assembleLevel(input.id))!
},
getLevel(levelId) { return assembleLevel(levelId) },
async saveLevel(state) {
const client = await pool.connect()
try {
await client.query('BEGIN')
const level = await findLevel(client, state.id, true)
if (!level) throw new Error('Level not found')
await replaceBoard(client, level, 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() }
// A level without a source template has no earlier canonical state to restore.
// Template cloning will replace this branch when template lifecycle endpoints land.
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])
if (!uuidPattern.test(assetId)) return null
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])
const level = await findLevel(client, levelId, true)
if (!level) { await client.query('ROLLBACK'); return null }
const candidateAssetId = randomUUID(); const exhibitId = randomUUID()
const checksum = createHash('sha256').update(file.buffer).digest('hex')
const asset = await client.query<{ id: string }>(`INSERT INTO osint.assets
(id,original_name,mime_type,byte_size,content,checksum_sha256) VALUES ($1,$2,$3,$4,$5,$6)
ON CONFLICT (checksum_sha256,byte_size) DO UPDATE SET checksum_sha256=EXCLUDED.checksum_sha256 RETURNING id`,
[candidateAssetId, file.originalname, file.mimetype || 'application/octet-stream', file.size, file.buffer, checksum])
const fileType: SourceFileType = file.mimetype.startsWith('image/') ? 'image' : file.mimetype === 'application/pdf' ? 'pdf' : file.mimetype.startsWith('text/') ? 'text' : 'file'
await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,z_index,hidden)
VALUES ($1,$2,'document',100,100,174,145,(SELECT COUNT(*) FROM osint.exhibits WHERE board_id=$2),FALSE)`, [exhibitId, level.board_id])
await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title) VALUES ($1,$2,$3,$4)`,
[exhibitId, fileType, asset.rows[0].id, file.originalname])
if (fileType === 'image') await client.query('INSERT INTO osint.image_documents (exhibit_id) VALUES ($1)', [exhibitId])
await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id])
await client.query('UPDATE osint.boards SET revision=revision+1,updated_at=NOW() WHERE id=$1', [level.board_id])
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() }
return { id: exhibitId, title: file.originalname, kind: documentKind(fileType), fileType, metadata: {}, date: '', body: [], regions: [],
assetId: asset.rows[0].id, fileName: file.originalname, mimeType: file.mimetype, fileSize: file.size }
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
},
}
}