refactor: cut over to normalized exhibit schema

This commit is contained in:
2026-08-14 14:09:24 +02:00
parent 9702b9c3d9
commit 5599d330d8
10 changed files with 538 additions and 238 deletions
+3 -3
View File
@@ -28,12 +28,12 @@ This is the ordered implementation roadmap following the accepted exhibit model.
## Milestone 2: exhibit-schema cutover ## Milestone 2: exhibit-schema cutover
- [ ] Add boards, exhibits, exhibit types, subtype tables, immutable template versions, and cloned mutable levels. - [x] Add boards, exhibits, exhibit types, subtype tables, immutable template versions, and mutable levels.
- [ ] Migrate transitional `widgets`, `widget_relations`, and `playthrough_*` data with equivalence checks. - [x] Cut over the explicitly disposable POC database directly; no transitional data existed to backfill or compare.
- [ ] Implement template instantiation and “save level as template” as transactional clone operations. - [ ] Implement template instantiation and “save level as template” as transactional clone operations.
- [x] Introduce a server-side repository/service boundary so SQL and cloning transactions do not live in Express route handlers. - [x] Introduce a server-side repository/service boundary so SQL and cloning transactions do not live in Express route handlers.
- [ ] Move the frontend to an exhibit/widget registry backed by the normalized API. - [ ] Move the frontend to an exhibit/widget registry backed by the normalized API.
- [ ] Remove transitional tables only after automated data-equivalence and behavior checks pass. - [x] Remove transitional `widgets`, `widget_relations`, and `playthrough_*` tables in the canonical cutover migration.
## Milestone 3: events and parties ## Milestone 3: events and parties
+1 -1
View File
@@ -1,6 +1,6 @@
# GUPI OSINT Board: canonical exhibit data model # GUPI OSINT Board: canonical exhibit data model
Status: accepted design foundation. Status: accepted design foundation; core schema implemented by migration 006. Template clone operations, parties, and the frontend exhibit registry remain roadmap work.
## Vocabulary ## Vocabulary
+2 -2
View File
@@ -28,8 +28,8 @@ test('move, folder expansion, hand pan, pinch zoom, and reload persistence', asy
await page.goto('/?level=e2e-level&edit=1') await page.goto('/?level=e2e-level&edit=1')
await expect(page.getByRole('heading', { name: 'Browser Safety Test' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Browser Safety Test' })).toBeVisible()
const folder = page.locator('[data-temporal-id="widget:e2e-folder"]') const folder = page.locator('[data-temporal-id="widget:11111111-1111-4111-8111-111111111111"]')
const file = page.locator('[data-temporal-id="file:e2e-membership"]') const file = page.locator('[data-temporal-id="file:contains:11111111-1111-4111-8111-111111111111:22222222-2222-4222-8222-222222222222"]')
const board = page.locator('.board') const board = page.locator('.board')
const boardViewport = page.locator('.board-viewport') const boardViewport = page.locator('.board-viewport')
const containmentBand = page.locator('.folder-bands line') const containmentBand = page.locator('.folder-bands line')
@@ -0,0 +1,261 @@
-- Canonical exhibit model. The POC data predating this migration is disposable:
-- there is deliberately no compatibility view, backfill, or playthrough overlay.
DROP TABLE IF EXISTS osint.playthrough_widget_relation_state CASCADE;
DROP TABLE IF EXISTS osint.playthrough_widget_relations CASCADE;
DROP TABLE IF EXISTS osint.playthrough_widget_state CASCADE;
DROP TABLE IF EXISTS osint.playthrough_connections CASCADE;
DROP TABLE IF EXISTS osint.playthrough_widgets CASCADE;
DROP TABLE IF EXISTS osint.playthroughs CASCADE;
DROP TABLE IF EXISTS osint.level_connections CASCADE;
DROP TABLE IF EXISTS osint.widget_regions CASCADE;
DROP TABLE IF EXISTS osint.widget_relations CASCADE;
DROP TABLE IF EXISTS osint.widgets CASCADE;
DROP TABLE IF EXISTS osint.assets CASCADE;
DROP TABLE IF EXISTS osint.levels CASCADE;
DROP TABLE IF EXISTS osint.cases CASCADE;
CREATE TABLE osint.boards (
id UUID PRIMARY KEY,
board_kind TEXT NOT NULL CHECK (board_kind IN ('level', 'template_version')),
revision BIGINT NOT NULL DEFAULT 0 CHECK (revision >= 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE osint.assets (
id UUID PRIMARY KEY,
original_name TEXT NOT NULL,
mime_type TEXT NOT NULL,
byte_size BIGINT NOT NULL CHECK (byte_size >= 0),
content BYTEA NOT NULL,
checksum_sha256 TEXT NOT NULL CHECK (checksum_sha256 ~ '^[0-9a-f]{64}$'),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (checksum_sha256, byte_size)
);
CREATE TABLE osint.level_templates (
id UUID PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
current_version_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE osint.level_template_versions (
id UUID PRIMARY KEY,
template_id UUID NOT NULL REFERENCES osint.level_templates(id) ON DELETE CASCADE,
version INTEGER NOT NULL CHECK (version > 0),
board_id UUID NOT NULL UNIQUE REFERENCES osint.boards(id) ON DELETE RESTRICT,
title TEXT NOT NULL,
subtitle TEXT NOT NULL DEFAULT '',
created_from_level_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (template_id, version)
);
CREATE TABLE osint.levels (
id UUID PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
board_id UUID NOT NULL UNIQUE REFERENCES osint.boards(id) ON DELETE CASCADE,
source_template_version_id UUID REFERENCES osint.level_template_versions(id) ON DELETE RESTRICT,
title TEXT NOT NULL,
subtitle TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'complete', 'archived')),
viewport_x DOUBLE PRECISION NOT NULL DEFAULT 0,
viewport_y DOUBLE PRECISION NOT NULL DEFAULT 28,
viewport_zoom DOUBLE PRECISION NOT NULL DEFAULT 0.7 CHECK (viewport_zoom > 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE osint.level_templates
ADD CONSTRAINT level_templates_current_version_fk
FOREIGN KEY (current_version_id) REFERENCES osint.level_template_versions(id) ON DELETE SET NULL;
ALTER TABLE osint.level_template_versions
ADD CONSTRAINT level_template_versions_source_level_fk
FOREIGN KEY (created_from_level_id) REFERENCES osint.levels(id) ON DELETE SET NULL;
CREATE TABLE osint.exhibit_types (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
is_spatial BOOLEAN NOT NULL DEFAULT TRUE
);
INSERT INTO osint.exhibit_types (id, name) VALUES
('folder', 'Folder'), ('document', 'Document'), ('note', 'Note'), ('event', 'Event');
CREATE TABLE osint.exhibits (
id UUID PRIMARY KEY,
board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE,
exhibit_type_id TEXT NOT NULL REFERENCES osint.exhibit_types(id),
origin_exhibit_id UUID REFERENCES osint.exhibits(id) ON DELETE SET NULL,
xpos DOUBLE PRECISION NOT NULL DEFAULT 100,
ypos DOUBLE PRECISION NOT NULL DEFAULT 100,
width DOUBLE PRECISION NOT NULL DEFAULT 240 CHECK (width > 0),
height DOUBLE PRECISION NOT NULL DEFAULT 160 CHECK (height > 0),
rotation DOUBLE PRECISION NOT NULL DEFAULT 0,
z_index INTEGER NOT NULL DEFAULT 0,
hidden BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (board_id, id)
);
CREATE INDEX exhibits_board_idx ON osint.exhibits (board_id, z_index, created_at);
CREATE TABLE osint.folder_exhibits (
exhibit_id UUID PRIMARY KEY REFERENCES osint.exhibits(id) ON DELETE CASCADE,
title TEXT NOT NULL,
label_text TEXT NOT NULL DEFAULT '',
is_open BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE osint.document_types (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
INSERT INTO osint.document_types (id, name) VALUES
('image', 'Image'), ('pdf', 'PDF'), ('web_capture', 'Web capture'),
('email', 'Email'), ('article', 'Article'), ('filing', 'Filing'),
('price_list', 'Price list'), ('text', 'Text'), ('file', 'Generic file');
CREATE TABLE osint.document_exhibits (
exhibit_id UUID PRIMARY KEY REFERENCES osint.exhibits(id) ON DELETE CASCADE,
document_type_id TEXT NOT NULL REFERENCES osint.document_types(id),
asset_id UUID REFERENCES osint.assets(id) ON DELETE RESTRICT,
title TEXT NOT NULL,
published_at TIMESTAMPTZ,
captured_at TIMESTAMPTZ,
source_uri TEXT
);
CREATE INDEX document_exhibits_published_idx ON osint.document_exhibits (published_at);
CREATE TABLE osint.image_documents (
exhibit_id UUID PRIMARY KEY REFERENCES osint.document_exhibits(exhibit_id) ON DELETE CASCADE,
pixel_width INTEGER CHECK (pixel_width > 0),
pixel_height INTEGER CHECK (pixel_height > 0),
alt_text TEXT NOT NULL DEFAULT ''
);
CREATE TABLE osint.note_exhibits (
exhibit_id UUID PRIMARY KEY REFERENCES osint.exhibits(id) ON DELETE CASCADE,
title TEXT NOT NULL,
note_text TEXT NOT NULL DEFAULT ''
);
CREATE TABLE osint.event_exhibits (
exhibit_id UUID PRIMARY KEY REFERENCES osint.exhibits(id) ON DELETE CASCADE,
title TEXT NOT NULL,
narrative_text TEXT NOT NULL DEFAULT '',
occurred_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE osint.document_content_blocks (
id UUID PRIMARY KEY,
document_exhibit_id UUID NOT NULL REFERENCES osint.document_exhibits(exhibit_id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL CHECK (sort_order >= 0),
content TEXT NOT NULL,
UNIQUE (document_exhibit_id, sort_order)
);
CREATE TABLE osint.document_regions (
id UUID PRIMARY KEY,
document_exhibit_id UUID NOT NULL REFERENCES osint.document_exhibits(exhibit_id) ON DELETE CASCADE,
region_key TEXT NOT NULL,
label TEXT NOT NULL,
excerpt TEXT NOT NULL,
occurred_at TIMESTAMPTZ,
sort_order INTEGER NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
UNIQUE (document_exhibit_id, region_key)
);
CREATE TABLE osint.folder_memberships (
board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE,
folder_exhibit_id UUID NOT NULL REFERENCES osint.folder_exhibits(exhibit_id) ON DELETE CASCADE,
child_exhibit_id UUID NOT NULL REFERENCES osint.exhibits(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
PRIMARY KEY (folder_exhibit_id, child_exhibit_id),
UNIQUE (board_id, child_exhibit_id),
CHECK (folder_exhibit_id <> child_exhibit_id)
);
CREATE TABLE osint.event_evidence (
board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE,
event_exhibit_id UUID NOT NULL REFERENCES osint.event_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 (event_exhibit_id, evidence_exhibit_id),
CHECK (event_exhibit_id <> evidence_exhibit_id)
);
CREATE TABLE osint.connection_types (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
directed BOOLEAN NOT NULL DEFAULT FALSE
);
INSERT INTO osint.connection_types (id, name) VALUES ('thread', 'Red thread');
CREATE TABLE osint.exhibit_connections (
id UUID PRIMARY KEY,
board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE,
connection_type_id TEXT NOT NULL REFERENCES osint.connection_types(id),
from_exhibit_id UUID NOT NULL REFERENCES osint.exhibits(id) ON DELETE CASCADE,
to_exhibit_id UUID NOT NULL REFERENCES osint.exhibits(id) ON DELETE CASCADE,
label TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK (from_exhibit_id <> to_exhibit_id)
);
CREATE TABLE osint.exhibit_sources (
exhibit_id UUID PRIMARY KEY REFERENCES osint.exhibits(id) ON DELETE CASCADE,
source_document_exhibit_id UUID NOT NULL REFERENCES osint.document_exhibits(exhibit_id) ON DELETE CASCADE,
source_region_id UUID REFERENCES osint.document_regions(id) ON DELETE SET NULL
);
CREATE TABLE osint.metadata_fields (
id UUID PRIMARY KEY,
board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE,
field_key TEXT NOT NULL,
label TEXT NOT NULL,
value_type TEXT NOT NULL CHECK (value_type IN ('text', 'timestamp', 'number', 'boolean')),
UNIQUE (board_id, field_key)
);
CREATE TABLE osint.exhibit_metadata_text_values (
exhibit_id UUID NOT NULL REFERENCES osint.exhibits(id) ON DELETE CASCADE,
field_id UUID NOT NULL REFERENCES osint.metadata_fields(id) ON DELETE CASCADE,
value TEXT NOT NULL,
PRIMARY KEY (exhibit_id, field_id)
);
CREATE TABLE osint.exhibit_metadata_timestamp_values (
exhibit_id UUID NOT NULL REFERENCES osint.exhibits(id) ON DELETE CASCADE,
field_id UUID NOT NULL REFERENCES osint.metadata_fields(id) ON DELETE CASCADE,
value TIMESTAMPTZ NOT NULL,
PRIMARY KEY (exhibit_id, field_id)
);
CREATE TABLE osint.exhibit_metadata_number_values (
exhibit_id UUID NOT NULL REFERENCES osint.exhibits(id) ON DELETE CASCADE,
field_id UUID NOT NULL REFERENCES osint.metadata_fields(id) ON DELETE CASCADE,
value NUMERIC NOT NULL,
PRIMARY KEY (exhibit_id, field_id)
);
CREATE TABLE osint.exhibit_metadata_boolean_values (
exhibit_id UUID NOT NULL REFERENCES osint.exhibits(id) ON DELETE CASCADE,
field_id UUID NOT NULL REFERENCES osint.metadata_fields(id) ON DELETE CASCADE,
value BOOLEAN NOT NULL,
PRIMARY KEY (exhibit_id, field_id)
);
-- Relationships carry board_id so cross-board references can be rejected by FKs.
ALTER TABLE osint.folder_memberships
ADD CONSTRAINT folder_membership_folder_board_fk FOREIGN KEY (board_id, folder_exhibit_id) REFERENCES osint.exhibits(board_id, id) ON DELETE CASCADE,
ADD CONSTRAINT folder_membership_child_board_fk FOREIGN KEY (board_id, child_exhibit_id) REFERENCES osint.exhibits(board_id, id) ON DELETE CASCADE;
ALTER TABLE osint.event_evidence
ADD CONSTRAINT event_evidence_event_board_fk FOREIGN KEY (board_id, event_exhibit_id) REFERENCES osint.exhibits(board_id, id) ON DELETE CASCADE,
ADD CONSTRAINT event_evidence_evidence_board_fk FOREIGN KEY (board_id, evidence_exhibit_id) REFERENCES osint.exhibits(board_id, id) ON DELETE CASCADE;
ALTER TABLE osint.exhibit_connections
ADD CONSTRAINT exhibit_connections_from_board_fk FOREIGN KEY (board_id, from_exhibit_id) REFERENCES osint.exhibits(board_id, id) ON DELETE CASCADE,
ADD CONSTRAINT exhibit_connections_to_board_fk FOREIGN KEY (board_id, to_exhibit_id) REFERENCES osint.exhibits(board_id, id) ON DELETE CASCADE;
COMMENT ON TABLE osint.exhibits IS 'Canonical domain objects; frontend widgets are projections selected by exhibit_type_id.';
COMMENT ON TABLE osint.level_template_versions IS 'Immutable template snapshots. Application code must clone, never update, their boards.';
COMMENT ON TABLE osint.assets IS 'Immutable shared binary content referenced by document exhibits.';
+24 -10
View File
@@ -1,4 +1,5 @@
import { createServer } from 'node:net' import { createServer } from 'node:net'
import { randomUUID } from 'node:crypto'
import path from 'node:path' import path from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import pg from 'pg' import pg from 'pg'
@@ -58,7 +59,7 @@ suite('level persistence API', () => {
await adminClient.end() await adminClient.end()
}) })
it('persists authoring, uploads, player state, and reset behavior', async () => { it('persists one normalized level across authoring and play views', async () => {
const createResponse = await fetch(`${baseUrl}/api/levels`, { const createResponse = await fetch(`${baseUrl}/api/levels`, {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
@@ -67,9 +68,11 @@ suite('level persistence API', () => {
expect(createResponse.status).toBe(201) expect(createResponse.status).toBe(201)
const state = await createResponse.json() as CaseState const state = await createResponse.json() as CaseState
state.viewport = { x: 91, y: -42, zoom: 0.85 } state.viewport = { x: 91, y: -42, zoom: 0.85 }
state.documents = [{ id: 'doc-1', title: 'Evidence', kind: 'IMAGE', date: '2021-04-17', publishedAt: '2021-04-17T12:00:00Z', body: [], regions: [], fileType: 'image', metadata: {} }] const documentId = randomUUID()
state.evidence = [{ id: 'folder-1', type: 'folder', title: 'Folder', content: 'Evidence folder', x: 685, y: 417, width: 260, config: { open: true }, containedDocumentIds: ['doc-1'] }] const folderId = randomUUID()
state.relations = [{ id: 'membership-1', fromWidgetId: 'folder-1', toWidgetId: 'doc-1', type: 'contains', sortOrder: 0, config: { x: 1051, y: 417 } }] state.documents = [{ id: documentId, title: 'Evidence', kind: 'IMAGE', date: '2021-04-17', publishedAt: '2021-04-17T12:00:00Z', body: [], regions: [], 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] }]
state.relations = [{ id: `contains:${folderId}:${documentId}`, fromWidgetId: folderId, toWidgetId: documentId, type: 'contains', sortOrder: 0, config: { x: 1051, y: 417 } }]
const saveResponse = await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, { const saveResponse = await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
method: 'PUT', method: 'PUT',
@@ -80,15 +83,15 @@ suite('level persistence API', () => {
const loaded = await (await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState const loaded = await (await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
expect(loaded.viewport).toEqual(state.viewport) expect(loaded.viewport).toEqual(state.viewport)
expect(loaded.evidence[0]).toMatchObject({ id: 'folder-1', x: 685, y: 417, config: { open: true } }) expect(loaded.evidence[0]).toMatchObject({ id: folderId, x: 685, y: 417, config: { open: true } })
expect(loaded.relations[0]).toMatchObject({ id: 'membership-1', config: { x: 1051, y: 417 } }) expect(loaded.relations[0]).toMatchObject({ id: `contains:${folderId}:${documentId}`, config: { x: 1051, y: 417 } })
const upload = new FormData() const upload = new FormData()
upload.append('file', new Blob(['OSINT smoke evidence'], { type: 'text/plain' }), 'smoke-evidence.txt') upload.append('file', new Blob(['OSINT smoke evidence'], { type: 'text/plain' }), 'smoke-evidence.txt')
const uploadResponse = await fetch(`${baseUrl}/api/levels/${state.id}/documents?edit=1`, { method: 'POST', body: upload }) const uploadResponse = await fetch(`${baseUrl}/api/levels/${state.id}/documents?edit=1`, { method: 'POST', body: upload })
expect(uploadResponse.status).toBe(201) expect(uploadResponse.status).toBe(201)
const uploaded = await uploadResponse.json() as CaseState['documents'][number] const uploaded = await uploadResponse.json() as CaseState['documents'][number]
expect(uploaded).toMatchObject({ title: 'smoke-evidence.txt', fileName: 'smoke-evidence.txt', mimeType: 'text/plain', fileType: 'file' }) expect(uploaded).toMatchObject({ title: 'smoke-evidence.txt', fileName: 'smoke-evidence.txt', mimeType: 'text/plain', fileType: 'text' })
expect(uploaded.assetId).toBeTruthy() expect(uploaded.assetId).toBeTruthy()
expect(await (await fetch(`${baseUrl}/api/assets/${uploaded.assetId}`)).text()).toBe('OSINT smoke evidence') expect(await (await fetch(`${baseUrl}/api/assets/${uploaded.assetId}`)).text()).toBe('OSINT smoke evidence')
@@ -121,12 +124,23 @@ suite('level persistence API', () => {
expect(await playerSave.json()).toEqual({ ok: true, mode: 'play' }) expect(await playerSave.json()).toEqual({ ok: true, mode: 'play' })
const savedPlayerState = await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState const savedPlayerState = await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState
expect(savedPlayerState.viewport).toEqual(playerState.viewport) expect(savedPlayerState.viewport).toEqual(playerState.viewport)
expect(savedPlayerState.evidence[0]).toMatchObject({ id: 'folder-1', x: 812, y: 533 }) expect(savedPlayerState.evidence[0]).toMatchObject({ id: folderId, x: 812, y: 533 })
const sameLevelInEditView = await (await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
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 }>(`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`)
expect(normalized.rows[0]).toEqual({ exhibits: '3', documents: '2', folders: '1', memberships: '1', metadata: '2' })
const resetResponse = await fetch(`${baseUrl}/api/levels/${state.id}/reset`, { method: 'POST' }) const resetResponse = await fetch(`${baseUrl}/api/levels/${state.id}/reset`, { method: 'POST' })
expect(resetResponse.ok).toBe(true) expect(resetResponse.ok).toBe(true)
const resetState = await resetResponse.json() as CaseState const resetState = await resetResponse.json() as CaseState
expect(resetState.viewport).toEqual({ x: 0, y: 28, zoom: 0.7 }) expect(resetState.viewport).toEqual(playerState.viewport)
expect(resetState.evidence[0]).toMatchObject({ id: 'folder-1', x: 685, y: 417 }) expect(resetState.evidence[0]).toMatchObject({ id: folderId, x: 812, y: 533 })
}) })
}) })
+6 -4
View File
@@ -30,6 +30,8 @@ process.env.OSINT_MANAGED_SERVER = 'true'
const { server, pool } = await import('./index.js') const { server, pool } = await import('./index.js')
if (!server.listening) await once(server, 'listening') if (!server.listening) await once(server, 'listening')
const baseUrl = `http://127.0.0.1:${port}` const baseUrl = `http://127.0.0.1:${port}`
const documentId = '22222222-2222-4222-8222-222222222222'
const folderId = '11111111-1111-4111-8111-111111111111'
const created = await fetch(`${baseUrl}/api/levels`, { const created = await fetch(`${baseUrl}/api/levels`, {
method: 'POST', method: 'POST',
@@ -39,15 +41,15 @@ const created = await fetch(`${baseUrl}/api/levels`, {
if (!created.ok) throw new Error(`Could not create browser test level: ${created.status}`) if (!created.ok) throw new Error(`Could not create browser test level: ${created.status}`)
const state = await created.json() as CaseState const state = await created.json() as CaseState
state.documents = [{ state.documents = [{
id: 'e2e-document', title: 'Dated source image', kind: 'IMAGE', date: '2021-04-17', publishedAt: '2021-04-17T12:00:00.000Z', id: documentId, title: 'Dated source image', kind: 'IMAGE', date: '2021-04-17', publishedAt: '2021-04-17T12:00:00.000Z',
body: [], regions: [], fileType: 'image', metadata: {}, body: [], regions: [], fileType: 'image', metadata: {},
}] }]
state.evidence = [{ state.evidence = [{
id: 'e2e-folder', type: 'folder', title: 'BROWSER TEST FOLDER', content: 'Disposable evidence', id: folderId, type: 'folder', title: 'BROWSER TEST FOLDER', content: 'Disposable evidence',
x: 600, y: 360, width: 260, config: { open: false }, containedDocumentIds: ['e2e-document'], x: 600, y: 360, width: 260, config: { open: false }, containedDocumentIds: [documentId],
}] }]
state.relations = [{ state.relations = [{
id: 'e2e-membership', fromWidgetId: 'e2e-folder', toWidgetId: 'e2e-document', type: 'contains', sortOrder: 0, id: `contains:${folderId}:${documentId}`, fromWidgetId: folderId, toWidgetId: documentId, type: 'contains', sortOrder: 0,
config: { x: 980, y: 360 }, config: { x: 980, y: 360 },
}] }]
state.connections = [] state.connections = []
+2 -2
View File
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url'
import multer from 'multer' import multer from 'multer'
import pg from 'pg' import pg from 'pg'
import type { CaseState } from '../src/types.js' import type { CaseState } from '../src/types.js'
import { createLegacyLevelRepository } from './levelRepository.js' import { createLevelRepository } from './levelRepository.js'
const { Pool } = pg const { Pool } = pg
const databaseUrl = process.env.DATABASE_URL const databaseUrl = process.env.DATABASE_URL
@@ -18,7 +18,7 @@ if (!databaseUrl) {
export const pool = new Pool({ connectionString: databaseUrl }) export const pool = new Pool({ connectionString: databaseUrl })
const editingEnabled = process.env.LEVEL_EDITING_ENABLED === 'true' const editingEnabled = process.env.LEVEL_EDITING_ENABLED === 'true'
const levels = createLegacyLevelRepository(pool, editingEnabled) const levels = createLevelRepository(pool, editingEnabled)
function wantsEdit(req: express.Request) { function wantsEdit(req: express.Request) {
return editingEnabled && req.query.edit === '1' return editingEnabled && req.query.edit === '1'
+228 -209
View File
@@ -1,20 +1,9 @@
import { createHash, randomUUID } from 'node:crypto' import { createHash, randomUUID } from 'node:crypto'
import type { Pool, PoolClient } from 'pg' import type { Pool, PoolClient } from 'pg'
import type { CaseDocument, CaseState, Connection, Evidence, WidgetRelation } from '../src/types.js' import type { CaseDocument, CaseState, Evidence, SourceFileType, WidgetRelation } from '../src/types.js'
export type UploadedDocument = { export type UploadedDocument = { buffer: Buffer; originalname: string; mimetype: string; size: number }
buffer: Buffer export type AssetRecord = { original_name: string; mime_type: string; byte_size: string; content: Buffer }
originalname: string
mimetype: string
size: number
}
export type AssetRecord = {
original_name: string
mime_type: string
byte_size: string
content: Buffer
}
export interface LevelRepository { export interface LevelRepository {
listLevels(): Promise<unknown[]> listLevels(): Promise<unknown[]>
@@ -26,242 +15,272 @@ export interface LevelRepository {
uploadDocument(levelId: string, file: UploadedDocument): Promise<CaseDocument | null> uploadDocument(levelId: string, file: UploadedDocument): Promise<CaseDocument | null>
} }
type WidgetRow = { type LevelRow = {
id: string; widget_type: 'document' | Evidence['type']; title: string; content: string id: string; slug: string; board_id: string; title: string; subtitle: string; status: string
config: { kind?: string; date?: string; body?: string[]; fileType?: CaseDocument['fileType']; metadata?: Record<string, string>; [key: string]: unknown }; source_widget_id?: string; source_region_key?: string viewport_x: number; viewport_y: number; viewport_zoom: number; updated_at: Date
event_date?: string; published_at?: string; x?: number; y?: number; width?: number; sort_order: number; asset_id?: string source_template_version_id: string | null
original_name?: string; mime_type?: string; byte_size?: number }
type ExhibitRow = {
id: string; exhibit_type_id: 'folder' | 'document' | 'note' | 'event'; 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
} }
function isoTimestamp(value: unknown) { 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
if (!value) return undefined function requireUuid(value: string, label: string) {
const parsed = new Date(String(value)) if (!uuidPattern.test(value)) throw new Error(`${label} must be a UUID`)
return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : undefined return value
}
function timestamp(value: string | undefined) {
if (!value) return null
const date = new Date(value)
return Number.isFinite(date.getTime()) ? date.toISOString() : null
}
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'
}
function documentKind(type: SourceFileType) {
return type === 'web_capture' ? 'WEB CAPTURE' : type.toUpperCase()
} }
export function createLegacyLevelRepository(pool: Pool, editingEnabled: boolean): LevelRepository { export function createLevelRepository(pool: Pool, editingEnabled: boolean): LevelRepository {
async function assembleLevel(levelId: string, playthroughId = `default:${levelId}`, authorMode = false): Promise<CaseState | null> { async function findLevel(client: Pool | PoolClient, slug: string, lock = false) {
const levelResult = await pool.query<{ id: string; title: string; subtitle: string; status: string }>( const result = await client.query<LevelRow>(`SELECT id, slug, board_id, title, subtitle, status,
'SELECT id, title, subtitle, status FROM osint.levels WHERE id = $1', [levelId], viewport_x, viewport_y, viewport_zoom, updated_at, source_template_version_id
) FROM osint.levels WHERE slug = $1${lock ? ' FOR UPDATE' : ''}`, [slug])
const level = levelResult.rows[0] return result.rows[0] || null
}
async function assembleLevel(slug: string): Promise<CaseState | null> {
const level = await findLevel(pool, slug)
if (!level) return null if (!level) return null
const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult] = await Promise.all([
await pool.query( pool.query<ExhibitRow>(`SELECT e.id, e.exhibit_type_id, e.xpos, e.ypos, e.width, e.hidden,
`INSERT INTO osint.playthroughs (id, level_id) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING`, COALESCE(f.title, d.title, n.title, ev.title, '') AS title,
[playthroughId, levelId], COALESCE(f.label_text, n.note_text, ev.narrative_text, '') AS content,
) f.is_open, d.document_type_id, d.asset_id, d.published_at, ev.occurred_at,
const [widgetsResult, regionsResult, authoredConnections, authoredRelations, playthroughResult, generatedResult, playerConnections, playerRelations, stateResult, relationStateResult] = await Promise.all([ a.original_name, a.mime_type, a.byte_size,
pool.query<WidgetRow>(`SELECT w.id, w.widget_type, w.title, w.content, w.config, w.source_widget_id, w.source_region_key, s.source_document_exhibit_id AS source_document_id, sr.region_key AS source_region_key
w.event_date::text, w.published_at::text, w.x, w.y, w.width, w.sort_order, w.asset_id, a.original_name, a.mime_type, a.byte_size FROM osint.exhibits e
FROM osint.widgets w LEFT JOIN osint.assets a ON a.id = w.asset_id LEFT JOIN osint.folder_exhibits f ON f.exhibit_id = e.id
WHERE w.level_id = $1 ORDER BY w.sort_order, w.id`, [levelId]), LEFT JOIN osint.document_exhibits d ON d.exhibit_id = e.id
pool.query<{ document_widget_id: string; region_key: string; label: string; excerpt: string; event_date?: string }>( LEFT JOIN osint.note_exhibits n ON n.exhibit_id = e.id
`SELECT r.document_widget_id, r.region_key, r.label, r.excerpt, r.event_date::text LEFT JOIN osint.event_exhibits ev ON ev.exhibit_id = e.id
FROM osint.widget_regions r JOIN osint.widgets w ON w.id = r.document_widget_id LEFT JOIN osint.assets a ON a.id = d.asset_id
WHERE w.level_id = $1 ORDER BY r.sort_order, r.id`, [levelId]), LEFT JOIN osint.exhibit_sources s ON s.exhibit_id = e.id
pool.query<{ id: string; from_widget_id: string; to_widget_id: string }>( LEFT JOIN osint.document_regions sr ON sr.id = s.source_region_id
'SELECT id, from_widget_id, to_widget_id FROM osint.level_connections WHERE level_id = $1', [levelId]), WHERE e.board_id = $1 ORDER BY e.z_index, e.created_at, e.id`, [level.board_id]),
pool.query<{ id: string; from_widget_id: string; to_widget_id: string; relation_type: string; sort_order: number; config: Record<string, unknown> }>( pool.query<{ document_exhibit_id: string; content: string }>(
`SELECT id, from_widget_id, to_widget_id, relation_type, sort_order, config FROM osint.widget_relations `SELECT b.document_exhibit_id, b.content FROM osint.document_content_blocks b
WHERE level_id = $1 ORDER BY sort_order, id`, [levelId]), JOIN osint.exhibits e ON e.id = b.document_exhibit_id WHERE e.board_id = $1 ORDER BY b.document_exhibit_id, b.sort_order`, [level.board_id]),
pool.query<{ viewport: CaseState['viewport']; updated_at: Date }>( pool.query<{ document_exhibit_id: string; region_key: string; label: string; excerpt: string; occurred_at: Date | null }>(
'SELECT viewport, updated_at FROM osint.playthroughs WHERE id = $1', [playthroughId]), `SELECT r.document_exhibit_id, r.region_key, r.label, r.excerpt, r.occurred_at FROM osint.document_regions r
pool.query<WidgetRow>(`SELECT id, widget_type, title, content, config, source_widget_id, JOIN osint.exhibits e ON e.id = r.document_exhibit_id WHERE e.board_id = $1 ORDER BY r.document_exhibit_id, r.sort_order`, [level.board_id]),
source_region_key, event_date::text, x, y, width, 0 AS sort_order pool.query<{ folder_exhibit_id: string; child_exhibit_id: string; sort_order: number; xpos: number; ypos: number }>(
FROM osint.playthrough_widgets WHERE playthrough_id = $1 ORDER BY created_at, id`, [playthroughId]), `SELECT m.folder_exhibit_id, m.child_exhibit_id, m.sort_order, child.xpos, child.ypos
pool.query<{ id: string; from_widget_id: string; to_widget_id: string }>( FROM osint.folder_memberships m JOIN osint.exhibits child ON child.id = m.child_exhibit_id
'SELECT id, from_widget_id, to_widget_id FROM osint.playthrough_connections WHERE playthrough_id = $1', [playthroughId]), WHERE m.board_id = $1 ORDER BY m.sort_order, m.child_exhibit_id`, [level.board_id]),
pool.query<{ id: string; from_widget_id: string; to_widget_id: string; relation_type: string; sort_order: number; config: Record<string, unknown> }>( pool.query<{ id: string; from_exhibit_id: string; to_exhibit_id: string }>(
`SELECT id, from_widget_id, to_widget_id, relation_type, sort_order, config FROM osint.playthrough_widget_relations `SELECT id, from_exhibit_id, to_exhibit_id FROM osint.exhibit_connections WHERE board_id = $1 ORDER BY created_at, id`, [level.board_id]),
WHERE playthrough_id = $1 ORDER BY sort_order, id`, [playthroughId]), pool.query<{ exhibit_id: string; field_key: string; value: string }>(
pool.query<{ widget_id: string; x: number; y: number; width: number; hidden: boolean; config: Record<string, unknown> }>( `SELECT v.exhibit_id, f.field_key, v.value FROM osint.exhibit_metadata_text_values v
'SELECT widget_id, x, y, width, hidden, config FROM osint.playthrough_widget_state WHERE playthrough_id = $1', [playthroughId]), 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<{ relation_id: string; config: Record<string, unknown> }>(
'SELECT relation_id, config FROM osint.playthrough_widget_relation_state WHERE playthrough_id = $1', [playthroughId]),
]) ])
const stateByWidget = new Map(authorMode ? [] : stateResult.rows.map(row => [row.widget_id, row])) const blocks = new Map<string, string[]>()
const documents: CaseDocument[] = widgetsResult.rows.filter(w => w.widget_type === 'document').map(w => { const override = stateByWidget.get(w.id)?.config || {}; const publishedAt = isoTimestamp(override.publishedAt || w.published_at); return ({ for (const row of blocksResult.rows) blocks.set(row.document_exhibit_id, [...(blocks.get(row.document_exhibit_id) || []), row.content])
id: w.id, title: String(override.title || w.title), kind: w.config.kind || 'DOCUMENT', date: publishedAt?.slice(0, 10) || w.config.date || '', publishedAt, const regions = new Map<string, CaseDocument['regions']>()
body: w.config.body || [], fileType: (override.fileType || w.config.fileType || (w.mime_type?.startsWith('image/') ? 'image' : 'file')) as CaseDocument['fileType'], metadata: (override.metadata || w.config.metadata || {}) as Record<string, string>, for (const row of regionsResult.rows) regions.set(row.document_exhibit_id, [...(regions.get(row.document_exhibit_id) || []), {
assetId: w.asset_id, fileName: w.original_name, mimeType: w.mime_type, fileSize: w.byte_size, id: row.region_key, label: row.label, excerpt: row.excerpt, date: row.occurred_at?.toISOString(),
regions: regionsResult.rows.filter(r => r.document_widget_id === w.id).map(r => ({ id: r.region_key, label: r.label, excerpt: r.excerpt, date: r.event_date })), }])
}) }) const metadata = new Map<string, Record<string, string>>()
const relationState = new Map(authorMode ? [] : relationStateResult.rows.map(row => [row.relation_id, row.config])) for (const row of metadataResult.rows) metadata.set(row.exhibit_id, { ...(metadata.get(row.exhibit_id) || {}), [row.field_key]: row.value })
const containedByFolder = new Map<string, string[]>() const contained = new Map<string, string[]>()
for (const relation of [...authoredRelations.rows, ...(authorMode ? [] : playerRelations.rows)]) { const relations: WidgetRelation[] = membershipsResult.rows.map(row => {
if (relation.relation_type !== 'contains') continue contained.set(row.folder_exhibit_id, [...(contained.get(row.folder_exhibit_id) || []), row.child_exhibit_id])
containedByFolder.set(relation.from_widget_id, [...(containedByFolder.get(relation.from_widget_id) || []), relation.to_widget_id]) return { id: `contains:${row.folder_exhibit_id}:${row.child_exhibit_id}`, fromWidgetId: row.folder_exhibit_id,
} toWidgetId: row.child_exhibit_id, type: 'contains', sortOrder: row.sort_order, config: { x: row.xpos, y: row.ypos } }
const toEvidence = (w: WidgetRow): Evidence => { })
const override = stateByWidget.get(w.id) const documents: CaseDocument[] = exhibitsResult.rows.filter(row => row.exhibit_type_id === 'document').map(row => {
const runtimeConfig = { ...w.config, ...(override?.config || {}) } const type = row.document_type_id || 'file'
return { id: w.id, type: w.widget_type as Evidence['type'], title: String(override?.config?.title || w.title), content: String(override?.config?.content ?? w.content), config: runtimeConfig, const publishedAt = row.published_at?.toISOString()
sourceDocumentId: w.source_widget_id, sourceRegionId: w.source_region_key, eventDate: w.widget_type === 'event' ? w.event_date : undefined, return { id: row.id, title: row.title, kind: documentKind(type), date: publishedAt?.slice(0, 10) || '', publishedAt,
containedDocumentIds: containedByFolder.get(w.id) || (w.source_widget_id ? [w.source_widget_id] : []), body: blocks.get(row.id) || [], regions: regions.get(row.id) || [], assetId: row.asset_id || undefined,
x: override?.x ?? w.x ?? 100, y: override?.y ?? w.y ?? 100, width: override?.width ?? w.width ?? 240 } 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 authoredEvidence = widgetsResult.rows.filter(w => w.widget_type !== 'document' && !stateByWidget.get(w.id)?.hidden).map(toEvidence) })
const evidence = [...authoredEvidence, ...(authorMode ? [] : generatedResult.rows.map(toEvidence))] const evidence: Evidence[] = exhibitsResult.rows.filter(row => row.exhibit_type_id !== 'document' && !row.hidden).map(row => ({
const connections: Connection[] = [...authoredConnections.rows, ...(authorMode ? [] : playerConnections.rows)].map(c => ({ id: row.id, type: row.exhibit_type_id as Evidence['type'], title: row.title, content: row.content,
id: c.id, fromEvidenceId: c.from_widget_id, toEvidenceId: c.to_widget_id, 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,
config: row.exhibit_type_id === 'folder' ? { open: Boolean(row.is_open) } : {}, containedDocumentIds: contained.get(row.id) || [],
})) }))
const relations: WidgetRelation[] = [...authoredRelations.rows, ...(authorMode ? [] : playerRelations.rows)].map(relation => ({ return { id: level.slug, title: level.title, subtitle: level.subtitle, documents, evidence, relations,
id: relation.id, fromWidgetId: relation.from_widget_id, toWidgetId: relation.to_widget_id, type: relation.relation_type, connections: connectionsResult.rows.map(row => ({ id: row.id, fromEvidenceId: row.from_exhibit_id, toEvidenceId: row.to_exhibit_id })),
sortOrder: relation.sort_order, config: { ...relation.config, ...(relationState.get(relation.id) || {}) }, viewport: { x: level.viewport_x, y: level.viewport_y, zoom: level.viewport_zoom }, updatedAt: level.updated_at.toISOString(),
})) levelStatus: level.status, editingAllowed: editingEnabled }
const playthrough = playthroughResult.rows[0]
return { id: level.id, title: level.title, subtitle: level.subtitle, documents, evidence, relations, connections,
viewport: playthrough.viewport, updatedAt: playthrough.updated_at.toISOString(), levelStatus: level.status, editingAllowed: editingEnabled }
} }
async function savePlaythrough(client: PoolClient, state: CaseState) { async function replaceBoard(client: PoolClient, level: LevelRow, state: CaseState) {
const playthroughId = `default:${state.id}` const documentIds = new Set(state.documents.map(document => requireUuid(document.id, 'Document id')))
const authored = await client.query<{ id: string }>('SELECT id FROM osint.widgets WHERE level_id = $1', [state.id]) const evidenceIds = new Set(state.evidence.map(exhibit => requireUuid(exhibit.id, 'Exhibit id')))
const authoredIds = new Set(authored.rows.map(row => row.id)) const allIds = [...documentIds, ...evidenceIds]
const authoredRelations = await client.query<{ id: string }>('SELECT id FROM osint.widget_relations WHERE level_id = $1', [state.id]) if (new Set(allIds).size !== allIds.length) throw new Error('An id cannot identify both a document and another exhibit')
const authoredRelationIds = new Set(authoredRelations.rows.map(row => row.id))
await client.query('UPDATE osint.playthroughs SET viewport = $2::jsonb, updated_at = NOW() WHERE id = $1', [playthroughId, JSON.stringify(state.viewport)]) const relationList = state.relations || state.evidence.flatMap(exhibit => (exhibit.containedDocumentIds || []).map((documentId, index) => ({
await client.query('DELETE FROM osint.playthrough_widget_state WHERE playthrough_id = $1', [playthroughId]) id: `contains:${exhibit.id}:${documentId}`, fromWidgetId: exhibit.id, toWidgetId: documentId, type: 'contains', sortOrder: index,
await client.query('DELETE FROM osint.playthrough_widget_relation_state WHERE playthrough_id = $1', [playthroughId]) })))
await client.query('DELETE FROM osint.playthrough_widget_relations WHERE playthrough_id = $1', [playthroughId]) const positions = new Map<string, { x: number; y: number }>()
await client.query('DELETE FROM osint.playthrough_widgets WHERE playthrough_id = $1', [playthroughId]) for (const relation of relationList.filter(item => item.type === 'contains')) {
for (const document of state.documents) { positions.set(relation.toWidgetId, { x: Number(relation.config?.x ?? 100), y: Number(relation.config?.y ?? 100) })
await client.query(`INSERT INTO osint.playthrough_widget_state (playthrough_id, widget_id, x, y, width, config)
VALUES ($1,$2,0,0,0,$3::jsonb)`, [playthroughId, document.id, JSON.stringify({ title: document.title, publishedAt: document.publishedAt || null, fileType: document.fileType, metadata: document.metadata })])
} }
for (const widget of state.evidence) { 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])
if (authoredIds.has(widget.id)) { for (const row of existing.rows) if (!positions.has(row.id)) positions.set(row.id, { x: row.xpos, y: row.ypos })
await client.query(`INSERT INTO osint.playthrough_widget_state (playthrough_id, widget_id, x, y, width, config)
VALUES ($1, $2, $3, $4, $5, $6::jsonb)`, [playthroughId, widget.id, widget.x, widget.y, widget.width, JSON.stringify({ ...(widget.config || {}), title: widget.title, content: widget.content })]) await client.query(`UPDATE osint.levels SET title=$2, subtitle=$3, viewport_x=$4, viewport_y=$5, viewport_zoom=$6,
} else { updated_at=NOW() WHERE id=$1`, [level.id, state.title, state.subtitle, state.viewport.x, state.viewport.y, state.viewport.zoom])
await client.query(`INSERT INTO osint.playthrough_widgets await client.query('UPDATE osint.boards SET revision=revision+1, updated_at=NOW() WHERE id=$1', [level.board_id])
(id, playthrough_id, widget_type, title, content, config, source_widget_id, source_region_key, event_date, x, y, width) await client.query('DELETE FROM osint.exhibit_connections WHERE board_id=$1', [level.board_id])
VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9,$10,$11,$12)`, [widget.id, playthroughId, widget.type, widget.title, widget.content, JSON.stringify(widget.config || {}), await client.query('DELETE FROM osint.folder_memberships WHERE board_id=$1', [level.board_id])
widget.sourceDocumentId || null, widget.sourceRegionId || null, widget.eventDate || null, widget.x, widget.y, widget.width]) await client.query('DELETE FROM osint.event_evidence 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']) {
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])
else await client.query('DELETE FROM osint.exhibits WHERE board_id=$1', [level.board_id])
for (const [index, document] of state.documents.entries()) {
const position = positions.get(document.id) || { x: 100, 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',$3,$4,174,145,$5,FALSE)
ON CONFLICT (id) DO UPDATE SET exhibit_type_id='document',xpos=$3,ypos=$4,width=174,height=145,z_index=$5,hidden=FALSE,updated_at=NOW()`,
[document.id, level.board_id, position.x, position.y, index])
await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title,published_at)
VALUES ($1,$2,$3,$4,$5)`, [document.id, documentType(document), document.assetId || null, document.title, timestamp(document.publishedAt || document.date)])
if (document.fileType === 'image') await client.query('INSERT INTO osint.image_documents (exhibit_id) VALUES ($1)', [document.id])
for (const [sortOrder, content] of document.body.entries()) await client.query(
'INSERT INTO osint.document_content_blocks (id,document_exhibit_id,sort_order,content) VALUES ($1,$2,$3,$4)', [randomUUID(), document.id, sortOrder, content])
for (const [sortOrder, region] of document.regions.entries()) await client.query(
`INSERT INTO osint.document_regions (id,document_exhibit_id,region_key,label,excerpt,occurred_at,sort_order)
VALUES ($1,$2,$3,$4,$5,$6,$7)`, [randomUUID(), document.id, region.id, region.label, region.excerpt, timestamp(region.date), sortOrder])
} }
const fallbackRelations: WidgetRelation[] = state.evidence.flatMap(widget => (widget.containedDocumentIds || []).map((documentId, index) => ({ id: `contains:${widget.id}:${documentId}`, fromWidgetId: widget.id, toWidgetId: documentId, type: 'contains', sortOrder: index }))) for (const [index, exhibit] of state.evidence.entries()) {
for (const relation of (state.relations || fallbackRelations).filter(relation => authoredRelationIds.has(relation.id))) { const type = exhibit.type === 'evidence' ? 'folder' : exhibit.type
await client.query(`INSERT INTO osint.playthrough_widget_relation_state (playthrough_id, relation_id, config) const canonicalType = type === 'folder' || type === 'note' || type === 'event' ? type : 'note'
VALUES ($1,$2,$3::jsonb)`, [playthroughId, relation.id, JSON.stringify(relation.config || {})]) 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)
for (const relation of (state.relations || fallbackRelations).filter(relation => !authoredRelationIds.has(relation.id))) { 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()`,
await client.query(`INSERT INTO osint.playthrough_widget_relations [exhibit.id, level.board_id, canonicalType, exhibit.x, exhibit.y, exhibit.width, state.documents.length + index])
(id, playthrough_id, from_widget_id, to_widget_id, relation_type, sort_order, config) if (canonicalType === 'folder') await client.query(
VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb)`, 'INSERT INTO osint.folder_exhibits (exhibit_id,title,label_text,is_open) VALUES ($1,$2,$3,$4)',
[relation.id, playthroughId, relation.fromWidgetId, relation.toWidgetId, relation.type, relation.sortOrder || 0, JSON.stringify(relation.config || {})]) [exhibit.id, exhibit.title, exhibit.content, Boolean(exhibit.config?.open)])
} if (canonicalType === 'note') await client.query('INSERT INTO osint.note_exhibits (exhibit_id,title,note_text) VALUES ($1,$2,$3)', [exhibit.id, exhibit.title, exhibit.content])
await client.query('DELETE FROM osint.playthrough_connections WHERE playthrough_id = $1', [playthroughId]) if (canonicalType === 'event') await client.query(
const authoredConnections = await client.query<{ id: string }>('SELECT id FROM osint.level_connections WHERE level_id = $1', [state.id]) 'INSERT INTO osint.event_exhibits (exhibit_id,title,narrative_text,occurred_at) VALUES ($1,$2,$3,$4)',
const authoredConnectionIds = new Set(authoredConnections.rows.map(row => row.id)) [exhibit.id, exhibit.title, exhibit.content, timestamp(exhibit.eventDate) || new Date().toISOString()])
for (const connection of state.connections.filter(c => !authoredConnectionIds.has(c.id))) {
await client.query(`INSERT INTO osint.playthrough_connections (id, playthrough_id, from_widget_id, to_widget_id)
VALUES ($1, $2, $3, $4)`, [connection.id, playthroughId, connection.fromEvidenceId, connection.toEvidenceId])
}
} }
async function saveAuthoredLevel(client: PoolClient, state: CaseState) { for (const relation of relationList.filter(item => item.type === 'contains')) {
await client.query('UPDATE osint.levels SET title = $2, subtitle = $3, updated_at = NOW() WHERE id = $1', [state.id, state.title, state.subtitle]) if (!evidenceIds.has(relation.fromWidgetId) || !allIds.includes(relation.toWidgetId)) throw new Error('Folder membership references an unknown exhibit')
await client.query(`INSERT INTO osint.playthroughs (id, level_id, viewport, updated_at) await client.query(`INSERT INTO osint.folder_memberships (board_id,folder_exhibit_id,child_exhibit_id,sort_order)
VALUES ($1, $2, $3::jsonb, NOW()) VALUES ($1,$2,$3,$4)`, [level.board_id, relation.fromWidgetId, relation.toWidgetId, relation.sortOrder || 0])
ON CONFLICT (id) DO UPDATE SET viewport = EXCLUDED.viewport, updated_at = NOW()`,
[`default:${state.id}`, state.id, JSON.stringify(state.viewport)])
await client.query('DELETE FROM osint.level_connections WHERE level_id = $1', [state.id])
await client.query('DELETE FROM osint.widget_relations WHERE level_id = $1', [state.id])
await client.query('DELETE FROM osint.widgets WHERE level_id = $1', [state.id])
for (const [index, doc] of state.documents.entries()) {
await client.query(`INSERT INTO osint.widgets (id, level_id, widget_type, title, config, published_at, asset_id, sort_order)
VALUES ($1,$2,'document',$3,$4::jsonb,$5,$6,$7)`, [doc.id, state.id, doc.title, JSON.stringify({ kind: doc.kind, body: doc.body, fileType: doc.fileType, metadata: doc.metadata }), doc.publishedAt || doc.date || null, doc.assetId || null, index])
for (const [regionIndex, region] of doc.regions.entries()) {
await client.query(`INSERT INTO osint.widget_regions (id, document_widget_id, region_key, label, excerpt, event_date, sort_order)
VALUES ($1,$2,$3,$4,$5,$6,$7)`, [`${doc.id}:${region.id}`, doc.id, region.id, region.label, region.excerpt, region.date || null, regionIndex])
}
}
for (const [index, widget] of state.evidence.entries()) {
await client.query(`INSERT INTO osint.widgets
(id, level_id, widget_type, title, content, config, source_widget_id, source_region_key, event_date, x, y, width, sort_order)
VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9,$10,$11,$12,$13)`, [widget.id, state.id, widget.type, widget.title, widget.content, JSON.stringify(widget.config || {}),
widget.sourceDocumentId || null, widget.sourceRegionId || null, widget.eventDate || null, widget.x, widget.y, widget.width, index])
}
const fallbackRelations: WidgetRelation[] = state.evidence.flatMap(widget => (widget.containedDocumentIds || []).map((documentId, index) => ({ id: `contains:${widget.id}:${documentId}`, fromWidgetId: widget.id, toWidgetId: documentId, type: 'contains', sortOrder: index })))
for (const relation of state.relations || fallbackRelations) {
await client.query(`INSERT INTO osint.widget_relations
(id, level_id, from_widget_id, to_widget_id, relation_type, sort_order, config)
VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb)`,
[relation.id, state.id, relation.fromWidgetId, relation.toWidgetId, relation.type, relation.sortOrder || 0, JSON.stringify(relation.config || {})])
} }
for (const connection of state.connections) { for (const connection of state.connections) {
await client.query(`INSERT INTO osint.level_connections (id, level_id, from_widget_id, to_widget_id) requireUuid(connection.id, 'Connection id')
VALUES ($1,$2,$3,$4)`, [connection.id, state.id, connection.fromEvidenceId, connection.toEvidenceId]) if (!evidenceIds.has(connection.fromEvidenceId) || !evidenceIds.has(connection.toEvidenceId)) throw new Error('Connection references an unknown exhibit')
await client.query(`INSERT INTO osint.exhibit_connections (id,board_id,connection_type_id,from_exhibit_id,to_exhibit_id)
VALUES ($1,$2,'thread',$3,$4)`, [connection.id, level.board_id, connection.fromEvidenceId, connection.toEvidenceId])
}
for (const exhibit of state.evidence.filter(item => item.sourceDocumentId)) {
if (!documentIds.has(exhibit.sourceDocumentId!)) throw new Error('Exhibit source references an unknown document')
let regionId: string | null = null
if (exhibit.sourceRegionId) {
const region = await client.query<{ id: string }>(
'SELECT id FROM osint.document_regions WHERE document_exhibit_id=$1 AND region_key=$2', [exhibit.sourceDocumentId, exhibit.sourceRegionId])
regionId = region.rows[0]?.id || null
}
await client.query('INSERT INTO osint.exhibit_sources (exhibit_id,source_document_exhibit_id,source_region_id) VALUES ($1,$2,$3)',
[exhibit.id, exhibit.sourceDocumentId, regionId])
}
const fields = new Map<string, string>()
for (const document of state.documents) for (const key of Object.keys(document.metadata || {})) {
if (!fields.has(key)) {
const fieldId = randomUUID(); fields.set(key, fieldId)
await client.query(`INSERT INTO osint.metadata_fields (id,board_id,field_key,label,value_type) VALUES ($1,$2,$3,$3,'text')`, [fieldId, level.board_id, key])
}
await client.query('INSERT INTO osint.exhibit_metadata_text_values (exhibit_id,field_id,value) VALUES ($1,$2,$3)',
[document.id, fields.get(key), document.metadata[key]])
} }
} }
return { return {
async listLevels() { async listLevels() {
const result = await pool.query('SELECT id, title, subtitle, status, updated_at AS "updatedAt" FROM osint.levels ORDER BY updated_at DESC') const result = await pool.query(`SELECT slug AS id, title, subtitle, status, updated_at AS "updatedAt"
FROM osint.levels ORDER BY updated_at DESC`)
return result.rows return result.rows
}, },
async createLevel(input) { async createLevel(input) {
await pool.query('INSERT INTO osint.levels (id, title, subtitle) VALUES ($1, $2, $3)', [input.id, input.title, input.subtitle])
return (await assembleLevel(input.id, `default:${input.id}`, true))!
},
getLevel(levelId, authorMode = false) {
return assembleLevel(levelId, `default:${levelId}`, authorMode)
},
async saveLevel(state, authorMode) {
const client = await pool.connect() const client = await pool.connect()
try { try {
await client.query('BEGIN') await client.query('BEGIN')
if (authorMode) await saveAuthoredLevel(client, state) const boardId = randomUUID(); const levelId = randomUUID()
else await savePlaythrough(client, state) await client.query(`INSERT INTO osint.boards (id,board_kind) VALUES ($1,'level')`, [boardId])
await client.query(`INSERT INTO osint.levels (id,slug,board_id,title,subtitle) VALUES ($1,$2,$3,$4,$5)`,
[levelId, input.id, boardId, input.title, input.subtitle])
await client.query('COMMIT') await client.query('COMMIT')
} catch (error) { } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
await client.query('ROLLBACK') return (await assembleLevel(input.id))!
throw error },
} finally { client.release() } getLevel(levelId) { return assembleLevel(levelId) },
async saveLevel(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 client.query('COMMIT')
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
}, },
async resetLevel(levelId) { async resetLevel(levelId) {
const client = await pool.connect() // A level without a source template has no earlier canonical state to restore.
try { // Template cloning will replace this branch when template lifecycle endpoints land.
await client.query('BEGIN')
await client.query('DELETE FROM osint.playthroughs WHERE id = $1', [`default:${levelId}`])
await client.query('COMMIT')
} catch (error) {
await client.query('ROLLBACK')
throw error
} finally { client.release() }
return assembleLevel(levelId) return assembleLevel(levelId)
}, },
async getAsset(assetId) { async getAsset(assetId) {
const result = await pool.query<AssetRecord>('SELECT original_name, mime_type, byte_size, content FROM osint.assets WHERE id = $1', [assetId]) if (!uuidPattern.test(assetId)) return null
const result = await pool.query<AssetRecord>('SELECT original_name,mime_type,byte_size,content FROM osint.assets WHERE id=$1', [assetId])
return result.rows[0] || null return result.rows[0] || null
}, },
async uploadDocument(levelId, file) { async uploadDocument(levelId, file) {
const client = await pool.connect() const client = await pool.connect()
try { try {
const level = await client.query('SELECT id FROM osint.levels WHERE id = $1', [levelId])
if (!level.rows[0]) return null
const assetId = `asset-${randomUUID()}`
const widgetId = `document-${randomUUID()}`
const checksum = createHash('sha256').update(file.buffer).digest('hex')
const kind = file.mimetype === 'application/pdf' ? 'PDF' : file.mimetype.startsWith('image/') ? 'IMAGE' : 'FILE'
const fileType: CaseDocument['fileType'] = file.mimetype.startsWith('image/') ? 'image' : file.mimetype === 'application/pdf' ? 'pdf' : 'file'
await client.query('BEGIN') await client.query('BEGIN')
await client.query(`INSERT INTO osint.assets (id, level_id, original_name, mime_type, byte_size, content, checksum_sha256) const level = await findLevel(client, levelId, true)
VALUES ($1,$2,$3,$4,$5,$6,$7)`, [assetId, levelId, file.originalname, file.mimetype || 'application/octet-stream', file.size, file.buffer, checksum]) if (!level) { await client.query('ROLLBACK'); return null }
await client.query(`INSERT INTO osint.widgets (id, level_id, widget_type, title, config, asset_id, sort_order) const candidateAssetId = randomUUID(); const exhibitId = randomUUID()
VALUES ($1,$2,'document',$3,$4::jsonb,$5,(SELECT COALESCE(MAX(sort_order),-1)+1 FROM osint.widgets WHERE level_id=$2 AND widget_type='document'))`, const checksum = createHash('sha256').update(file.buffer).digest('hex')
[widgetId, levelId, file.originalname, JSON.stringify({ kind, body: [], fileType, metadata: {} }), assetId]) const asset = await client.query<{ id: string }>(`INSERT INTO osint.assets
await client.query('UPDATE osint.levels SET updated_at = NOW() WHERE id = $1', [levelId]) (id,original_name,mime_type,byte_size,content,checksum_sha256) VALUES ($1,$2,$3,$4,$5,$6)
ON CONFLICT (checksum_sha256,byte_size) DO UPDATE SET checksum_sha256=EXCLUDED.checksum_sha256 RETURNING id`,
[candidateAssetId, file.originalname, file.mimetype || 'application/octet-stream', file.size, file.buffer, checksum])
const fileType: SourceFileType = file.mimetype.startsWith('image/') ? 'image' : file.mimetype === 'application/pdf' ? 'pdf' : file.mimetype.startsWith('text/') ? 'text' : 'file'
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])
await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title) VALUES ($1,$2,$3,$4)`,
[exhibitId, fileType, asset.rows[0].id, file.originalname])
if (fileType === 'image') await client.query('INSERT INTO osint.image_documents (exhibit_id) VALUES ($1)', [exhibitId])
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') await client.query('COMMIT')
return { id: widgetId, title: file.originalname, kind, fileType, metadata: {}, date: '', body: [], regions: [], assetId, return { id: exhibitId, title: file.originalname, kind: documentKind(fileType), fileType, metadata: {}, date: '', body: [], regions: [],
fileName: file.originalname, mimeType: file.mimetype, fileSize: file.size } assetId: asset.rows[0].id, fileName: file.originalname, mimeType: file.mimetype, fileSize: file.size }
} catch (error) { } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
await client.query('ROLLBACK')
throw error
} finally { client.release() }
}, },
} }
} }
+8 -4
View File
@@ -33,20 +33,24 @@ suite('PostgreSQL migrations', () => {
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations') const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
const firstRun: string[] = [] const firstRun: string[] = []
await runMigrations(testDatabaseUrl, migrationsDir, message => firstRun.push(message)) await runMigrations(testDatabaseUrl, migrationsDir, message => firstRun.push(message))
expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(5) expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(6)
const client = new Client({ connectionString: testDatabaseUrl }) const client = new Client({ connectionString: testDatabaseUrl })
await client.connect() await client.connect()
const tables = await client.query<{ table_name: string }>(`SELECT table_name FROM information_schema.tables WHERE table_schema = 'osint'`) const tables = await client.query<{ table_name: string }>(`SELECT table_name FROM information_schema.tables WHERE table_schema = 'osint'`)
const tableNames = tables.rows.map(row => row.table_name) const tableNames = tables.rows.map(row => row.table_name)
expect(tableNames).toEqual(expect.arrayContaining(['levels', 'widgets', 'widget_relations', 'playthroughs', 'assets', 'schema_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',
]))
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') const ledger = await client.query<{ count: string }>('SELECT COUNT(*)::text AS count FROM osint.schema_migrations')
expect(ledger.rows[0].count).toBe('5') expect(ledger.rows[0].count).toBe('6')
await client.end() await client.end()
const secondRun: string[] = [] const secondRun: string[] = []
await runMigrations(testDatabaseUrl, migrationsDir, message => secondRun.push(message)) await runMigrations(testDatabaseUrl, migrationsDir, message => secondRun.push(message))
expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(5) expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(6)
expect(secondRun.some(message => message.startsWith('apply '))).toBe(false) expect(secondRun.some(message => message.startsWith('apply '))).toBe(false)
}) })
}) })
+1 -1
View File
@@ -11,7 +11,7 @@ const SOURCE_FILE_TYPES: { value: SourceFileType; label: string }[] = [
{ value: 'price_list', label: 'Price list' }, { value: 'text', label: 'Text document' }, { value: 'file', label: 'Generic file' }, { value: 'price_list', label: 'Price list' }, { value: 'text', label: 'Text document' }, { value: 'file', label: 'Generic file' },
] ]
function uid(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}` } function uid(_prefix: string) { return crypto.randomUUID() }
function connectionPoint(item: Evidence) { function connectionPoint(item: Evidence) {
return item.type === 'note' ? { x: item.x + 54, y: item.y + 12 } : { x: item.x + item.width / 2, y: item.y + 68 } return item.type === 'note' ? { x: item.x + 54, y: item.y + 12 } : { x: item.x + item.width / 2, y: item.y + 68 }
} }