Add Scene 7 claim report workflow
This commit is contained in:
@@ -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() }
|
||||
}
|
||||
Reference in New Issue
Block a user