Files
gupi-osint-board/server/caseReports.ts
T

145 lines
11 KiB
TypeScript

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() }
}