Adding migrations and lot of work. Starting work on the demo scope

This commit is contained in:
2026-08-22 14:53:23 +02:00
parent 1893bf23af
commit 94ccbfd1b9
29 changed files with 1280 additions and 122 deletions
+235 -12
View File
@@ -1,15 +1,25 @@
import { createHash, randomUUID } from 'node:crypto'
import { Readable } from 'node:stream'
import type { Pool, PoolClient } from 'pg'
import type { BoardView, BriefConcept, CaseDocument, CaseState, Evidence, Exhibit, ExhibitRelation, OrganizationKind, PartyKind, SourceFileType } from '../src/types.js'
import type { BoardView, BriefConcept, CaseDocument, CaseState, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, LevelFlag, OrganizationKind, PartyKind, SourceFileType, UploadedCaseDocument } from '../src/types.js'
import { isDocumentExhibit, isEventExhibit, isFolderExhibit, isPartyExhibit } from '../src/types.js'
import { clearBoard, cloneBoard } from './boardClone.js'
import { evaluateEvidenceRules, type EvidenceMatchRule } from './evidenceMatching.js'
import { filterLevelVisibility, mergePlayerStateForPersistence } from './levelVisibility.js'
import type { ObjectStorage } from './objectStorage.js'
import type { TextExtractionResult } from './ocr.js'
export type UploadedDocument = { buffer: Buffer; originalname: string; mimetype: string; size: number }
export type AssetRecord = { original_name: string; mime_type: string; byte_size: string; content: Buffer | null; storage_provider: 'postgres' | 's3'; object_key: string | null }
export type AssetResponse = { originalName: string; mimeType: string; byteSize: number; stream: NodeJS.ReadableStream }
export type TemplateSummary = { id: string; slug: string; name: string; currentVersion: number; versionCount: number; updatedAt: string }
export type EvidenceMatchRuleInput = {
name: string
flagKey: string
minimumAnchorMatches?: number
enabled?: boolean
anchors: { phrase: string; minimumSimilarity?: number }[]
}
export interface LevelRepository {
listLevels(): Promise<unknown[]>
@@ -21,7 +31,14 @@ export interface LevelRepository {
saveLevel(state: CaseState, authorMode: boolean): Promise<void>
resetLevel(levelId: string): Promise<CaseState | null>
getAsset(assetId: string): Promise<AssetResponse | null>
uploadDocument(levelId: string, file: UploadedDocument): Promise<CaseDocument | null>
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>
}
type LevelRow = {
@@ -39,6 +56,7 @@ type ExhibitRow = {
}
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
const flagPattern = /^[a-z][a-z0-9_.-]{0,63}$/
function requireUuid(value: string, label: string) {
if (!uuidPattern.test(value)) throw new Error(`${label} must be a UUID`)
return value
@@ -48,6 +66,26 @@ function timestamp(value: string | undefined) {
const date = new Date(value)
return Number.isFinite(date.getTime()) ? date.toISOString() : null
}
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 }
}
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'
@@ -67,11 +105,49 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
await client.query("INSERT INTO osint.timeline_views (view_id,range_mode) VALUES ($1,'auto')", [viewId])
}
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
}
async function assembleLevel(slug: string, authorMode = false): Promise<CaseState | null> {
const level = await findLevel(pool, slug)
if (!level) return null
const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult, eventEvidenceResult,
aliasesResult, partyEvidenceResult, briefResult, conceptsResult, viewsResult] = await Promise.all([
aliasesResult, partyEvidenceResult, briefResult, conceptsResult, viewsResult, requirementsResult, flagsResult, seenResult] = await Promise.all([
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,
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,
@@ -122,6 +198,10 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
`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]),
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]),
])
const blocks = new Map<string, string[]>()
@@ -134,6 +214,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
for (const row of metadataResult.rows) metadata.set(row.exhibit_id, { ...(metadata.get(row.exhibit_id) || {}), [row.field_key]: row.value })
const aliases = new Map<string, string[]>()
for (const row of aliasesResult.rows) aliases.set(row.party_exhibit_id, [...(aliases.get(row.party_exhibit_id) || []), row.alias])
const 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])
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 })),
@@ -149,13 +231,13 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
const documents: CaseDocument[] = exhibitsResult.rows.filter(row => row.exhibit_type_id === 'document').map(row => {
const type = row.document_type_id || 'file'
const publishedAt = row.published_at?.toISOString()
return { ...base(row), type: 'document', publishedAt,
return { ...base(row), type: 'document', publishedAt, requiredFlags: requirements.get(row.id) || [],
body: blocks.get(row.id) || [], regions: regions.get(row.id) || [], assetId: row.asset_id || undefined,
fileName: row.original_name || undefined, mimeType: row.mime_type || undefined,
fileSize: row.byte_size === null ? undefined : Number(row.byte_size), fileType: type, metadata: metadata.get(row.id) || {} }
})
const evidence: Evidence[] = []
for (const row of exhibitsResult.rows.filter(row => row.exhibit_type_id !== 'document' && !row.hidden)) {
for (const row of exhibitsResult.rows.filter(row => row.exhibit_type_id !== 'document')) {
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() })
@@ -170,7 +252,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
rangeMode: row.range_mode, range: row.range_start && row.range_end ? { start: row.range_start, end: row.range_end } : undefined }))
const concepts: BriefConcept[] = conceptsResult.rows.map(row => ({ id: row.id, label: row.label, context: row.context_text,
...(authorMode && row.expected_party_kind ? { expectedPartyKind: row.expected_party_kind } : {}), resolvedPartyExhibitId: row.resolved_party_exhibit_id || undefined }))
return { id: level.slug, title: level.title, subtitle: level.subtitle, exhibits: [...documents, ...evidence], relations,
const fullState: CaseState = { id: level.slug, title: level.title, subtitle: level.subtitle, exhibits: [...documents, ...evidence], relations,
connections: connectionsResult.rows.map(row => ({ id: row.id, fromExhibitId: row.from_exhibit_id, toExhibitId: row.to_exhibit_id,
label: row.label || undefined, tightness: row.tightness, tagStyle: row.tag_style,
tagPosition: row.tag_position_percent, tagOffset: row.tag_lateral_offset })),
@@ -178,6 +260,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
views, revision: Number(level.revision),
brief: { body: briefResult.rows[0]?.body || '', concepts }, levelStatus: level.status, editingAllowed: editingEnabled && authorMode,
sourceTemplateVersionId: level.source_template_version_id || undefined }
return filterLevelVisibility(fullState, flagsResult.rows.map(row => row.flag_key), seenResult.rows.map(row => row.document_exhibit_id), authorMode)
}
async function templateSummary(slug: string): Promise<TemplateSummary | null> {
@@ -234,6 +317,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
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])
if (document.fileType === 'image') await client.query('INSERT INTO osint.image_documents (exhibit_id) VALUES ($1)', [document.id])
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])
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(
@@ -411,13 +496,19 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
return templateSummary(input.slug)
},
getLevel(levelId, authorMode = false) { return assembleLevel(levelId, authorMode) },
async saveLevel(state) {
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)
}
const client = await pool.connect()
try {
await client.query('BEGIN')
const level = await findLevel(client, state.id, true)
if (!level) throw new Error('Level not found')
await replaceBoard(client, level, state)
await replaceBoard(client, level, persistedState)
await client.query('COMMIT')
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
},
@@ -454,7 +545,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
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
},
async uploadDocument(levelId, file) {
async uploadDocument(levelId, file, extraction, placement) {
const client = await pool.connect()
try {
await client.query('BEGIN')
@@ -474,17 +565,149 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
assetId = asset.rows[0].id
}
const fileType: SourceFileType = file.mimetype.startsWith('image/') ? 'image' : file.mimetype === 'application/pdf' ? 'pdf' : file.mimetype.startsWith('text/') ? 'text' : 'file'
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
await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,z_index,hidden)
VALUES ($1,$2,'document',100,100,174,145,(SELECT COUNT(*) FROM osint.exhibits WHERE board_id=$2),FALSE)`, [exhibitId, level.board_id])
VALUES ($1,$2,'document',$3,$4,174,145,(SELECT COUNT(*) FROM osint.exhibits WHERE board_id=$2),FALSE)`, [exhibitId, level.board_id, xpos, ypos])
await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title) VALUES ($1,$2,$3,$4)`,
[exhibitId, fileType, assetId, file.originalname])
if (fileType === 'image') await client.query('INSERT INTO osint.image_documents (exhibit_id) VALUES ($1)', [exhibitId])
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)
}
await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id])
await client.query('UPDATE osint.boards SET revision=revision+1,updated_at=NOW() WHERE id=$1', [level.board_id])
await client.query('COMMIT')
return { id: exhibitId,type:'document',title:file.originalname,x:100,y:100,width:174,height:145,rotation:0,zIndex:0,hidden:false,
fileType,metadata:{},body:[],regions:[],assetId,fileName:file.originalname,mimeType:file.mimetype,fileSize:file.size }
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)] } }
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
},
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
},
}
}