feat: add immutable level template lifecycle

This commit is contained in:
2026-08-14 14:17:54 +02:00
parent 5599d330d8
commit c8a870549d
10 changed files with 386 additions and 26 deletions
+64 -5
View File
@@ -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 })
})
})
+131
View File
@@ -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
View File
@@ -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)
+97 -3
View File
@@ -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) {