import { createHash, randomUUID } from 'node:crypto' import type { Pool, PoolClient } from 'pg' import type { BriefConcept, CaseDocument, CaseState, Evidence, OrganizationKind, PartyKind, SourceFileType, WidgetRelation } from '../src/types.js' import { clearBoard, cloneBoard } from './boardClone.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 TemplateSummary = { id: string; slug: string; name: string; currentVersion: number; versionCount: number; updatedAt: string } export interface LevelRepository { listLevels(): Promise createLevel(input: { id: string; title: string; subtitle: string }): Promise listTemplates(): Promise instantiateTemplate(templateSlug: string, input: { id: string; title?: string; version?: number }): Promise saveLevelAsTemplate(levelId: string, input: { slug: string; name: 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 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' | 'party'; 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 party_kind: PartyKind | null; organization_kind: OrganizationKind | null } 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 createLevelRepository(pool: Pool, editingEnabled: boolean): LevelRepository { async function findLevel(client: Pool | PoolClient, slug: string, lock = false) { const result = await client.query(`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, authorMode = false): Promise { const level = await findLevel(pool, slug) if (!level) return null const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult, eventEvidenceResult, aliasesResult, partyEvidenceResult, briefResult, conceptsResult, timelineResult] = await Promise.all([ pool.query(`SELECT e.id, e.exhibit_type_id, e.xpos, e.ypos, e.width, e.hidden, COALESCE(f.title, d.title, n.title, ev.title, p.display_name, '') AS title, COALESCE(f.label_text, n.note_text, ev.narrative_text, p.summary, '') AS content, f.is_open, d.document_type_id, d.asset_id, d.published_at, ev.occurred_at, p.party_kind, op.organization_kind, 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.party_exhibits p ON p.exhibit_id = e.id LEFT JOIN osint.organization_parties op ON op.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; label: string | null; tightness: number; tag_style: 'luggage' | 'compact'; tag_position_percent: number; tag_lateral_offset: number }>( `SELECT id, from_exhibit_id, to_exhibit_id, label, tightness, tag_style, tag_position_percent, tag_lateral_offset 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]), pool.query<{ event_exhibit_id: string; evidence_exhibit_id: string }>( `SELECT event_exhibit_id,evidence_exhibit_id FROM osint.event_evidence WHERE board_id=$1 ORDER BY event_exhibit_id,sort_order,evidence_exhibit_id`, [level.board_id]), pool.query<{ party_exhibit_id: string; alias: string }>( `SELECT a.party_exhibit_id,a.alias FROM osint.party_aliases a JOIN osint.exhibits e ON e.id=a.party_exhibit_id WHERE e.board_id=$1 ORDER BY a.party_exhibit_id,a.sort_order,a.id`, [level.board_id]), pool.query<{ party_exhibit_id: string; evidence_exhibit_id: string }>( `SELECT party_exhibit_id,evidence_exhibit_id FROM osint.party_evidence WHERE board_id=$1 ORDER BY party_exhibit_id,sort_order,evidence_exhibit_id`, [level.board_id]), pool.query<{ body: string }>('SELECT body FROM osint.level_briefs WHERE board_id=$1', [level.board_id]), pool.query<{ id: string; label: string; context_text: string; expected_party_kind: PartyKind | null; resolved_party_exhibit_id: string | null }>( `SELECT id,label,context_text,expected_party_kind,resolved_party_exhibit_id FROM osint.brief_concepts WHERE board_id=$1 ORDER BY sort_order,id`, [level.board_id]), pool.query<{ range_start: string; range_end: string }>( 'SELECT range_start::text,range_end::text FROM osint.board_timeline_settings WHERE board_id=$1', [level.board_id]), ]) const blocks = new Map() for (const row of blocksResult.rows) blocks.set(row.document_exhibit_id, [...(blocks.get(row.document_exhibit_id) || []), row.content]) const regions = new Map() 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>() 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() const eventEvidence = new Map() for (const row of eventEvidenceResult.rows) eventEvidence.set(row.event_exhibit_id, [...(eventEvidence.get(row.event_exhibit_id) || []), row.evidence_exhibit_id]) const aliases = new Map() for (const row of aliasesResult.rows) aliases.set(row.party_exhibit_id, [...(aliases.get(row.party_exhibit_id) || []), row.alias]) const partyEvidence = new Map() for (const row of partyEvidenceResult.rows) partyEvidence.set(row.party_exhibit_id, [...(partyEvidence.get(row.party_exhibit_id) || []), row.evidence_exhibit_id]) 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, supportingEvidenceIds: eventEvidence.get(row.id) || [], partyKind: row.party_kind || undefined, organizationKind: row.organization_kind || undefined, aliases: aliases.get(row.id) || [], relatedEvidenceIds: partyEvidence.get(row.id) || [], config: row.exhibit_type_id === 'folder' ? { open: Boolean(row.is_open) } : {}, containedDocumentIds: contained.get(row.id) || [], })) const concepts: BriefConcept[] = conceptsResult.rows.map(row => ({ id: row.id, label: row.label, context: row.context_text, ...(authorMode && row.expected_party_kind ? { expectedPartyKind: row.expected_party_kind } : {}), resolvedPartyExhibitId: row.resolved_party_exhibit_id || undefined })) 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, label: row.label || undefined, tightness: row.tightness, tagStyle: row.tag_style, tagPosition: row.tag_position_percent, tagOffset: row.tag_lateral_offset })), viewport: { x: level.viewport_x, y: level.viewport_y, zoom: level.viewport_zoom }, updatedAt: level.updated_at.toISOString(), timelineRange: timelineResult.rows[0] ? { start: timelineResult.rows[0].range_start, end: timelineResult.rows[0].range_end } : undefined, brief: { body: briefResult.rows[0]?.body || '', concepts }, levelStatus: level.status, editingAllowed: editingEnabled && authorMode, sourceTemplateVersionId: level.source_template_version_id || undefined } } async function templateSummary(slug: string): Promise { const result = await pool.query<{ id: string; slug: string; name: string; current_version: number; version_count: number; updated_at: Date }>(`SELECT t.id,t.slug,t.name,current.version AS current_version,COUNT(v.id)::int AS version_count,t.updated_at FROM osint.level_templates t JOIN osint.level_template_versions current ON current.id=t.current_version_id JOIN osint.level_template_versions v ON v.template_id=t.id WHERE t.slug=$1 GROUP BY t.id,current.version`, [slug]) const row = result.rows[0] return row ? { id: row.id, slug: row.slug, name: row.name, currentVersion: row.current_version, versionCount: row.version_count, updatedAt: row.updated_at.toISOString() } : null } 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') 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() 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) }) } 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 }) const expectedConceptKinds = new Map((await client.query<{ id: string; expected_party_kind: PartyKind | null }>( 'SELECT id,expected_party_kind FROM osint.brief_concepts WHERE board_id=$1', [level.board_id])).rows.map(row => [row.id, row.expected_party_kind])) const existingTimelineResult = await client.query<{ range_start: string; range_end: string }>( 'SELECT range_start::text,range_end::text FROM osint.board_timeline_settings WHERE board_id=$1', [level.board_id]) const existingTimeline = existingTimelineResult.rows[0] ? { start: existingTimelineResult.rows[0].range_start, end: existingTimelineResult.rows[0].range_end } : null 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.party_evidence WHERE board_id=$1', [level.board_id]) await client.query('DELETE FROM osint.party_relationships WHERE board_id=$1', [level.board_id]) await client.query('DELETE FROM osint.board_timeline_settings WHERE board_id=$1', [level.board_id]) await client.query('DELETE FROM osint.brief_concepts WHERE board_id=$1', [level.board_id]) await client.query('DELETE FROM osint.level_briefs 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', 'person_parties', 'organization_parties', 'party_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]) } 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 === 'party' ? 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)]) if (canonicalType === 'party') { const partyKind: PartyKind = exhibit.partyKind === 'organization' ? 'organization' : 'person' await client.query('INSERT INTO osint.party_exhibits (exhibit_id,party_kind,display_name,summary) VALUES ($1,$2,$3,$4)', [exhibit.id, partyKind, exhibit.title, exhibit.content]) if (partyKind === 'person') await client.query('INSERT INTO osint.person_parties (exhibit_id) VALUES ($1)', [exhibit.id]) else await client.query('INSERT INTO osint.organization_parties (exhibit_id,organization_kind) VALUES ($1,$2)', [exhibit.id, exhibit.organizationKind || 'business']) for (const [sortOrder, alias] of (exhibit.aliases || []).filter(Boolean).entries()) await client.query( 'INSERT INTO osint.party_aliases (id,party_exhibit_id,alias,sort_order) VALUES ($1,$2,$3,$4)', [randomUUID(), exhibit.id, alias, sortOrder]) } } 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 event of state.evidence.filter(item => item.type === 'event')) { for (const [sortOrder, evidenceId] of (event.supportingEvidenceIds || []).entries()) { if (evidenceId === event.id || !allIds.includes(evidenceId)) throw new Error('Event evidence references an unknown or identical exhibit') await client.query(`INSERT INTO osint.event_evidence (board_id,event_exhibit_id,evidence_exhibit_id,sort_order) VALUES ($1,$2,$3,$4)`, [level.board_id, event.id, evidenceId, sortOrder]) } } for (const party of state.evidence.filter(item => item.type === 'party')) { for (const [sortOrder, evidenceId] of (party.relatedEvidenceIds || []).entries()) { if (evidenceId === party.id || !allIds.includes(evidenceId)) throw new Error('Party evidence references an unknown or identical exhibit') await client.query(`INSERT INTO osint.party_evidence (board_id,party_exhibit_id,evidence_exhibit_id,sort_order) VALUES ($1,$2,$3,$4)`, [level.board_id, party.id, evidenceId, sortOrder]) } } for (const connection of state.connections) { requireUuid(connection.id, 'Connection id') if (!allIds.includes(connection.fromEvidenceId) || !allIds.includes(connection.toEvidenceId) || connection.fromEvidenceId === connection.toEvidenceId) throw new Error('Connection references an unknown or identical exhibit') const tightness = Math.max(0, Math.min(100, Math.round(Number(connection.tightness ?? 65)))) const tagStyle = connection.tagStyle === 'compact' ? 'compact' : 'luggage' const tagPosition = Math.max(0, Math.min(100, Math.round(Number(connection.tagPosition ?? 50)))) const lateralLimit = Math.round(10 + (100 - tightness) * .6) const tagOffset = Math.max(-lateralLimit, Math.min(lateralLimit, Math.round(Number(connection.tagOffset ?? 0)))) await client.query(`INSERT INTO osint.exhibit_connections (id,board_id,connection_type_id,from_exhibit_id,to_exhibit_id,label,tightness,tag_style,tag_position_percent,tag_lateral_offset) VALUES ($1,$2,'thread',$3,$4,$5,$6,$7,$8,$9)`, [connection.id, level.board_id, connection.fromEvidenceId, connection.toEvidenceId, connection.label?.trim() || null, tightness, tagStyle, tagPosition, tagOffset]) } 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() 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]]) } const brief = state.brief || { body: '', concepts: [] } const savedTimeline = state.timelineRange === undefined ? existingTimeline : state.timelineRange if (savedTimeline) { const start = timestamp(savedTimeline.start) const end = timestamp(savedTimeline.end) if (!start || !end || Date.parse(end) <= Date.parse(start)) throw new Error('Timeline end must be after timeline start') await client.query('INSERT INTO osint.board_timeline_settings (board_id,range_start,range_end) VALUES ($1,$2,$3)', [level.board_id, savedTimeline.start, savedTimeline.end]) } await client.query('INSERT INTO osint.level_briefs (board_id,body) VALUES ($1,$2)', [level.board_id, brief.body || '']) for (const [sortOrder, concept] of brief.concepts.entries()) { requireUuid(concept.id, 'Brief concept id') if (concept.resolvedPartyExhibitId && !evidenceIds.has(concept.resolvedPartyExhibitId)) throw new Error('Concept resolution references an unknown party') const expected = concept.expectedPartyKind || expectedConceptKinds.get(concept.id) || null await client.query(`INSERT INTO osint.brief_concepts (id,board_id,label,context_text,sort_order,expected_party_kind,resolved_party_exhibit_id) VALUES ($1,$2,$3,$4,$5,$6,$7)`, [concept.id, level.board_id, concept.label, concept.context, sortOrder, expected, concept.resolvedPartyExhibitId || null]) } } return { async listLevels() { 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) { const client = await pool.connect() try { await client.query('BEGIN') 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() } return (await assembleLevel(input.id))! }, async listTemplates() { const result = await pool.query<{ id: string; slug: string; name: string; current_version: number; version_count: number; updated_at: Date }>(`SELECT t.id,t.slug,t.name,current.version AS current_version,COUNT(v.id)::int AS version_count,t.updated_at FROM osint.level_templates t JOIN osint.level_template_versions current ON current.id=t.current_version_id JOIN osint.level_template_versions v ON v.template_id=t.id GROUP BY t.id,current.version ORDER BY t.updated_at DESC,t.slug`) return result.rows.map(row => ({ id: row.id, slug: row.slug, name: row.name, currentVersion: row.current_version, versionCount: row.version_count, updatedAt: row.updated_at.toISOString() })) }, async instantiateTemplate(templateSlug, input) { const client = await pool.connect() try { await client.query('BEGIN') const version = await client.query<{ id: string; board_id: string; title: string; subtitle: string }>( `SELECT v.id,v.board_id,v.title,v.subtitle FROM osint.level_templates t JOIN osint.level_template_versions v ON v.template_id=t.id WHERE t.slug=$1 AND (($2::int IS NULL AND v.id=t.current_version_id) OR v.version=$2) FOR SHARE OF t,v`, [templateSlug, input.version ?? null]) const source = version.rows[0] if (!source) { await client.query('ROLLBACK'); return null } 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,source_template_version_id,title,subtitle) VALUES ($1,$2,$3,$4,$5,$6)`, [levelId, input.id, boardId, source.id, input.title || source.title, source.subtitle]) await cloneBoard(client, source.board_id, boardId) await client.query('COMMIT') } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() } return assembleLevel(input.id) }, async saveLevelAsTemplate(levelId, input) { const client = await pool.connect() try { await client.query('BEGIN') const level = await findLevel(client, levelId, true) if (!level) { await client.query('ROLLBACK'); return null } await client.query('SELECT pg_advisory_xact_lock(hashtext($1))', [input.slug]) let template = await client.query<{ id: string }>('SELECT id FROM osint.level_templates WHERE slug=$1 FOR UPDATE', [input.slug]) let templateId = template.rows[0]?.id if (!templateId) { templateId = randomUUID() await client.query('INSERT INTO osint.level_templates (id,slug,name) VALUES ($1,$2,$3)', [templateId, input.slug, input.name]) template = await client.query<{ id: string }>('SELECT id FROM osint.level_templates WHERE id=$1 FOR UPDATE', [templateId]) } const versionResult = await client.query<{ version: number }>( 'SELECT COALESCE(MAX(version),0)::int+1 AS version FROM osint.level_template_versions WHERE template_id=$1', [templateId]) const version = versionResult.rows[0].version const boardId = randomUUID(); const versionId = randomUUID() await client.query(`INSERT INTO osint.boards (id,board_kind) VALUES ($1,'template_version')`, [boardId]) await client.query(`INSERT INTO osint.level_template_versions (id,template_id,version,board_id,title,subtitle,created_from_level_id) VALUES ($1,$2,$3,$4,$5,$6,$7)`, [versionId, templateId, version, boardId, level.title, level.subtitle, level.id]) await cloneBoard(client, level.board_id, boardId) await client.query('UPDATE osint.level_templates SET name=$2,current_version_id=$3,updated_at=NOW() WHERE id=$1', [templateId, input.name, versionId]) await client.query('COMMIT') } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() } return templateSummary(input.slug) }, getLevel(levelId, authorMode = false) { return assembleLevel(levelId, authorMode) }, 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') const level = await findLevel(client, levelId, true) if (!level) { await client.query('ROLLBACK'); return null } if (level.source_template_version_id) { const version = await client.query<{ board_id: string; title: string; subtitle: string }>( 'SELECT board_id,title,subtitle FROM osint.level_template_versions WHERE id=$1 FOR SHARE', [level.source_template_version_id]) const source = version.rows[0] if (!source) throw new Error('Source template version not found') await clearBoard(client, level.board_id) await cloneBoard(client, source.board_id, level.board_id) await client.query(`UPDATE osint.levels SET title=$2,subtitle=$3,viewport_x=0,viewport_y=28,viewport_zoom=0.7,updated_at=NOW() WHERE id=$1`, [level.id, source.title, source.subtitle]) } await client.query('COMMIT') } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() } return assembleLevel(levelId) }, async getAsset(assetId) { if (!uuidPattern.test(assetId)) return null 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 { await client.query('BEGIN') 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: 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() } }, } }