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
+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) {