Add Scene 7 claim report workflow
This commit is contained in:
@@ -89,7 +89,7 @@ suite('normalized level persistence API', () => {
|
||||
})
|
||||
|
||||
it('round-trips exhibits, relations, board views, private objects, and template clones', async () => {
|
||||
expect(await (await fetch(`${baseUrl}/api/session`)).json()).toEqual({ authenticated: false, isAdmin: false })
|
||||
expect(await (await fetch(`${baseUrl}/api/session`)).json()).toEqual({ authenticated: false, isAdmin: false, playerName:'Player' })
|
||||
const createResponse = await adminFetch(`${baseUrl}/api/levels`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: 'api-smoke-level', title: 'API Smoke Level' }),
|
||||
})
|
||||
@@ -252,7 +252,9 @@ suite('normalized level persistence API', () => {
|
||||
const { importMysteryTemplate } = await import('../scripts/importMysteryTemplate.js')
|
||||
const manifestPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'mysteries', 'barricelli-scene-7', 'mystery.json')
|
||||
const imported = await importMysteryTemplate(manifestPath, baseUrl, adminAuthorization.replace(/^Bearer /, ''))
|
||||
expect(imported.playableLevel).toMatchObject({ title:'The Barricelli Files',exhibits:[],goals:[expect.objectContaining({
|
||||
expect(imported.playableLevel).toMatchObject({ title:'The Barricelli Files',exhibits:[expect.objectContaining({ type:'claim',statement:'Nils Aall Barricelli was an inventor.' })],report:expect.objectContaining({
|
||||
title:'Barricelli Inventor Finding',requiredForCompletion:true,status:'draft',
|
||||
}),goals:[expect.objectContaining({
|
||||
key:'barricelli.inventor-proof',status:'pending',newlyCompleted:false,
|
||||
})] })
|
||||
expect(await (await adminFetch(`${baseUrl}/api/levels/${imported.playableLevel.id}/evidence-match-rules`)).json()).toEqual([
|
||||
@@ -282,8 +284,28 @@ suite('normalized level persistence API', () => {
|
||||
targetUpload.append('file', new Blob([readFileSync(path.join(fixtureDir, 'google-patents-target-ocr.txt'))], { type:'text/plain' }), 'google-patents-source.txt')
|
||||
const targetResponse = await fetch(`${baseUrl}/api/levels/${imported.playableLevel.id}/documents`, { method:'POST',body:targetUpload })
|
||||
expect(targetResponse.status).toBe(201)
|
||||
expect(await targetResponse.json()).toMatchObject({ analysis:{ awardedFlags:['scene7.nils_inventor_proved'],
|
||||
const targetDocument=await targetResponse.json() as DocumentExhibit & { analysis:{ awardedFlags:string[];goals:CaseState['goals'] } }
|
||||
expect(targetDocument).toMatchObject({ displayNumber:expect.any(Number),analysis:{ awardedFlags:['scene7.nils_inventor_proved'],
|
||||
goals:[expect.objectContaining({ key:'barricelli.inventor-proof',status:'complete',newlyCompleted:true })] } })
|
||||
const reportState=await (await fetch(`${baseUrl}/api/levels/${imported.playableLevel.id}`)).json() as CaseState
|
||||
const claim=reportState.exhibits.find(exhibit => exhibit.type === 'claim')!
|
||||
const connectionId=randomUUID()
|
||||
reportState.connections.push({ id:connectionId,fromExhibitId:claim.id,toExhibitId:targetDocument.id,label:'Proof that…',tightness:65,tagStyle:'luggage',tagPosition:50,tagOffset:0 })
|
||||
expect((await fetch(`${baseUrl}/api/levels/${reportState.id}`,{ method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(reportState) })).status).toBe(200)
|
||||
const incomplete=await fetch(`${baseUrl}/api/levels/${reportState.id}/report/submissions`,{ method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({
|
||||
investigatorName:'Test Player',evidence:[{ connectionId,documentExhibitId:targetDocument.id,relationText:'Proof that…' }],
|
||||
}) })
|
||||
expect(incomplete.status).toBe(201)
|
||||
expect(await incomplete.json()).toMatchObject({ status:'evidence_accepted_report_incomplete',issues:expect.arrayContaining(['unfinished_relation','missing_date','missing_source']),
|
||||
feedback:"The evidence is good enough, but the report itself won't hold up in court. Add the date, cite the source, and provide the link if you can. Then we can accept it." })
|
||||
const accepted=await fetch(`${baseUrl}/api/levels/${reportState.id}/report/submissions`,{ method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({
|
||||
investigatorName:'Test Player',evidence:[{ connectionId,documentExhibitId:targetDocument.id,relationText:'Proof that Barricelli is named as the inventor on patent GB695913A.',
|
||||
publishedAt:'1953-08-19',sourceCitation:'Google Patents · GB695913A',sourceUri:'https://patents.google.com/patent/GB695913A/en' }],
|
||||
}) })
|
||||
expect(accepted.status).toBe(201)
|
||||
expect(await accepted.json()).toMatchObject({ status:'accepted',investigatorName:'Test Player',issues:[],claims:[expect.objectContaining({ evidence:[expect.objectContaining({
|
||||
displayNumber:targetDocument.displayNumber,evidenceAccepted:true,sourceCitation:'Google Patents · GB695913A',publishedAt:'1953-08-19T00:00:00.000Z',
|
||||
})] })] })
|
||||
judgeVerdict = { subject:'target',supports_claim:true,evidence_excerpt:'Ada Example patented a pocket telescope',confidence:.96 }
|
||||
judgeHttpStatus = 200
|
||||
})
|
||||
|
||||
+6
-1
@@ -1,7 +1,7 @@
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import jwt, { type JwtPayload } from 'jsonwebtoken'
|
||||
|
||||
export type OsintClaims = JwtPayload & { role?: string; isAdmin?: boolean }
|
||||
export type OsintClaims = JwtPayload & { role?: string; isAdmin?: boolean; name?:string; preferred_username?:string }
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
@@ -39,6 +39,11 @@ export function resolveUserId(req: Request): string {
|
||||
return typeof sub === 'string' && sub.length > 0 ? sub : DEVELOPMENT_TEST_USER_ID
|
||||
}
|
||||
|
||||
export function resolvePlayerName(req: Request): string {
|
||||
const candidate = req.authClaims?.name || req.authClaims?.preferred_username || req.authClaims?.sub
|
||||
return typeof candidate === 'string' && candidate.trim() ? candidate.trim().slice(0,300) : 'Player'
|
||||
}
|
||||
|
||||
export function requireAdmin(req: Request, res: Response, next: NextFunction) {
|
||||
if (!hasAdminClaim(req)) return res.status(403).json({ error: 'Administrator claim required' })
|
||||
next()
|
||||
|
||||
+22
-3
@@ -10,6 +10,8 @@ function mapped(ids: IdMap, sourceId: string, label: string) {
|
||||
}
|
||||
|
||||
export async function clearBoard(client: PoolClient, boardId: string) {
|
||||
await client.query('DELETE FROM osint.case_report_submissions WHERE board_id=$1', [boardId])
|
||||
await client.query('DELETE FROM osint.case_reports WHERE board_id=$1', [boardId])
|
||||
await client.query('DELETE FROM osint.board_views WHERE board_id=$1', [boardId])
|
||||
await client.query('DELETE FROM osint.brief_concepts WHERE board_id=$1', [boardId])
|
||||
await client.query('DELETE FROM osint.level_briefs WHERE board_id=$1', [boardId])
|
||||
@@ -64,11 +66,17 @@ export async function cloneBoard(client: PoolClient, sourceBoardId: string, targ
|
||||
|
||||
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
|
||||
published_at: Date | null; captured_at: Date | null; source_uri: string | null; citation_text:string
|
||||
}>(`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])
|
||||
(exhibit_id,document_type_id,asset_id,title,published_at,captured_at,source_uri,citation_text) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
|
||||
[mapped(exhibitIds, row.exhibit_id, 'document'), row.document_type_id, row.asset_id, row.title, row.published_at, row.captured_at, row.source_uri,row.citation_text])
|
||||
|
||||
const citations = await client.query<{ exhibit_id:string;display_number:number }>(
|
||||
'SELECT exhibit_id,display_number FROM osint.exhibit_citations WHERE board_id=$1 ORDER BY display_number',[sourceBoardId])
|
||||
for (const row of citations.rows) await client.query(
|
||||
'INSERT INTO osint.exhibit_citations (board_id,exhibit_id,display_number) VALUES ($1,$2,$3)',
|
||||
[targetBoardId,mapped(exhibitIds,row.exhibit_id,'cited exhibit'),row.display_number])
|
||||
|
||||
const documentRequirements = await client.query<{ document_exhibit_id: string; flag_key: string }>(
|
||||
'SELECT document_exhibit_id,flag_key FROM osint.document_flag_requirements WHERE board_id=$1 ORDER BY document_exhibit_id,flag_key', [sourceBoardId])
|
||||
@@ -137,6 +145,12 @@ export async function cloneBoard(client: PoolClient, sourceBoardId: string, targ
|
||||
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 claims = await client.query<{ exhibit_id:string;statement:string }>(
|
||||
`SELECT claim.exhibit_id,claim.statement FROM osint.claim_exhibits claim
|
||||
JOIN osint.exhibits exhibit ON exhibit.id=claim.exhibit_id WHERE exhibit.board_id=$1`,[sourceBoardId])
|
||||
for (const row of claims.rows) await client.query('INSERT INTO osint.claim_exhibits (exhibit_id,statement) VALUES ($1,$2)',
|
||||
[mapped(exhibitIds,row.exhibit_id,'claim'),row.statement])
|
||||
|
||||
const events = await client.query<{ exhibit_id: string; title: string; narrative_text: string; occurred_at: Date | null }>(
|
||||
`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(
|
||||
@@ -246,5 +260,10 @@ export async function cloneBoard(client: PoolClient, sourceBoardId: string, targ
|
||||
[randomUUID(), targetBoardId, row.id, row.label, row.context_text, row.sort_order, row.expected_party_kind,
|
||||
row.resolved_party_exhibit_id ? mapped(exhibitIds, row.resolved_party_exhibit_id, 'resolved party') : null])
|
||||
|
||||
const report = (await client.query<{ title:string;required_for_completion:boolean }>(
|
||||
'SELECT title,required_for_completion FROM osint.case_reports WHERE board_id=$1',[sourceBoardId])).rows[0]
|
||||
if (report) await client.query(`INSERT INTO osint.case_reports (board_id,title,investigator_name,required_for_completion)
|
||||
VALUES ($1,$2,'',$3)`,[targetBoardId,report.title,report.required_for_completion])
|
||||
|
||||
return exhibitIds
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Pool, PoolClient } from 'pg'
|
||||
import type { CaseReport, CaseReportEvidence, CaseReportSubmissionInput, CaseReportSubmissionStatus, SourceFileType } from '../src/types.js'
|
||||
|
||||
type LevelRef = { id:string; board_id:string }
|
||||
|
||||
function trimmed(value: unknown, max: number) { return String(value || '').trim().slice(0,max) }
|
||||
function optionalUrl(value: unknown) {
|
||||
const candidate = trimmed(value,2_000)
|
||||
if (!candidate) return null
|
||||
try { return new URL(candidate).toString() } catch { throw new Error('Source links must be absolute URLs') }
|
||||
}
|
||||
function optionalTimestamp(value: unknown) {
|
||||
const candidate = trimmed(value,100)
|
||||
if (!candidate) return null
|
||||
const date = new Date(candidate)
|
||||
if (!Number.isFinite(date.getTime())) throw new Error('Evidence dates must be valid dates')
|
||||
return date.toISOString()
|
||||
}
|
||||
function unfinishedRelation(value: string) {
|
||||
return !value.trim() || /^proof\s+that(?:\s*(?:…|\.{3}))?\s*$/iu.test(value.trim())
|
||||
}
|
||||
|
||||
export async function loadCaseReport(client: Pool | PoolClient, level: LevelRef): Promise<CaseReport | undefined> {
|
||||
const config = (await client.query<{ title:string; investigator_name:string; required_for_completion:boolean }>(
|
||||
'SELECT title,investigator_name,required_for_completion FROM osint.case_reports WHERE board_id=$1', [level.board_id])).rows[0]
|
||||
if (!config) return undefined
|
||||
const claims = await client.query<{ exhibit_id:string; statement:string }>(`SELECT claim.exhibit_id,claim.statement
|
||||
FROM osint.claim_exhibits claim JOIN osint.exhibits exhibit ON exhibit.id=claim.exhibit_id
|
||||
WHERE exhibit.board_id=$1 ORDER BY exhibit.created_at,exhibit.id`, [level.board_id])
|
||||
const evidence = await client.query<{
|
||||
claim_exhibit_id:string; connection_id:string; document_exhibit_id:string; display_number:number; document_title:string
|
||||
document_type_id:SourceFileType; relation_text:string; published_at:Date|null; citation_text:string; source_uri:string|null; evidence_accepted:boolean
|
||||
}>(`SELECT claim.exhibit_id AS claim_exhibit_id,connection.id AS connection_id,document.exhibit_id AS document_exhibit_id,
|
||||
citation.display_number,document.title AS document_title,document.document_type_id,COALESCE(connection.label,'') AS relation_text,
|
||||
document.published_at,document.citation_text,document.source_uri,
|
||||
(EXISTS (SELECT 1 FROM osint.level_flags flag JOIN osint.evidence_match_evaluations evaluation
|
||||
ON evaluation.id=flag.awarded_by_evidence_match_id
|
||||
WHERE flag.level_id=$2 AND evaluation.document_exhibit_id=document.exhibit_id)
|
||||
OR EXISTS (SELECT 1 FROM osint.level_flags flag JOIN osint.evidence_semantic_evaluations evaluation
|
||||
ON evaluation.id=flag.awarded_by_semantic_evaluation_id
|
||||
WHERE flag.level_id=$2 AND evaluation.document_exhibit_id=document.exhibit_id
|
||||
AND evaluation.status='succeeded' AND evaluation.subject='target' AND evaluation.supports_claim)) AS evidence_accepted
|
||||
FROM osint.claim_exhibits claim
|
||||
JOIN osint.exhibits claim_exhibit ON claim_exhibit.id=claim.exhibit_id AND claim_exhibit.board_id=$1
|
||||
JOIN osint.exhibit_connections connection ON connection.board_id=$1
|
||||
AND (connection.from_exhibit_id=claim.exhibit_id OR connection.to_exhibit_id=claim.exhibit_id)
|
||||
JOIN osint.document_exhibits document ON document.exhibit_id=CASE
|
||||
WHEN connection.from_exhibit_id=claim.exhibit_id THEN connection.to_exhibit_id ELSE connection.from_exhibit_id END
|
||||
JOIN osint.exhibit_citations citation ON citation.board_id=$1 AND citation.exhibit_id=document.exhibit_id
|
||||
ORDER BY claim_exhibit.created_at,claim.exhibit_id,citation.display_number,connection.created_at`, [level.board_id,level.id])
|
||||
const latest = (await client.query<{ id:string;status:CaseReportSubmissionStatus; feedback:string }>(
|
||||
'SELECT id,status,feedback FROM osint.case_report_submissions WHERE level_id=$1 ORDER BY submitted_at DESC,id DESC LIMIT 1', [level.id])).rows[0]
|
||||
const issues = latest ? (await client.query<{ issue_key:string }>(
|
||||
'SELECT issue_key FROM osint.case_report_submission_issues WHERE submission_id=$1 ORDER BY issue_key', [latest.id])).rows.map(row => row.issue_key) : []
|
||||
const byClaim = new Map<string,CaseReportEvidence[]>()
|
||||
for (const row of evidence.rows) byClaim.set(row.claim_exhibit_id,[...(byClaim.get(row.claim_exhibit_id) || []),{
|
||||
connectionId:row.connection_id,documentExhibitId:row.document_exhibit_id,displayNumber:row.display_number,
|
||||
documentTitle:row.document_title,fileType:row.document_type_id,relationText:row.relation_text,
|
||||
publishedAt:row.published_at?.toISOString(),sourceCitation:row.citation_text || undefined,sourceUri:row.source_uri || undefined,
|
||||
evidenceAccepted:row.evidence_accepted,
|
||||
}])
|
||||
return { title:config.title,investigatorName:config.investigator_name,requiredForCompletion:config.required_for_completion,
|
||||
status:latest?.status || 'draft',feedback:latest?.feedback,issues,
|
||||
claims:claims.rows.map(row => ({ claimExhibitId:row.exhibit_id,statement:row.statement,evidence:byClaim.get(row.exhibit_id) || [] })) }
|
||||
}
|
||||
|
||||
export async function submitCaseReport(pool: Pool, levelSlug: string, rawInput: CaseReportSubmissionInput): Promise<CaseReport | null> {
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
const level = (await client.query<LevelRef>(
|
||||
'SELECT id,board_id FROM osint.levels WHERE slug=$1 FOR UPDATE', [levelSlug])).rows[0]
|
||||
if (!level) { await client.query('ROLLBACK'); return null }
|
||||
const report = (await client.query<{ board_id:string }>('SELECT board_id FROM osint.case_reports WHERE board_id=$1 FOR UPDATE', [level.board_id])).rows[0]
|
||||
if (!report) throw new Error('This level does not have a case report')
|
||||
const investigatorName = trimmed(rawInput?.investigatorName,300) || 'Player'
|
||||
const drafts = Array.isArray(rawInput?.evidence) ? rawInput.evidence.slice(0,100) : []
|
||||
if (new Set(drafts.map(item => item.connectionId)).size !== drafts.length) throw new Error('A report cannot submit the same connection twice')
|
||||
for (const draft of drafts) {
|
||||
const relationText = trimmed(draft.relationText,2_000)
|
||||
const sourceCitation = trimmed(draft.sourceCitation,1_000)
|
||||
const sourceUri = optionalUrl(draft.sourceUri)
|
||||
const publishedAt = optionalTimestamp(draft.publishedAt)
|
||||
const owned = (await client.query<{ connection_id:string; document_id:string }>(`SELECT connection.id AS connection_id,document.exhibit_id AS document_id
|
||||
FROM osint.exhibit_connections connection
|
||||
JOIN osint.claim_exhibits claim ON claim.exhibit_id=CASE
|
||||
WHEN connection.from_exhibit_id=$3 THEN connection.to_exhibit_id ELSE connection.from_exhibit_id END
|
||||
JOIN osint.document_exhibits document ON document.exhibit_id=CASE
|
||||
WHEN connection.from_exhibit_id=$3 THEN connection.from_exhibit_id ELSE connection.to_exhibit_id END
|
||||
WHERE connection.id=$1 AND connection.board_id=$2
|
||||
AND $3::uuid IN (connection.from_exhibit_id,connection.to_exhibit_id)`, [draft.connectionId,level.board_id,draft.documentExhibitId])).rows[0]
|
||||
if (!owned || owned.document_id !== draft.documentExhibitId) throw new Error('Report evidence must reference a claim-to-document connection on this board')
|
||||
await client.query('UPDATE osint.exhibit_connections SET label=$2 WHERE id=$1', [draft.connectionId,relationText || null])
|
||||
await client.query(`UPDATE osint.document_exhibits SET published_at=$2,citation_text=$3,source_uri=$4 WHERE exhibit_id=$1`,
|
||||
[draft.documentExhibitId,publishedAt,sourceCitation,sourceUri])
|
||||
await client.query(`INSERT INTO osint.exhibit_citations (board_id,exhibit_id,display_number)
|
||||
SELECT $1,$2,COALESCE(MAX(display_number),0)+1 FROM osint.exhibit_citations WHERE board_id=$1
|
||||
ON CONFLICT (board_id,exhibit_id) DO NOTHING`, [level.board_id,draft.documentExhibitId])
|
||||
}
|
||||
await client.query('UPDATE osint.case_reports SET investigator_name=$2,updated_at=NOW() WHERE board_id=$1', [level.board_id,investigatorName])
|
||||
const assembled = (await loadCaseReport(client,level))!
|
||||
const pendingGoals = Number((await client.query<{ count:string }>(`SELECT COUNT(*)::text AS count FROM osint.level_goals goal
|
||||
WHERE goal.board_id=$1 AND goal.enabled AND (
|
||||
NOT EXISTS (SELECT 1 FROM osint.level_goal_flag_requirements requirement WHERE requirement.goal_id=goal.id)
|
||||
OR EXISTS (SELECT 1 FROM osint.level_goal_flag_requirements requirement WHERE requirement.goal_id=goal.id
|
||||
AND NOT EXISTS (SELECT 1 FROM osint.level_flags flag WHERE flag.level_id=$2 AND flag.flag_key=requirement.flag_key)))`,
|
||||
[level.board_id,level.id])).rows[0].count)
|
||||
const connectedAccepted = assembled.claims.length > 0 && assembled.claims.every(claim => claim.evidence.some(item => item.evidenceAccepted))
|
||||
const blockingIssues = new Set<string>()
|
||||
if (!assembled.claims.length) blockingIssues.add('missing_claim')
|
||||
for (const claim of assembled.claims) {
|
||||
const accepted = claim.evidence.filter(item => item.evidenceAccepted)
|
||||
if (!accepted.length) { blockingIssues.add('missing_accepted_evidence'); continue }
|
||||
for (const item of accepted) {
|
||||
if (unfinishedRelation(item.relationText)) blockingIssues.add('unfinished_relation')
|
||||
if (!item.publishedAt) blockingIssues.add('missing_date')
|
||||
if (!item.sourceCitation) blockingIssues.add('missing_source')
|
||||
}
|
||||
}
|
||||
let status:CaseReportSubmissionStatus
|
||||
let feedback:string
|
||||
if (pendingGoals || !connectedAccepted) {
|
||||
status='evidence_insufficient'
|
||||
feedback='The report does not yet connect the claim to evidence that proves it. Find the source, add it to the board, and connect it with red thread.'
|
||||
} else if (blockingIssues.size) {
|
||||
status='evidence_accepted_report_incomplete'
|
||||
feedback=blockingIssues.has('missing_date') || blockingIssues.has('missing_source')
|
||||
? "The evidence is good enough, but the report itself won't hold up in court. Add the date, cite the source, and provide the link if you can. Then we can accept it."
|
||||
: 'The evidence is good enough, but the report still says “Proof that…”. Finish the evidentiary statement before submitting it.'
|
||||
} else {
|
||||
status='accepted'
|
||||
feedback='Case report accepted. The claim is supported by identified, dated source evidence.'
|
||||
}
|
||||
const submissionId=randomUUID()
|
||||
await client.query(`INSERT INTO osint.case_report_submissions (id,level_id,board_id,status,investigator_name,feedback)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)`, [submissionId,level.id,level.board_id,status,investigatorName,feedback])
|
||||
for (const issue of blockingIssues) await client.query(
|
||||
'INSERT INTO osint.case_report_submission_issues (submission_id,issue_key) VALUES ($1,$2)', [submissionId,issue])
|
||||
await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id])
|
||||
await client.query('COMMIT')
|
||||
return { ...(await loadCaseReport(pool,level))!,status,feedback,issues:[...blockingIssues].sort() }
|
||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||
}
|
||||
+13
-3
@@ -8,7 +8,8 @@ import { fileURLToPath } from 'node:url'
|
||||
import multer from 'multer'
|
||||
import pg from 'pg'
|
||||
import type { CaseState } from '../src/types.js'
|
||||
import { authenticateJwt, createDevelopmentAdminToken, hasAdminClaim, requireAdmin, resolveUserId } from './auth.js'
|
||||
import { authenticateJwt, createDevelopmentAdminToken, hasAdminClaim, requireAdmin, resolvePlayerName, resolveUserId } from './auth.js'
|
||||
import { submitCaseReport } from './caseReports.js'
|
||||
import { createLevelRepository } from './levelRepository.js'
|
||||
import { createEvidenceJudgeFromEnv } from './evidenceJudge.js'
|
||||
import { createNarrativeRepository } from './narrativeRepository.js'
|
||||
@@ -57,7 +58,7 @@ app.get('/api/health', async (_req, res) => {
|
||||
evidenceJudge: evidenceJudge.enabled ? evidenceJudge.provider : 'disabled', 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) }))
|
||||
app.get('/api/session', (req, res) => res.json({ authenticated: Boolean(req.authClaims), isAdmin: hasAdminClaim(req), playerName:resolvePlayerName(req) }))
|
||||
if (process.env.NODE_ENV !== 'production') app.get('/api/dev/admin-session', (req, res) => {
|
||||
const requestedReturn = String(req.query.returnTo || '/')
|
||||
const returnTo = requestedReturn.startsWith('/') && !requestedReturn.startsWith('//') ? requestedReturn : '/'
|
||||
@@ -176,6 +177,15 @@ app.post('/api/levels/:id/reset', async (req, res, next) => {
|
||||
level ? res.json(level) : res.status(404).json({ error: 'Level not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.post('/api/levels/:id/report/submissions', async (req, res, next) => {
|
||||
try {
|
||||
const report = await submitCaseReport(pool,String(req.params.id),{
|
||||
investigatorName:String(req.body?.investigatorName || resolvePlayerName(req)),
|
||||
evidence:Array.isArray(req.body?.evidence) ? req.body.evidence : [],
|
||||
})
|
||||
report ? res.status(201).json(report) : res.status(404).json({ error:'Level not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
|
||||
// Admin authoring panel: NPC template library and mystery listing. Reads require an
|
||||
// admin claim; writes additionally require editing to be enabled on this deployment.
|
||||
@@ -471,7 +481,7 @@ app.get('/api/playthroughs/current', async (req, res, next) => {
|
||||
app.post('/api/playthroughs/:id/advance', async (req, res, next) => {
|
||||
try {
|
||||
const result = await narrative.advancePlaythrough(resolveUserId(req), String(req.params.id), req.body?.terminalKey)
|
||||
result.ok ? res.json(result.state ?? null) : res.status(result.error === 'Playthrough not found' ? 404 : result.errorCode === 'goals_incomplete' ? 409 : 400)
|
||||
result.ok ? res.json(result.state ?? null) : res.status(result.error === 'Playthrough not found' ? 404 : result.errorCode ? 409 : 400)
|
||||
.json({ error: result.error, errorCode: result.errorCode, pendingGoals: result.pendingGoals })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
|
||||
@@ -2,8 +2,9 @@ import { createHash, randomUUID } from 'node:crypto'
|
||||
import { Readable } from 'node:stream'
|
||||
import type { Pool, PoolClient } from 'pg'
|
||||
import type { BoardView, BriefConcept, CaseDocument, CaseState, DocumentSemanticAnalysis, Evidence, EvidenceMatchRuleDefinition, EvidenceSemanticRuleDefinition, Exhibit, ExhibitRelation, LevelFlag, LevelGoal, OrganizationKind, PartyKind, SourceFileType, UploadedCaseDocument } from '../src/types.js'
|
||||
import { isDocumentExhibit, isEventExhibit, isFolderExhibit, isPartyExhibit } from '../src/types.js'
|
||||
import { isClaimExhibit, isDocumentExhibit, isEventExhibit, isFolderExhibit, isPartyExhibit } from '../src/types.js'
|
||||
import { clearBoard, cloneBoard } from './boardClone.js'
|
||||
import { loadCaseReport } from './caseReports.js'
|
||||
import { evaluateEvidenceRules, type EvidenceMatchRule } from './evidenceMatching.js'
|
||||
import { EvidenceJudgeError, type EvidenceJudge } from './evidenceJudge.js'
|
||||
import { filterLevelVisibility, mergePlayerStateForPersistence } from './levelVisibility.js'
|
||||
@@ -82,6 +83,7 @@ type ExhibitRow = {
|
||||
id: string; exhibit_type_id: Exhibit['type']; xpos: number; ypos: number; width: number; height: number; rotation: number; z_index: number; hidden: boolean
|
||||
title: string; content: string; is_open: boolean | null; document_type_id: SourceFileType | null
|
||||
asset_id: string | null; published_at: Date | null; occurred_at: Date | null
|
||||
captured_at: Date | null; source_uri: string | null; citation_text: string | null; display_number: number | null; statement: string | null
|
||||
original_name: string | null; mime_type: string | null; byte_size: string | null
|
||||
source_document_id: string | null; source_region_key: string | null
|
||||
party_kind: PartyKind | null; organization_kind: OrganizationKind | null
|
||||
@@ -309,9 +311,10 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
||||
const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult, eventEvidenceResult,
|
||||
aliasesResult, partyEvidenceResult, briefResult, conceptsResult, viewsResult, requirementsResult, flagsResult, seenResult] = await Promise.all([
|
||||
pool.query<ExhibitRow>(`SELECT e.id,e.exhibit_type_id,e.xpos,e.ypos,e.width,e.height,e.rotation,e.z_index,e.hidden,
|
||||
COALESCE(f.title, d.title, n.title, ev.title, p.display_name, '') AS title,
|
||||
COALESCE(f.title, d.title, n.title, ev.title, p.display_name, claim.statement, '') AS title,
|
||||
COALESCE(f.label_text, n.note_text, ev.narrative_text, p.summary, '') AS content,
|
||||
f.is_open, d.document_type_id, d.asset_id, d.published_at, ev.occurred_at,
|
||||
f.is_open, d.document_type_id, d.asset_id, d.published_at, d.captured_at, d.source_uri,d.citation_text,citation.display_number,
|
||||
ev.occurred_at,claim.statement,
|
||||
p.party_kind, op.organization_kind,
|
||||
a.original_name, a.mime_type, a.byte_size,
|
||||
s.source_document_exhibit_id AS source_document_id, sr.region_key AS source_region_key
|
||||
@@ -322,6 +325,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
||||
LEFT JOIN osint.event_exhibits ev ON ev.exhibit_id = e.id
|
||||
LEFT JOIN osint.party_exhibits p ON p.exhibit_id = e.id
|
||||
LEFT JOIN osint.organization_parties op ON op.exhibit_id = e.id
|
||||
LEFT JOIN osint.claim_exhibits claim ON claim.exhibit_id = e.id
|
||||
LEFT JOIN osint.exhibit_citations citation ON citation.board_id=e.board_id AND citation.exhibit_id=e.id
|
||||
LEFT JOIN osint.assets a ON a.id = d.asset_id
|
||||
LEFT JOIN osint.exhibit_sources s ON s.exhibit_id = e.id
|
||||
LEFT JOIN osint.document_regions sr ON sr.id = s.source_region_id
|
||||
@@ -391,7 +396,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
||||
const documents: CaseDocument[] = exhibitsResult.rows.filter(row => row.exhibit_type_id === 'document').map(row => {
|
||||
const type = row.document_type_id || 'file'
|
||||
const publishedAt = row.published_at?.toISOString()
|
||||
return { ...base(row), type: 'document', publishedAt, requiredFlags: requirements.get(row.id) || [],
|
||||
return { ...base(row), type: 'document', publishedAt, capturedAt:row.captured_at?.toISOString(),sourceUri:row.source_uri || undefined,
|
||||
sourceCitation:row.citation_text || undefined,displayNumber:row.display_number || undefined,requiredFlags: requirements.get(row.id) || [],
|
||||
body: blocks.get(row.id) || [], regions: regions.get(row.id) || [], assetId: row.asset_id || undefined,
|
||||
fileName: row.original_name || undefined, mimeType: row.mime_type || undefined,
|
||||
fileSize: row.byte_size === null ? undefined : Number(row.byte_size), fileType: type, metadata: metadata.get(row.id) || {} }
|
||||
@@ -402,6 +408,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
||||
if (row.exhibit_type_id === 'folder') evidence.push({ ...common, type:'folder', isOpen:Boolean(row.is_open) })
|
||||
else if (row.exhibit_type_id === 'event') evidence.push({ ...common, type:'event', eventDate:row.occurred_at?.toISOString() })
|
||||
else if (row.exhibit_type_id === 'party') evidence.push({ ...common, type:'party', partyKind:row.party_kind || 'person', organizationKind:row.organization_kind || undefined, aliases:aliases.get(row.id) || [] })
|
||||
else if (row.exhibit_type_id === 'claim') evidence.push({ ...base(row), type:'claim', title:row.title, statement:row.statement || row.title })
|
||||
else if (row.exhibit_type_id === 'note') evidence.push({ ...common, type:'note' })
|
||||
else throw new Error(`Unsupported exhibit type ${row.exhibit_type_id}`)
|
||||
}
|
||||
@@ -413,13 +420,14 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
||||
const concepts: BriefConcept[] = conceptsResult.rows.map(row => ({ id: row.id, label: row.label, context: row.context_text,
|
||||
...(authorMode && row.expected_party_kind ? { expectedPartyKind: row.expected_party_kind } : {}), resolvedPartyExhibitId: row.resolved_party_exhibit_id || undefined }))
|
||||
const goals = await levelGoalStates(pool, level, authorMode)
|
||||
const report = await loadCaseReport(pool,level)
|
||||
const fullState: CaseState = { id: level.slug, title: level.title, subtitle: level.subtitle, exhibits: [...documents, ...evidence], relations,
|
||||
connections: connectionsResult.rows.map(row => ({ id: row.id, fromExhibitId: row.from_exhibit_id, toExhibitId: row.to_exhibit_id,
|
||||
label: row.label || undefined, tightness: row.tightness, tagStyle: row.tag_style,
|
||||
tagPosition: row.tag_position_percent, tagOffset: row.tag_lateral_offset })),
|
||||
viewport: { x: level.viewport_x, y: level.viewport_y, zoom: level.viewport_zoom }, updatedAt: level.updated_at.toISOString(),
|
||||
views, revision: Number(level.revision),
|
||||
brief: { body: briefResult.rows[0]?.body || '', concepts }, goals, levelStatus: level.status, editingAllowed: editingEnabled && authorMode,
|
||||
brief: { body: briefResult.rows[0]?.body || '', concepts }, goals, report, levelStatus: level.status, editingAllowed: editingEnabled && authorMode,
|
||||
sourceTemplateVersionId: level.source_template_version_id || undefined }
|
||||
return filterLevelVisibility(fullState, flagsResult.rows.map(row => row.flag_key), seenResult.rows.map(row => row.document_exhibit_id), authorMode)
|
||||
}
|
||||
@@ -461,9 +469,19 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
||||
await client.query('DELETE FROM osint.level_briefs WHERE board_id=$1', [level.board_id])
|
||||
await client.query('DELETE FROM osint.exhibit_sources WHERE exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)', [level.board_id])
|
||||
await client.query('DELETE FROM osint.metadata_fields WHERE board_id=$1', [level.board_id])
|
||||
for (const table of ['folder_exhibits', 'image_documents', 'note_exhibits', 'event_exhibits', 'person_parties', 'organization_parties', 'party_exhibits', 'document_exhibits']) {
|
||||
await client.query('DELETE FROM osint.document_flag_requirements WHERE board_id=$1',[level.board_id])
|
||||
await client.query('DELETE FROM osint.document_content_blocks WHERE document_exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)',[level.board_id])
|
||||
await client.query('DELETE FROM osint.document_regions WHERE document_exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)',[level.board_id])
|
||||
for (const table of ['folder_exhibits', 'image_documents', 'note_exhibits', 'event_exhibits', 'person_parties', 'organization_parties', 'party_exhibits', 'claim_exhibits']) {
|
||||
await client.query(`DELETE FROM osint.${table} WHERE exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)`, [level.board_id])
|
||||
}
|
||||
if (documentIds.size) {
|
||||
await client.query('DELETE FROM osint.document_exhibits WHERE exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1) AND NOT (exhibit_id=ANY($2::uuid[]))',[level.board_id,[...documentIds]])
|
||||
await client.query('DELETE FROM osint.exhibit_citations WHERE board_id=$1 AND NOT (exhibit_id=ANY($2::uuid[]))',[level.board_id,[...documentIds]])
|
||||
} else {
|
||||
await client.query('DELETE FROM osint.document_exhibits WHERE exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)',[level.board_id])
|
||||
await client.query('DELETE FROM osint.exhibit_citations WHERE board_id=$1',[level.board_id])
|
||||
}
|
||||
if (allIds.length) await client.query('DELETE FROM osint.exhibits WHERE board_id=$1 AND NOT (id = ANY($2::uuid[]))', [level.board_id, allIds])
|
||||
else await client.query('DELETE FROM osint.exhibits WHERE board_id=$1', [level.board_id])
|
||||
|
||||
@@ -473,10 +491,22 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
||||
ON CONFLICT (id) DO UPDATE SET exhibit_type_id=$3,xpos=$4,ypos=$5,width=$6,height=$7,rotation=$8,z_index=$9,hidden=$10,updated_at=NOW()`,
|
||||
[exhibit.id, level.board_id, exhibit.type, exhibit.x, exhibit.y, exhibit.width, exhibit.height, exhibit.rotation, exhibit.zIndex, exhibit.hidden])
|
||||
}
|
||||
let nextCitation = Number((await client.query<{ maximum:number }>('SELECT COALESCE(MAX(display_number),0)::int AS maximum FROM osint.exhibit_citations WHERE board_id=$1',[level.board_id])).rows[0].maximum)
|
||||
for (const document of documents) {
|
||||
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)`, [document.id, documentType(document), document.assetId || null, document.title,
|
||||
timestamp(document.publishedAt), timestamp(document.capturedAt), document.sourceUri || null])
|
||||
await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title,published_at,captured_at,source_uri,citation_text)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT (exhibit_id) DO UPDATE SET
|
||||
document_type_id=EXCLUDED.document_type_id,asset_id=EXCLUDED.asset_id,title=EXCLUDED.title,published_at=EXCLUDED.published_at,
|
||||
captured_at=EXCLUDED.captured_at,source_uri=EXCLUDED.source_uri,citation_text=EXCLUDED.citation_text`, [document.id, documentType(document), document.assetId || null, document.title,
|
||||
timestamp(document.publishedAt), timestamp(document.capturedAt), document.sourceUri || null,document.sourceCitation || ''])
|
||||
const existingCitation = await client.query<{ display_number:number }>('SELECT display_number FROM osint.exhibit_citations WHERE board_id=$1 AND exhibit_id=$2',[level.board_id,document.id])
|
||||
if (!existingCitation.rows[0]) {
|
||||
const requested = Number(document.displayNumber)
|
||||
const displayNumber = Number.isInteger(requested) && requested > 0
|
||||
&& !(await client.query('SELECT 1 FROM osint.exhibit_citations WHERE board_id=$1 AND display_number=$2',[level.board_id,requested])).rowCount
|
||||
? requested : ++nextCitation
|
||||
await client.query('INSERT INTO osint.exhibit_citations (board_id,exhibit_id,display_number) VALUES ($1,$2,$3)',[level.board_id,document.id,displayNumber])
|
||||
nextCitation=Math.max(nextCitation,displayNumber)
|
||||
}
|
||||
if (document.fileType === 'image') await client.query('INSERT INTO osint.image_documents (exhibit_id) VALUES ($1)', [document.id])
|
||||
for (const flag of new Set((document.requiredFlags || []).map(value => value.trim()).filter(Boolean).map(requireFlagKey))) await client.query(
|
||||
'INSERT INTO osint.document_flag_requirements (board_id,document_exhibit_id,flag_key) VALUES ($1,$2,$3)', [level.board_id, document.id, flag])
|
||||
@@ -504,6 +534,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
||||
for (const [sortOrder, alias] of (exhibit.aliases || []).filter(Boolean).entries()) await client.query(
|
||||
'INSERT INTO osint.party_aliases (id,party_exhibit_id,alias,sort_order) VALUES ($1,$2,$3,$4)', [randomUUID(), exhibit.id, alias, sortOrder])
|
||||
}
|
||||
if (isClaimExhibit(exhibit)) await client.query(
|
||||
'INSERT INTO osint.claim_exhibits (exhibit_id,statement) VALUES ($1,$2)', [exhibit.id,exhibit.statement.trim()])
|
||||
}
|
||||
|
||||
for (const relation of state.relations) {
|
||||
@@ -574,6 +606,10 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
||||
(id,board_id,label,context_text,sort_order,expected_party_kind,resolved_party_exhibit_id) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||
[concept.id, level.board_id, concept.label, concept.context, sortOrder, expected, concept.resolvedPartyExhibitId || null])
|
||||
}
|
||||
if (state.report) await client.query(`INSERT INTO osint.case_reports (board_id,title,required_for_completion)
|
||||
VALUES ($1,$2,$3) ON CONFLICT (board_id) DO UPDATE SET title=EXCLUDED.title,
|
||||
required_for_completion=EXCLUDED.required_for_completion,updated_at=NOW()`,
|
||||
[level.board_id,state.report.title,state.report.requiredForCompletion])
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -735,6 +771,9 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
||||
VALUES ($1,$2,'document',$3,$4,174,145,(SELECT COUNT(*) FROM osint.exhibits WHERE board_id=$2),FALSE)`, [exhibitId, level.board_id, xpos, ypos])
|
||||
await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title) VALUES ($1,$2,$3,$4)`,
|
||||
[exhibitId, fileType, assetId, file.originalname])
|
||||
const citation = await client.query<{ display_number:number }>(`INSERT INTO osint.exhibit_citations (board_id,exhibit_id,display_number)
|
||||
SELECT $1,$2,COALESCE(MAX(display_number),0)+1 FROM osint.exhibit_citations WHERE board_id=$1 RETURNING display_number`,
|
||||
[level.board_id,exhibitId])
|
||||
if (fileType === 'image') await client.query('INSERT INTO osint.image_documents (exhibit_id) VALUES ($1)', [exhibitId])
|
||||
if (extraction.status === 'succeeded' && extraction.text.trim()) await client.query(
|
||||
'INSERT INTO osint.document_content_blocks (id,document_exhibit_id,sort_order,content) VALUES ($1,$2,0,$3)', [randomUUID(), exhibitId, extraction.text.trim()])
|
||||
@@ -778,6 +817,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
||||
await client.query('UPDATE osint.boards SET revision=revision+1,updated_at=NOW() WHERE id=$1', [level.board_id])
|
||||
await client.query('COMMIT')
|
||||
return { id: exhibitId,type:'document',title:file.originalname,x:xpos,y:ypos,width:174,height:145,rotation:0,zIndex:0,hidden:false,
|
||||
displayNumber:citation.rows[0].display_number,
|
||||
fileType,metadata:{},body:extraction.status === 'succeeded' && extraction.text.trim() ? [extraction.text.trim()] : [],regions:[],assetId,
|
||||
fileName:file.originalname,mimeType:file.mimetype,fileSize:file.size,
|
||||
analysis:{ extractionStatus:extraction.status, matchedFlags:[...new Set(matchedFlags)], awardedFlags:[...new Set(awardedFlags)], goals } }
|
||||
|
||||
@@ -41,6 +41,7 @@ export function mergePlayerStateForPersistence(full: CaseState, visible: CaseSta
|
||||
const touchesUnavailable = (item: ExhibitRelation | Connection) => unavailableIds.has(item.fromExhibitId) || unavailableIds.has(item.toExhibitId)
|
||||
return {
|
||||
...submitted,
|
||||
report: full.report,
|
||||
exhibits: appendMissingById(submittedExhibits, preservedExhibits),
|
||||
relations: appendMissingById(submitted.relations, full.relations.filter(touchesUnavailable)),
|
||||
connections: appendMissingById(submitted.connections, full.connections.filter(touchesUnavailable)),
|
||||
|
||||
@@ -53,6 +53,7 @@ suite('PostgreSQL migrations', () => {
|
||||
'evidence_match_evaluations', 'evidence_match_anchor_evaluations',
|
||||
'level_goals', 'level_goal_flag_requirements',
|
||||
'evidence_semantic_rules', 'evidence_semantic_evaluations',
|
||||
'claim_exhibits', 'exhibit_citations', 'case_reports', 'case_report_submissions', 'case_report_submission_issues',
|
||||
]))
|
||||
expect(tableNames).not.toEqual(expect.arrayContaining(['cases', 'widgets', 'widget_relations', 'cutscenes', 'dialogue_steps', 'mystery_chapters', 'seen_dialogue']))
|
||||
const ledger = await client.query<{ count: string }>('SELECT COUNT(*)::text AS count FROM osint.schema_migrations')
|
||||
|
||||
@@ -24,7 +24,7 @@ export type PlaythroughAdvanceResult = {
|
||||
ok: boolean
|
||||
state?: PlaythroughState
|
||||
error?: string
|
||||
errorCode?: 'goals_incomplete'
|
||||
errorCode?: 'goals_incomplete' | 'report_incomplete'
|
||||
pendingGoals?: { key: string; title: string }[]
|
||||
}
|
||||
|
||||
@@ -317,6 +317,15 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
||||
return { ok:false,error:'Complete the level objective before continuing',errorCode:'goals_incomplete',
|
||||
pendingGoals:pendingGoals.map(goal => ({ key:goal.goal_key,title:goal.title })) }
|
||||
}
|
||||
const reportIncomplete = (await client.query<{ required:boolean;accepted:boolean }>(`SELECT report.required_for_completion AS required,
|
||||
EXISTS (SELECT 1 FROM osint.case_report_submissions submission
|
||||
WHERE submission.level_id=level.id AND submission.status='accepted') AS accepted
|
||||
FROM osint.levels level JOIN osint.case_reports report ON report.board_id=level.board_id
|
||||
WHERE level.id=$1`,[playthrough.current_level_id])).rows[0]
|
||||
if (reportIncomplete?.required && !reportIncomplete.accepted) {
|
||||
await client.query('ROLLBACK')
|
||||
return { ok:false,error:'Submit an accepted case report before continuing',errorCode:'report_incomplete' }
|
||||
}
|
||||
await client.query(`INSERT INTO osint.achievements (playthrough_id,flag_key,awarded_by_node_id)
|
||||
SELECT $1,requirement.flag_key,$3 FROM osint.levels level
|
||||
JOIN osint.level_goals goal ON goal.board_id=level.board_id AND goal.enabled
|
||||
|
||||
Reference in New Issue
Block a user