From d46a4254019e58eeab60b3b3160c07478a26b0c4 Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Fri, 14 Aug 2026 14:44:58 +0200 Subject: [PATCH] feat: add brief concept party classification --- README.md | 2 +- docs/TODO.md | 12 +-- docs/exhibit-data-model.md | 6 +- e2e/board.smoke.spec.ts | 19 ++++ migrations/007_party_concepts.sql | 83 +++++++++++++++++ server/api.integration.test.ts | 36 +++++++- server/boardClone.ts | 43 +++++++++ server/e2eHarness.ts | 4 + server/levelRepository.ts | 78 +++++++++++++--- server/migrations.integration.test.ts | 7 +- src/App.tsx | 123 ++++++++++++++++++++++++-- src/boardDomain.test.ts | 1 + src/boardDomain.ts | 1 + src/exhibitRegistry.test.ts | 2 +- src/exhibitRegistry.tsx | 13 ++- src/styles.css | 16 ++++ src/types.ts | 19 +++- 17 files changed, 431 insertions(+), 34 deletions(-) create mode 100644 migrations/007_party_concepts.sql diff --git a/README.md b/README.md index 85f9201..b724019 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ The current POC defines four exhibit families and corresponding frontend widgets - **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 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. +The **Party** family has distinct Person and Organization subtypes (businesses are organizations). Names begin as concepts in the level brief. The investigator classifies each concept, which creates the appropriate dossier exhibit and records the resolution. Expected classifications remain author-only. Dossiers store aliases and normalized evidence associations, but parties remain identity objects rather than special folders. Folder ownership is stored as a normalized membership. The contained document exhibit owns its expanded `xpos` and `ypos`. For deterministic collapse behavior, one exhibit has at most one owning folder; two exhibits may reuse the same immutable asset when the same source file must appear in multiple folders. diff --git a/docs/TODO.md b/docs/TODO.md index 088a648..f681003 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -45,13 +45,13 @@ This is the ordered implementation roadmap following the accepted exhibit model. ### Party exhibits -- [ ] Add a Party exhibit supertype representing an investigation participant. -- [ ] Add a Person subtype with display name, normalized aliases, and extensible structured identity fields. -- [ ] Add an Organization subtype covering businesses, public bodies, associations, and informal groups. -- [ ] Add normalized many-to-many Party-to-Evidence associations with an optional explanatory note. +- [x] Add a Party exhibit supertype representing an investigation participant. +- [x] Add a Person subtype with display name, normalized aliases, and extensible structured identity fields. +- [x] Add an Organization subtype covering businesses, public bodies, associations, and informal groups. +- [x] Add normalized many-to-many Party-to-Evidence associations with an optional explanatory note. - [ ] Add typed Party-to-Party relationships such as employment, ownership, membership, control, and representation. -- [ ] Build distinct Person and Organization widgets that can open as dossiers and reveal associated evidence without using folder ownership semantics. -- [ ] Include parties, aliases, evidence associations, and party relationships in template/level cloning. +- [x] Build distinct Person and Organization dossier presentations that reveal associated evidence without using folder ownership semantics. +- [x] Include brief concepts, parties, aliases, evidence associations, and party relationships in template/level cloning. ## Milestone 4: first playable mystery diff --git a/docs/exhibit-data-model.md b/docs/exhibit-data-model.md index a2b1b51..9618848 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, frontend exhibit registry, and Event workflow are implemented. Parties remain the next model expansion. +Status: accepted design foundation; the core schema, template lifecycle, frontend registry, Event workflow, brief concepts, and Party subtypes are implemented. Interactive Party-to-Party relationships remain roadmap work. ## Vocabulary @@ -180,7 +180,7 @@ A deferred constraint trigger verifies that both exhibits belong to the same boa In the frontend, the Event widget shows its occurrence time, narrative text, and evidence count. Opening or selecting it reveals its supporting exhibits. Lines between an event and its evidence visualize `event_evidence`; they are not ordinary folder containment bands. -### Party semantics (planned) +### Party and brief-concept semantics A party is a person or organization that participates in the investigation. Businesses are organizations. A party is a first-class exhibit, not a special folder: identity and evidence association must not be represented as file ownership. @@ -193,6 +193,8 @@ The intended normalized shape is a `party_exhibits` supertype with one-to-one `p The frontend provides distinct Person and Organization widgets through the exhibit registry. They may visually behave like dossiers—opening one can reveal associated evidence—but that interaction is derived from `party_evidence`; it does not turn the party into a folder or cause evidence to be owned by or disappear into the party. +A name appearing in the level brief begins as a normalized `brief_concept`, not as an exhibit. The author may record the expected Party classification, which is omitted from play-mode API responses. When the investigator classifies a concept as Person or Organization, the level creates a Party exhibit and records it in `resolved_party_exhibit_id`. This keeps the reasoning action explicit: the game does not pre-create a correctly typed party and merely hide its widget. + ## Assets and document content `assets` stores immutable uploaded bytes, checksum, MIME type, original filename, and size. Multiple cloned document exhibits may reference one asset. diff --git a/e2e/board.smoke.spec.ts b/e2e/board.smoke.spec.ts index 1c7147a..6e448c5 100644 --- a/e2e/board.smoke.spec.ts +++ b/e2e/board.smoke.spec.ts @@ -87,6 +87,25 @@ 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: 'BRIEF', exact: true }).click() + await expect(page.locator('.brief-panel')).toContainText('Ada Lovelace') + const personConcept = page.locator('.brief-concepts section').filter({ hasText: 'Ada Lovelace' }) + await personConcept.getByRole('button', { name: 'PERSON', exact: true }).click() + await expect(page.getByText('Edit person dossier')).toBeVisible() + await page.getByLabel('Party aliases').fill('A. A. L.') + await page.locator('.party-editor .folder-members input[type="checkbox"]').first().check() + await waitForSave(page, () => page.getByRole('button', { name: 'SAVE DOSSIER', exact: true }).click()) + await expect(page.locator('.evidence-card.party')).toContainText('Ada Lovelace') + + await page.getByRole('button', { name: 'BRIEF', exact: true }).click() + const organizationConcept = page.locator('.brief-concepts section').filter({ hasText: 'Difference Engine Bureau' }) + await organizationConcept.getByRole('button', { name: 'ORGANIZATION', exact: true }).click() + await page.getByLabel('Organization type').selectOption('public_body') + await waitForSave(page, () => page.getByRole('button', { name: 'SAVE DOSSIER', exact: true }).click()) + await page.reload() + await expect(page.locator('.evidence-card.party')).toHaveCount(2) + await expect(page.locator('.evidence-card.party')).toContainText(['Ada Lovelace', 'Difference Engine Bureau']) + 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') diff --git a/migrations/007_party_concepts.sql b/migrations/007_party_concepts.sql new file mode 100644 index 0000000..9363b09 --- /dev/null +++ b/migrations/007_party_concepts.sql @@ -0,0 +1,83 @@ +INSERT INTO osint.exhibit_types (id,name) VALUES ('party','Party'); + +CREATE TABLE osint.party_exhibits ( + exhibit_id UUID PRIMARY KEY REFERENCES osint.exhibits(id) ON DELETE CASCADE, + party_kind TEXT NOT NULL CHECK (party_kind IN ('person','organization')), + display_name TEXT NOT NULL, + summary TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE osint.person_parties ( + exhibit_id UUID PRIMARY KEY REFERENCES osint.party_exhibits(exhibit_id) ON DELETE CASCADE, + given_name TEXT, + family_name TEXT +); + +CREATE TABLE osint.organization_parties ( + exhibit_id UUID PRIMARY KEY REFERENCES osint.party_exhibits(exhibit_id) ON DELETE CASCADE, + organization_kind TEXT NOT NULL DEFAULT 'business' + CHECK (organization_kind IN ('business','public_body','association','informal_group','other')) +); + +CREATE TABLE osint.party_aliases ( + id UUID PRIMARY KEY, + party_exhibit_id UUID NOT NULL REFERENCES osint.party_exhibits(exhibit_id) ON DELETE CASCADE, + alias TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0 CHECK (sort_order >= 0), + UNIQUE (party_exhibit_id,alias) +); + +CREATE TABLE osint.party_evidence ( + board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE, + party_exhibit_id UUID NOT NULL REFERENCES osint.party_exhibits(exhibit_id) ON DELETE CASCADE, + evidence_exhibit_id UUID NOT NULL REFERENCES osint.exhibits(id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0 CHECK (sort_order >= 0), + note TEXT, + PRIMARY KEY (party_exhibit_id,evidence_exhibit_id), + CHECK (party_exhibit_id <> evidence_exhibit_id), + FOREIGN KEY (board_id,party_exhibit_id) REFERENCES osint.exhibits(board_id,id) ON DELETE CASCADE, + FOREIGN KEY (board_id,evidence_exhibit_id) REFERENCES osint.exhibits(board_id,id) ON DELETE CASCADE +); + +CREATE TABLE osint.party_relationship_types ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + directed BOOLEAN NOT NULL DEFAULT TRUE +); +INSERT INTO osint.party_relationship_types (id,name,directed) VALUES + ('employment','Employment',TRUE), ('ownership','Ownership',TRUE), + ('membership','Membership',TRUE), ('control','Control',TRUE), + ('representation','Representation',TRUE), ('associated','Associated',FALSE); + +CREATE TABLE osint.party_relationships ( + id UUID PRIMARY KEY, + board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE, + relationship_type_id TEXT NOT NULL REFERENCES osint.party_relationship_types(id), + from_party_exhibit_id UUID NOT NULL REFERENCES osint.party_exhibits(exhibit_id) ON DELETE CASCADE, + to_party_exhibit_id UUID NOT NULL REFERENCES osint.party_exhibits(exhibit_id) ON DELETE CASCADE, + note TEXT, + CHECK (from_party_exhibit_id <> to_party_exhibit_id), + FOREIGN KEY (board_id,from_party_exhibit_id) REFERENCES osint.exhibits(board_id,id) ON DELETE CASCADE, + FOREIGN KEY (board_id,to_party_exhibit_id) REFERENCES osint.exhibits(board_id,id) ON DELETE CASCADE +); + +CREATE TABLE osint.level_briefs ( + board_id UUID PRIMARY KEY REFERENCES osint.boards(id) ON DELETE CASCADE, + body TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE osint.brief_concepts ( + id UUID PRIMARY KEY, + board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE, + origin_concept_id UUID REFERENCES osint.brief_concepts(id) ON DELETE SET NULL, + label TEXT NOT NULL, + context_text TEXT NOT NULL DEFAULT '', + sort_order INTEGER NOT NULL DEFAULT 0 CHECK (sort_order >= 0), + expected_party_kind TEXT CHECK (expected_party_kind IN ('person','organization')), + resolved_party_exhibit_id UUID REFERENCES osint.party_exhibits(exhibit_id) ON DELETE SET NULL, + UNIQUE (board_id,label), + FOREIGN KEY (board_id,resolved_party_exhibit_id) REFERENCES osint.exhibits(board_id,id) +); + +COMMENT ON TABLE osint.brief_concepts IS 'Named concepts in the level brief which may be classified into Party exhibits by the investigator.'; +COMMENT ON COLUMN osint.brief_concepts.expected_party_kind IS 'Author-only expected classification; omitted from play-mode API projections.'; diff --git a/server/api.integration.test.ts b/server/api.integration.test.ts index 5e4203e..ea594cc 100644 --- a/server/api.integration.test.ts +++ b/server/api.integration.test.ts @@ -72,6 +72,12 @@ suite('level persistence API', () => { const folderId = randomUUID() const noteId = randomUUID() const eventId = randomUUID() + const personConceptId = randomUUID() + const organizationConceptId = randomUUID() + state.brief = { body: 'Identify Ada Lovelace and Analytical Engines Ltd in the source material.', concepts: [ + { id: personConceptId, label: 'Ada Lovelace', context: 'Named as the correspondent.', expectedPartyKind: 'person' }, + { id: organizationConceptId, label: 'Analytical Engines Ltd', context: 'Issued the filing.', expectedPartyKind: 'organization' }, + ] } 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] }, @@ -92,6 +98,10 @@ suite('level persistence API', () => { expect(loaded.viewport).toEqual(state.viewport) expect(loaded.evidence[0]).toMatchObject({ id: folderId, x: 685, y: 417, config: { open: true } }) expect(loaded.relations[0]).toMatchObject({ id: `contains:${folderId}:${documentId}`, config: { x: 1051, y: 417 } }) + expect(loaded.brief.concepts).toEqual(expect.arrayContaining([ + expect.objectContaining({ label: 'Ada Lovelace', expectedPartyKind: 'person' }), + expect.objectContaining({ label: 'Analytical Engines Ltd', expectedPartyKind: 'organization' }), + ])) const upload = new FormData() upload.append('file', new Blob(['OSINT smoke evidence'], { type: 'text/plain' }), 'smoke-evidence.txt') @@ -121,6 +131,15 @@ suite('level persistence API', () => { }) const playerState = await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState + expect(playerState.brief.concepts.every(concept => concept.expectedPartyKind === undefined)).toBe(true) + const personPartyId = randomUUID() + const organizationPartyId = randomUUID() + playerState.evidence.push( + { id: personPartyId, type: 'party', partyKind: 'person', title: 'Ada Lovelace', content: 'Named as the correspondent.', aliases: ['A. A. L.'], relatedEvidenceIds: [documentId, noteId], x: 720, y: 250, width: 280 }, + { id: organizationPartyId, type: 'party', partyKind: 'organization', organizationKind: 'business', title: 'Analytical Engines Ltd', content: 'Issued the filing.', aliases: ['AEL'], relatedEvidenceIds: [documentId], x: 1020, y: 250, width: 280 }, + ) + playerState.brief.concepts = playerState.brief.concepts.map(concept => ({ ...concept, + resolvedPartyExhibitId: concept.id === personConceptId ? personPartyId : organizationPartyId })) playerState.viewport = { x: -150, y: 88, zoom: 1.1 } playerState.evidence[0] = { ...playerState.evidence[0], x: 812, y: 533 } const playerSave = await fetch(`${baseUrl}/api/levels/${state.id}`, { @@ -136,7 +155,7 @@ 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; events: string; event_evidence: 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; parties: string; people: string; organizations: string; party_evidence: string; concepts: string; hidden_answers: 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, @@ -145,8 +164,14 @@ suite('level persistence API', () => { (SELECT COUNT(*) FROM osint.exhibit_sources)::text AS sources, (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' }) + (SELECT COUNT(*) FROM osint.event_evidence)::text AS event_evidence, + (SELECT COUNT(*) FROM osint.party_exhibits)::text AS parties, + (SELECT COUNT(*) FROM osint.person_parties)::text AS people, + (SELECT COUNT(*) FROM osint.organization_parties)::text AS organizations, + (SELECT COUNT(*) FROM osint.party_evidence)::text AS party_evidence, + (SELECT COUNT(*) FROM osint.brief_concepts)::text AS concepts, + (SELECT COUNT(*) FROM osint.brief_concepts WHERE expected_party_kind IS NOT NULL)::text AS hidden_answers`) + expect(normalized.rows[0]).toEqual({ exhibits: '7', documents: '2', folders: '1', memberships: '1', metadata: '2', sources: '1', connections: '1', events: '1', event_evidence: '2', parties: '2', people: '1', organizations: '1', party_evidence: '3', concepts: '2', hidden_answers: '2' }) const resetResponse = await fetch(`${baseUrl}/api/levels/${state.id}/reset`, { method: 'POST' }) expect(resetResponse.ok).toBe(true) @@ -186,6 +211,11 @@ suite('level persistence API', () => { expect(clonedEvent.supportingEvidenceIds).toHaveLength(2) expect(clonedEvent.supportingEvidenceIds).not.toContain(documentId) expect(clonedEvent.supportingEvidenceIds).not.toContain(noteId) + expect(clone.evidence.filter(item => item.type === 'party')).toHaveLength(2) + expect(clone.brief.concepts.every(concept => Boolean(concept.resolvedPartyExhibitId))).toBe(true) + expect(clone.brief.concepts.map(concept => concept.resolvedPartyExhibitId)).not.toContain(personPartyId) + const authoredClone = await (await fetch(`${baseUrl}/api/levels/${clone.id}?edit=1`)).json() as CaseState + expect(authoredClone.brief.concepts.map(concept => concept.expectedPartyKind).sort()).toEqual(['organization', 'person']) const clonedFolder = clone.evidence.find(item => item.type === 'folder')! clonedFolder.x = 1234 diff --git a/server/boardClone.ts b/server/boardClone.ts index 7a86cdc..6b18ed5 100644 --- a/server/boardClone.ts +++ b/server/boardClone.ts @@ -10,6 +10,8 @@ function mapped(ids: IdMap, sourceId: string, label: string) { } export async function clearBoard(client: PoolClient, boardId: string) { + await client.query('DELETE FROM osint.brief_concepts WHERE board_id=$1', [boardId]) + await client.query('DELETE FROM osint.level_briefs WHERE board_id=$1', [boardId]) await client.query('DELETE FROM osint.metadata_fields WHERE board_id=$1', [boardId]) await client.query('DELETE FROM osint.exhibits WHERE board_id=$1', [boardId]) await client.query('UPDATE osint.boards SET revision=0,updated_at=NOW() WHERE id=$1', [boardId]) @@ -65,6 +67,25 @@ export async function cloneBoard(client: PoolClient, sourceBoardId: string, targ 'INSERT INTO osint.event_exhibits (exhibit_id,title,narrative_text,occurred_at) VALUES ($1,$2,$3,$4)', [mapped(exhibitIds, row.exhibit_id, 'event'), row.title, row.narrative_text, row.occurred_at]) + const parties = await client.query<{ exhibit_id: string; party_kind: string; display_name: string; summary: string }>( + `SELECT p.* FROM osint.party_exhibits p JOIN osint.exhibits e ON e.id=p.exhibit_id WHERE e.board_id=$1`, [sourceBoardId]) + for (const row of parties.rows) await client.query( + 'INSERT INTO osint.party_exhibits (exhibit_id,party_kind,display_name,summary) VALUES ($1,$2,$3,$4)', + [mapped(exhibitIds, row.exhibit_id, 'party'), row.party_kind, row.display_name, row.summary]) + const people = await client.query<{ exhibit_id: string; given_name: string | null; family_name: string | null }>( + `SELECT p.* FROM osint.person_parties p JOIN osint.exhibits e ON e.id=p.exhibit_id WHERE e.board_id=$1`, [sourceBoardId]) + for (const row of people.rows) await client.query('INSERT INTO osint.person_parties (exhibit_id,given_name,family_name) VALUES ($1,$2,$3)', + [mapped(exhibitIds, row.exhibit_id, 'person'), row.given_name, row.family_name]) + const organizations = await client.query<{ exhibit_id: string; organization_kind: string }>( + `SELECT o.* FROM osint.organization_parties o JOIN osint.exhibits e ON e.id=o.exhibit_id WHERE e.board_id=$1`, [sourceBoardId]) + for (const row of organizations.rows) await client.query('INSERT INTO osint.organization_parties (exhibit_id,organization_kind) VALUES ($1,$2)', + [mapped(exhibitIds, row.exhibit_id, 'organization'), row.organization_kind]) + const aliases = await client.query<{ party_exhibit_id: string; alias: string; sort_order: number }>( + `SELECT a.party_exhibit_id,a.alias,a.sort_order FROM osint.party_aliases a JOIN osint.exhibits e ON e.id=a.party_exhibit_id + WHERE e.board_id=$1 ORDER BY a.sort_order`, [sourceBoardId]) + for (const row of aliases.rows) await client.query('INSERT INTO osint.party_aliases (id,party_exhibit_id,alias,sort_order) VALUES ($1,$2,$3,$4)', + [randomUUID(), mapped(exhibitIds, row.party_exhibit_id, 'party alias'), row.alias, row.sort_order]) + const blocks = await client.query<{ document_exhibit_id: string; sort_order: number; content: string }>( `SELECT b.document_exhibit_id,b.sort_order,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.sort_order`, [sourceBoardId]) @@ -113,6 +134,18 @@ export async function cloneBoard(client: PoolClient, sourceBoardId: string, targ (board_id,event_exhibit_id,evidence_exhibit_id,sort_order,note) VALUES ($1,$2,$3,$4,$5)`, [targetBoardId, mapped(exhibitIds, row.event_exhibit_id, 'event'), mapped(exhibitIds, row.evidence_exhibit_id, 'event evidence'), row.sort_order, row.note]) + const partyEvidence = await client.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', [sourceBoardId]) + for (const row of partyEvidence.rows) 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)`, + [targetBoardId, mapped(exhibitIds, row.party_exhibit_id, 'party'), mapped(exhibitIds, row.evidence_exhibit_id, 'party evidence'), row.sort_order, row.note]) + const partyRelationships = await client.query<{ relationship_type_id: string; from_party_exhibit_id: string; to_party_exhibit_id: string; note: string | null }>( + 'SELECT relationship_type_id,from_party_exhibit_id,to_party_exhibit_id,note FROM osint.party_relationships WHERE board_id=$1', [sourceBoardId]) + for (const row of partyRelationships.rows) await client.query(`INSERT INTO osint.party_relationships + (id,board_id,relationship_type_id,from_party_exhibit_id,to_party_exhibit_id,note) VALUES ($1,$2,$3,$4,$5,$6)`, + [randomUUID(), targetBoardId, row.relationship_type_id, mapped(exhibitIds, row.from_party_exhibit_id, 'related party'), + mapped(exhibitIds, row.to_party_exhibit_id, 'related party'), row.note]) + const connections = await client.query<{ connection_type_id: string; from_exhibit_id: string; to_exhibit_id: string; label: string | null }>( 'SELECT connection_type_id,from_exhibit_id,to_exhibit_id,label FROM osint.exhibit_connections WHERE board_id=$1', [sourceBoardId]) for (const row of connections.rows) await client.query(`INSERT INTO osint.exhibit_connections @@ -127,5 +160,15 @@ export async function cloneBoard(client: PoolClient, sourceBoardId: string, targ [mapped(exhibitIds, row.exhibit_id, 'sourced exhibit'), mapped(exhibitIds, row.source_document_exhibit_id, 'source document'), row.source_region_id ? mapped(regionIds, row.source_region_id, 'source region') : null]) + const brief = await client.query<{ body: string }>('SELECT body FROM osint.level_briefs WHERE board_id=$1', [sourceBoardId]) + if (brief.rows[0]) await client.query('INSERT INTO osint.level_briefs (board_id,body) VALUES ($1,$2)', [targetBoardId, brief.rows[0].body]) + const concepts = await client.query<{ + id: string; label: string; context_text: string; sort_order: number; expected_party_kind: string | null; resolved_party_exhibit_id: string | null + }>('SELECT id,label,context_text,sort_order,expected_party_kind,resolved_party_exhibit_id FROM osint.brief_concepts WHERE board_id=$1 ORDER BY sort_order,id', [sourceBoardId]) + for (const row of concepts.rows) await client.query(`INSERT INTO osint.brief_concepts + (id,board_id,origin_concept_id,label,context_text,sort_order,expected_party_kind,resolved_party_exhibit_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, + [randomUUID(), targetBoardId, row.id, row.label, row.context_text, row.sort_order, row.expected_party_kind, + row.resolved_party_exhibit_id ? mapped(exhibitIds, row.resolved_party_exhibit_id, 'resolved party') : null]) + return exhibitIds } diff --git a/server/e2eHarness.ts b/server/e2eHarness.ts index ca951ff..97c9f42 100644 --- a/server/e2eHarness.ts +++ b/server/e2eHarness.ts @@ -40,6 +40,10 @@ const created = await fetch(`${baseUrl}/api/levels`, { }) if (!created.ok) throw new Error(`Could not create browser test level: ${created.status}`) const state = await created.json() as CaseState +state.brief = { body: 'Classify the named people and organizations in this investigation.', concepts: [ + { id: '44444444-4444-4444-8444-444444444444', label: 'Ada Lovelace', context: 'Named as the correspondent.', expectedPartyKind: 'person' }, + { id: '55555555-5555-4555-8555-555555555555', label: 'Difference Engine Bureau', context: 'Issued the archive notice.', expectedPartyKind: 'organization' }, +] } state.documents = [{ id: documentId, title: 'Dated source image', kind: 'IMAGE', date: '2021-04-17', publishedAt: '2021-04-17T12:00:00.000Z', body: [], regions: [], fileType: 'image', metadata: {}, diff --git a/server/levelRepository.ts b/server/levelRepository.ts index 3958f52..b657ce5 100644 --- a/server/levelRepository.ts +++ b/server/levelRepository.ts @@ -1,6 +1,6 @@ import { createHash, randomUUID } from 'node:crypto' import type { Pool, PoolClient } from 'pg' -import type { CaseDocument, CaseState, Evidence, SourceFileType, WidgetRelation } from '../src/types.js' +import type { BriefConcept, CaseDocument, CaseState, Evidence, OrganizationKind, PartyKind, SourceFileType, WidgetRelation } from '../src/types.js' import { clearBoard, cloneBoard } from './boardClone.js' export type UploadedDocument = { buffer: Buffer; originalname: string; mimetype: string; size: number } @@ -26,11 +26,12 @@ type LevelRow = { source_template_version_id: string | null } type ExhibitRow = { - id: string; exhibit_type_id: 'folder' | 'document' | 'note' | 'event'; xpos: number; ypos: number; width: number; hidden: boolean + id: string; exhibit_type_id: 'folder' | 'document' | 'note' | 'event' | 'party'; xpos: number; ypos: number; width: number; hidden: boolean title: string; content: string; is_open: boolean | null; document_type_id: SourceFileType | null asset_id: string | null; published_at: Date | null; occurred_at: Date | null original_name: string | null; mime_type: string | null; byte_size: string | null source_document_id: string | null; source_region_key: string | null + party_kind: PartyKind | null; organization_kind: OrganizationKind | null } 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 @@ -59,14 +60,16 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve return result.rows[0] || null } - async function assembleLevel(slug: string): Promise { + async function assembleLevel(slug: string, authorMode = false): Promise { const level = await findLevel(pool, slug) if (!level) return null - const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult, eventEvidenceResult] = await Promise.all([ + const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult, eventEvidenceResult, + aliasesResult, partyEvidenceResult, briefResult, conceptsResult] = 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, + 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, f.is_open, d.document_type_id, d.asset_id, d.published_at, ev.occurred_at, + p.party_kind, op.organization_kind, 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 @@ -74,6 +77,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve 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 + LEFT JOIN osint.party_exhibits p ON p.exhibit_id = e.id + LEFT JOIN osint.organization_parties op ON op.exhibit_id = e.id 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 @@ -96,6 +101,16 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve 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]), + 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]), + pool.query<{ party_exhibit_id: string; evidence_exhibit_id: string }>( + `SELECT party_exhibit_id,evidence_exhibit_id FROM osint.party_evidence WHERE board_id=$1 + 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]), ]) const blocks = new Map() @@ -109,6 +124,10 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve 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 aliases = new Map() + for (const row of aliasesResult.rows) aliases.set(row.party_exhibit_id, [...(aliases.get(row.party_exhibit_id) || []), row.alias]) + const partyEvidence = new Map() + for (const row of partyEvidenceResult.rows) partyEvidence.set(row.party_exhibit_id, [...(partyEvidence.get(row.party_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, @@ -127,12 +146,17 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve 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) || [], + partyKind: row.party_kind || undefined, organizationKind: row.organization_kind || undefined, + aliases: aliases.get(row.id) || [], relatedEvidenceIds: partyEvidence.get(row.id) || [], config: row.exhibit_type_id === 'folder' ? { open: Boolean(row.is_open) } : {}, containedDocumentIds: contained.get(row.id) || [], })) + 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, documents, evidence, relations, connections: connectionsResult.rows.map(row => ({ id: row.id, fromEvidenceId: row.from_exhibit_id, toEvidenceId: row.to_exhibit_id })), viewport: { x: level.viewport_x, y: level.viewport_y, zoom: level.viewport_zoom }, updatedAt: level.updated_at.toISOString(), - levelStatus: level.status, editingAllowed: editingEnabled, sourceTemplateVersionId: level.source_template_version_id || undefined } + brief: { body: briefResult.rows[0]?.body || '', concepts }, levelStatus: level.status, editingAllowed: editingEnabled, + sourceTemplateVersionId: level.source_template_version_id || undefined } } async function templateSummary(slug: string): Promise { @@ -163,6 +187,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve } const existing = await client.query<{ id: string; xpos: number; ypos: number }>('SELECT id, xpos, ypos FROM osint.exhibits WHERE board_id = $1', [level.board_id]) for (const row of existing.rows) if (!positions.has(row.id)) positions.set(row.id, { x: row.xpos, y: row.ypos }) + 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])) 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]) @@ -170,9 +196,13 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve 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]) + 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]) + 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]) 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]) - for (const table of ['folder_exhibits', 'image_documents', 'note_exhibits', 'event_exhibits', 'document_exhibits']) { + for (const table of ['folder_exhibits', 'image_documents', 'note_exhibits', 'event_exhibits', 'person_parties', 'organization_parties', 'party_exhibits', 'document_exhibits']) { await client.query(`DELETE FROM osint.${table} WHERE exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)`, [level.board_id]) } if (allIds.length) await client.query('DELETE FROM osint.exhibits WHERE board_id=$1 AND NOT (id = ANY($2::uuid[]))', [level.board_id, allIds]) @@ -195,7 +225,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve } for (const [index, exhibit] of state.evidence.entries()) { const type = exhibit.type === 'evidence' ? 'folder' : exhibit.type - const canonicalType = type === 'folder' || type === 'note' || type === 'event' ? type : 'note' + const canonicalType = type === 'folder' || type === 'note' || type === 'event' || type === 'party' ? type : 'note' await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,z_index,hidden) VALUES ($1,$2,$3,$4,$5,$6,160,$7,FALSE) ON CONFLICT (id) DO UPDATE SET exhibit_type_id=$3,xpos=$4,ypos=$5,width=$6,height=160,z_index=$7,hidden=FALSE,updated_at=NOW()`, @@ -207,6 +237,16 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve if (canonicalType === 'event') await client.query( 'INSERT INTO osint.event_exhibits (exhibit_id,title,narrative_text,occurred_at) VALUES ($1,$2,$3,$4)', [exhibit.id, exhibit.title, exhibit.content, timestamp(exhibit.eventDate) || new Date().toISOString()]) + if (canonicalType === 'party') { + const partyKind: PartyKind = exhibit.partyKind === 'organization' ? 'organization' : 'person' + 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]) + } } for (const relation of relationList.filter(item => item.type === 'contains')) { @@ -222,6 +262,14 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve [level.board_id, event.id, evidenceId, sortOrder]) } } + for (const party of state.evidence.filter(item => item.type === 'party')) { + for (const [sortOrder, evidenceId] of (party.relatedEvidenceIds || []).entries()) { + if (evidenceId === party.id || !allIds.includes(evidenceId)) throw new Error('Party evidence references an unknown or identical exhibit') + await client.query(`INSERT INTO osint.party_evidence + (board_id,party_exhibit_id,evidence_exhibit_id,sort_order) VALUES ($1,$2,$3,$4)`, + [level.board_id, party.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') @@ -248,6 +296,16 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve 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]]) } + 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]) + } } return { @@ -329,7 +387,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() } return templateSummary(input.slug) }, - getLevel(levelId) { return assembleLevel(levelId) }, + getLevel(levelId, authorMode = false) { return assembleLevel(levelId, authorMode) }, async saveLevel(state) { const client = await pool.connect() try { diff --git a/server/migrations.integration.test.ts b/server/migrations.integration.test.ts index f63dc20..1d29ace 100644 --- a/server/migrations.integration.test.ts +++ b/server/migrations.integration.test.ts @@ -33,7 +33,7 @@ suite('PostgreSQL migrations', () => { const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations') const firstRun: string[] = [] await runMigrations(testDatabaseUrl, migrationsDir, message => firstRun.push(message)) - expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(6) + expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(7) const client = new Client({ connectionString: testDatabaseUrl }) await client.connect() @@ -42,15 +42,16 @@ suite('PostgreSQL migrations', () => { expect(tableNames).toEqual(expect.arrayContaining([ 'boards', 'levels', 'level_templates', 'level_template_versions', 'exhibits', 'folder_exhibits', 'document_exhibits', 'folder_memberships', 'exhibit_connections', 'metadata_fields', 'assets', 'schema_migrations', + 'party_exhibits', 'person_parties', 'organization_parties', 'brief_concepts', 'level_briefs', ])) expect(tableNames).not.toEqual(expect.arrayContaining(['cases', 'widgets', 'widget_relations', 'playthroughs'])) const ledger = await client.query<{ count: string }>('SELECT COUNT(*)::text AS count FROM osint.schema_migrations') - expect(ledger.rows[0].count).toBe('6') + expect(ledger.rows[0].count).toBe('7') await client.end() const secondRun: string[] = [] await runMigrations(testDatabaseUrl, migrationsDir, message => secondRun.push(message)) - expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(6) + expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(7) expect(secondRun.some(message => message.startsWith('apply '))).toBe(false) }) }) diff --git a/src/App.tsx b/src/App.tsx index 5345b8b..3bb8158 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { BookOpen, CalendarClock, ChevronRight, CircleHelp, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, X, ZoomIn, ZoomOut } from 'lucide-react' -import type { CaseDocument, CaseState, Evidence, SourceFileType, WidgetRelation } from './types' +import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react' +import type { BriefConcept, CaseDocument, CaseState, Evidence, LevelBrief, OrganizationKind, PartyKind, SourceFileType, WidgetRelation } from './types' import { clampBoardZoom, containedIds, dateValue, folderIsOpen, moveBoardPoint, normalizeCase, panViewport, relationPosition, timelinePositionPercent, timelineRange, zoomFromWheel } from './boardDomain' import { documentWidget, exhibitWidget } from './exhibitRegistry' @@ -33,6 +33,9 @@ export function App() { const [editingFolderId, setEditingFolderId] = useState(null) const [editingFileId, setEditingFileId] = useState(null) const [editingEventId, setEditingEventId] = useState(null) + const [editingPartyId, setEditingPartyId] = useState(null) + const [briefOpen, setBriefOpen] = useState(false) + const [editingBrief, setEditingBrief] = useState(false) const saveTimer = useRef(undefined) const boardRef = useRef(null) const fileInputRef = useRef(null) @@ -118,6 +121,23 @@ export function App() { setSelected(event.id); setEditingEventId(event.id) } + const classifyConcept = (conceptId: string, partyKind: PartyKind) => { + if (!caseState) return + const concept = caseState.brief.concepts.find(item => item.id === conceptId) + if (!concept) return + const existingId = concept.resolvedPartyExhibitId + const partyId = existingId || uid('party') + const { viewport } = caseState + const party: Evidence = { id: partyId, type: 'party', partyKind, organizationKind: partyKind === 'organization' ? 'business' : undefined, + title: concept.label, content: concept.context, aliases: [], relatedEvidenceIds: [], + x: Math.max(100, (670 - viewport.x) / viewport.zoom), y: Math.max(100, (310 - viewport.y) / viewport.zoom), width: 280 } + update(state => ({ ...state, + evidence: existingId ? state.evidence.map(item => item.id === existingId ? { ...item, partyKind, organizationKind: partyKind === 'organization' ? item.organizationKind || 'business' : undefined } : item) : [...state.evidence, party], + brief: { ...state.brief, concepts: state.brief.concepts.map(item => item.id === conceptId ? { ...item, resolvedPartyExhibitId: partyId } : item) }, + })) + setSelected(partyId); setBriefOpen(false); setEditingPartyId(partyId) + } + 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))) { @@ -209,7 +229,7 @@ export function App() {
GUOSINT BOARD / {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}
{status}{clock}
@@ -230,7 +250,16 @@ 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} onEditEvent={setEditingEventId} /> + setOpenDoc(caseState.documents.find(d => d.id === id) || null)} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} /> + {briefOpen && item.type === 'party')} + canEdit={requestedEditMode && Boolean(caseState.editingAllowed)} + onClose={() => setBriefOpen(false)} + onEdit={() => setEditingBrief(true)} + onClassify={classifyConcept} + onLocate={focusEvidence} + />} {storyEvents.length > 0 && } {!docsOpen && }
@@ -263,6 +292,27 @@ export function App() { 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') }} />} + {editingPartyId && item.id === editingPartyId)!} + evidence={caseState.evidence} + documents={caseState.documents} + onClose={() => setEditingPartyId(null)} + onSave={party => { + update(state => ({ ...state, evidence: state.evidence.map(item => item.id === party.id ? party : item) })) + setEditingPartyId(null) + setStatus('PARTY DOSSIER UPDATED') + }} + />} + {editingBrief && setEditingBrief(false)} + onSave={brief => { + update(state => ({ ...state, brief })) + setEditingBrief(false) + setStatus('LEVEL BRIEF UPDATED') + }} + />} {helpOpen && setHelpOpen(false)}/>} } @@ -282,7 +332,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, 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 }) { +function Board({ state, selected, linkFrom, tool, boardRef, update, onCardClick, onOpenSource, onEditFolder, onEditFile, onEditEvent, onEditParty }: { 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; onEditParty: (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]) @@ -339,6 +389,20 @@ function Board({ state, selected, linkFrom, tool, boardRef, update, onCardClick, return [] }))} + + {state.evidence.filter(party => party.type === 'party').flatMap(party => (party.relatedEvidenceIds || []).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(party) + 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 })} @@ -347,7 +411,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 }) }} @@ -411,6 +475,53 @@ function localDateTime(value?: string) { return local.toISOString().slice(0, 16) } +function BriefPanel({ brief, parties, canEdit, onClose, onEdit, onClassify, onLocate }: { brief: LevelBrief; parties: Evidence[]; canEdit: boolean; onClose: () => void; onEdit: () => void; onClassify: (id: string, kind: PartyKind) => void; onLocate: (id: string) => void }) { + const partyById = new Map(parties.map(party => [party.id, party])) + return +} + +function BriefEditor({ brief, onClose, onSave }: { brief: LevelBrief; onClose: () => void; onSave: (brief: LevelBrief) => void }) { + const [body, setBody] = useState(brief.body) + const [concepts, setConcepts] = useState(brief.concepts) + const addConcept = () => setConcepts(current => [...current, { id: uid('concept'), label: '', context: '', expectedPartyKind: 'person' }]) + return
{ submit.preventDefault(); onSave({ body: body.trim(), concepts: concepts.filter(item => item.label.trim()).map(item => ({ ...item, label: item.label.trim(), context: item.context.trim() })) }) }}> +
Edit level brief
+
AUTHORING · PLAYER CONCEPTS +