2026-08-14 13:57:51 +02:00
import { createHash , randomUUID } from 'node:crypto'
import type { Pool , PoolClient } from 'pg'
2026-08-14 14:44:58 +02:00
import type { BriefConcept , CaseDocument , CaseState , Evidence , OrganizationKind , PartyKind , SourceFileType , WidgetRelation } from '../src/types.js'
2026-08-14 14:17:54 +02:00
import { clearBoard , cloneBoard } from './boardClone.js'
2026-08-14 13:57:51 +02:00
2026-08-14 14:09:24 +02:00
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 }
2026-08-14 14:17:54 +02:00
export type TemplateSummary = { id : string ; slug : string ; name : string ; currentVersion : number ; versionCount : number ; updatedAt : string }
2026-08-14 13:57:51 +02:00
export interface LevelRepository {
listLevels () : Promise < unknown [] >
createLevel ( input : { id : string ; title : string ; subtitle : string }) : Promise < CaseState >
2026-08-14 14:17:54 +02:00
listTemplates () : Promise < TemplateSummary [] >
instantiateTemplate ( templateSlug : string , input : { id : string ; title? : string ; version? : number }) : Promise < CaseState | null >
saveLevelAsTemplate ( levelId : string , input : { slug : string ; name : string }) : Promise < TemplateSummary | null >
2026-08-14 13:57:51 +02:00
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 >
}
2026-08-14 14:09:24 +02:00
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 = {
2026-08-14 14:44:58 +02:00
id : string ; exhibit_type_id : 'folder' | 'document' | 'note' | 'event' | 'party' ; xpos : number ; ypos : number ; width : number ; hidden : boolean
2026-08-14 14:09:24 +02:00
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
2026-08-14 14:44:58 +02:00
party_kind : PartyKind | null ; organization_kind : OrganizationKind | null
2026-08-14 13:57:51 +02:00
}
2026-08-14 14:09:24 +02:00
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 ()
2026-08-14 13:57:51 +02:00
}
2026-08-14 14:09:24 +02:00
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
}
2026-08-14 14:44:58 +02:00
async function assembleLevel ( slug : string , authorMode = false ) : Promise < CaseState | null > {
2026-08-14 14:09:24 +02:00
const level = await findLevel ( pool , slug )
2026-08-14 13:57:51 +02:00
if ( ! level ) return null
2026-08-14 14:44:58 +02:00
const [ exhibitsResult , blocksResult , regionsResult , membershipsResult , connectionsResult , metadataResult , eventEvidenceResult ,
2026-08-14 15:40:54 +02:00
aliasesResult , partyEvidenceResult , briefResult , conceptsResult , timelineResult ] = await Promise . all ([
2026-08-14 14:09:24 +02:00
pool . query < ExhibitRow >( `SELECT e.id, e.exhibit_type_id, e.xpos, e.ypos, e.width, e.hidden,
2026-08-14 14:44:58 +02:00
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,
2026-08-14 14:09:24 +02:00
f.is_open, d.document_type_id, d.asset_id, d.published_at, ev.occurred_at,
2026-08-14 14:44:58 +02:00
p.party_kind, op.organization_kind,
2026-08-14 14:09:24 +02:00
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
2026-08-14 14:44:58 +02:00
LEFT JOIN osint.party_exhibits p ON p.exhibit_id = e.id
LEFT JOIN osint.organization_parties op ON op.exhibit_id = e.id
2026-08-14 14:09:24 +02:00
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 ]),
2026-08-14 17:07:30 +02:00
pool . query < { id : string ; from_exhibit_id : string ; to_exhibit_id : string ; label : string | null ; tightness : number ; tag_style : 'luggage' | 'compact' } > (
`SELECT id, from_exhibit_id, to_exhibit_id, label, tightness, tag_style FROM osint.exhibit_connections WHERE board_id = $1 ORDER BY created_at, id` , [ level . board_id ]),
2026-08-14 14:09:24 +02:00
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 ]),
2026-08-14 14:31:52 +02:00
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 ]),
2026-08-14 14:44:58 +02:00
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 ]),
2026-08-14 15:40:54 +02:00
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 ]),
2026-08-14 13:57:51 +02:00
])
2026-08-14 14:09:24 +02:00
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 [] >()
2026-08-14 14:31:52 +02:00
const eventEvidence = new Map < string , string [] >()
for ( const row of eventEvidenceResult . rows ) eventEvidence . set ( row . event_exhibit_id , [...( eventEvidence . get ( row . event_exhibit_id ) || []), row . evidence_exhibit_id ])
2026-08-14 14:44:58 +02:00
const aliases = new Map < string , string [] >()
for ( const row of aliasesResult . rows ) aliases . set ( row . party_exhibit_id , [...( aliases . get ( row . party_exhibit_id ) || []), row . alias ])
const partyEvidence = new Map < string , string [] >()
for ( const row of partyEvidenceResult . rows ) partyEvidence . set ( row . party_exhibit_id , [...( partyEvidence . get ( row . party_exhibit_id ) || []), row . evidence_exhibit_id ])
2026-08-14 14:09:24 +02:00
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 ,
2026-08-14 14:31:52 +02:00
supportingEvidenceIds : eventEvidence.get ( row . id ) || [],
2026-08-14 14:44:58 +02:00
partyKind : row.party_kind || undefined , organizationKind : row.organization_kind || undefined ,
aliases : aliases.get ( row . id ) || [], relatedEvidenceIds : partyEvidence.get ( row . id ) || [],
2026-08-14 14:09:24 +02:00
config : row.exhibit_type_id === 'folder' ? { open : Boolean ( row . is_open ) } : {}, containedDocumentIds : contained.get ( row . id ) || [],
2026-08-14 13:57:51 +02:00
}))
2026-08-14 14:44:58 +02:00
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 }))
2026-08-14 14:09:24 +02:00
return { id : level.slug , title : level.title , subtitle : level.subtitle , documents , evidence , relations ,
2026-08-14 16:37:50 +02:00
connections : connectionsResult.rows.map ( row => ({ id : row.id , fromEvidenceId : row.from_exhibit_id , toEvidenceId : row.to_exhibit_id ,
2026-08-14 17:07:30 +02:00
label : row.label || undefined , tightness : row.tightness , tagStyle : row.tag_style })),
2026-08-14 14:09:24 +02:00
viewport : { x : level.viewport_x , y : level.viewport_y , zoom : level.viewport_zoom }, updatedAt : level.updated_at.toISOString (),
2026-08-14 15:40:54 +02:00
timelineRange : timelineResult.rows [ 0 ] ? { start : timelineResult.rows [ 0 ]. range_start , end : timelineResult.rows [ 0 ]. range_end } : undefined ,
2026-08-14 14:44:58 +02:00
brief : { body : briefResult.rows [ 0 ] ? . body || '' , concepts }, levelStatus : level.status , editingAllowed : editingEnabled ,
sourceTemplateVersionId : level.source_template_version_id || undefined }
2026-08-14 14:17:54 +02:00
}
async function templateSummary ( slug : string ) : Promise < TemplateSummary | null > {
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
2026-08-14 13:57:51 +02:00
}
2026-08-14 14:09:24 +02:00
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' )
2026-08-14 13:57:51 +02:00
2026-08-14 14:09:24 +02:00
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 ) })
2026-08-14 13:57:51 +02:00
}
2026-08-14 14:09:24 +02:00
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 })
2026-08-14 14:44:58 +02:00
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 ]))
2026-08-14 15:40:54 +02:00
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
2026-08-14 14:09:24 +02:00
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 ])
2026-08-14 14:44:58 +02:00
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 ])
2026-08-14 15:40:54 +02:00
await client . query ( 'DELETE FROM osint.board_timeline_settings WHERE board_id=$1' , [ level . board_id ])
2026-08-14 14:44:58 +02:00
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 ])
2026-08-14 14:09:24 +02:00
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 ])
2026-08-14 14:44:58 +02:00
for ( const table of [ 'folder_exhibits' , 'image_documents' , 'note_exhibits' , 'event_exhibits' , 'person_parties' , 'organization_parties' , 'party_exhibits' , 'document_exhibits' ]) {
2026-08-14 14:09:24 +02:00
await client . query ( `DELETE FROM osint. ${ table } WHERE exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)` , [ level . board_id ])
2026-08-14 13:57:51 +02:00
}
2026-08-14 14:09:24 +02:00
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
2026-08-14 14:44:58 +02:00
const canonicalType = type === 'folder' || type === 'note' || type === 'event' || type === 'party' ? type : 'note'
2026-08-14 14:09:24 +02:00
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 ()])
2026-08-14 14:44:58 +02:00
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 ])
}
2026-08-14 14:09:24 +02:00
}
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 ])
2026-08-14 13:57:51 +02:00
}
2026-08-14 14:31:52 +02:00
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 ])
}
}
2026-08-14 14:44:58 +02:00
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 ])
}
}
2026-08-14 13:57:51 +02:00
for ( const connection of state . connections ) {
2026-08-14 14:09:24 +02:00
requireUuid ( connection . id , 'Connection id' )
2026-08-14 16:37:50 +02:00
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 ))))
2026-08-14 17:07:30 +02:00
const tagStyle = connection . tagStyle === 'compact' ? 'compact' : 'luggage'
await client . query ( `INSERT INTO osint.exhibit_connections (id,board_id,connection_type_id,from_exhibit_id,to_exhibit_id,label,tightness,tag_style)
VALUES ($1,$2,'thread',$3,$4,$5,$6,$7)` , [ connection . id , level . board_id , connection . fromEvidenceId , connection . toEvidenceId , connection . label ? . trim () || null , tightness , tagStyle ])
2026-08-14 14:09:24 +02:00
}
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 ]])
2026-08-14 13:57:51 +02:00
}
2026-08-14 14:44:58 +02:00
const brief = state . brief || { body : '' , concepts : [] }
2026-08-14 15:40:54 +02:00
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 ])
}
2026-08-14 14:44:58 +02:00
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 ])
}
2026-08-14 13:57:51 +02:00
}
return {
async listLevels() {
2026-08-14 14:09:24 +02:00
const result = await pool . query ( `SELECT slug AS id, title, subtitle, status, updated_at AS "updatedAt"
FROM osint.levels ORDER BY updated_at DESC` )
2026-08-14 13:57:51 +02:00
return result . rows
},
async createLevel ( input ) {
const client = await pool . connect ()
try {
await client . query ( 'BEGIN' )
2026-08-14 14:09:24 +02:00
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 ])
2026-08-14 13:57:51 +02:00
await client . query ( 'COMMIT' )
2026-08-14 14:09:24 +02:00
} catch ( error ) { await client . query ( 'ROLLBACK' ); throw error } finally { client . release () }
return ( await assembleLevel ( input . id )) !
},
2026-08-14 14:17:54 +02:00
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 )
},
2026-08-14 14:44:58 +02:00
getLevel ( levelId , authorMode = false ) { return assembleLevel ( levelId , authorMode ) },
2026-08-14 14:09:24 +02:00
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 () }
2026-08-14 13:57:51 +02:00
},
async resetLevel ( levelId ) {
2026-08-14 14:17:54 +02:00
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 () }
2026-08-14 13:57:51 +02:00
return assembleLevel ( levelId )
},
async getAsset ( assetId ) {
2026-08-14 14:09:24 +02:00
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 ])
2026-08-14 13:57:51 +02:00
return result . rows [ 0 ] || null
},
async uploadDocument ( levelId , file ) {
const client = await pool . connect ()
try {
await client . query ( 'BEGIN' )
2026-08-14 14:09:24 +02:00
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 ])
2026-08-14 13:57:51 +02:00
await client . query ( 'COMMIT' )
2026-08-14 14:09:24 +02:00
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 () }
2026-08-14 13:57:51 +02:00
},
}
}