feat: add immutable level template lifecycle
This commit is contained in:
@@ -70,9 +70,14 @@ suite('level persistence API', () => {
|
||||
state.viewport = { x: 91, y: -42, zoom: 0.85 }
|
||||
const documentId = 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: {} }]
|
||||
state.evidence = [{ id: folderId, type: 'folder', title: 'Folder', content: 'Evidence folder', x: 685, y: 417, width: 260, config: { open: true }, containedDocumentIds: [documentId] }]
|
||||
const noteId = randomUUID()
|
||||
state.documents = [{ id: documentId, title: 'Evidence', kind: 'IMAGE', date: '2021-04-17', publishedAt: '2021-04-17T12:00:00Z', body: ['Extracted body'], regions: [{ id: 'stamp', label: 'Date stamp', excerpt: '17 April 2021', date: '2021-04-17T09:00:00Z' }], fileType: 'image', metadata: {} }]
|
||||
state.evidence = [
|
||||
{ id: folderId, type: 'folder', title: 'Folder', content: 'Evidence folder', x: 685, y: 417, width: 260, config: { open: true }, containedDocumentIds: [documentId] },
|
||||
{ id: noteId, type: 'note', title: 'Extract', content: 'Date matters', sourceDocumentId: documentId, sourceRegionId: 'stamp', x: 420, y: 300, width: 108 },
|
||||
]
|
||||
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`, {
|
||||
method: 'PUT',
|
||||
@@ -129,18 +134,72 @@ suite('level persistence API', () => {
|
||||
expect(sameLevelInEditView.viewport).toEqual(playerState.viewport)
|
||||
expect(sameLevelInEditView.evidence[0]).toMatchObject({ id: folderId, x: 812, y: 533 })
|
||||
|
||||
const normalized = await appPool.query<{ exhibits: string; documents: string; folders: string; memberships: string; metadata: string }>(`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.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' })
|
||||
(SELECT COUNT(*) FROM osint.exhibit_metadata_text_values)::text AS metadata,
|
||||
(SELECT COUNT(*) FROM osint.exhibit_sources)::text AS sources,
|
||||
(SELECT COUNT(*) FROM osint.exhibit_connections)::text AS connections`)
|
||||
expect(normalized.rows[0]).toEqual({ exhibits: '4', documents: '2', folders: '1', memberships: '1', metadata: '2', sources: '1', connections: '1' })
|
||||
|
||||
const resetResponse = await fetch(`${baseUrl}/api/levels/${state.id}/reset`, { method: 'POST' })
|
||||
expect(resetResponse.ok).toBe(true)
|
||||
const resetState = await resetResponse.json() as CaseState
|
||||
expect(resetState.viewport).toEqual(playerState.viewport)
|
||||
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) {
|
||||
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()
|
||||
app.disable('x-powered-by')
|
||||
@@ -41,14 +44,36 @@ app.get('/api/levels', async (_req, res, next) => {
|
||||
try { res.json(await levels.listLevels()) }
|
||||
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) => {
|
||||
try {
|
||||
if (!editingEnabled) return res.status(403).json({ error: 'Level editing is disabled' })
|
||||
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 || '') }))
|
||||
} 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) => {
|
||||
try {
|
||||
const asset = await levels.getAsset(req.params.id)
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import type { Pool, PoolClient } from 'pg'
|
||||
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 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 {
|
||||
listLevels(): Promise<unknown[]>
|
||||
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>
|
||||
saveLevel(state: CaseState, authorMode: boolean): Promise<void>
|
||||
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,
|
||||
connections: connectionsResult.rows.map(row => ({ id: row.id, fromEvidenceId: row.from_exhibit_id, toEvidenceId: row.to_exhibit_id })),
|
||||
viewport: { x: level.viewport_x, y: level.viewport_y, zoom: level.viewport_zoom }, updatedAt: level.updated_at.toISOString(),
|
||||
levelStatus: level.status, editingAllowed: editingEnabled }
|
||||
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) {
|
||||
@@ -236,6 +254,67 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
|
||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||
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) },
|
||||
async saveLevel(state) {
|
||||
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() }
|
||||
},
|
||||
async resetLevel(levelId) {
|
||||
// A level without a source template has no earlier canonical state to restore.
|
||||
// Template cloning will replace this branch when template lifecycle endpoints land.
|
||||
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 }
|
||||
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)
|
||||
},
|
||||
async getAsset(assetId) {
|
||||
|
||||
Reference in New Issue
Block a user