2026-08-14 13:57:51 +02:00
import { createHash , randomUUID } from 'node:crypto'
2026-08-17 09:24:53 +02:00
import { Readable } from 'node:stream'
2026-08-14 13:57:51 +02:00
import type { Pool , PoolClient } from 'pg'
2026-08-22 14:53:23 +02:00
import type { BoardView , BriefConcept , CaseDocument , CaseState , Evidence , EvidenceMatchRuleDefinition , Exhibit , ExhibitRelation , LevelFlag , OrganizationKind , PartyKind , SourceFileType , UploadedCaseDocument } from '../src/types.js'
2026-08-17 09:24:53 +02:00
import { isDocumentExhibit , isEventExhibit , isFolderExhibit , isPartyExhibit } from '../src/types.js'
2026-08-14 14:17:54 +02:00
import { clearBoard , cloneBoard } from './boardClone.js'
2026-08-22 14:53:23 +02:00
import { evaluateEvidenceRules , type EvidenceMatchRule } from './evidenceMatching.js'
import { filterLevelVisibility , mergePlayerStateForPersistence } from './levelVisibility.js'
2026-08-17 09:24:53 +02:00
import type { ObjectStorage } from './objectStorage.js'
2026-08-22 14:53:23 +02:00
import type { TextExtractionResult } from './ocr.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 }
2026-08-17 09:24:53 +02:00
export type AssetRecord = { original_name : string ; mime_type : string ; byte_size : string ; content : Buffer | null ; storage_provider : 'postgres' | 's3' ; object_key : string | null }
export type AssetResponse = { originalName : string ; mimeType : string ; byteSize : number ; stream : NodeJS.ReadableStream }
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-22 14:53:23 +02:00
export type EvidenceMatchRuleInput = {
name : string
flagKey : string
minimumAnchorMatches? : number
enabled? : boolean
anchors : { phrase : string ; minimumSimilarity? : number }[]
}
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 >
2026-08-17 09:24:53 +02:00
getAsset ( assetId : string ) : Promise < AssetResponse | null >
2026-08-22 14:53:23 +02:00
uploadDocument ( levelId : string , file : UploadedDocument , extraction : TextExtractionResult , placement ?: { x : number ; y : number }) : Promise < UploadedCaseDocument | null >
listFlags ( levelId : string ) : Promise < LevelFlag [] | null >
setFlag ( levelId : string , key : string , earned : boolean ) : Promise < boolean >
acknowledgeRevealedDocuments ( levelId : string , documentIds : string []) : Promise < number | null >
listEvidenceMatchRules ( levelId : string ) : Promise < EvidenceMatchRuleDefinition [] | null >
createEvidenceMatchRule ( levelId : string , input : EvidenceMatchRuleInput ) : Promise < EvidenceMatchRuleDefinition | null >
updateEvidenceMatchRule ( levelId : string , ruleId : string , input : EvidenceMatchRuleInput ) : Promise < EvidenceMatchRuleDefinition | null >
deleteEvidenceMatchRule ( levelId : string , ruleId : string ) : Promise < boolean | null >
2026-08-14 13:57:51 +02:00
}
2026-08-14 14:09:24 +02:00
type LevelRow = {
id : string ; slug : string ; board_id : string ; title : string ; subtitle : string ; status : string
2026-08-17 09:24:53 +02:00
viewport_x : number ; viewport_y : number ; viewport_zoom : number ; updated_at : Date ; revision : string
2026-08-14 14:09:24 +02:00
source_template_version_id : string | null
}
type ExhibitRow = {
2026-08-17 09:24:53 +02:00
id : string ; exhibit_type_id : Exhibit [ 'type' ]; xpos : number ; ypos : number ; width : number ; height : number ; rotation : number ; z_index : 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
2026-08-22 14:53:23 +02:00
const flagPattern = /^[a-z][a-z0-9_.-]{0,63}$/
2026-08-14 14:09:24 +02:00
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
}
2026-08-22 14:53:23 +02:00
function requireFlagKey ( value : string ) {
if ( ! flagPattern . test ( value )) throw new Error ( 'Flag keys must start with a lowercase letter and contain only lowercase letters, numbers, dots, dashes, or underscores' )
return value
}
function requireRuleInput ( input : EvidenceMatchRuleInput ) {
const name = String ( input . name || '' ). trim ()
if ( ! name || name . length > 160 ) throw new Error ( 'Evidence match rule names must be between 1 and 160 characters' )
const flagKey = requireFlagKey ( String ( input . flagKey || '' ). trim ())
if ( ! Array . isArray ( input . anchors ) || ! input . anchors . length || input . anchors . length > 20 ) throw new Error ( 'Evidence match rules require between 1 and 20 anchors' )
const anchors = input . anchors . map ( anchor => {
const phrase = String ( anchor . phrase || '' ). trim ()
if ( phrase . length < 12 || phrase . length > 1 _000 ) throw new Error ( 'Evidence match anchors must be between 12 and 1000 characters' )
const minimumSimilarity = anchor . minimumSimilarity === undefined ? 0.72 : Number ( anchor . minimumSimilarity )
if ( ! Number . isFinite ( minimumSimilarity ) || minimumSimilarity < 0.5 || minimumSimilarity > 1 ) throw new Error ( 'Anchor similarity must be between 0.5 and 1' )
return { phrase , minimumSimilarity }
})
const minimumAnchorMatches = input . minimumAnchorMatches === undefined ? 1 : Number ( input . minimumAnchorMatches )
if ( ! Number . isInteger ( minimumAnchorMatches ) || minimumAnchorMatches < 1 || minimumAnchorMatches > anchors . length ) throw new Error ( 'Required anchor matches must be between 1 and the number of anchors' )
return { name , flagKey , minimumAnchorMatches , enabled : input.enabled !== false , anchors }
}
2026-08-14 14:09:24 +02:00
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'
}
2026-08-17 09:24:53 +02:00
export function createLevelRepository ( pool : Pool , editingEnabled : boolean , objectStorage : ObjectStorage ) : LevelRepository {
2026-08-14 14:09:24 +02:00
async function findLevel ( client : Pool | PoolClient , slug : string , lock = false ) {
2026-08-17 09:24:53 +02:00
const result = await client . query < LevelRow >( `SELECT l.id,l.slug,l.board_id,l.title,l.subtitle,l.status,
l.viewport_x,l.viewport_y,l.viewport_zoom,l.updated_at,l.source_template_version_id,b.revision::text
FROM osint.levels l JOIN osint.boards b ON b.id=l.board_id WHERE l.slug = $1 ${ lock ? ' FOR UPDATE OF l,b' : '' } ` , [ slug ])
2026-08-14 14:09:24 +02:00
return result . rows [ 0 ] || null
}
2026-08-17 09:24:53 +02:00
async function createDefaultBoardViews ( client : PoolClient , boardId : string ) {
const viewId = randomUUID ()
await client . query ( `INSERT INTO osint.board_views (id,board_id,view_type_id,placement_mode,dock_edge,height)
VALUES ($1,$2,'timeline','docked','bottom',112)` , [ viewId , boardId ])
await client . query ( "INSERT INTO osint.timeline_views (view_id,range_mode) VALUES ($1,'auto')" , [ viewId ])
}
2026-08-22 14:53:23 +02:00
async function evidenceMatchRules ( client : Pool | PoolClient , boardId : string , includeDisabled = false ) : Promise < EvidenceMatchRuleDefinition [] > {
const result = await client . query < {
rule_id : string ; name : string ; flag_key : string ; matcher_version : 'char_trigram_v1' ; minimum_anchor_matches : number ; enabled : boolean
anchor_id : string ; phrase_text : string ; minimum_similarity : string ; sort_order : number
} > ( `SELECT r.id AS rule_id,r.name,r.flag_key,r.matcher_version,r.minimum_anchor_matches,r.enabled,
a.id AS anchor_id,a.phrase_text,a.minimum_similarity::text,a.sort_order
FROM osint.evidence_match_rules r
JOIN osint.evidence_match_anchors a ON a.rule_id=r.id
WHERE r.board_id=$1 ${ includeDisabled ? '' : 'AND r.enabled' }
ORDER BY r.created_at,r.id,a.sort_order,a.id` , [ boardId ])
const rules = new Map < string , EvidenceMatchRuleDefinition >()
for ( const row of result . rows ) {
const rule = rules . get ( row . rule_id ) || { id : row.rule_id , name : row.name , flagKey : row.flag_key ,
matcherVersion : row.matcher_version , minimumAnchorMatches : row.minimum_anchor_matches , enabled : row.enabled , anchors : [] }
rule . anchors . push ({ id : row.anchor_id , phrase : row.phrase_text , minimumSimilarity : Number ( row . minimum_similarity ), sortOrder : row.sort_order })
rules . set ( row . rule_id , rule )
}
return [... rules . values ()]
}
async function writeEvidenceMatchRule ( client : PoolClient , level : LevelRow , ruleId : string , rawInput : EvidenceMatchRuleInput , update : boolean ) {
const input = requireRuleInput ( rawInput )
if ( update ) {
const changed = await client . query ( `UPDATE osint.evidence_match_rules SET name=$3,flag_key=$4,minimum_anchor_matches=$5,enabled=$6,updated_at=NOW()
WHERE id=$1 AND board_id=$2` , [ ruleId , level . board_id , input . name , input . flagKey , input . minimumAnchorMatches , input . enabled ])
if ( ! changed . rowCount ) return null
await client . query ( 'DELETE FROM osint.evidence_match_anchors WHERE rule_id=$1' , [ ruleId ])
} else {
await client . query ( `INSERT INTO osint.evidence_match_rules (id,board_id,name,flag_key,minimum_anchor_matches,enabled)
VALUES ($1,$2,$3,$4,$5,$6)` , [ ruleId , level . board_id , input . name , input . flagKey , input . minimumAnchorMatches , input . enabled ])
}
for ( const [ sortOrder , anchor ] of input . anchors . entries ()) await client . query ( `INSERT INTO osint.evidence_match_anchors
(id,rule_id,phrase_text,minimum_similarity,sort_order) VALUES ($1,$2,$3,$4,$5)` ,
[ randomUUID (), ruleId , anchor . phrase , anchor . minimumSimilarity , sortOrder ])
await client . query ( 'UPDATE osint.levels SET updated_at=NOW() WHERE id=$1' , [ level . id ])
return ( await evidenceMatchRules ( client , level . board_id , true )). find ( rule => rule . id === ruleId ) || 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-22 14:53:23 +02:00
aliasesResult , partyEvidenceResult , briefResult , conceptsResult , viewsResult , requirementsResult , flagsResult , seenResult ] = await Promise . all ([
2026-08-17 09:24:53 +02:00
pool . query < ExhibitRow >( `SELECT e.id,e.exhibit_type_id,e.xpos,e.ypos,e.width,e.height,e.rotation,e.z_index,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 18:26:20 +02:00
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 ]),
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-17 09:24:53 +02:00
pool . query < { event_exhibit_id : string ; evidence_exhibit_id : string ; sort_order : number ; note : string | null } > (
`SELECT event_exhibit_id,evidence_exhibit_id,sort_order,note FROM osint.event_evidence WHERE board_id=$1
2026-08-14 14:31:52 +02:00
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 ]),
2026-08-17 09:24:53 +02:00
pool . query < { party_exhibit_id : string ; evidence_exhibit_id : string ; sort_order : number ; note : string | null } > (
`SELECT party_exhibit_id,evidence_exhibit_id,sort_order,note FROM osint.party_evidence WHERE board_id=$1
2026-08-14 14:44:58 +02:00
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-17 09:24:53 +02:00
pool . query < { id : string ; view_type_id : 'timeline' ; placement_mode : 'docked' | 'canvas' | 'window' ; dock_edge : 'top' | 'right' | 'bottom' | 'left' | null ; xpos : number | null ; ypos : number | null ; width : number | null ; height : number ; z_index : number ; visible : boolean ; range_mode : 'auto' | 'fixed' ; range_start : string | null ; range_end : string | null } > (
`SELECT v.id,v.view_type_id,v.placement_mode,v.dock_edge,v.xpos,v.ypos,v.width,v.height,v.z_index,v.visible,
t.range_mode,t.range_start::text,t.range_end::text FROM osint.board_views v
JOIN osint.timeline_views t ON t.view_id=v.id WHERE v.board_id=$1 ORDER BY v.z_index,v.created_at` , [ level . board_id ]),
2026-08-22 14:53:23 +02:00
pool . query < { document_exhibit_id : string ; flag_key : string } > (
'SELECT document_exhibit_id,flag_key FROM osint.document_flag_requirements WHERE board_id=$1 ORDER BY document_exhibit_id,flag_key' , [ level . board_id ]),
pool . query < { flag_key : string } > ( 'SELECT flag_key FROM osint.level_flags WHERE level_id=$1 ORDER BY flag_key' , [ level . id ]),
pool . query < { document_exhibit_id : string } > ( 'SELECT document_exhibit_id FROM osint.level_seen_documents WHERE level_id=$1' , [ level . 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 })
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 ])
2026-08-22 14:53:23 +02:00
const requirements = new Map < string , string [] >()
for ( const row of requirementsResult . rows ) requirements . set ( row . document_exhibit_id , [...( requirements . get ( row . document_exhibit_id ) || []), row . flag_key ])
2026-08-17 09:24:53 +02:00
const relations : ExhibitRelation [] = [
... membershipsResult . rows . map ( row => ({ id : `contains: ${ row . folder_exhibit_id } : ${ row . child_exhibit_id } ` , fromExhibitId : row.folder_exhibit_id ,
toExhibitId : row.child_exhibit_id , type : 'contains' as const , sortOrder : row.sort_order })),
... eventEvidenceResult . rows . map ( row => ({ id : `supports: ${ row . event_exhibit_id } : ${ row . evidence_exhibit_id } ` , fromExhibitId : row.event_exhibit_id ,
toExhibitId : row.evidence_exhibit_id , type : 'supports' as const , sortOrder : row.sort_order , note : row.note || undefined })),
... partyEvidenceResult . rows . map ( row => ({ id : `concerns: ${ row . party_exhibit_id } : ${ row . evidence_exhibit_id } ` , fromExhibitId : row.party_exhibit_id ,
toExhibitId : row.evidence_exhibit_id , type : 'concerns' as const , sortOrder : row.sort_order , note : row.note || undefined })),
... exhibitsResult . rows . flatMap ( row => row . source_document_id ? [{ id : `source: ${ row . id } ` , fromExhibitId : row.id , toExhibitId : row.source_document_id ,
type : 'source' as const , sourceRegionId : row.source_region_key || undefined , sortOrder : 0 }] : []),
]
const base = ( row : ExhibitRow ) => ({ id : row.id , title : row.title , x : row.xpos , y : row.ypos , width : row.width , height : row.height ,
rotation : row.rotation , zIndex : row.z_index , hidden : row.hidden })
2026-08-14 14:09:24 +02:00
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 ()
2026-08-22 14:53:23 +02:00
return { ... base ( row ), type : 'document' , publishedAt , requiredFlags : requirements.get ( row . id ) || [],
2026-08-14 14:09:24 +02:00
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 ) || {} }
})
2026-08-17 09:24:53 +02:00
const evidence : Evidence [] = []
2026-08-22 14:53:23 +02:00
for ( const row of exhibitsResult . rows . filter ( row => row . exhibit_type_id !== 'document' )) {
2026-08-17 09:24:53 +02:00
const common = { ... base ( row ), title : row.title , content : row.content }
if ( row . exhibit_type_id === 'folder' ) evidence . push ({ ... common , type : 'folder' , isOpen :Boolean ( row . is_open ) })
else if ( row . exhibit_type_id === 'event' ) evidence . push ({ ... common , type : 'event' , eventDate :row.occurred_at?.toISOString () })
else if ( row . exhibit_type_id === 'party' ) evidence . push ({ ... common , type : 'party' , partyKind :row.party_kind || 'person' , organizationKind :row.organization_kind || undefined , aliases :aliases.get ( row . id ) || [] })
else if ( row . exhibit_type_id === 'note' ) evidence . push ({ ... common , type : 'note' })
else throw new Error ( `Unsupported exhibit type ${ row . exhibit_type_id } ` )
}
const views : BoardView [] = viewsResult . rows . map ( row => ({ id : row.id , type : 'timeline' , visible : row.visible , zIndex : row.z_index ,
placement : row.placement_mode === 'docked'
? { mode : 'docked' , dockEdge : row.dock_edge || 'bottom' , size : row.height }
: { mode : row.placement_mode , x : row.xpos || 0 , y : row.ypos || 0 , width : row.width || 900 , height : row.height },
rangeMode : row.range_mode , range : row.range_start && row . range_end ? { start : row.range_start , end : row.range_end } : undefined }))
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-22 14:53:23 +02:00
const fullState : CaseState = { id : level.slug , title : level.title , subtitle : level.subtitle , exhibits : [... documents , ... evidence ], relations ,
2026-08-17 09:24:53 +02:00
connections : connectionsResult.rows.map ( row => ({ id : row.id , fromExhibitId : row.from_exhibit_id , toExhibitId : row.to_exhibit_id ,
2026-08-14 18:26:20 +02:00
label : row.label || undefined , tightness : row.tightness , tagStyle : row.tag_style ,
tagPosition : row.tag_position_percent , tagOffset : row.tag_lateral_offset })),
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-17 09:24:53 +02:00
views , revision : Number ( level . revision ),
2026-08-15 09:29:08 +02:00
brief : { body : briefResult.rows [ 0 ] ? . body || '' , concepts }, levelStatus : level.status , editingAllowed : editingEnabled && authorMode ,
2026-08-14 14:44:58 +02:00
sourceTemplateVersionId : level.source_template_version_id || undefined }
2026-08-22 14:53:23 +02:00
return filterLevelVisibility ( fullState , flagsResult . rows . map ( row => row . flag_key ), seenResult . rows . map ( row => row . document_exhibit_id ), authorMode )
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 ) {
2026-08-17 09:24:53 +02:00
if ( ! Array . isArray ( state . exhibits ) || ! Array . isArray ( state . views )) throw new Error ( 'Level state must contain exhibits and views' )
const documents = state . exhibits . filter ( isDocumentExhibit )
const evidence = state . exhibits . filter (( exhibit ) : exhibit is Evidence => ! isDocumentExhibit ( exhibit ))
const documentIds = new Set ( documents . map ( document => requireUuid ( document . id , 'Document id' )))
const evidenceIds = new Set ( evidence . map ( exhibit => requireUuid ( exhibit . id , 'Exhibit id' )))
const allIds = state . exhibits . map ( exhibit => exhibit . id )
if ( new Set ( allIds ). size !== allIds . length ) throw new Error ( 'Exhibit ids must be unique within a board' )
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 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-17 09:24:53 +02:00
await client . query ( 'DELETE FROM osint.board_views 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 ])
2026-08-17 09:24:53 +02:00
for ( const exhibit of state . exhibits ) {
await client . query ( `INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,rotation,z_index,hidden)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT (id) DO UPDATE SET exhibit_type_id=$3,xpos=$4,ypos=$5,width=$6,height=$7,rotation=$8,z_index=$9,hidden=$10,updated_at=NOW()` ,
[ exhibit . id , level . board_id , exhibit . type , exhibit . x , exhibit . y , exhibit . width , exhibit . height , exhibit . rotation , exhibit . zIndex , exhibit . hidden ])
}
for ( const document of documents ) {
await client . query ( `INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title,published_at,captured_at,source_uri)
VALUES ($1,$2,$3,$4,$5,$6,$7)` , [ document . id , documentType ( document ), document . assetId || null , document . title ,
timestamp ( document . publishedAt ), timestamp ( document . capturedAt ), document . sourceUri || null ])
2026-08-14 14:09:24 +02:00
if ( document . fileType === 'image' ) await client . query ( 'INSERT INTO osint.image_documents (exhibit_id) VALUES ($1)' , [ document . id ])
2026-08-22 14:53:23 +02:00
for ( const flag of new Set (( document . requiredFlags || []). map ( value => value . trim ()). filter ( Boolean ). map ( requireFlagKey ))) await client . query (
'INSERT INTO osint.document_flag_requirements (board_id,document_exhibit_id,flag_key) VALUES ($1,$2,$3)' , [ level . board_id , document . id , flag ])
2026-08-14 14:09:24 +02:00
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 ])
}
2026-08-17 09:24:53 +02:00
for ( const exhibit of evidence ) {
if ( isFolderExhibit ( exhibit )) await client . query (
2026-08-14 14:09:24 +02:00
'INSERT INTO osint.folder_exhibits (exhibit_id,title,label_text,is_open) VALUES ($1,$2,$3,$4)' ,
2026-08-17 09:24:53 +02:00
[ exhibit . id , exhibit . title , exhibit . content , exhibit . isOpen ])
if ( exhibit . type === '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 ( isEventExhibit ( exhibit )) await client . query (
2026-08-14 14:09:24 +02:00
'INSERT INTO osint.event_exhibits (exhibit_id,title,narrative_text,occurred_at) VALUES ($1,$2,$3,$4)' ,
2026-08-14 19:57:22 +02:00
[ exhibit . id , exhibit . title , exhibit . content , timestamp ( exhibit . eventDate )])
2026-08-17 09:24:53 +02:00
if ( isPartyExhibit ( exhibit )) {
const partyKind = exhibit . partyKind
2026-08-14 14:44:58 +02:00
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
}
2026-08-17 09:24:53 +02:00
for ( const relation of state . relations ) {
if ( ! allIds . includes ( relation . fromExhibitId ) || ! allIds . includes ( relation . toExhibitId ) || relation . fromExhibitId === relation . toExhibitId ) throw new Error ( 'Relation references an unknown or identical exhibit' )
if ( relation . type === 'contains' ) 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 . fromExhibitId , relation . toExhibitId , relation . sortOrder ])
if ( relation . type === 'supports' ) await client . query ( `INSERT INTO osint.event_evidence
(board_id,event_exhibit_id,evidence_exhibit_id,sort_order,note) VALUES ($1,$2,$3,$4,$5)` ,
[ level . board_id , relation . fromExhibitId , relation . toExhibitId , relation . sortOrder , relation . note || null ])
if ( relation . type === 'concerns' ) await client . query ( `INSERT INTO osint.party_evidence
(board_id,party_exhibit_id,evidence_exhibit_id,sort_order,note) VALUES ($1,$2,$3,$4,$5)` ,
[ level . board_id , relation . fromExhibitId , relation . toExhibitId , relation . sortOrder , relation . note || null ])
if ( relation . type === 'source' ) {
if ( ! documentIds . has ( relation . toExhibitId )) throw new Error ( 'Exhibit source must reference a document' )
let regionId : string | null = null
if ( relation . sourceRegionId ) {
const region = await client . query < { id : string } > (
'SELECT id FROM osint.document_regions WHERE document_exhibit_id=$1 AND region_key=$2' , [ relation . toExhibitId , relation . 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)' ,
[ relation . fromExhibitId , relation . toExhibitId , regionId ])
2026-08-14 14:44:58 +02:00
}
}
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-17 09:24:53 +02:00
if ( ! allIds . includes ( connection . fromExhibitId ) || ! allIds . includes ( connection . toExhibitId ) || connection . fromExhibitId === connection . toExhibitId ) throw new Error ( 'Connection references an unknown or identical exhibit' )
2026-08-14 16:37:50 +02:00
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'
2026-08-14 18:26:20 +02:00
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)
2026-08-17 09:24:53 +02:00
VALUES ($1,$2,'thread',$3,$4,$5,$6,$7,$8,$9)` , [ connection . id , level . board_id , connection . fromExhibitId , connection . toExhibitId , connection . label ? . trim () || null , tightness , tagStyle , tagPosition , tagOffset ])
2026-08-14 14:09:24 +02:00
}
2026-08-17 09:24:53 +02:00
for ( const view of state . views ) {
requireUuid ( view . id , 'Board view id' )
const placement = view . placement
const docked = placement . mode === 'docked'
await client . query ( `INSERT INTO osint.board_views
(id,board_id,view_type_id,placement_mode,dock_edge,xpos,ypos,width,height,z_index,visible)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)` , [ view . id , level . board_id , view . type , placement . mode ,
placement . mode === 'docked' ? placement.dockEdge : null , placement . mode === 'docked' ? null : placement . x , placement . mode === 'docked' ? null : placement . y ,
placement . mode === 'docked' ? null : placement . width , placement . mode === 'docked' ? placement.size : placement.height , view . zIndex , view . visible ])
if ( view . type === 'timeline' ) {
if ( view . rangeMode === 'fixed' && ( ! view . range || ! timestamp ( view . range . start ) || ! timestamp ( view . range . end ) || Date . parse ( view . range . end ) <= Date . parse ( view . range . start ))) throw new Error ( 'Timeline end must be after timeline start' )
await client . query ( `INSERT INTO osint.timeline_views (view_id,range_mode,range_start,range_end) VALUES ($1,$2,$3,$4)` ,
[ view . id , view . rangeMode , view . rangeMode === 'fixed' ? view . range ! . start : null , view . rangeMode === 'fixed' ? view . range ! . end : null ])
2026-08-14 14:09:24 +02:00
}
}
const fields = new Map < string , string >()
2026-08-17 09:24:53 +02:00
for ( const document of documents ) for ( const key of Object . keys ( document . metadata || {})) {
2026-08-14 14:09:24 +02:00
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 : [] }
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-17 09:24:53 +02:00
await createDefaultBoardViews ( client , boardId )
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-22 14:53:23 +02:00
async saveLevel ( state , authorMode ) {
let persistedState = state
if ( ! authorMode ) {
const [ full , visible ] = await Promise . all ([ assembleLevel ( state . id , true ), assembleLevel ( state . id , false )])
if ( ! full || ! visible ) throw new Error ( 'Level not found' )
persistedState = mergePlayerStateForPersistence ( full , visible , state )
}
2026-08-14 14:09:24 +02:00
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' )
2026-08-22 14:53:23 +02:00
await replaceBoard ( client , level , persistedState )
2026-08-14 14:09:24 +02:00
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
2026-08-17 09:24:53 +02:00
const result = await pool . query < AssetRecord >( 'SELECT original_name,mime_type,byte_size,content,storage_provider,object_key FROM osint.assets WHERE id=$1' , [ assetId ])
const asset = result . rows [ 0 ]
if ( ! asset ) return null
if ( asset . storage_provider === 'postgres' ) {
if ( ! asset . content ) throw new Error ( `PostgreSQL asset ${ assetId } has no content` )
return { originalName : asset.original_name , mimeType : asset.mime_type , byteSize : Number ( asset . byte_size ), stream : Readable.from ( asset . content ) }
}
if ( ! asset . object_key ) throw new Error ( `Object asset ${ assetId } has no object key` )
const object = await objectStorage . getObject ( asset . object_key )
return object ? { originalName : asset.original_name , mimeType : asset.mime_type , byteSize : Number ( asset . byte_size ), stream : object.stream } : null
2026-08-14 13:57:51 +02:00
},
2026-08-22 14:53:23 +02:00
async uploadDocument ( levelId , file , extraction , placement ) {
2026-08-14 13:57:51 +02:00
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' )
2026-08-17 09:24:53 +02:00
let assetId = ( await client . query < { id : string } > ( 'SELECT id FROM osint.assets WHERE checksum_sha256=$1 AND byte_size=$2 FOR SHARE' , [ checksum , file . size ])). rows [ 0 ] ? . id
if ( ! assetId ) {
const objectKey = `assets/ ${ checksum . slice ( 0 , 2 ) } / ${ checksum } `
const stored = await objectStorage . putObject ( objectKey , file . buffer , file . mimetype || 'application/octet-stream' )
const asset = await client . query < { id : string } > ( `INSERT INTO osint.assets
(id,original_name,mime_type,byte_size,content,checksum_sha256,storage_provider,storage_bucket,object_key,etag)
VALUES ($1,$2,$3,$4,NULL,$5,'s3',$6,$7,$8)
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 , checksum , objectStorage . bucket , objectKey , stored . etag || null ])
assetId = asset . rows [ 0 ]. id
}
2026-08-14 14:09:24 +02:00
const fileType : SourceFileType = file . mimetype . startsWith ( 'image/' ) ? 'image' : file . mimetype === 'application/pdf' ? 'pdf' : file . mimetype . startsWith ( 'text/' ) ? 'text' : 'file'
2026-08-22 14:53:23 +02:00
const xpos = Number . isFinite ( placement ? . x ) ? Math . max ( 0 , Math . min ( 10 _000 , Number ( placement ? . x ))) : 100
const ypos = Number . isFinite ( placement ? . y ) ? Math . max ( 0 , Math . min ( 10 _000 , Number ( placement ? . y ))) : 100
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)
2026-08-22 14:53:23 +02:00
VALUES ($1,$2,'document',$3,$4,174,145,(SELECT COUNT(*) FROM osint.exhibits WHERE board_id=$2),FALSE)` , [ exhibitId , level . board_id , xpos , ypos ])
2026-08-14 14:09:24 +02:00
await client . query ( `INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title) VALUES ($1,$2,$3,$4)` ,
2026-08-17 09:24:53 +02:00
[ exhibitId , fileType , assetId , file . originalname ])
2026-08-14 14:09:24 +02:00
if ( fileType === 'image' ) await client . query ( 'INSERT INTO osint.image_documents (exhibit_id) VALUES ($1)' , [ exhibitId ])
2026-08-22 14:53:23 +02:00
if ( extraction . status === 'succeeded' && extraction . text . trim ()) await client . query (
'INSERT INTO osint.document_content_blocks (id,document_exhibit_id,sort_order,content) VALUES ($1,$2,0,$3)' , [ randomUUID (), exhibitId , extraction . text . trim ()])
const extractionResult = await client . query < { id : string } > ( `INSERT INTO osint.asset_text_extractions
(id,asset_id,extractor,extractor_version,language,status,extracted_text,error_message)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
ON CONFLICT (asset_id,extractor,extractor_version,language) DO UPDATE SET
status=EXCLUDED.status,extracted_text=EXCLUDED.extracted_text,error_message=EXCLUDED.error_message,updated_at=NOW()
RETURNING id` , [ randomUUID (), assetId , extraction . extractor , extraction . extractorVersion , extraction . language ,
extraction . status , extraction . text , extraction . error ? . slice ( 0 , 2 _000 ) || null ])
const extractionId = extractionResult . rows [ 0 ]. id
const ruleDefinitions = extraction . status === 'succeeded' && extraction . text . trim ()
? await evidenceMatchRules ( client , level . board_id )
: []
const evaluations = evaluateEvidenceRules ( extraction . text , ruleDefinitions as EvidenceMatchRule [])
const matchedFlags : string [] = []
const awardedFlags : string [] = []
for ( const evaluation of evaluations ) {
const evaluationId = randomUUID ()
await client . query ( `INSERT INTO osint.evidence_match_evaluations
(id,level_id,board_id,document_exhibit_id,extraction_id,rule_id,matched,matched_anchor_count,score)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)` , [ evaluationId , level . id , level . board_id , exhibitId , extractionId ,
evaluation . ruleId , evaluation . matched , evaluation . matchedAnchorCount , evaluation . score ])
for ( const anchor of evaluation . anchors ) await client . query ( `INSERT INTO osint.evidence_match_anchor_evaluations
(evaluation_id,anchor_id,similarity,matched,matched_text) VALUES ($1,$2,$3,$4,$5)` ,
[ evaluationId , anchor . anchorId , anchor . similarity , anchor . matched , anchor . matchedText ])
if ( ! evaluation . matched ) continue
matchedFlags . push ( evaluation . flagKey )
const awarded = await client . query ( `INSERT INTO osint.level_flags (level_id,board_id,flag_key,awarded_by_evidence_match_id)
VALUES ($1,$2,$3,$4) ON CONFLICT (level_id,flag_key) DO NOTHING RETURNING flag_key` ,
[ level . id , level . board_id , evaluation . flagKey , evaluationId ])
if ( awarded . rowCount ) awardedFlags . push ( evaluation . flagKey )
}
2026-08-14 14:09:24 +02:00
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-22 14:53:23 +02:00
return { id : exhibitId , type : 'document' , title :file.originalname , x :xpos , y :ypos , width :174 , height :145 , rotation :0 , zIndex :0 , hidden :false ,
fileType , metadata : {}, body :extraction.status === 'succeeded' && extraction . text . trim () ? [ extraction . text . trim ()] : [], regions : [], assetId ,
fileName :file.originalname , mimeType :file.mimetype , fileSize :file.size ,
analysis : { extractionStatus :extraction.status , matchedFlags : [... new Set ( matchedFlags )], awardedFlags : [... new Set ( awardedFlags )] } }
2026-08-14 14:09:24 +02:00
} catch ( error ) { await client . query ( 'ROLLBACK' ); throw error } finally { client . release () }
2026-08-14 13:57:51 +02:00
},
2026-08-22 14:53:23 +02:00
async listFlags ( levelId ) {
const level = await findLevel ( pool , levelId )
if ( ! level ) return null
const result = await pool . query < { flag_key : string ; earned_at : Date | null ; gated_document_count : number } > ( `
WITH keys AS (
SELECT flag_key FROM osint.level_flags WHERE level_id=$1
UNION
SELECT flag_key FROM osint.document_flag_requirements WHERE board_id=$2
UNION
SELECT flag_key FROM osint.evidence_match_rules WHERE board_id=$2
)
SELECT keys.flag_key,flags.earned_at,COUNT(requirements.document_exhibit_id)::int AS gated_document_count
FROM keys
LEFT JOIN osint.level_flags flags ON flags.level_id=$1 AND flags.flag_key=keys.flag_key
LEFT JOIN osint.document_flag_requirements requirements ON requirements.board_id=$2 AND requirements.flag_key=keys.flag_key
GROUP BY keys.flag_key,flags.earned_at ORDER BY keys.flag_key` , [ level . id , level . board_id ])
return result . rows . map ( row => ({ key : row.flag_key , earnedAt : row.earned_at?.toISOString (), gatedDocumentCount : row.gated_document_count }))
},
async setFlag ( levelId , rawKey , earned ) {
const key = requireFlagKey ( rawKey . trim ())
const client = await pool . connect ()
try {
await client . query ( 'BEGIN' )
const level = await findLevel ( client , levelId , true )
if ( ! level ) { await client . query ( 'ROLLBACK' ); return false }
if ( earned ) await client . query ( `INSERT INTO osint.level_flags (level_id,board_id,flag_key) VALUES ($1,$2,$3)
ON CONFLICT (level_id,flag_key) DO NOTHING` , [ level . id , level . board_id , key ])
else {
await client . query ( 'DELETE FROM osint.level_flags WHERE level_id=$1 AND flag_key=$2' , [ level . id , key ])
await client . query ( `DELETE FROM osint.level_seen_documents seen USING osint.document_flag_requirements requirement
WHERE seen.level_id=$1 AND seen.document_exhibit_id=requirement.document_exhibit_id AND requirement.board_id=$2 AND requirement.flag_key=$3` ,
[ level . id , level . board_id , key ])
}
await client . query ( 'UPDATE osint.levels SET updated_at=NOW() WHERE id=$1' , [ level . id ])
await client . query ( 'COMMIT' )
return true
} catch ( error ) { await client . query ( 'ROLLBACK' ); throw error } finally { client . release () }
},
async listEvidenceMatchRules ( levelId ) {
const level = await findLevel ( pool , levelId )
return level ? evidenceMatchRules ( pool , level . board_id , true ) : null
},
async createEvidenceMatchRule ( 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 }
const rule = await writeEvidenceMatchRule ( client , level , randomUUID (), input , false )
await client . query ( 'COMMIT' )
return rule
} catch ( error ) { await client . query ( 'ROLLBACK' ); throw error } finally { client . release () }
},
async updateEvidenceMatchRule ( levelId , ruleId , input ) {
if ( ! uuidPattern . test ( ruleId )) return null
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 rule = await writeEvidenceMatchRule ( client , level , ruleId , input , true )
await client . query ( 'COMMIT' )
return rule
} catch ( error ) { await client . query ( 'ROLLBACK' ); throw error } finally { client . release () }
},
async deleteEvidenceMatchRule ( levelId , ruleId ) {
if ( ! uuidPattern . test ( ruleId )) return false
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 removed = await client . query ( 'DELETE FROM osint.evidence_match_rules WHERE id=$1 AND board_id=$2' , [ ruleId , level . board_id ])
if ( removed . rowCount ) await client . query ( 'UPDATE osint.levels SET updated_at=NOW() WHERE id=$1' , [ level . id ])
await client . query ( 'COMMIT' )
return Boolean ( removed . rowCount )
} catch ( error ) { await client . query ( 'ROLLBACK' ); throw error } finally { client . release () }
},
async acknowledgeRevealedDocuments ( levelId , rawDocumentIds ) {
const documentIds = [... new Set ( rawDocumentIds . filter ( id => uuidPattern . test ( id )))]
const level = await findLevel ( pool , levelId )
if ( ! level ) return null
if ( ! documentIds . length ) return 0
const result = await pool . query ( `INSERT INTO osint.level_seen_documents (level_id,board_id,document_exhibit_id)
SELECT $1,$2,e.id FROM osint.exhibits e
WHERE e.board_id=$2 AND e.id=ANY($3::uuid[]) AND e.exhibit_type_id='document' AND NOT e.hidden
AND NOT EXISTS (
SELECT 1 FROM osint.document_flag_requirements requirement
WHERE requirement.document_exhibit_id=e.id AND NOT EXISTS (
SELECT 1 FROM osint.level_flags flag WHERE flag.level_id=$1 AND flag.flag_key=requirement.flag_key
)
)
ON CONFLICT (level_id,document_exhibit_id) DO NOTHING` , [ level . id , level . board_id , documentIds ])
return result . rowCount || 0
},
2026-08-14 13:57:51 +02:00
}
}