diff --git a/README.md b/README.md index 8fdc49b..85f9201 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ The current POC defines four exhibit families and corresponding frontend widgets - **Folder** — a titled, annotated collection. Opening it expands its contained source files to their saved relation-bound board positions; closing it retracts and hides them. - **Source file** — immutable binary evidence plus user-editable title, publication time, file type, and arbitrary key/value metadata. Image is the first fully visual file renderer. PDF, web capture, email, article, filing, price list, text, and generic file are registered types with a generic fallback until their renderers are built. - **Note** — investigator-authored interpretation, visually distinct from source evidence. -- **Event** — an investigator-authored “this happened” statement with narrative text, an occurrence time, and normalized links to its supporting evidence. Chronologically ordered events become the emerging story. +- **Event** — an investigator-authored “this happened” statement with narrative text, an occurrence time, and normalized links to supporting exhibits. The editor manages citations, dashed support lines keep them visually distinct from red investigative thread, and chronologically ordered events form the reconstructed-story strip. The planned **Party** family adds distinct Person and Organization exhibits (businesses are organizations). Their dossier-like widgets reveal normalized evidence associations, but parties remain identity objects rather than special folders. diff --git a/docs/TODO.md b/docs/TODO.md index 7257a2a..088a648 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -39,9 +39,9 @@ This is the ordered implementation roadmap following the accepted exhibit model. ### Events and narrative -- [ ] Implement Event exhibits with occurrence time and investigator-authored narrative text. -- [ ] Implement normalized Event-to-Evidence links and their board visualization. -- [ ] Present chronologically ordered events as the emerging investigation story. +- [x] Implement Event exhibits with occurrence time and investigator-authored narrative text. +- [x] Implement normalized Event-to-Evidence links and their distinct board visualization. +- [x] Present chronologically ordered events as the emerging investigation story. ### Party exhibits diff --git a/docs/exhibit-data-model.md b/docs/exhibit-data-model.md index d92d992..a2b1b51 100644 --- a/docs/exhibit-data-model.md +++ b/docs/exhibit-data-model.md @@ -1,6 +1,6 @@ # GUPI OSINT Board: canonical exhibit data model -Status: accepted design foundation; the core schema, transactional template lifecycle, and frontend exhibit registry are implemented. Parties and richer event behavior remain roadmap work. +Status: accepted design foundation; the core schema, transactional template lifecycle, frontend exhibit registry, and Event workflow are implemented. Parties remain the next model expansion. ## Vocabulary diff --git a/e2e/board.smoke.spec.ts b/e2e/board.smoke.spec.ts index eb0a908..1c7147a 100644 --- a/e2e/board.smoke.spec.ts +++ b/e2e/board.smoke.spec.ts @@ -87,6 +87,17 @@ test('move, folder expansion, hand pan, pinch zoom, and reload persistence', asy await expect.poll(() => board.getAttribute('style')).not.toEqual(transformBeforePinch) expect(await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio }))).toEqual(browserMetricsBeforePinch) + await page.getByRole('button', { name: 'NEW EVENT', exact: true }).click() + await expect(page.getByText('Edit reconstructed event')).toBeVisible() + await page.getByLabel('Event title').fill('The browser clue was connected') + await page.getByLabel('Event narrative').fill('The investigator connected the folder to a dated source.') + await page.locator('.event-support-list input[type="checkbox"]').first().check() + await waitForSave(page, () => page.getByRole('button', { name: 'SAVE EVENT', exact: true }).click()) + await page.reload() + await expect(page.locator('.evidence-card.event')).toContainText('The browser clue was connected') + await expect(page.locator('.story-strip')).toContainText('The investigator connected the folder') + await expect(page.locator('.event-support-lines line')).toHaveCount(1) + page.once('dialog', dialog => dialog.accept('Browser Template')) const templateSaved = page.waitForResponse(candidate => candidate.request().method() === 'POST' && candidate.url().includes('/templates?edit=1') && candidate.ok()) await page.getByRole('button', { name: 'SAVE TEMPLATE', exact: true }).click() diff --git a/server/api.integration.test.ts b/server/api.integration.test.ts index d4c6952..5e4203e 100644 --- a/server/api.integration.test.ts +++ b/server/api.integration.test.ts @@ -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 diff --git a/server/levelRepository.ts b/server/levelRepository.ts index f4fb9a4..3958f52 100644 --- a/server/levelRepository.ts +++ b/server/levelRepository.ts @@ -62,7 +62,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve async function assembleLevel(slug: string): Promise { 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(`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() @@ -104,6 +107,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve const metadata = new Map>() 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() + const eventEvidence = new Map() + 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') diff --git a/src/App.tsx b/src/App.tsx index 8de386c..5345b8b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,6 +32,7 @@ export function App() { const [boardTool, setBoardTool] = useState<'move' | 'hand'>('move') const [editingFolderId, setEditingFolderId] = useState(null) const [editingFileId, setEditingFileId] = useState(null) + const [editingEventId, setEditingEventId] = useState(null) const saveTimer = useRef(undefined) const boardRef = useRef(null) const fileInputRef = useRef(null) @@ -107,6 +108,16 @@ export function App() { update(s => ({ ...s, evidence: [...s.evidence, note] })); setSelected(note.id) } + const addEvent = () => { + if (!caseState) return + const { viewport } = caseState + const event: Evidence = { id: uid('event'), type: 'event', title: 'UNTITLED EVENT', content: 'Describe what happened.', + eventDate: new Date().toISOString(), supportingEvidenceIds: [], x: Math.max(100, (620 - viewport.x) / viewport.zoom), + y: Math.max(100, (290 - viewport.y) / viewport.zoom), width: 270 } + update(state => ({ ...state, evidence: [...state.evidence, event] })) + setSelected(event.id); setEditingEventId(event.id) + } + const handleCardClick = (id: string) => { if (!linkFrom) { setSelected(current => current === id ? null : id); return } if (linkFrom !== id && caseState && !caseState.connections.some(c => (c.fromEvidenceId === linkFrom && c.toEvidenceId === id) || (c.fromEvidenceId === id && c.toEvidenceId === linkFrom))) { @@ -193,6 +204,7 @@ export function App() { ...caseState.documents.filter(document => !containedDocumentIds.has(document.id) && (document.publishedAt || document.date)).map(document => ({ id: `document:${document.id}`, sourceTemporalId: `document:${document.id}`, date: document.publishedAt || document.date, label: document.title, kind: 'document' as const, documentId: document.id })), ...caseState.evidence.filter(widget => widget.type === 'event' && widget.eventDate).map(widget => ({ id: `widget:${widget.id}`, sourceTemporalId: `widget:${widget.id}`, date: widget.eventDate!, label: widget.content, kind: 'widget' as const, evidenceId: widget.id })), ].sort((a, b) => dateValue(a.date) - dateValue(b.date)) + const storyEvents = caseState.evidence.filter(item => item.type === 'event' && item.eventDate).sort((a, b) => dateValue(a.eventDate!) - dateValue(b.eventDate!)) return
GUOSINT BOARD / {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}
@@ -218,13 +230,15 @@ export function App() {
{ if (e.dataTransfer.types.includes('Files') && requestedEditMode && caseState.editingAllowed) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { if (requestedEditMode && caseState.editingAllowed) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' } }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files) }}>
{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}

{caseState.title}

{caseState.subtitle || caseState.id.toUpperCase()}

{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}
{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}
- setOpenDoc(caseState.documents.find(d => d.id === id) || null)} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} /> + setOpenDoc(caseState.documents.find(d => d.id === id) || null)} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} /> + {storyEvents.length > 0 && } {!docsOpen && }
+ @@ -241,6 +255,14 @@ export function App() { {openDoc && setOpenDoc(null)} onExtract={id => extract(openDoc, id)} extracted={caseState.evidence.filter(e => e.sourceDocumentId === openDoc.id).map(e => e.sourceRegionId)} />} {editingFolderId && widget.id === editingFolderId)!} memberIds={containedIds(caseState, editingFolderId)} documents={caseState.documents} canManageContents={requestedEditMode && Boolean(caseState.editingAllowed)} onClose={() => setEditingFolderId(null)} onSave={(folder, members) => { update(state => ({ ...state, evidence: state.evidence.map(widget => widget.id === folder.id ? { ...folder, containedDocumentIds: members } : widget), relations: [...state.relations.filter(relation => relation.type !== 'contains' || relation.fromWidgetId !== folder.id), ...members.map((documentId, index) => { const existing = state.relations.find(relation => relation.type === 'contains' && relation.fromWidgetId === folder.id && relation.toWidgetId === documentId); const position = relationPosition(state, existing || { id: '', fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index }); return { id: existing?.id || `contains:${folder.id}:${documentId}`, fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index, config: existing?.config || position } })] })); setEditingFolderId(null); setStatus('FOLDER UPDATED') }}/>} {editingFileId && document.id === editingFileId)!} onClose={() => setEditingFileId(null)} onSave={document => { update(state => ({ ...state, documents: state.documents.map(candidate => candidate.id === document.id ? document : candidate) })); setEditingFileId(null); setStatus('FILE METADATA UPDATED') }}/>} + {editingEventId && item.id === editingEventId)!} + evidence={caseState.evidence} + documents={caseState.documents} + onClose={() => setEditingEventId(null)} + onSave={event => { update(state => ({ ...state, evidence: state.evidence.map(item => item.id === event.id ? event : item) })); setEditingEventId(null); setStatus('EVENT NARRATIVE UPDATED') }} + />} {helpOpen && setHelpOpen(false)}/>}
} @@ -260,7 +282,7 @@ function EmptyArchive({ canEdit, onCreated }: { canEdit: boolean; onCreated: (le return
GU
GLITCH UNIVERSITY LEVEL ARCHIVE

No investigations found.

The database is ready, but no authored level exists yet.

{canEdit ? :

Add ?edit=1 and enable level editing on the server to begin authoring.

}
} -function Board({ state, selected, linkFrom, tool, boardRef, update, onCardClick, onOpenSource, onEditFolder, onEditFile }: { state: CaseState; selected: string | null; linkFrom: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onOpenSource: (id: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void }) { +function Board({ state, selected, linkFrom, tool, boardRef, update, onCardClick, onOpenSource, onEditFolder, onEditFile, onEditEvent }: { state: CaseState; selected: string | null; linkFrom: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onOpenSource: (id: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void }) { const drag = useRef<{ kind: 'pan' | 'widget' | 'relation'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null) const suppressClick = useRef(false) const byId = useMemo(() => new Map(state.evidence.map(e => [e.id, e])), [state.evidence]) @@ -303,6 +325,20 @@ function Board({ state, selected, linkFrom, tool, boardRef, update, onCardClick, {state.connections.map(c => { const a = byId.get(c.fromEvidenceId), b = byId.get(c.toEvidenceId); if (!a || !b) return null; const p1 = connectionPoint(a), p2 = connectionPoint(b); return })} + + {state.evidence.filter(event => event.type === 'event').flatMap(event => (event.supportingEvidenceIds || []).flatMap(evidenceId => { + const evidence = byId.get(evidenceId) + let target = evidence ? connectionPoint(evidence) : undefined + if (!target) { + const relation = containmentRelations.find(item => item.toWidgetId === evidenceId) + const folder = relation ? byId.get(relation.fromWidgetId) : undefined + if (relation && folder) target = folderIsOpen(folder) ? { x: relationPosition(state, relation).x + 87, y: relationPosition(state, relation).y + 72 } : connectionPoint(folder) + } + if (!target) return [] + const origin = connectionPoint(event) + return [] + }))} + {containmentRelations.map(relation => { const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), position = relationPosition(state, relation), origin = { x: folder.x + folder.width / 2, y: folder.y + 78 }; return })} @@ -311,7 +347,7 @@ function Board({ state, selected, linkFrom, tool, boardRef, update, onCardClick, onPointerMove={e => { e.stopPropagation(); pointerMove(e) }} onPointerUp={e => { e.stopPropagation(); finishDrag() }} onAuxClick={e => { if (e.button === 1) e.preventDefault() }} onClick={e => { e.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (tool === 'move') onCardClick(ev.id) }}>
{definition.heading(ev, containedDocuments)}{String(i + 1).padStart(3, '0')}
- + })} {containmentRelations.map(relation => { const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), target = relationPosition(state, relation); const left = open ? target.x : folder.x + folder.width / 2 - 87, top = open ? target.y : folder.y + 45; const definition = documentWidget(document.fileType); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return
{ event.stopPropagation(); if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'relation', id: relation.id }) }} @@ -403,6 +439,39 @@ function FolderEditor({ folder, memberIds, documents, canManageContents, onClose } +function EventEditor({ event, evidence, documents, onClose, onSave }: { event: Evidence; evidence: Evidence[]; documents: CaseDocument[]; onClose: () => void; onSave: (event: Evidence) => void }) { + const [title, setTitle] = useState(event.title) + const [narrative, setNarrative] = useState(event.content) + const [occurredAt, setOccurredAt] = useState(localDateTime(event.eventDate)) + const [supports, setSupports] = useState(event.supportingEvidenceIds || []) + const candidates = [ + ...evidence.filter(item => item.id !== event.id && item.type !== 'event').map(item => ({ id: item.id, title: item.title, kind: item.type.toUpperCase() })), + ...documents.map(document => ({ id: document.id, title: document.title, kind: document.fileType.replaceAll('_', ' ').toUpperCase() })), + ] + const toggle = (id: string) => setSupports(current => current.includes(id) ? current.filter(item => item !== id) : [...current, id]) + const submit = (submitEvent: React.FormEvent) => { + submitEvent.preventDefault() + const eventDate = occurredAt ? new Date(occurredAt).toISOString() : new Date().toISOString() + onSave({ ...event, title: title.trim() || 'UNTITLED EVENT', content: narrative.trim() || 'Something happened.', eventDate, supportingEvidenceIds: supports }) + } + return
+
Edit reconstructed event
+
+ EVENT EXHIBIT · THIS HAPPENED + +