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 19:28:06 +02:00
import type { BoardView , BriefConcept , CaseDocument , CaseState , DocumentCaptureKind , DocumentSemanticAnalysis , Evidence , EvidenceMatchRuleDefinition , EvidenceSemanticRuleDefinition , Exhibit , ExhibitRelation , LevelFlag , LevelGoal , OrganizationKind , PartyKind , SourceFileType , UploadedCaseDocument } from '../src/types.js'
2026-08-22 17:02:30 +02:00
import { isClaimExhibit , 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 17:02:30 +02:00
import { loadCaseReport } from './caseReports.js'
2026-08-22 14:53:23 +02:00
import { evaluateEvidenceRules , type EvidenceMatchRule } from './evidenceMatching.js'
2026-08-22 16:02:10 +02:00
import { EvidenceJudgeError , type EvidenceJudge } from './evidenceJudge.js'
2026-08-22 14:53:23 +02:00
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
2026-08-22 16:02:10 +02:00
sourceLabel? : string
sourceUri? : string
2026-08-22 14:53:23 +02:00
flagKey : string
minimumAnchorMatches? : number
enabled? : boolean
anchors : { phrase : string ; minimumSimilarity? : number }[]
}
2026-08-22 16:02:10 +02:00
export type LevelGoalInput = {
key : string
title : string
instructions? : string
completionMessage? : string
enabled? : boolean
requiredFlags : string []
}
export type EvidenceSemanticRuleInput = {
goalId : string
name : string
targetSubject : string
relatedSubject? : string
assertion : string
successFlagKey : string
relatedFlagKey? : string
minimumConfidence? : number
evaluatorVersion? : string
enabled? : boolean
}
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-22 16:02:10 +02:00
listGoals ( levelId : string ) : Promise < LevelGoal [] | null >
createGoal ( levelId : string , input : LevelGoalInput ) : Promise < LevelGoal | null >
updateGoal ( levelId : string , goalId : string , input : LevelGoalInput ) : Promise < LevelGoal | null >
deleteGoal ( levelId : string , goalId : string ) : Promise < boolean | null >
listEvidenceSemanticRules ( levelId : string ) : Promise < EvidenceSemanticRuleDefinition [] | null >
createEvidenceSemanticRule ( levelId : string , input : EvidenceSemanticRuleInput ) : Promise < EvidenceSemanticRuleDefinition | null >
updateEvidenceSemanticRule ( levelId : string , ruleId : string , input : EvidenceSemanticRuleInput ) : Promise < EvidenceSemanticRuleDefinition | null >
deleteEvidenceSemanticRule ( levelId : string , ruleId : string ) : Promise < boolean | null >
judgeDocument ( levelId : string , documentId : string ) : Promise < DocumentSemanticAnalysis | 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
2026-08-22 19:28:06 +02:00
capture_kind_id : DocumentCaptureKind | null
2026-08-14 14:09:24 +02:00
asset_id : string | null ; published_at : Date | null ; occurred_at : Date | null
2026-08-22 17:02:30 +02:00
captured_at : Date | null ; source_uri : string | null ; citation_text : string | null ; display_number : number | null ; statement : string | null
2026-08-14 14:09:24 +02:00
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 ())
2026-08-22 16:02:10 +02:00
const sourceLabel = String ( input . sourceLabel || '' ). trim ()
const sourceUri = String ( input . sourceUri || '' ). trim ()
if ( sourceLabel . length > 300 ) throw new Error ( 'Evidence source labels cannot exceed 300 characters' )
if ( sourceUri . length > 2 _000 ) throw new Error ( 'Evidence source URIs cannot exceed 2000 characters' )
if ( sourceUri ) { try { new URL ( sourceUri ) } catch { throw new Error ( 'Evidence source URI must be an absolute URL' ) } }
2026-08-22 14:53:23 +02:00
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' )
2026-08-22 16:02:10 +02:00
return { name , sourceLabel :sourceLabel || undefined , sourceUri :sourceUri || undefined , flagKey , minimumAnchorMatches , enabled : input.enabled !== false , anchors }
}
function requireGoalInput ( input : LevelGoalInput ) {
const key = requireFlagKey ( String ( input . key || '' ). trim ())
const title = String ( input . title || '' ). trim ()
const instructions = String ( input . instructions || '' ). trim ()
const completionMessage = String ( input . completionMessage || '' ). trim ()
if ( ! title || title . length > 200 ) throw new Error ( 'Level goal titles must be between 1 and 200 characters' )
if ( instructions . length > 10 _000 ) throw new Error ( 'Level goal instructions cannot exceed 10000 characters' )
if ( completionMessage . length > 2 _000 ) throw new Error ( 'Level goal completion messages cannot exceed 2000 characters' )
if ( ! Array . isArray ( input . requiredFlags )) throw new Error ( 'Level goal requiredFlags must be an array' )
const requiredFlags = [... new Set ( input . requiredFlags . map ( value => requireFlagKey ( String ( value || '' ). trim ())))]
if ( ! requiredFlags . length || requiredFlags . length > 20 ) throw new Error ( 'Level goals require between 1 and 20 flags' )
return { key , title , instructions , completionMessage , enabled : input.enabled !== false , requiredFlags }
}
function requireSemanticRuleInput ( input : EvidenceSemanticRuleInput ) {
const goalId = requireUuid ( String ( input . goalId || '' ). trim (), 'Goal id' )
const name = String ( input . name || '' ). trim ()
const targetSubject = String ( input . targetSubject || '' ). trim ()
const relatedSubject = String ( input . relatedSubject || '' ). trim ()
const assertion = String ( input . assertion || '' ). trim ()
const successFlagKey = requireFlagKey ( String ( input . successFlagKey || '' ). trim ())
const relatedFlagKey = relatedSubject && input . relatedFlagKey ? requireFlagKey ( String ( input . relatedFlagKey ). trim ()) : undefined
const minimumConfidence = input . minimumConfidence === undefined ? . 85 : Number ( input . minimumConfidence )
const evaluatorVersion = String ( input . evaluatorVersion || 'evidence_claim_v1' ). trim ()
if ( ! name || name . length > 160 ) throw new Error ( 'Semantic rule names must be between 1 and 160 characters' )
if ( ! targetSubject || targetSubject . length > 300 ) throw new Error ( 'Target subjects must be between 1 and 300 characters' )
if ( relatedSubject . length > 300 ) throw new Error ( 'Related subjects cannot exceed 300 characters' )
if ( ! assertion || assertion . length > 2 _000 ) throw new Error ( 'Assertions must be between 1 and 2000 characters' )
if ( ! Number . isFinite ( minimumConfidence ) || minimumConfidence < . 5 || minimumConfidence > 1 ) throw new Error ( 'Semantic confidence must be between 0.5 and 1' )
if ( ! evaluatorVersion || evaluatorVersion . length > 100 ) throw new Error ( 'Evaluator versions must be between 1 and 100 characters' )
return { goalId , name , targetSubject , relatedSubject :relatedSubject || undefined , assertion , successFlagKey , relatedFlagKey ,
minimumConfidence , evaluatorVersion , enabled :input.enabled !== false }
2026-08-22 14:53:23 +02:00
}
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-22 19:28:06 +02:00
function documentCaptureKind ( value : unknown ) : DocumentCaptureKind {
const allowed : DocumentCaptureKind [] = [ 'unclassified' , 'photo' , 'scene' , 'clipping' , 'full_page' ]
return allowed . includes ( value as DocumentCaptureKind ) ? value as DocumentCaptureKind : 'unclassified'
}
2026-08-22 16:02:10 +02:00
export function createLevelRepository ( pool : Pool , editingEnabled : boolean , objectStorage : ObjectStorage , evidenceJudge : EvidenceJudge ) : 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 < {
2026-08-22 16:02:10 +02:00
rule_id : string ; name : string ; source_label :string | null ; source_uri :string | null ; flag_key : string ; matcher_version : 'char_trigram_v1' ; minimum_anchor_matches : number ; enabled : boolean
2026-08-22 14:53:23 +02:00
anchor_id : string ; phrase_text : string ; minimum_similarity : string ; sort_order : number
2026-08-22 16:02:10 +02:00
} > ( `SELECT r.id AS rule_id,r.name,r.source_label,r.source_uri,r.flag_key,r.matcher_version,r.minimum_anchor_matches,r.enabled,
2026-08-22 14:53:23 +02:00
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 ) {
2026-08-22 16:02:10 +02:00
const rule = rules . get ( row . rule_id ) || { id : row.rule_id , name : row.name , sourceLabel :row.source_label || undefined , sourceUri :row.source_uri || undefined , flagKey : row.flag_key ,
2026-08-22 14:53:23 +02:00
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 ) {
2026-08-22 16:02:10 +02:00
const changed = await client . query ( `UPDATE osint.evidence_match_rules SET name=$3,source_label=$4,source_uri=$5,flag_key=$6,minimum_anchor_matches=$7,enabled=$8,updated_at=NOW()
WHERE id=$1 AND board_id=$2` , [ ruleId , level . board_id , input . name , input . sourceLabel || null , input . sourceUri || null , input . flagKey , input . minimumAnchorMatches , input . enabled ])
2026-08-22 14:53:23 +02:00
if ( ! changed . rowCount ) return null
await client . query ( 'DELETE FROM osint.evidence_match_anchors WHERE rule_id=$1' , [ ruleId ])
} else {
2026-08-22 16:02:10 +02:00
await client . query ( `INSERT INTO osint.evidence_match_rules (id,board_id,name,source_label,source_uri,flag_key,minimum_anchor_matches,enabled)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)` , [ ruleId , level . board_id , input . name , input . sourceLabel || null , input . sourceUri || null , input . flagKey , input . minimumAnchorMatches , input . enabled ])
2026-08-22 14:53:23 +02:00
}
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 ])
2026-08-22 21:52:58 +02:00
const rule = ( await evidenceMatchRules ( client , level . board_id , true )). find ( candidate => candidate . id === ruleId ) || null
if ( rule ) await evaluateRuleForExistingDocuments ( client , level , rule )
2026-08-22 14:53:23 +02:00
await client . query ( 'UPDATE osint.levels SET updated_at=NOW() WHERE id=$1' , [ level . id ])
2026-08-22 21:52:58 +02:00
return rule
}
async function evaluateRuleForExistingDocuments ( client : PoolClient , level : LevelRow , rule : EvidenceMatchRuleDefinition ) {
if ( ! rule . enabled ) return
const documents = await client . query < { document_exhibit_id :string ; extraction_id :string ; extracted_text :string } > ( `
SELECT DISTINCT ON (document.exhibit_id) document.exhibit_id AS document_exhibit_id,
extraction.id AS extraction_id,extraction.extracted_text
FROM osint.document_exhibits document
JOIN osint.exhibits exhibit ON exhibit.id=document.exhibit_id AND exhibit.board_id=$1
JOIN osint.asset_text_extractions extraction ON extraction.asset_id=document.asset_id
AND extraction.status='succeeded' AND BTRIM(extraction.extracted_text)<>''
ORDER BY document.exhibit_id,extraction.updated_at DESC,extraction.id DESC` , [ level . board_id ])
for ( const document of documents . rows ) {
const evaluation = evaluateEvidenceRules ( document . extracted_text , [ rule as EvidenceMatchRule ])[ 0 ]
const persisted = await client . query < { id :string } > ( `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)
ON CONFLICT (level_id,document_exhibit_id,rule_id) DO UPDATE SET
extraction_id=EXCLUDED.extraction_id,matched=EXCLUDED.matched,
matched_anchor_count=EXCLUDED.matched_anchor_count,score=EXCLUDED.score,evaluated_at=NOW()
RETURNING id` , [ randomUUID (), level . id , level . board_id , document . document_exhibit_id , document . extraction_id ,
rule . id , evaluation . matched , evaluation . matchedAnchorCount , evaluation . score ])
const evaluationId = persisted . rows [ 0 ]. id
await client . query ( 'DELETE FROM osint.evidence_match_anchor_evaluations WHERE evaluation_id=$1' , [ evaluationId ])
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 ) 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` , [ level . id , level . board_id , evaluation . flagKey , evaluationId ])
}
2026-08-22 14:53:23 +02:00
}
2026-08-22 16:02:10 +02:00
async function levelGoalStates (
client : Pool | PoolClient ,
level : Pick < LevelRow , 'id' | 'board_id' >,
authorMode = false ,
newlyCompletedKeys : ReadonlySet < string > = new Set (),
) : Promise < LevelGoal [] > {
const result = await client . query < {
id : string ; goal_key : string ; title : string ; instructions : string ; completion_message : string ; enabled : boolean
required_flags : string []; requirement_count : number ; earned_count : number ; completed_at : Date | null
} > ( `SELECT goal.id,goal.goal_key,goal.title,goal.instructions,goal.completion_message,goal.enabled,
COALESCE(array_agg(requirement.flag_key ORDER BY requirement.flag_key)
FILTER (WHERE requirement.flag_key IS NOT NULL),'{}'::text[]) AS required_flags,
COUNT(requirement.flag_key)::int AS requirement_count,
COUNT(flag.flag_key)::int AS earned_count,
MAX(flag.earned_at) AS completed_at
FROM osint.level_goals goal
LEFT JOIN osint.level_goal_flag_requirements requirement ON requirement.goal_id=goal.id
LEFT JOIN osint.level_flags flag ON flag.level_id=$1 AND flag.flag_key=requirement.flag_key
WHERE goal.board_id=$2 ${ authorMode ? '' : 'AND goal.enabled' }
GROUP BY goal.id
ORDER BY goal.created_at,goal.id` , [ level . id , level . board_id ])
return result . rows . map ( row => {
const complete = row . requirement_count > 0 && row . earned_count === row . requirement_count
return {
...( authorMode ? { id : row.id , enabled : row.enabled , requiredFlags : row.required_flags } : {}),
key : row.goal_key ,
title : row.title ,
instructions : row.instructions ,
completionMessage : row.completion_message ,
status : complete ? 'complete' as const : 'pending' as const ,
...( complete && row . completed_at ? { completedAt : row.completed_at.toISOString () } : {}),
newlyCompleted : complete && newlyCompletedKeys . has ( row . goal_key ),
}
})
}
async function writeGoal ( client : PoolClient , level : LevelRow , goalId : string , rawInput : LevelGoalInput , update : boolean ) {
const input = requireGoalInput ( rawInput )
if ( update ) {
const changed = await client . query ( `UPDATE osint.level_goals SET
goal_key=$3,title=$4,instructions=$5,completion_message=$6,enabled=$7,updated_at=NOW()
WHERE id=$1 AND board_id=$2` , [ goalId , level . board_id , input . key , input . title , input . instructions , input . completionMessage , input . enabled ])
if ( ! changed . rowCount ) return null
await client . query ( 'DELETE FROM osint.level_goal_flag_requirements WHERE goal_id=$1' , [ goalId ])
} else await client . query ( `INSERT INTO osint.level_goals
(id,board_id,goal_key,title,instructions,completion_message,enabled) VALUES ($1,$2,$3,$4,$5,$6,$7)` ,
[ goalId , level . board_id , input . key , input . title , input . instructions , input . completionMessage , input . enabled ])
for ( const flag of input . requiredFlags ) await client . query (
'INSERT INTO osint.level_goal_flag_requirements (board_id,goal_id,flag_key) VALUES ($1,$2,$3)' ,
[ level . board_id , goalId , flag ])
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 ])
return ( await levelGoalStates ( client , level , true )). find ( goal => goal . id === goalId ) || null
}
async function evidenceSemanticRules ( client : Pool | PoolClient , boardId : string , includeDisabled = false ) : Promise < EvidenceSemanticRuleDefinition [] > {
const result = await client . query < {
id :string ; goal_id :string ; goal_key :string ; name :string ; target_subject :string ; related_subject :string | null ; assertion_text :string
success_flag_key :string ; related_flag_key :string | null ; minimum_confidence :string ; evaluator_version :string ; enabled :boolean
} > ( `SELECT rule.id,rule.goal_id,goal.goal_key,rule.name,rule.target_subject,rule.related_subject,rule.assertion_text,
rule.success_flag_key,rule.related_flag_key,rule.minimum_confidence::text,rule.evaluator_version,rule.enabled
FROM osint.evidence_semantic_rules rule JOIN osint.level_goals goal ON goal.id=rule.goal_id
WHERE rule.board_id=$1 ${ includeDisabled ? '' : 'AND rule.enabled AND goal.enabled' }
ORDER BY rule.created_at,rule.id` , [ boardId ])
return result . rows . map ( row => ({
id :row.id , goalId :row.goal_id , goalKey :row.goal_key , name :row.name , targetSubject :row.target_subject ,
relatedSubject :row.related_subject || undefined , assertion :row.assertion_text , successFlagKey :row.success_flag_key ,
relatedFlagKey :row.related_flag_key || undefined , minimumConfidence :Number ( row . minimum_confidence ),
evaluatorVersion :row.evaluator_version , enabled :row.enabled ,
}))
}
async function writeEvidenceSemanticRule ( client : PoolClient , level : LevelRow , ruleId : string , rawInput : EvidenceSemanticRuleInput , update : boolean ) {
const input = requireSemanticRuleInput ( rawInput )
if ( update ) {
const changed = await client . query ( `UPDATE osint.evidence_semantic_rules SET goal_id=$3,name=$4,target_subject=$5,
related_subject=$6,assertion_text=$7,success_flag_key=$8,related_flag_key=$9,minimum_confidence=$10,
evaluator_version=$11,enabled=$12,updated_at=NOW() WHERE id=$1 AND board_id=$2` ,
[ ruleId , level . board_id , input . goalId , input . name , input . targetSubject , input . relatedSubject || null , input . assertion , input . successFlagKey ,
input . relatedFlagKey || null , input . minimumConfidence , input . evaluatorVersion , input . enabled ])
if ( ! changed . rowCount ) return null
} else await client . query ( `INSERT INTO osint.evidence_semantic_rules
(id,board_id,goal_id,name,target_subject,related_subject,assertion_text,success_flag_key,related_flag_key,
minimum_confidence,evaluator_version,enabled) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)` ,
[ ruleId , level . board_id , input . goalId , input . name , input . targetSubject , input . relatedSubject || null , input . assertion , input . successFlagKey ,
input . relatedFlagKey || null , input . minimumConfidence , input . evaluatorVersion , input . enabled ])
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 ])
return ( await evidenceSemanticRules ( 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-22 17:02:30 +02:00
COALESCE(f.title, d.title, n.title, ev.title, p.display_name, claim.statement, '') AS title,
2026-08-14 14:44:58 +02:00
COALESCE(f.label_text, n.note_text, ev.narrative_text, p.summary, '') AS content,
2026-08-22 19:28:06 +02:00
f.is_open, d.document_type_id, d.capture_kind_id, d.asset_id, d.published_at, d.captured_at, d.source_uri,d.citation_text,citation.display_number,
2026-08-22 17:02:30 +02:00
ev.occurred_at,claim.statement,
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-22 17:02:30 +02:00
LEFT JOIN osint.claim_exhibits claim ON claim.exhibit_id = e.id
LEFT JOIN osint.exhibit_citations citation ON citation.board_id=e.board_id AND citation.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 17:02:30 +02:00
return { ... base ( row ), type : 'document' , publishedAt , capturedAt :row.captured_at?.toISOString (), sourceUri :row.source_uri || undefined ,
sourceCitation :row.citation_text || undefined , displayNumber :row.display_number || undefined , 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 ,
2026-08-22 19:28:06 +02:00
fileSize : row.byte_size === null ? undefined : Number ( row . byte_size ), fileType : type ,
captureKind : documentCaptureKind ( row . capture_kind_id ), metadata : metadata.get ( row . id ) || {} }
2026-08-14 14:09:24 +02:00
})
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 ) || [] })
2026-08-22 17:02:30 +02:00
else if ( row . exhibit_type_id === 'claim' ) evidence . push ({ ... base ( row ), type : 'claim' , title :row.title , statement :row.statement || row . title })
2026-08-17 09:24:53 +02:00
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 16:02:10 +02:00
const goals = await levelGoalStates ( pool , level , authorMode )
2026-08-22 17:02:30 +02:00
const report = await loadCaseReport ( pool , level )
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-22 17:02:30 +02:00
brief : { body : briefResult.rows [ 0 ] ? . body || '' , concepts }, goals , report , 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-22 17:02:30 +02:00
await client . query ( 'DELETE FROM osint.document_flag_requirements WHERE board_id=$1' ,[ level . board_id ])
await client . query ( 'DELETE FROM osint.document_content_blocks WHERE document_exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)' ,[ level . board_id ])
await client . query ( 'DELETE FROM osint.document_regions WHERE document_exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)' ,[ level . board_id ])
for ( const table of [ 'folder_exhibits' , 'image_documents' , 'note_exhibits' , 'event_exhibits' , 'person_parties' , 'organization_parties' , 'party_exhibits' , 'claim_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-22 17:02:30 +02:00
if ( documentIds . size ) {
await client . query ( 'DELETE FROM osint.document_exhibits WHERE exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1) AND NOT (exhibit_id=ANY($2::uuid[]))' ,[ level . board_id ,[... documentIds ]])
await client . query ( 'DELETE FROM osint.exhibit_citations WHERE board_id=$1 AND NOT (exhibit_id=ANY($2::uuid[]))' ,[ level . board_id ,[... documentIds ]])
} else {
await client . query ( 'DELETE FROM osint.document_exhibits WHERE exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)' ,[ level . board_id ])
await client . query ( 'DELETE FROM osint.exhibit_citations WHERE board_id=$1' ,[ level . board_id ])
}
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 ])
}
2026-08-22 17:02:30 +02:00
let nextCitation = Number (( await client . query < { maximum :number } > ( 'SELECT COALESCE(MAX(display_number),0)::int AS maximum FROM osint.exhibit_citations WHERE board_id=$1' ,[ level . board_id ])). rows [ 0 ]. maximum )
2026-08-17 09:24:53 +02:00
for ( const document of documents ) {
2026-08-22 19:28:06 +02:00
await client . query ( `INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,capture_kind_id,asset_id,title,published_at,captured_at,source_uri,citation_text)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT (exhibit_id) DO UPDATE SET
2026-08-22 17:02:30 +02:00
document_type_id=EXCLUDED.document_type_id,asset_id=EXCLUDED.asset_id,title=EXCLUDED.title,published_at=EXCLUDED.published_at,
2026-08-22 19:28:06 +02:00
capture_kind_id=EXCLUDED.capture_kind_id,captured_at=EXCLUDED.captured_at,source_uri=EXCLUDED.source_uri,citation_text=EXCLUDED.citation_text` ,
[ document . id , documentType ( document ), documentCaptureKind ( document . captureKind ), document . assetId || null , document . title ,
2026-08-22 17:02:30 +02:00
timestamp ( document . publishedAt ), timestamp ( document . capturedAt ), document . sourceUri || null , document . sourceCitation || '' ])
const existingCitation = await client . query < { display_number :number } > ( 'SELECT display_number FROM osint.exhibit_citations WHERE board_id=$1 AND exhibit_id=$2' ,[ level . board_id , document . id ])
if ( ! existingCitation . rows [ 0 ]) {
const requested = Number ( document . displayNumber )
const displayNumber = Number . isInteger ( requested ) && requested > 0
&& ! ( await client . query ( 'SELECT 1 FROM osint.exhibit_citations WHERE board_id=$1 AND display_number=$2' ,[ level . board_id , requested ])). rowCount
? requested : ++ nextCitation
await client . query ( 'INSERT INTO osint.exhibit_citations (board_id,exhibit_id,display_number) VALUES ($1,$2,$3)' ,[ level . board_id , document . id , displayNumber ])
nextCitation = Math . max ( nextCitation , displayNumber )
}
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-22 17:02:30 +02:00
if ( isClaimExhibit ( exhibit )) await client . query (
'INSERT INTO osint.claim_exhibits (exhibit_id,statement) VALUES ($1,$2)' , [ exhibit . id , exhibit . statement . trim ()])
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-22 17:02:30 +02:00
if ( state . report ) await client . query ( `INSERT INTO osint.case_reports (board_id,title,required_for_completion)
VALUES ($1,$2,$3) ON CONFLICT (board_id) DO UPDATE SET title=EXCLUDED.title,
required_for_completion=EXCLUDED.required_for_completion,updated_at=NOW()` ,
[ level . board_id , state . report . title , state . report . requiredForCompletion ])
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' )
2026-08-22 16:02:10 +02:00
await client . query ( 'DELETE FROM osint.level_flags WHERE level_id=$1' , [ level . id ])
await client . query ( 'DELETE FROM osint.level_seen_documents WHERE level_id=$1' , [ level . id ])
2026-08-14 14:17:54 +02:00
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 }
2026-08-22 16:02:10 +02:00
const goalsBeforeUpload = await levelGoalStates ( client , level )
2026-08-14 14:09:24 +02:00
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-22 17:02:30 +02:00
const citation = await client . query < { display_number :number } > ( `INSERT INTO osint.exhibit_citations (board_id,exhibit_id,display_number)
SELECT $1,$2,COALESCE(MAX(display_number),0)+1 FROM osint.exhibit_citations WHERE board_id=$1 RETURNING display_number` ,
[ level . board_id , exhibitId ])
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-22 16:02:10 +02:00
const priorGoalStatus = new Map ( goalsBeforeUpload . map ( goal => [ goal . key , goal . status ]))
const goals = ( await levelGoalStates ( client , level )). map ( goal => ({
... goal ,
newlyCompleted : goal.status === 'complete' && priorGoalStatus . get ( goal . key ) !== 'complete' ,
}))
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 ,
2026-08-22 17:02:30 +02:00
displayNumber :citation.rows [ 0 ]. display_number ,
2026-08-22 19:28:06 +02:00
fileType , captureKind : 'unclassified' , metadata : {}, body :extraction.status === 'succeeded' && extraction . text . trim () ? [ extraction . text . trim ()] : [], regions : [], assetId ,
2026-08-22 14:53:23 +02:00
fileName :file.originalname , mimeType :file.mimetype , fileSize :file.size ,
2026-08-22 16:02:10 +02:00
analysis : { extractionStatus :extraction.status , matchedFlags : [... new Set ( matchedFlags )], awardedFlags : [... new Set ( awardedFlags )], goals } }
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
2026-08-22 16:02:10 +02:00
UNION
SELECT flag_key FROM osint.level_goal_flag_requirements WHERE board_id=$2
2026-08-22 14:53:23 +02:00
)
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 () }
},
2026-08-22 16:02:10 +02:00
async listGoals ( levelId ) {
const level = await findLevel ( pool , levelId )
return level ? levelGoalStates ( pool , level , true ) : null
},
async createGoal ( 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 goal = await writeGoal ( client , level , randomUUID (), input , false )
await client . query ( 'COMMIT' )
return goal
} catch ( error ) { await client . query ( 'ROLLBACK' ); throw error } finally { client . release () }
},
async updateGoal ( levelId , goalId , input ) {
if ( ! uuidPattern . test ( goalId )) 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 goal = await writeGoal ( client , level , goalId , input , true )
await client . query ( 'COMMIT' )
return goal
} catch ( error ) { await client . query ( 'ROLLBACK' ); throw error } finally { client . release () }
},
async deleteGoal ( levelId , goalId ) {
if ( ! uuidPattern . test ( goalId )) 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.level_goals WHERE id=$1 AND board_id=$2' , [ goalId , level . board_id ])
if ( removed . rowCount ) {
await client . query ( 'UPDATE osint.levels SET updated_at=NOW() WHERE id=$1' , [ level . id ])
await client . query ( 'UPDATE osint.boards SET revision=revision+1,updated_at=NOW() WHERE id=$1' , [ level . board_id ])
}
await client . query ( 'COMMIT' )
return Boolean ( removed . rowCount )
} catch ( error ) { await client . query ( 'ROLLBACK' ); throw error } finally { client . release () }
},
async listEvidenceSemanticRules ( levelId ) {
const level = await findLevel ( pool , levelId )
return level ? evidenceSemanticRules ( pool , level . board_id , true ) : null
},
async createEvidenceSemanticRule ( 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 writeEvidenceSemanticRule ( client , level , randomUUID (), input , false )
await client . query ( 'COMMIT' )
return rule
} catch ( error ) { await client . query ( 'ROLLBACK' ); throw error } finally { client . release () }
},
async updateEvidenceSemanticRule ( 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 writeEvidenceSemanticRule ( client , level , ruleId , input , true )
await client . query ( 'COMMIT' )
return rule
} catch ( error ) { await client . query ( 'ROLLBACK' ); throw error } finally { client . release () }
},
async deleteEvidenceSemanticRule ( 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_semantic_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 ( 'UPDATE osint.boards SET revision=revision+1,updated_at=NOW() WHERE id=$1' , [ level . board_id ])
}
await client . query ( 'COMMIT' )
return Boolean ( removed . rowCount )
} catch ( error ) { await client . query ( 'ROLLBACK' ); throw error } finally { client . release () }
},
async judgeDocument ( levelId , documentId ) {
if ( ! uuidPattern . test ( documentId )) return null
const level = await findLevel ( pool , levelId )
if ( ! level ) return null
const document = ( await pool . query < { extraction_id :string ; extracted_text :string } > ( `SELECT extraction.id AS extraction_id,extraction.extracted_text
FROM osint.document_exhibits document
JOIN osint.exhibits exhibit ON exhibit.id=document.exhibit_id
JOIN osint.asset_text_extractions extraction ON extraction.asset_id=document.asset_id AND extraction.status='succeeded'
WHERE document.exhibit_id=$1 AND exhibit.board_id=$2
ORDER BY extraction.updated_at DESC LIMIT 1` , [ documentId , level . board_id ])). rows [ 0 ]
const goalsBefore = await levelGoalStates ( pool , level )
if ( ! document ? . extracted_text . trim ()) return { status : 'failed' , retryable :false , awardedFlags : [], goals :goalsBefore }
const pendingGoalKeys = new Set ( goalsBefore . filter ( goal => goal . status === 'pending' ). map ( goal => goal . key ))
const rules = ( await evidenceSemanticRules ( pool , level . board_id )). filter ( rule => pendingGoalKeys . has ( rule . goalKey )). slice ( 0 , 5 )
if ( ! rules . length ) return { status : 'not_needed' , retryable :false , awardedFlags : [], goals :goalsBefore }
if ( ! evidenceJudge . enabled ) return { status : 'unavailable' , retryable :true , awardedFlags : [], goals :goalsBefore }
type EvaluationRow = { id :string ; status : 'pending' | 'succeeded' | 'failed' ; subject : 'target' | 'related' | 'ambiguous' | 'neither' | null
supports_claim :boolean | null ; evidence_excerpt :string ; confidence :string | null }
const awardedFlags : string [] = []
let resultStatus : DocumentSemanticAnalysis [ 'status' ] = 'failed'
let lastVerdict : Pick < DocumentSemanticAnalysis , 'subject' | 'supportsClaim' | 'evidenceExcerpt' | 'confidence' > = {}
for ( const rule of rules ) {
const evaluatorVersion = ` ${ rule . evaluatorVersion } : ${ evidenceJudge . evaluatorVersion } ` . slice ( 0 , 100 )
const evaluationId = randomUUID ()
const claimed = await pool . query < { id :string } > ( `INSERT INTO osint.evidence_semantic_evaluations
(id,level_id,board_id,document_exhibit_id,extraction_id,rule_id,evaluator_version,provider,model,status)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'pending')
ON CONFLICT (level_id,document_exhibit_id,rule_id,evaluator_version) DO UPDATE SET
provider=EXCLUDED.provider,model=EXCLUDED.model,status='pending',subject=NULL,supports_claim=NULL,
evidence_excerpt='',confidence=NULL,failure_code=NULL,attempt_count=osint.evidence_semantic_evaluations.attempt_count+1,updated_at=NOW()
WHERE osint.evidence_semantic_evaluations.status='failed'
OR (osint.evidence_semantic_evaluations.status='pending' AND osint.evidence_semantic_evaluations.updated_at < NOW()-INTERVAL '2 minutes')
RETURNING id` , [ evaluationId , level . id , level . board_id , documentId , document . extraction_id , rule . id , evaluatorVersion , evidenceJudge . provider , evidenceJudge . model ])
const activeId = claimed . rows [ 0 ] ? . id
if ( ! activeId ) {
const existing = ( await pool . query < EvaluationRow >( `SELECT id,status,subject,supports_claim,evidence_excerpt,confidence::text
FROM osint.evidence_semantic_evaluations WHERE level_id=$1 AND document_exhibit_id=$2 AND rule_id=$3 AND evaluator_version=$4` ,
[ level . id , documentId , rule . id , evaluatorVersion ])). rows [ 0 ]
if ( existing ? . status === 'pending' ) { if ( resultStatus !== 'succeeded' ) resultStatus = 'pending' ; continue }
if ( existing ? . status === 'succeeded' ) {
resultStatus = 'succeeded'
lastVerdict = { subject :existing.subject || undefined , supportsClaim :existing.supports_claim ?? undefined ,
evidenceExcerpt :existing.evidence_excerpt , confidence :existing.confidence === null ? undefined : Number ( existing . confidence ) }
continue
}
continue
}
try {
const verdict = await evidenceJudge . judge ({ targetSubject :rule.targetSubject , relatedSubject :rule.relatedSubject ,
assertion :rule.assertion , evidenceText :document.extracted_text })
const flagKey = verdict . supportsClaim && verdict . confidence >= rule . minimumConfidence
? verdict . subject === 'target' ? rule.successFlagKey : verdict.subject === 'related' ? rule.relatedFlagKey : undefined
: undefined
const client = await pool . connect ()
try {
await client . query ( 'BEGIN' )
await client . query ( `UPDATE osint.evidence_semantic_evaluations SET status='succeeded',subject=$2,supports_claim=$3,
evidence_excerpt=$4,confidence=$5,failure_code=NULL,evaluated_at=NOW(),updated_at=NOW() WHERE id=$1` ,
[ activeId , verdict . subject , verdict . supportsClaim , verdict . evidenceExcerpt , verdict . confidence ])
if ( flagKey ) {
const awarded = await client . query ( `INSERT INTO osint.level_flags
(level_id,board_id,flag_key,awarded_by_semantic_evaluation_id) VALUES ($1,$2,$3,$4)
ON CONFLICT (level_id,flag_key) DO NOTHING RETURNING flag_key` , [ level . id , level . board_id , flagKey , activeId ])
if ( awarded . rowCount ) awardedFlags . push ( flagKey )
}
await client . query ( 'COMMIT' )
} catch ( error ) { await client . query ( 'ROLLBACK' ); throw error } finally { client . release () }
resultStatus = 'succeeded'
lastVerdict = { subject :verdict.subject , supportsClaim :verdict.supportsClaim , evidenceExcerpt :verdict.evidenceExcerpt , confidence :verdict.confidence }
if ( flagKey === rule . successFlagKey ) break
} catch ( error ) {
const code = ( error instanceof EvidenceJudgeError ? error . code : 'provider_error' ). slice ( 0 , 100 )
await pool . query ( `UPDATE osint.evidence_semantic_evaluations SET status='failed',failure_code=$2,
evaluated_at=NOW(),updated_at=NOW() WHERE id=$1` , [ activeId , code ])
}
}
const priorGoalStatus = new Map ( goalsBefore . map ( goal => [ goal . key , goal . status ]))
const goals = ( await levelGoalStates ( pool , level )). map ( goal => ({ ... goal ,
newlyCompleted :goal.status === 'complete' && priorGoalStatus . get ( goal . key ) !== 'complete' }))
return { status :resultStatus , retryable :resultStatus === 'failed' || resultStatus === 'pending' , awardedFlags , goals ,... lastVerdict }
},
2026-08-22 14:53:23 +02:00
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
}
}