feat: add event narrative and evidence workflow

This commit is contained in:
2026-08-14 14:31:52 +02:00
parent edfa4c866c
commit e333d3b634
10 changed files with 143 additions and 14 deletions
+12 -3
View File
@@ -71,10 +71,12 @@ suite('level persistence API', () => {
const documentId = randomUUID()
const folderId = randomUUID()
const noteId = randomUUID()
const eventId = randomUUID()
state.documents = [{ id: documentId, title: 'Evidence', kind: 'IMAGE', date: '2021-04-17', publishedAt: '2021-04-17T12:00:00Z', body: ['Extracted body'], regions: [{ id: 'stamp', label: 'Date stamp', excerpt: '17 April 2021', date: '2021-04-17T09:00:00Z' }], fileType: 'image', metadata: {} }]
state.evidence = [
{ id: folderId, type: 'folder', title: 'Folder', content: 'Evidence folder', x: 685, y: 417, width: 260, config: { open: true }, containedDocumentIds: [documentId] },
{ id: noteId, type: 'note', title: 'Extract', content: 'Date matters', sourceDocumentId: documentId, sourceRegionId: 'stamp', x: 420, y: 300, width: 108 },
{ id: eventId, type: 'event', title: 'The meeting occurred', content: 'The evidence places the meeting on 18 April.', eventDate: '2021-04-18T14:30:00Z', supportingEvidenceIds: [documentId, noteId], x: 520, y: 610, width: 270 },
]
state.relations = [{ id: `contains:${folderId}:${documentId}`, fromWidgetId: folderId, toWidgetId: documentId, type: 'contains', sortOrder: 0, config: { x: 1051, y: 417 } }]
state.connections = [{ id: randomUUID(), fromEvidenceId: folderId, toEvidenceId: noteId }]
@@ -134,15 +136,17 @@ suite('level persistence API', () => {
expect(sameLevelInEditView.viewport).toEqual(playerState.viewport)
expect(sameLevelInEditView.evidence[0]).toMatchObject({ id: folderId, x: 812, y: 533 })
const normalized = await appPool.query<{ exhibits: string; documents: string; folders: string; memberships: string; metadata: string; sources: string; connections: string }>(`SELECT
const normalized = await appPool.query<{ exhibits: string; documents: string; folders: string; memberships: string; metadata: string; sources: string; connections: string; events: string; event_evidence: string }>(`SELECT
(SELECT COUNT(*) FROM osint.exhibits)::text AS exhibits,
(SELECT COUNT(*) FROM osint.document_exhibits)::text AS documents,
(SELECT COUNT(*) FROM osint.folder_exhibits)::text AS folders,
(SELECT COUNT(*) FROM osint.folder_memberships)::text AS memberships,
(SELECT COUNT(*) FROM osint.exhibit_metadata_text_values)::text AS metadata,
(SELECT COUNT(*) FROM osint.exhibit_sources)::text AS sources,
(SELECT COUNT(*) FROM osint.exhibit_connections)::text AS connections`)
expect(normalized.rows[0]).toEqual({ exhibits: '4', documents: '2', folders: '1', memberships: '1', metadata: '2', sources: '1', connections: '1' })
(SELECT COUNT(*) FROM osint.exhibit_connections)::text AS connections,
(SELECT COUNT(*) FROM osint.event_exhibits)::text AS events,
(SELECT COUNT(*) FROM osint.event_evidence)::text AS event_evidence`)
expect(normalized.rows[0]).toEqual({ exhibits: '5', documents: '2', folders: '1', memberships: '1', metadata: '2', sources: '1', connections: '1', events: '1', event_evidence: '2' })
const resetResponse = await fetch(`${baseUrl}/api/levels/${state.id}/reset`, { method: 'POST' })
expect(resetResponse.ok).toBe(true)
@@ -177,6 +181,11 @@ suite('level persistence API', () => {
expect(clone.evidence.map(item => item.id)).not.toContain(folderId)
expect(clone.connections).toHaveLength(1)
expect(clone.evidence.find(item => item.type === 'note')).toMatchObject({ sourceRegionId: 'stamp' })
const clonedEvent = clone.evidence.find(item => item.type === 'event')!
expect(clonedEvent).toMatchObject({ title: 'The meeting occurred', eventDate: '2021-04-18T14:30:00.000Z' })
expect(clonedEvent.supportingEvidenceIds).toHaveLength(2)
expect(clonedEvent.supportingEvidenceIds).not.toContain(documentId)
expect(clonedEvent.supportingEvidenceIds).not.toContain(noteId)
const clonedFolder = clone.evidence.find(item => item.type === 'folder')!
clonedFolder.x = 1234
+15 -1
View File
@@ -62,7 +62,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
async function assembleLevel(slug: string): Promise<CaseState | null> {
const level = await findLevel(pool, slug)
if (!level) return null
const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult] = await Promise.all([
const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult, eventEvidenceResult] = await Promise.all([
pool.query<ExhibitRow>(`SELECT e.id, e.exhibit_type_id, e.xpos, e.ypos, e.width, e.hidden,
COALESCE(f.title, d.title, n.title, ev.title, '') AS title,
COALESCE(f.label_text, n.note_text, ev.narrative_text, '') AS content,
@@ -93,6 +93,9 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
pool.query<{ exhibit_id: string; field_key: string; value: string }>(
`SELECT v.exhibit_id, f.field_key, v.value FROM osint.exhibit_metadata_text_values v
JOIN osint.metadata_fields f ON f.id = v.field_id WHERE f.board_id = $1 ORDER BY f.field_key`, [level.board_id]),
pool.query<{ event_exhibit_id: string; evidence_exhibit_id: string }>(
`SELECT event_exhibit_id,evidence_exhibit_id FROM osint.event_evidence WHERE board_id=$1
ORDER BY event_exhibit_id,sort_order,evidence_exhibit_id`, [level.board_id]),
])
const blocks = new Map<string, string[]>()
@@ -104,6 +107,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
const metadata = new Map<string, Record<string, string>>()
for (const row of metadataResult.rows) metadata.set(row.exhibit_id, { ...(metadata.get(row.exhibit_id) || {}), [row.field_key]: row.value })
const contained = new Map<string, string[]>()
const eventEvidence = new Map<string, string[]>()
for (const row of eventEvidenceResult.rows) eventEvidence.set(row.event_exhibit_id, [...(eventEvidence.get(row.event_exhibit_id) || []), row.evidence_exhibit_id])
const relations: WidgetRelation[] = membershipsResult.rows.map(row => {
contained.set(row.folder_exhibit_id, [...(contained.get(row.folder_exhibit_id) || []), row.child_exhibit_id])
return { id: `contains:${row.folder_exhibit_id}:${row.child_exhibit_id}`, fromWidgetId: row.folder_exhibit_id,
@@ -121,6 +126,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
id: row.id, type: row.exhibit_type_id as Evidence['type'], title: row.title, content: row.content,
sourceDocumentId: row.source_document_id || undefined, sourceRegionId: row.source_region_key || undefined,
eventDate: row.occurred_at?.toISOString(), x: row.xpos, y: row.ypos, width: row.width,
supportingEvidenceIds: eventEvidence.get(row.id) || [],
config: row.exhibit_type_id === 'folder' ? { open: Boolean(row.is_open) } : {}, containedDocumentIds: contained.get(row.id) || [],
}))
return { id: level.slug, title: level.title, subtitle: level.subtitle, documents, evidence, relations,
@@ -208,6 +214,14 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
await client.query(`INSERT INTO osint.folder_memberships (board_id,folder_exhibit_id,child_exhibit_id,sort_order)
VALUES ($1,$2,$3,$4)`, [level.board_id, relation.fromWidgetId, relation.toWidgetId, relation.sortOrder || 0])
}
for (const event of state.evidence.filter(item => item.type === 'event')) {
for (const [sortOrder, evidenceId] of (event.supportingEvidenceIds || []).entries()) {
if (evidenceId === event.id || !allIds.includes(evidenceId)) throw new Error('Event evidence references an unknown or identical exhibit')
await client.query(`INSERT INTO osint.event_evidence
(board_id,event_exhibit_id,evidence_exhibit_id,sort_order) VALUES ($1,$2,$3,$4)`,
[level.board_id, event.id, evidenceId, sortOrder])
}
}
for (const connection of state.connections) {
requireUuid(connection.id, 'Connection id')
if (!evidenceIds.has(connection.fromEvidenceId) || !evidenceIds.has(connection.toEvidenceId)) throw new Error('Connection references an unknown exhibit')