Adding migrations and lot of work. Starting work on the demo scope

This commit is contained in:
2026-08-22 14:53:23 +02:00
parent 1893bf23af
commit 94ccbfd1b9
29 changed files with 1280 additions and 122 deletions
+78 -4
View File
@@ -11,6 +11,7 @@ import type { CaseState } from '../src/types.js'
import { authenticateJwt, createDevelopmentAdminToken, hasAdminClaim, requireAdmin, resolveUserId } from './auth.js'
import { createLevelRepository } from './levelRepository.js'
import { createNarrativeRepository } from './narrativeRepository.js'
import { createTextExtractorFromEnv } from './ocr.js'
import { createStoryGraphRepository, type StoryNodeType } from './storyGraphRepository.js'
import { createObjectStorageFromEnv } from './objectStorage.js'
@@ -25,6 +26,7 @@ export const pool = new Pool({ connectionString: databaseUrl })
const editingEnabled = process.env.LEVEL_EDITING_ENABLED === 'true'
const objectStorage = createObjectStorageFromEnv()
await objectStorage.initialize()
const textExtractor = createTextExtractorFromEnv()
const levels = createLevelRepository(pool, editingEnabled, objectStorage)
const narrative = createNarrativeRepository(pool, objectStorage)
const storyGraph = createStoryGraphRepository(pool)
@@ -49,7 +51,7 @@ const upload = multer({
})
app.get('/api/health', async (_req, res) => {
try { await pool.query('SELECT 1'); res.json({ ok: true, database: 'connected', objectStorage: objectStorage.provider, schema: 'osint', editingEnabled }) }
try { await pool.query('SELECT 1'); res.json({ ok: true, database: 'connected', objectStorage: objectStorage.provider, textExtraction: textExtractor.provider, schema: 'osint', editingEnabled }) }
catch { res.status(503).json({ ok: false, database: 'unavailable' }) }
})
app.get('/api/session', (req, res) => res.json({ authenticated: Boolean(req.authClaims), isAdmin: hasAdminClaim(req) }))
@@ -106,14 +108,41 @@ app.get('/api/assets/:id', async (req, res, next) => {
asset.stream.pipe(res)
} catch (error) { next(error) }
})
app.post('/api/levels/:id/documents', requireAdmin, upload.single('file'), async (req, res, next) => {
app.post('/api/levels/:id/documents', upload.single('file'), async (req, res, next) => {
try {
if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' })
if (!req.file) return res.status(400).json({ error: 'A file is required' })
const document = await levels.uploadDocument(String(req.params.id), req.file)
const extraction = await textExtractor.extract(req.file)
const x = Number(req.body?.x); const y = Number(req.body?.y)
const placement = Number.isFinite(x) && Number.isFinite(y) ? { x, y } : undefined
const document = await levels.uploadDocument(String(req.params.id), req.file, extraction, placement)
document ? res.status(201).json(document) : res.status(404).json({ error: 'Level not found' })
} catch (error) { next(error) }
})
app.post('/api/levels/:id/reveals/seen', async (req, res, next) => {
try {
const ids = Array.isArray(req.body?.documentIds) ? req.body.documentIds.map(String) : []
const acknowledged = await levels.acknowledgeRevealedDocuments(String(req.params.id), ids)
acknowledged === null ? res.status(404).json({ error: 'Level not found' }) : res.json({ acknowledged })
} catch (error) { next(error) }
})
app.get('/api/levels/:id/flags', requireAdmin, async (req, res, next) => {
try {
const flags = await levels.listFlags(String(req.params.id))
flags ? res.json(flags) : res.status(404).json({ error: 'Level not found' })
} catch (error) { next(error) }
})
app.put('/api/levels/:id/flags/:key', requireAdmin, async (req, res, next) => {
try {
const updated = await levels.setFlag(String(req.params.id), String(req.params.key), true)
updated ? res.json({ ok: true }) : res.status(404).json({ error: 'Level not found' })
} catch (error) { next(error) }
})
app.delete('/api/levels/:id/flags/:key', requireAdmin, async (req, res, next) => {
try {
const updated = await levels.setFlag(String(req.params.id), String(req.params.key), false)
updated ? res.json({ ok: true }) : res.status(404).json({ error: 'Level not found' })
} catch (error) { next(error) }
})
app.get('/api/levels/:id', async (req, res, next) => {
try {
const level = await levels.getLevel(req.params.id, wantsEdit(req))
@@ -142,6 +171,34 @@ function requireEditing(res: express.Response) {
if (!editingEnabled) { res.status(403).json({ error: 'Level editing is disabled' }); return false }
return true
}
app.get('/api/levels/:id/evidence-match-rules', requireAdmin, async (req, res, next) => {
try {
const rules = await levels.listEvidenceMatchRules(String(req.params.id))
rules ? res.json(rules) : res.status(404).json({ error: 'Level not found' })
} catch (error) { next(error) }
})
app.post('/api/levels/:id/evidence-match-rules', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
const rule = await levels.createEvidenceMatchRule(String(req.params.id), req.body)
rule ? res.status(201).json(rule) : res.status(404).json({ error: 'Level not found' })
} catch (error) { next(error) }
})
app.put('/api/levels/:id/evidence-match-rules/:ruleId', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
const rule = await levels.updateEvidenceMatchRule(String(req.params.id), String(req.params.ruleId), req.body)
rule ? res.json(rule) : res.status(404).json({ error: 'Level or evidence match rule not found' })
} catch (error) { next(error) }
})
app.delete('/api/levels/:id/evidence-match-rules/:ruleId', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
const removed = await levels.deleteEvidenceMatchRule(String(req.params.id), String(req.params.ruleId))
if (removed === null) return res.status(404).json({ error: 'Level not found' })
removed ? res.json({ ok: true }) : res.status(404).json({ error: 'Evidence match rule not found' })
} catch (error) { next(error) }
})
app.get('/api/admin/mysteries', requireAdmin, async (_req, res, next) => {
try { res.json(await narrative.listMysteries()) } catch (error) { next(error) }
})
@@ -350,6 +407,23 @@ app.post('/api/playthroughs/:id/advance', async (req, res, next) => {
} catch (error) { next(error) }
})
// The playthrough case-state (achievements). Read is open; granting is a dev-only
// stand-in until the server-side achievement rule engine drives awards from play.
app.get('/api/playthroughs/:id/achievements', async (req, res, next) => {
try {
const flags = await narrative.listAchievements(String(req.params.id))
flags ? res.json(flags) : res.status(404).json({ error: 'Playthrough not found' })
} catch (error) { next(error) }
})
app.post('/api/playthroughs/:id/achievements', async (req, res, next) => {
try {
if (process.env.NODE_ENV === 'production') return res.status(403).json({ error: 'Manual grants are disabled' })
if (!req.body?.flagKey) return res.status(400).json({ error: 'A flagKey is required' })
const result = await narrative.awardAchievement(String(req.params.id), String(req.body.flagKey), req.body.nodeId ? String(req.body.nodeId) : null)
result.ok ? res.json({ earned: result.earned }) : res.status(result.error === 'Playthrough not found' ? 404 : 400).json({ error: result.error })
} catch (error) { next(error) }
})
app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
if (error instanceof multer.MulterError) {
return res.status(error.code === 'LIMIT_FILE_SIZE' ? 413 : 400).json({ error: error.code === 'LIMIT_FILE_SIZE' ? 'Document exceeds the upload limit' : error.message })