feat: add immutable level template lifecycle
This commit is contained in:
@@ -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