feat: add immutable level template lifecycle
This commit is contained in:
@@ -60,22 +60,27 @@ The server stores its tables and migration ledger in the dedicated `osint` schem
|
|||||||
|
|
||||||
The accepted model stores each playable or editable level as an isolated mutable board of normalized exhibits. Immutable template-version boards are cloned to create levels, and mutable levels can be cloned back into new template versions. Assets remain immutable and reusable across those copies.
|
The accepted model stores each playable or editable level as an isolated mutable board of normalized exhibits. Immutable template-version boards are cloned to create levels, and mutable levels can be cloned back into new template versions. Assets remain immutable and reusable across those copies.
|
||||||
|
|
||||||
The running POC still uses the earlier `widgets` and `playthrough_*` tables while the exhibit-schema cutover is prepared. They are explicitly transitional; new domain concepts should follow the exhibit model rather than extending those tables.
|
The running POC uses the canonical exhibit schema directly. The earlier JSON case store, `widgets`, and `playthrough_*` tables were removed in migration 006.
|
||||||
|
|
||||||
The API surface is:
|
The API surface is:
|
||||||
|
|
||||||
- `GET /api/levels`
|
- `GET /api/levels`
|
||||||
- `POST /api/levels` (editor only)
|
- `POST /api/levels` (editor only)
|
||||||
|
- `GET /api/templates`
|
||||||
|
- `POST /api/templates/:slug/levels` (instantiate a version; editor only)
|
||||||
- `GET /api/levels/:id`
|
- `GET /api/levels/:id`
|
||||||
- `PUT /api/levels/:id`
|
- `PUT /api/levels/:id`
|
||||||
- `POST /api/levels/:id/reset`
|
- `POST /api/levels/:id/reset`
|
||||||
|
- `POST /api/levels/:id/templates` (save a new immutable version; editor only)
|
||||||
|
- `POST /api/levels/:id/documents` (editor only)
|
||||||
|
- `GET /api/assets/:id`
|
||||||
- `GET /api/health`
|
- `GET /api/health`
|
||||||
|
|
||||||
The browser also keeps a local emergency copy so a network interruption does not lose an in-progress board.
|
The browser also keeps a local emergency copy so a network interruption does not lose an in-progress board.
|
||||||
|
|
||||||
## Level editor
|
## Level editor
|
||||||
|
|
||||||
Set `LEVEL_EDITING_ENABLED=true` and open `/?edit=1`. If the database is empty, this surface creates the first blank level. The target model intentionally makes editing and playing the same operation against a mutable level; authoring additionally exposes “save as template”. The current playthrough overlay remains only until the exhibit-schema migration is complete.
|
Set `LEVEL_EDITING_ENABLED=true` and open `/?edit=1`. If the database is empty, this surface creates the first blank level. Editing and playing are the same operation against a mutable level. Authoring additionally exposes **Save Template** and **New From Template**. Saving creates a new immutable version; instantiating gives every exhibit a fresh ID while sharing immutable binary assets. Reset restores the exact template version from which a level was created.
|
||||||
|
|
||||||
In edit mode, files can be dragged from the desktop onto the board or selected with **Import Document**. Images, PDFs, and text files render inside document windows; unknown formats remain downloadable source files. Extracted evidence becomes an editable folder widget. Its editor controls the title, annotation, contained documents, and each source document's publication time. The default upload limit is 25 MB and can be changed with `MAX_DOCUMENT_BYTES`.
|
In edit mode, files can be dragged from the desktop onto the board or selected with **Import Document**. Images, PDFs, and text files render inside document windows; unknown formats remain downloadable source files. Extracted evidence becomes an editable folder widget. Its editor controls the title, annotation, contained documents, and each source document's publication time. The default upload limit is 25 MB and can be changed with `MAX_DOCUMENT_BYTES`.
|
||||||
|
|
||||||
@@ -96,7 +101,7 @@ Folder ownership is stored as a normalized membership. The contained document ex
|
|||||||
|
|
||||||
An open folder draws a pale red containment band to each expanded file. Each dated file independently projects a grey line to the temporal index. This allows the player to arrange files until those grey lines are vertical, close the folder, and later reopen the same arrangement.
|
An open folder draws a pale red containment band to each expanded file. Each dated file independently projects a grey line to the temporal index. This allows the player to arrange files until those grey lines are vertical, close the folder, and later reopen the same arrangement.
|
||||||
|
|
||||||
The frontend widget registry maps an exhibit type, and optionally a document type, to its React visualization. Widgets do not own investigation-domain data.
|
The planned frontend widget registry will map an exhibit type, and optionally a document type, to its React visualization. Widgets do not own investigation-domain data.
|
||||||
|
|
||||||
## Deploy at osint.glitch.university
|
## Deploy at osint.glitch.university
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@ This is the ordered implementation roadmap following the accepted exhibit model.
|
|||||||
|
|
||||||
- [x] Add boards, exhibits, exhibit types, subtype tables, immutable template versions, and mutable levels.
|
- [x] Add boards, exhibits, exhibit types, subtype tables, immutable template versions, and mutable levels.
|
||||||
- [x] Cut over the explicitly disposable POC database directly; no transitional data existed to backfill or compare.
|
- [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.
|
- [x] Implement template instantiation, version selection, reset, 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.
|
||||||
- [x] Remove transitional `widgets`, `widget_relations`, and `playthrough_*` tables in the canonical cutover migration.
|
- [x] Remove transitional `widgets`, `widget_relations`, and `playthrough_*` tables in the canonical cutover migration.
|
||||||
|
|||||||
@@ -317,14 +317,6 @@ The following are computed and must not become duplicate source-of-truth tables:
|
|||||||
|
|
||||||
`save_level_as_template(level_id)` runs the same clone operation into a new immutable template-version board.
|
`save_level_as_template(level_id)` runs the same clone operation into a new immutable template-version board.
|
||||||
|
|
||||||
## Migration direction
|
## Implemented cutover
|
||||||
|
|
||||||
The current `widgets`, `widget_relations`, and `playthrough_*` tables are transitional. The cutover should:
|
Migration 006 made this the sole persistence model. Because the POC database contained no canonical or legacy content worth preserving, the cutover intentionally dropped the JSON case store, `widgets`, `widget_relations`, and `playthrough_*` tables without a backfill period. Template save, version selection, instantiation, and reset now use one transactional board-cloning service. The remaining architectural work is the frontend exhibit/widget registry and the planned exhibit families.
|
||||||
|
|
||||||
1. Introduce boards, exhibit tables, subtype tables, templates, and typed metadata.
|
|
||||||
2. Convert existing authored widgets to exhibits.
|
|
||||||
3. Materialize each existing playthrough as its own cloned mutable level.
|
|
||||||
4. Move document layout from relation JSON into document exhibit coordinates.
|
|
||||||
5. Convert `contains` relations to folder memberships and generic connections to exhibit connections.
|
|
||||||
6. Update the API to read and write exhibits directly.
|
|
||||||
7. Remove the transitional widget/playthrough tables only after data equivalence checks pass.
|
|
||||||
|
|||||||
@@ -86,4 +86,20 @@ test('move, folder expansion, hand pan, pinch zoom, and reload persistence', asy
|
|||||||
await page.keyboard.up('Control')
|
await page.keyboard.up('Control')
|
||||||
await expect.poll(() => board.getAttribute('style')).not.toEqual(transformBeforePinch)
|
await expect.poll(() => board.getAttribute('style')).not.toEqual(transformBeforePinch)
|
||||||
expect(await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio }))).toEqual(browserMetricsBeforePinch)
|
expect(await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio }))).toEqual(browserMetricsBeforePinch)
|
||||||
|
|
||||||
|
page.once('dialog', dialog => dialog.accept('Browser Template'))
|
||||||
|
const templateSaved = page.waitForResponse(candidate => candidate.request().method() === 'POST' && candidate.url().includes('/templates?edit=1') && candidate.ok())
|
||||||
|
await page.getByRole('button', { name: 'SAVE TEMPLATE', exact: true }).click()
|
||||||
|
await templateSaved
|
||||||
|
await expect(page.locator('.terminal-status')).toContainText('VERSION 1')
|
||||||
|
|
||||||
|
let promptIndex = 0
|
||||||
|
const prompts = ['browser-template', 'Browser Template Clone']
|
||||||
|
const promptHandler = (dialog: { accept(promptText?: string): Promise<void> }) => dialog.accept(prompts[promptIndex++])
|
||||||
|
page.on('dialog', promptHandler)
|
||||||
|
await page.getByRole('button', { name: 'NEW FROM TEMPLATE', exact: true }).click()
|
||||||
|
await page.waitForURL(url => url.searchParams.get('level')?.startsWith('browser-template-') === true)
|
||||||
|
page.off('dialog', promptHandler)
|
||||||
|
await expect(page.getByRole('heading', { name: 'Browser Template Clone' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('button', { name: 'Reset' })).toBeEnabled()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -70,9 +70,14 @@ suite('level persistence API', () => {
|
|||||||
state.viewport = { x: 91, y: -42, zoom: 0.85 }
|
state.viewport = { x: 91, y: -42, zoom: 0.85 }
|
||||||
const documentId = randomUUID()
|
const documentId = randomUUID()
|
||||||
const folderId = randomUUID()
|
const folderId = randomUUID()
|
||||||
state.documents = [{ id: documentId, title: 'Evidence', kind: 'IMAGE', date: '2021-04-17', publishedAt: '2021-04-17T12:00:00Z', body: [], regions: [], fileType: 'image', metadata: {} }]
|
const noteId = randomUUID()
|
||||||
state.evidence = [{ id: folderId, type: 'folder', title: 'Folder', content: 'Evidence folder', x: 685, y: 417, width: 260, config: { open: true }, containedDocumentIds: [documentId] }]
|
state.documents = [{ id: documentId, title: 'Evidence', kind: 'IMAGE', date: '2021-04-17', publishedAt: '2021-04-17T12:00:00Z', body: ['Extracted body'], regions: [{ id: 'stamp', label: 'Date stamp', excerpt: '17 April 2021', date: '2021-04-17T09:00:00Z' }], fileType: 'image', metadata: {} }]
|
||||||
|
state.evidence = [
|
||||||
|
{ id: folderId, type: 'folder', title: 'Folder', content: 'Evidence folder', x: 685, y: 417, width: 260, config: { open: true }, containedDocumentIds: [documentId] },
|
||||||
|
{ id: noteId, type: 'note', title: 'Extract', content: 'Date matters', sourceDocumentId: documentId, sourceRegionId: 'stamp', x: 420, y: 300, width: 108 },
|
||||||
|
]
|
||||||
state.relations = [{ id: `contains:${folderId}:${documentId}`, fromWidgetId: folderId, toWidgetId: documentId, type: 'contains', sortOrder: 0, config: { x: 1051, y: 417 } }]
|
state.relations = [{ id: `contains:${folderId}:${documentId}`, fromWidgetId: folderId, toWidgetId: documentId, type: 'contains', sortOrder: 0, config: { x: 1051, y: 417 } }]
|
||||||
|
state.connections = [{ id: randomUUID(), fromEvidenceId: folderId, toEvidenceId: noteId }]
|
||||||
|
|
||||||
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',
|
||||||
@@ -129,18 +134,72 @@ suite('level persistence API', () => {
|
|||||||
expect(sameLevelInEditView.viewport).toEqual(playerState.viewport)
|
expect(sameLevelInEditView.viewport).toEqual(playerState.viewport)
|
||||||
expect(sameLevelInEditView.evidence[0]).toMatchObject({ id: folderId, x: 812, y: 533 })
|
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
|
const normalized = await appPool.query<{ exhibits: string; documents: string; folders: string; memberships: string; metadata: string; sources: string; connections: string }>(`SELECT
|
||||||
(SELECT COUNT(*) FROM osint.exhibits)::text AS exhibits,
|
(SELECT COUNT(*) FROM osint.exhibits)::text AS exhibits,
|
||||||
(SELECT COUNT(*) FROM osint.document_exhibits)::text AS documents,
|
(SELECT COUNT(*) FROM osint.document_exhibits)::text AS documents,
|
||||||
(SELECT COUNT(*) FROM osint.folder_exhibits)::text AS folders,
|
(SELECT COUNT(*) FROM osint.folder_exhibits)::text AS folders,
|
||||||
(SELECT COUNT(*) FROM osint.folder_memberships)::text AS memberships,
|
(SELECT COUNT(*) FROM osint.folder_memberships)::text AS memberships,
|
||||||
(SELECT COUNT(*) FROM osint.exhibit_metadata_text_values)::text AS metadata`)
|
(SELECT COUNT(*) FROM osint.exhibit_metadata_text_values)::text AS metadata,
|
||||||
expect(normalized.rows[0]).toEqual({ exhibits: '3', documents: '2', folders: '1', memberships: '1', metadata: '2' })
|
(SELECT COUNT(*) FROM osint.exhibit_sources)::text AS sources,
|
||||||
|
(SELECT COUNT(*) FROM osint.exhibit_connections)::text AS connections`)
|
||||||
|
expect(normalized.rows[0]).toEqual({ exhibits: '4', documents: '2', folders: '1', memberships: '1', metadata: '2', sources: '1', connections: '1' })
|
||||||
|
|
||||||
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(playerState.viewport)
|
expect(resetState.viewport).toEqual(playerState.viewport)
|
||||||
expect(resetState.evidence[0]).toMatchObject({ id: folderId, x: 812, y: 533 })
|
expect(resetState.evidence[0]).toMatchObject({ id: folderId, x: 812, y: 533 })
|
||||||
|
|
||||||
|
const templateResponse = await fetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, {
|
||||||
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Smoke Template' }),
|
||||||
|
})
|
||||||
|
expect(templateResponse.status).toBe(201)
|
||||||
|
expect(await templateResponse.json()).toMatchObject({ slug: 'smoke-template', currentVersion: 1, versionCount: 1 })
|
||||||
|
expect(await (await fetch(`${baseUrl}/api/templates`)).json()).toEqual([
|
||||||
|
expect.objectContaining({ slug: 'smoke-template', currentVersion: 1, versionCount: 1 }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const changedSource = structuredClone(savedPlayerState)
|
||||||
|
changedSource.title = 'Changed after template freeze'
|
||||||
|
changedSource.evidence[0].x = 999
|
||||||
|
await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
||||||
|
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(changedSource),
|
||||||
|
})
|
||||||
|
const cloneResponse = await fetch(`${baseUrl}/api/templates/smoke-template/levels?edit=1`, {
|
||||||
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: 'smoke-template-copy', title: 'Playable copy' }),
|
||||||
|
})
|
||||||
|
expect(cloneResponse.status).toBe(201)
|
||||||
|
const clone = await cloneResponse.json() as CaseState
|
||||||
|
expect(clone).toMatchObject({ id: 'smoke-template-copy', title: 'Playable copy', sourceTemplateVersionId: expect.any(String) })
|
||||||
|
expect(clone.evidence.find(item => item.type === 'folder')).toMatchObject({ x: 812, y: 533 })
|
||||||
|
expect(clone.documents.find(item => item.title === 'Renamed smoke evidence')?.assetId).toBe(uploaded.assetId)
|
||||||
|
expect(clone.documents[0].id).not.toBe(savedPlayerState.documents[0].id)
|
||||||
|
expect(clone.evidence.map(item => item.id)).not.toContain(folderId)
|
||||||
|
expect(clone.connections).toHaveLength(1)
|
||||||
|
expect(clone.evidence.find(item => item.type === 'note')).toMatchObject({ sourceRegionId: 'stamp' })
|
||||||
|
|
||||||
|
const clonedFolder = clone.evidence.find(item => item.type === 'folder')!
|
||||||
|
clonedFolder.x = 1234
|
||||||
|
clone.viewport = { x: 333, y: 222, zoom: 1.2 }
|
||||||
|
await fetch(`${baseUrl}/api/levels/${clone.id}`, {
|
||||||
|
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(clone),
|
||||||
|
})
|
||||||
|
const cloneReset = await (await fetch(`${baseUrl}/api/levels/${clone.id}/reset`, { method: 'POST' })).json() as CaseState
|
||||||
|
expect(cloneReset).toMatchObject({ title: 'API Smoke Level', viewport: { x: 0, y: 28, zoom: 0.7 } })
|
||||||
|
expect(cloneReset.evidence.find(item => item.type === 'folder')).toMatchObject({ x: 812, y: 533 })
|
||||||
|
expect(cloneReset.evidence.map(item => item.id)).not.toContain(clonedFolder.id)
|
||||||
|
|
||||||
|
const versionTwoResponse = await fetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, {
|
||||||
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Smoke Template' }),
|
||||||
|
})
|
||||||
|
expect(await versionTwoResponse.json()).toMatchObject({ currentVersion: 2, versionCount: 2 })
|
||||||
|
const oldVersion = await (await fetch(`${baseUrl}/api/templates/smoke-template/levels?edit=1`, {
|
||||||
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: 'old-version-copy', version: 1 }),
|
||||||
|
})).json() as CaseState
|
||||||
|
const currentVersion = await (await fetch(`${baseUrl}/api/templates/smoke-template/levels?edit=1`, {
|
||||||
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: 'current-version-copy' }),
|
||||||
|
})).json() as CaseState
|
||||||
|
expect(oldVersion.evidence.find(item => item.type === 'folder')).toMatchObject({ x: 812 })
|
||||||
|
expect(currentVersion.evidence.find(item => item.type === 'folder')).toMatchObject({ x: 999 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import type { PoolClient } from 'pg'
|
||||||
|
|
||||||
|
type IdMap = Map<string, string>
|
||||||
|
|
||||||
|
function mapped(ids: IdMap, sourceId: string, label: string) {
|
||||||
|
const id = ids.get(sourceId)
|
||||||
|
if (!id) throw new Error(`Could not map ${label} ${sourceId}`)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearBoard(client: PoolClient, boardId: string) {
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clone a complete normalized board. The target board must be empty. */
|
||||||
|
export async function cloneBoard(client: PoolClient, sourceBoardId: string, targetBoardId: string) {
|
||||||
|
const exhibitIds: IdMap = new Map()
|
||||||
|
const regionIds: IdMap = new Map()
|
||||||
|
const fieldIds: IdMap = new Map()
|
||||||
|
|
||||||
|
const exhibits = await client.query<{
|
||||||
|
id: string; exhibit_type_id: string; xpos: number; ypos: number; width: number; height: number
|
||||||
|
rotation: number; z_index: number; hidden: boolean
|
||||||
|
}>(`SELECT id,exhibit_type_id,xpos,ypos,width,height,rotation,z_index,hidden
|
||||||
|
FROM osint.exhibits WHERE board_id=$1 ORDER BY created_at,id`, [sourceBoardId])
|
||||||
|
for (const row of exhibits.rows) {
|
||||||
|
const id = randomUUID(); exhibitIds.set(row.id, id)
|
||||||
|
await client.query(`INSERT INTO osint.exhibits
|
||||||
|
(id,board_id,exhibit_type_id,origin_exhibit_id,xpos,ypos,width,height,rotation,z_index,hidden)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
|
||||||
|
[id, targetBoardId, row.exhibit_type_id, row.id, row.xpos, row.ypos, row.width, row.height, row.rotation, row.z_index, row.hidden])
|
||||||
|
}
|
||||||
|
|
||||||
|
const folders = await client.query<{ exhibit_id: string; title: string; label_text: string; is_open: boolean }>(
|
||||||
|
`SELECT f.* FROM osint.folder_exhibits f JOIN osint.exhibits e ON e.id=f.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
|
||||||
|
for (const row of folders.rows) await client.query(
|
||||||
|
'INSERT INTO osint.folder_exhibits (exhibit_id,title,label_text,is_open) VALUES ($1,$2,$3,$4)',
|
||||||
|
[mapped(exhibitIds, row.exhibit_id, 'folder'), row.title, row.label_text, row.is_open])
|
||||||
|
|
||||||
|
const documents = await client.query<{
|
||||||
|
exhibit_id: string; document_type_id: string; asset_id: string | null; title: string
|
||||||
|
published_at: Date | null; captured_at: Date | null; source_uri: string | null
|
||||||
|
}>(`SELECT d.* FROM osint.document_exhibits d JOIN osint.exhibits e ON e.id=d.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
|
||||||
|
for (const row of documents.rows) await client.query(`INSERT INTO osint.document_exhibits
|
||||||
|
(exhibit_id,document_type_id,asset_id,title,published_at,captured_at,source_uri) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||||
|
[mapped(exhibitIds, row.exhibit_id, 'document'), row.document_type_id, row.asset_id, row.title, row.published_at, row.captured_at, row.source_uri])
|
||||||
|
|
||||||
|
const images = await client.query<{ exhibit_id: string; pixel_width: number | null; pixel_height: number | null; alt_text: string }>(
|
||||||
|
`SELECT i.* FROM osint.image_documents i JOIN osint.exhibits e ON e.id=i.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
|
||||||
|
for (const row of images.rows) await client.query(
|
||||||
|
'INSERT INTO osint.image_documents (exhibit_id,pixel_width,pixel_height,alt_text) VALUES ($1,$2,$3,$4)',
|
||||||
|
[mapped(exhibitIds, row.exhibit_id, 'image'), row.pixel_width, row.pixel_height, row.alt_text])
|
||||||
|
|
||||||
|
const notes = await client.query<{ exhibit_id: string; title: string; note_text: string }>(
|
||||||
|
`SELECT n.* FROM osint.note_exhibits n JOIN osint.exhibits e ON e.id=n.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
|
||||||
|
for (const row of notes.rows) await client.query('INSERT INTO osint.note_exhibits (exhibit_id,title,note_text) VALUES ($1,$2,$3)',
|
||||||
|
[mapped(exhibitIds, row.exhibit_id, 'note'), row.title, row.note_text])
|
||||||
|
|
||||||
|
const events = await client.query<{ exhibit_id: string; title: string; narrative_text: string; occurred_at: Date }>(
|
||||||
|
`SELECT ev.* FROM osint.event_exhibits ev JOIN osint.exhibits e ON e.id=ev.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
|
||||||
|
for (const row of events.rows) await client.query(
|
||||||
|
'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 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])
|
||||||
|
for (const row of blocks.rows) await client.query(
|
||||||
|
'INSERT INTO osint.document_content_blocks (id,document_exhibit_id,sort_order,content) VALUES ($1,$2,$3,$4)',
|
||||||
|
[randomUUID(), mapped(exhibitIds, row.document_exhibit_id, 'content document'), row.sort_order, row.content])
|
||||||
|
|
||||||
|
const regions = await client.query<{
|
||||||
|
id: string; document_exhibit_id: string; region_key: string; label: string; excerpt: string; occurred_at: Date | null; sort_order: number
|
||||||
|
}>(`SELECT r.* FROM osint.document_regions r JOIN osint.exhibits e ON e.id=r.document_exhibit_id
|
||||||
|
WHERE e.board_id=$1 ORDER BY r.sort_order`, [sourceBoardId])
|
||||||
|
for (const row of regions.rows) {
|
||||||
|
const id = randomUUID(); regionIds.set(row.id, id)
|
||||||
|
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)`,
|
||||||
|
[id, mapped(exhibitIds, row.document_exhibit_id, 'region document'), row.region_key, row.label, row.excerpt, row.occurred_at, row.sort_order])
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = await client.query<{ id: string; field_key: string; label: string; value_type: string }>(
|
||||||
|
'SELECT id,field_key,label,value_type FROM osint.metadata_fields WHERE board_id=$1 ORDER BY field_key', [sourceBoardId])
|
||||||
|
for (const row of fields.rows) {
|
||||||
|
const id = randomUUID(); fieldIds.set(row.id, id)
|
||||||
|
await client.query('INSERT INTO osint.metadata_fields (id,board_id,field_key,label,value_type) VALUES ($1,$2,$3,$4,$5)',
|
||||||
|
[id, targetBoardId, row.field_key, row.label, row.value_type])
|
||||||
|
}
|
||||||
|
for (const [table, cast] of [
|
||||||
|
['exhibit_metadata_text_values', 'text'], ['exhibit_metadata_timestamp_values', 'timestamptz'],
|
||||||
|
['exhibit_metadata_number_values', 'numeric'], ['exhibit_metadata_boolean_values', 'boolean'],
|
||||||
|
] as const) {
|
||||||
|
const values = await client.query<{ exhibit_id: string; field_id: string; value: unknown }>(
|
||||||
|
`SELECT v.exhibit_id,v.field_id,v.value::${cast} AS value FROM osint.${table} v
|
||||||
|
JOIN osint.exhibits e ON e.id=v.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
|
||||||
|
for (const row of values.rows) await client.query(`INSERT INTO osint.${table} (exhibit_id,field_id,value) VALUES ($1,$2,$3)`,
|
||||||
|
[mapped(exhibitIds, row.exhibit_id, 'metadata exhibit'), mapped(fieldIds, row.field_id, 'metadata field'), row.value])
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberships = await client.query<{ folder_exhibit_id: string; child_exhibit_id: string; sort_order: number }>(
|
||||||
|
'SELECT folder_exhibit_id,child_exhibit_id,sort_order FROM osint.folder_memberships WHERE board_id=$1', [sourceBoardId])
|
||||||
|
for (const row of memberships.rows) await client.query(`INSERT INTO osint.folder_memberships
|
||||||
|
(board_id,folder_exhibit_id,child_exhibit_id,sort_order) VALUES ($1,$2,$3,$4)`,
|
||||||
|
[targetBoardId, mapped(exhibitIds, row.folder_exhibit_id, 'membership folder'), mapped(exhibitIds, row.child_exhibit_id, 'membership child'), row.sort_order])
|
||||||
|
|
||||||
|
const eventEvidence = await client.query<{ event_exhibit_id: string; evidence_exhibit_id: string; sort_order: number; note: string | null }>(
|
||||||
|
'SELECT event_exhibit_id,evidence_exhibit_id,sort_order,note FROM osint.event_evidence WHERE board_id=$1', [sourceBoardId])
|
||||||
|
for (const row of eventEvidence.rows) await client.query(`INSERT INTO osint.event_evidence
|
||||||
|
(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 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
|
||||||
|
(id,board_id,connection_type_id,from_exhibit_id,to_exhibit_id,label) VALUES ($1,$2,$3,$4,$5,$6)`,
|
||||||
|
[randomUUID(), targetBoardId, row.connection_type_id, mapped(exhibitIds, row.from_exhibit_id, 'connection source'), mapped(exhibitIds, row.to_exhibit_id, 'connection target'), row.label])
|
||||||
|
|
||||||
|
const sources = await client.query<{ exhibit_id: string; source_document_exhibit_id: string; source_region_id: string | null }>(
|
||||||
|
`SELECT s.exhibit_id,s.source_document_exhibit_id,s.source_region_id FROM osint.exhibit_sources s
|
||||||
|
JOIN osint.exhibits e ON e.id=s.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
|
||||||
|
for (const row of sources.rows) await client.query(`INSERT INTO osint.exhibit_sources
|
||||||
|
(exhibit_id,source_document_exhibit_id,source_region_id) VALUES ($1,$2,$3)`,
|
||||||
|
[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])
|
||||||
|
|
||||||
|
return exhibitIds
|
||||||
|
}
|
||||||
+26
-1
@@ -23,6 +23,9 @@ 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'
|
||||||
}
|
}
|
||||||
|
function slug(value: unknown, fallback: string) {
|
||||||
|
return String(value || fallback).trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
export const app = express()
|
export const app = express()
|
||||||
app.disable('x-powered-by')
|
app.disable('x-powered-by')
|
||||||
@@ -41,14 +44,36 @@ app.get('/api/levels', async (_req, res, next) => {
|
|||||||
try { res.json(await levels.listLevels()) }
|
try { res.json(await levels.listLevels()) }
|
||||||
catch (error) { next(error) }
|
catch (error) { next(error) }
|
||||||
})
|
})
|
||||||
|
app.get('/api/templates', async (_req, res, next) => {
|
||||||
|
try { res.json(await levels.listTemplates()) }
|
||||||
|
catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
app.post('/api/templates/:slug/levels', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' })
|
||||||
|
const title = String(req.body?.title || '').trim() || undefined
|
||||||
|
const levelSlug = slug(req.body?.id, `${req.params.slug}-${Date.now()}`)
|
||||||
|
const level = await levels.instantiateTemplate(req.params.slug, { id: levelSlug, title, version: req.body?.version })
|
||||||
|
level ? res.status(201).json(level) : res.status(404).json({ error: 'Template version not found' })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
app.post('/api/levels', async (req, res, next) => {
|
app.post('/api/levels', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
if (!editingEnabled) return res.status(403).json({ error: 'Level editing is disabled' })
|
if (!editingEnabled) return res.status(403).json({ error: 'Level editing is disabled' })
|
||||||
const title = String(req.body?.title || 'Untitled Investigation').trim()
|
const title = String(req.body?.title || 'Untitled Investigation').trim()
|
||||||
const id = String(req.body?.id || `level-${Date.now()}`).trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-')
|
const id = slug(req.body?.id, `level-${Date.now()}`)
|
||||||
res.status(201).json(await levels.createLevel({ id, title, subtitle: String(req.body?.subtitle || '') }))
|
res.status(201).json(await levels.createLevel({ id, title, subtitle: String(req.body?.subtitle || '') }))
|
||||||
} catch (error) { next(error) }
|
} catch (error) { next(error) }
|
||||||
})
|
})
|
||||||
|
app.post('/api/levels/:id/templates', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' })
|
||||||
|
const name = String(req.body?.name || 'Untitled Template').trim()
|
||||||
|
const templateSlug = slug(req.body?.slug, name)
|
||||||
|
const template = await levels.saveLevelAsTemplate(req.params.id, { slug: templateSlug, name })
|
||||||
|
template ? res.status(201).json(template) : res.status(404).json({ error: 'Level not found' })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
app.get('/api/assets/:id', async (req, res, next) => {
|
app.get('/api/assets/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const asset = await levels.getAsset(req.params.id)
|
const asset = await levels.getAsset(req.params.id)
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
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, Evidence, SourceFileType, WidgetRelation } from '../src/types.js'
|
import type { CaseDocument, CaseState, Evidence, SourceFileType, WidgetRelation } from '../src/types.js'
|
||||||
|
import { clearBoard, cloneBoard } from './boardClone.js'
|
||||||
|
|
||||||
export type UploadedDocument = { buffer: Buffer; originalname: string; mimetype: string; size: number }
|
export type UploadedDocument = { buffer: Buffer; originalname: string; mimetype: string; size: number }
|
||||||
export type AssetRecord = { original_name: string; mime_type: string; byte_size: string; content: Buffer }
|
export type AssetRecord = { original_name: string; mime_type: string; byte_size: string; content: Buffer }
|
||||||
|
export type TemplateSummary = { id: string; slug: string; name: string; currentVersion: number; versionCount: number; updatedAt: string }
|
||||||
|
|
||||||
export interface LevelRepository {
|
export interface LevelRepository {
|
||||||
listLevels(): Promise<unknown[]>
|
listLevels(): Promise<unknown[]>
|
||||||
createLevel(input: { id: string; title: string; subtitle: string }): Promise<CaseState>
|
createLevel(input: { id: string; title: string; subtitle: string }): Promise<CaseState>
|
||||||
|
listTemplates(): Promise<TemplateSummary[]>
|
||||||
|
instantiateTemplate(templateSlug: string, input: { id: string; title?: string; version?: number }): Promise<CaseState | null>
|
||||||
|
saveLevelAsTemplate(levelId: string, input: { slug: string; name: string }): Promise<TemplateSummary | null>
|
||||||
getLevel(levelId: string, authorMode?: boolean): Promise<CaseState | null>
|
getLevel(levelId: string, authorMode?: boolean): Promise<CaseState | null>
|
||||||
saveLevel(state: CaseState, authorMode: boolean): Promise<void>
|
saveLevel(state: CaseState, authorMode: boolean): Promise<void>
|
||||||
resetLevel(levelId: string): Promise<CaseState | null>
|
resetLevel(levelId: string): Promise<CaseState | null>
|
||||||
@@ -121,7 +126,20 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
|
|||||||
return { id: level.slug, title: level.title, subtitle: level.subtitle, documents, evidence, relations,
|
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 })),
|
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(),
|
viewport: { x: level.viewport_x, y: level.viewport_y, zoom: level.viewport_zoom }, updatedAt: level.updated_at.toISOString(),
|
||||||
levelStatus: level.status, editingAllowed: editingEnabled }
|
levelStatus: level.status, editingAllowed: editingEnabled, sourceTemplateVersionId: level.source_template_version_id || undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function templateSummary(slug: string): Promise<TemplateSummary | null> {
|
||||||
|
const result = await pool.query<{
|
||||||
|
id: string; slug: string; name: string; current_version: number; version_count: number; updated_at: Date
|
||||||
|
}>(`SELECT t.id,t.slug,t.name,current.version AS current_version,COUNT(v.id)::int AS version_count,t.updated_at
|
||||||
|
FROM osint.level_templates t
|
||||||
|
JOIN osint.level_template_versions current ON current.id=t.current_version_id
|
||||||
|
JOIN osint.level_template_versions v ON v.template_id=t.id
|
||||||
|
WHERE t.slug=$1 GROUP BY t.id,current.version`, [slug])
|
||||||
|
const row = result.rows[0]
|
||||||
|
return row ? { id: row.id, slug: row.slug, name: row.name, currentVersion: row.current_version,
|
||||||
|
versionCount: row.version_count, updatedAt: row.updated_at.toISOString() } : null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function replaceBoard(client: PoolClient, level: LevelRow, state: CaseState) {
|
async function replaceBoard(client: PoolClient, level: LevelRow, state: CaseState) {
|
||||||
@@ -236,6 +254,67 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
|
|||||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||||
return (await assembleLevel(input.id))!
|
return (await assembleLevel(input.id))!
|
||||||
},
|
},
|
||||||
|
async listTemplates() {
|
||||||
|
const result = await pool.query<{
|
||||||
|
id: string; slug: string; name: string; current_version: number; version_count: number; updated_at: Date
|
||||||
|
}>(`SELECT t.id,t.slug,t.name,current.version AS current_version,COUNT(v.id)::int AS version_count,t.updated_at
|
||||||
|
FROM osint.level_templates t
|
||||||
|
JOIN osint.level_template_versions current ON current.id=t.current_version_id
|
||||||
|
JOIN osint.level_template_versions v ON v.template_id=t.id
|
||||||
|
GROUP BY t.id,current.version ORDER BY t.updated_at DESC,t.slug`)
|
||||||
|
return result.rows.map(row => ({ id: row.id, slug: row.slug, name: row.name, currentVersion: row.current_version,
|
||||||
|
versionCount: row.version_count, updatedAt: row.updated_at.toISOString() }))
|
||||||
|
},
|
||||||
|
async instantiateTemplate(templateSlug, input) {
|
||||||
|
const client = await pool.connect()
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN')
|
||||||
|
const version = await client.query<{ id: string; board_id: string; title: string; subtitle: string }>(
|
||||||
|
`SELECT v.id,v.board_id,v.title,v.subtitle FROM osint.level_templates t
|
||||||
|
JOIN osint.level_template_versions v ON v.template_id=t.id
|
||||||
|
WHERE t.slug=$1 AND (($2::int IS NULL AND v.id=t.current_version_id) OR v.version=$2)
|
||||||
|
FOR SHARE OF t,v`, [templateSlug, input.version ?? null])
|
||||||
|
const source = version.rows[0]
|
||||||
|
if (!source) { await client.query('ROLLBACK'); return null }
|
||||||
|
const boardId = randomUUID(); const levelId = randomUUID()
|
||||||
|
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,source_template_version_id,title,subtitle) VALUES ($1,$2,$3,$4,$5,$6)`,
|
||||||
|
[levelId, input.id, boardId, source.id, input.title || source.title, source.subtitle])
|
||||||
|
await cloneBoard(client, source.board_id, boardId)
|
||||||
|
await client.query('COMMIT')
|
||||||
|
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||||
|
return assembleLevel(input.id)
|
||||||
|
},
|
||||||
|
async saveLevelAsTemplate(levelId, input) {
|
||||||
|
const client = await pool.connect()
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN')
|
||||||
|
const level = await findLevel(client, levelId, true)
|
||||||
|
if (!level) { await client.query('ROLLBACK'); return null }
|
||||||
|
await client.query('SELECT pg_advisory_xact_lock(hashtext($1))', [input.slug])
|
||||||
|
let template = await client.query<{ id: string }>('SELECT id FROM osint.level_templates WHERE slug=$1 FOR UPDATE', [input.slug])
|
||||||
|
let templateId = template.rows[0]?.id
|
||||||
|
if (!templateId) {
|
||||||
|
templateId = randomUUID()
|
||||||
|
await client.query('INSERT INTO osint.level_templates (id,slug,name) VALUES ($1,$2,$3)', [templateId, input.slug, input.name])
|
||||||
|
template = await client.query<{ id: string }>('SELECT id FROM osint.level_templates WHERE id=$1 FOR UPDATE', [templateId])
|
||||||
|
}
|
||||||
|
const versionResult = await client.query<{ version: number }>(
|
||||||
|
'SELECT COALESCE(MAX(version),0)::int+1 AS version FROM osint.level_template_versions WHERE template_id=$1', [templateId])
|
||||||
|
const version = versionResult.rows[0].version
|
||||||
|
const boardId = randomUUID(); const versionId = randomUUID()
|
||||||
|
await client.query(`INSERT INTO osint.boards (id,board_kind) VALUES ($1,'template_version')`, [boardId])
|
||||||
|
await client.query(`INSERT INTO osint.level_template_versions
|
||||||
|
(id,template_id,version,board_id,title,subtitle,created_from_level_id) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||||
|
[versionId, templateId, version, boardId, level.title, level.subtitle, level.id])
|
||||||
|
await cloneBoard(client, level.board_id, boardId)
|
||||||
|
await client.query('UPDATE osint.level_templates SET name=$2,current_version_id=$3,updated_at=NOW() WHERE id=$1',
|
||||||
|
[templateId, input.name, versionId])
|
||||||
|
await client.query('COMMIT')
|
||||||
|
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||||
|
return templateSummary(input.slug)
|
||||||
|
},
|
||||||
getLevel(levelId) { return assembleLevel(levelId) },
|
getLevel(levelId) { return assembleLevel(levelId) },
|
||||||
async saveLevel(state) {
|
async saveLevel(state) {
|
||||||
const client = await pool.connect()
|
const client = await pool.connect()
|
||||||
@@ -248,8 +327,23 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
|
|||||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||||
},
|
},
|
||||||
async resetLevel(levelId) {
|
async resetLevel(levelId) {
|
||||||
// A level without a source template has no earlier canonical state to restore.
|
const client = await pool.connect()
|
||||||
// Template cloning will replace this branch when template lifecycle endpoints land.
|
try {
|
||||||
|
await client.query('BEGIN')
|
||||||
|
const level = await findLevel(client, levelId, true)
|
||||||
|
if (!level) { await client.query('ROLLBACK'); return null }
|
||||||
|
if (level.source_template_version_id) {
|
||||||
|
const version = await client.query<{ board_id: string; title: string; subtitle: string }>(
|
||||||
|
'SELECT board_id,title,subtitle FROM osint.level_template_versions WHERE id=$1 FOR SHARE', [level.source_template_version_id])
|
||||||
|
const source = version.rows[0]
|
||||||
|
if (!source) throw new Error('Source template version not found')
|
||||||
|
await clearBoard(client, level.board_id)
|
||||||
|
await cloneBoard(client, source.board_id, level.board_id)
|
||||||
|
await client.query(`UPDATE osint.levels SET title=$2,subtitle=$3,viewport_x=0,viewport_y=28,viewport_zoom=0.7,updated_at=NOW()
|
||||||
|
WHERE id=$1`, [level.id, source.title, source.subtitle])
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
|||||||
+40
-3
@@ -117,11 +117,48 @@ export function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const reset = async () => {
|
const reset = async () => {
|
||||||
if (!window.confirm('Reset the entire investigation board?')) return
|
if (!caseState?.sourceTemplateVersionId || !window.confirm('Reset this investigation to its original template version?')) return
|
||||||
const response = await fetch(`/api/levels/${encodeURIComponent(caseState!.id)}/reset`, { method: 'POST' })
|
const response = await fetch(`/api/levels/${encodeURIComponent(caseState!.id)}/reset`, { method: 'POST' })
|
||||||
if (response.ok) { const data = await response.json(); setCaseState(normalizeCase(data)); localStorage.removeItem('gupi-osint-board:last'); setSelected(null); setStatus('CASE RESET') }
|
if (response.ok) { const data = await response.json(); setCaseState(normalizeCase(data)); localStorage.removeItem('gupi-osint-board:last'); setSelected(null); setStatus('CASE RESET') }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const saveAsTemplate = async () => {
|
||||||
|
if (!caseState || !requestedEditMode || !caseState.editingAllowed) return
|
||||||
|
const name = window.prompt('Template name:', caseState.title)?.trim()
|
||||||
|
if (!name) return
|
||||||
|
window.clearTimeout(saveTimer.current)
|
||||||
|
setStatus('FREEZING TEMPLATE VERSION…')
|
||||||
|
const saved = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}?edit=1`, {
|
||||||
|
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(caseState),
|
||||||
|
})
|
||||||
|
if (!saved.ok) { setStatus('LEVEL SAVE FAILED'); return }
|
||||||
|
const response = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}/templates?edit=1`, {
|
||||||
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name }),
|
||||||
|
})
|
||||||
|
if (!response.ok) { setStatus('TEMPLATE SAVE FAILED'); return }
|
||||||
|
const template: { slug: string; currentVersion: number } = await response.json()
|
||||||
|
setStatus(`TEMPLATE ${template.slug.toUpperCase()} · VERSION ${template.currentVersion}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const instantiateTemplate = async () => {
|
||||||
|
if (!requestedEditMode || !caseState?.editingAllowed) return
|
||||||
|
const templatesResponse = await fetch('/api/templates')
|
||||||
|
if (!templatesResponse.ok) { setStatus('TEMPLATE ARCHIVE UNAVAILABLE'); return }
|
||||||
|
const templates: { slug: string; name: string; currentVersion: number }[] = await templatesResponse.json()
|
||||||
|
if (!templates.length) { setStatus('NO TEMPLATES SAVED'); return }
|
||||||
|
const templateSlug = window.prompt(`Template slug:\n${templates.map(item => `${item.slug} (v${item.currentVersion})`).join('\n')}`, templates[0].slug)?.trim()
|
||||||
|
if (!templateSlug) return
|
||||||
|
const title = window.prompt('Name the new investigation:', templates.find(item => item.slug === templateSlug)?.name || 'New Investigation')?.trim()
|
||||||
|
if (!title) return
|
||||||
|
setStatus('CLONING TEMPLATE…')
|
||||||
|
const response = await fetch(`/api/templates/${encodeURIComponent(templateSlug)}/levels?edit=1`, {
|
||||||
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title }),
|
||||||
|
})
|
||||||
|
if (!response.ok) { setStatus('TEMPLATE CLONE FAILED'); return }
|
||||||
|
const level: CaseState = await response.json()
|
||||||
|
window.location.assign(`?level=${encodeURIComponent(level.id)}&edit=1`)
|
||||||
|
}
|
||||||
|
|
||||||
const uploadFiles = async (files: FileList | File[]) => {
|
const uploadFiles = async (files: FileList | File[]) => {
|
||||||
if (!caseState || !requestedEditMode || !caseState.editingAllowed) return
|
if (!caseState || !requestedEditMode || !caseState.editingAllowed) return
|
||||||
const queue = Array.from(files)
|
const queue = Array.from(files)
|
||||||
@@ -161,7 +198,7 @@ export function App() {
|
|||||||
<header className="menubar">
|
<header className="menubar">
|
||||||
<div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div>
|
<div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div>
|
||||||
<nav>
|
<nav>
|
||||||
<button onClick={() => setDocsOpen(v => !v)}>FILE</button><button onClick={addNote}>EVIDENCE</button><button onClick={() => update(s => ({ ...s, viewport: { x: 0, y: 28, zoom: .7 } }))}>VIEW</button><button onClick={() => { const firstWidget = temporalItems.find(item => item.evidenceId); if (firstWidget?.evidenceId) focusEvidence(firstWidget.evidenceId) }}>TIMELINE</button><button onClick={() => setHelpOpen(true)}>HELP</button>
|
<button onClick={() => setDocsOpen(v => !v)}>FILE</button><button onClick={addNote}>EVIDENCE</button><button onClick={() => update(s => ({ ...s, viewport: { x: 0, y: 28, zoom: .7 } }))}>VIEW</button><button onClick={() => { const firstWidget = temporalItems.find(item => item.evidenceId); if (firstWidget?.evidenceId) focusEvidence(firstWidget.evidenceId) }}>TIMELINE</button>{requestedEditMode && caseState.editingAllowed && <><button onClick={saveAsTemplate}>SAVE TEMPLATE</button><button onClick={instantiateTemplate}>NEW FROM TEMPLATE</button></>}<button onClick={() => setHelpOpen(true)}>HELP</button>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="terminal-status"><i /> {status}<span>{clock}</span></div>
|
<div className="terminal-status"><i /> {status}<span>{clock}</span></div>
|
||||||
</header>
|
</header>
|
||||||
@@ -194,7 +231,7 @@ export function App() {
|
|||||||
<button aria-label="Zoom out" onClick={() => update(s => ({ ...s, viewport: { ...s.viewport, zoom: clampBoardZoom(s.viewport.zoom - .1) } }))}><ZoomOut size={18}/></button>
|
<button aria-label="Zoom out" onClick={() => update(s => ({ ...s, viewport: { ...s.viewport, zoom: clampBoardZoom(s.viewport.zoom - .1) } }))}><ZoomOut size={18}/></button>
|
||||||
<b>{Math.round(caseState.viewport.zoom * 100)}%</b>
|
<b>{Math.round(caseState.viewport.zoom * 100)}%</b>
|
||||||
<button aria-label="Zoom in" onClick={() => update(s => ({ ...s, viewport: { ...s.viewport, zoom: clampBoardZoom(s.viewport.zoom + .1) } }))}><ZoomIn size={18}/></button>
|
<button aria-label="Zoom in" onClick={() => update(s => ({ ...s, viewport: { ...s.viewport, zoom: clampBoardZoom(s.viewport.zoom + .1) } }))}><ZoomIn size={18}/></button>
|
||||||
<button aria-label="Reset" onClick={reset}><RotateCcw size={17}/></button>
|
<button aria-label="Reset" title={caseState.sourceTemplateVersionId ? 'Reset to source template' : 'This level has no source template'} disabled={!caseState.sourceTemplateVersionId} onClick={reset}><RotateCcw size={17}/></button>
|
||||||
</div>
|
</div>
|
||||||
{draggingFiles && <div className="file-drop-overlay"><div><Upload size={28}/><b>ADD SOURCE DOCUMENTS</b><span>DROP FILES INTO THIS LEVEL</span></div></div>}
|
{draggingFiles && <div className="file-drop-overlay"><div><Upload size={28}/><b>ADD SOURCE DOCUMENTS</b><span>DROP FILES INTO THIS LEVEL</span></div></div>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -68,5 +68,6 @@ export interface CaseState {
|
|||||||
viewport: Viewport
|
viewport: Viewport
|
||||||
updatedAt?: string
|
updatedAt?: string
|
||||||
levelStatus?: string
|
levelStatus?: string
|
||||||
|
sourceTemplateVersionId?: string
|
||||||
editingAllowed?: boolean
|
editingAllowed?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user