diff --git a/e2e/scene7.acceptance.spec.ts b/e2e/scene7.acceptance.spec.ts index 638a47b..351b03d 100644 --- a/e2e/scene7.acceptance.spec.ts +++ b/e2e/scene7.acceptance.spec.ts @@ -2,11 +2,20 @@ import { expect, test } from '@playwright/test' test('pasting, connecting, and citing one patent screenshot completes the Scene 7 report lesson', async ({ page, request }) => { test.setTimeout(60_000) - const levels = await (await request.get('/api/levels')).json() as { id:string }[] - const sceneSeven = levels.find(level => level.id.startsWith('barricelli-inventor-proof-case-')) + const pageErrors:Error[]=[] + page.on('pageerror',error => pageErrors.push(error)) + let sceneSeven:{ id:string } | undefined + await expect.poll(async () => { + const levels=await (await request.get('/api/levels')).json() as { id:string }[] + sceneSeven=levels.find(level => level.id.startsWith('barricelli-inventor-proof-case-')) + return Boolean(sceneSeven) + },{ timeout:20_000,message:'Scene 7 playable clone should be seeded' }).toBe(true) if (!sceneSeven) throw new Error('Scene 7 playable clone was not seeded') + const sceneSevenId=sceneSeven.id - await page.goto(`/level/${sceneSeven.id}`) + await page.goto(`/level/${sceneSevenId}`) + await page.waitForTimeout(250) + if (pageErrors.length) throw new Error(`Scene 7 failed to render: ${pageErrors.map(error => error.message).join('; ')}`) await expect(page.getByRole('heading', { name:'The Barricelli Files' })).toBeVisible() await expect(page.locator('.brief-panel')).toBeVisible() await expect(page.locator('.brief-goals')).toContainText('Prove Nils Aall Barricelli was an inventor') @@ -14,7 +23,7 @@ test('pasting, connecting, and citing one patent screenshot completes the Scene await page.getByRole('button', { name:'BEGIN INVESTIGATION',exact:true }).click() const uploadResponse = page.waitForResponse(response => response.request().method() === 'POST' - && response.url().includes(`/api/levels/${sceneSeven.id}/documents`) && response.status() === 201) + && response.url().includes(`/api/levels/${sceneSevenId}/documents`) && response.status() === 201) await page.evaluate(async () => { const canvas = document.createElement('canvas') canvas.width = 1280 @@ -46,6 +55,7 @@ test('pasting, connecting, and citing one patent screenshot completes the Scene await page.getByRole('button',{ name:'Classify as Clip' }).click() await expect(page.locator('.source-file-widget')).toHaveCount(1) await expect(page.locator('.source-file-widget')).toHaveAttribute('data-capture-kind','clipping') + await expect(page.locator('.source-file-widget > strong')).toHaveCount(0) await expect(page.locator('.source-file-widget')).toHaveClass(/\barriving\b/) await expect(page.locator('.goal-complete-card')).toHaveCount(0) await expect(page.locator('.evidence-card.claim')).toContainText('Nils Aall Barricelli was an inventor') @@ -60,35 +70,47 @@ test('pasting, connecting, and citing one patent screenshot completes the Scene await page.getByRole('button',{ name:'FILE',exact:true }).click() await page.getByRole('menuitem',{ name:/INFO/ }).click() await expect(page.getByLabel('Board presentation')).toHaveValue('clipping') + await expect(page.getByLabel('Document title')).toHaveValue('') + await page.getByLabel('Published date').fill('1953-08-19') + await expect(page.getByLabel('Published time')).toHaveValue('') + await page.getByLabel('Source publication').fill('Google Patents · GB695913A') + await page.getByLabel('Source URL').fill('https://patents.google.com/patent/GB695913A/en') + await page.getByRole('button',{ name:'SAVE METADATA' }).click() + await expect(page.locator('.file-editor')).toHaveCount(0) + await page.getByRole('button',{ name:'FILE',exact:true }).click() + await page.getByRole('menuitem',{ name:/INFO/ }).click() + await expect(page.getByLabel('Document title')).toHaveValue('Google Patents · GB695913A') + await expect(page.getByLabel('Source publication')).toHaveValue('Google Patents · GB695913A') await page.getByRole('button',{ name:'Close file editor' }).click() - await page.getByRole('button',{ name:'Close document' }).click() + await page.getByRole('button',{ name:'Close document',exact:true }).click() await page.locator('.evidence-card.claim').click() await page.getByRole('button',{ name:'Red thread' }).click() await page.locator('.source-file-widget').click() await expect(page.locator('.thread-editor')).toBeVisible() await expect(page.getByLabel('Thread tag')).toHaveValue('Proof that…') + await page.getByLabel('Thread tag').fill('Proof that Nils Aall Barricelli is named as inventor on patent GB695913A.') await page.getByRole('button',{ name:'ADD TAG & TIGHTEN' }).click() await page.getByRole('button',{ name:'Case report' }).click() await expect(page.locator('.case-report')).toBeVisible() await expect(page.locator('.report-claim')).toContainText('Nils Aall Barricelli was an inventor') await expect(page.locator('.report-evidence')).toContainText('Exhibit 1') - await page.getByRole('button',{ name:'SUBMIT CASE REPORT' }).click() - await expect(page.locator('.report-verdict')).toContainText("The evidence is good enough, but the report itself won't hold up in court") - - await page.getByLabel('Evidence statement for Exhibit 1').fill('Proof that Nils Aall Barricelli is named as inventor on patent GB695913A.') - await page.getByLabel('Date for Exhibit 1').fill('1953-08-19') - await page.getByLabel('Source for Exhibit 1').fill('Google Patents · GB695913A') - await page.getByLabel('Source link for Exhibit 1').fill('https://patents.google.com/patent/GB695913A/en') + await expect(page.locator('.report-evidence')).toContainText('Proof that Nils Aall Barricelli is named as inventor on patent GB695913A.') + await expect(page.locator('.report-evidence')).toContainText('1953-08-19') + await expect(page.locator('.report-evidence')).toContainText('Google Patents · GB695913A') + await expect(page.locator('.report-evidence a')).toHaveAttribute('href','https://patents.google.com/patent/GB695913A/en') await page.getByRole('button',{ name:'SUBMIT CASE REPORT' }).click() await expect(page.locator('.report-verdict')).toContainText('Case report accepted') await expect(page.getByRole('button',{ name:/CLOSE CASE/ })).toBeVisible() - const level = await (await request.get(`/api/levels/${sceneSeven.id}`)).json() + const level = await (await request.get(`/api/levels/${sceneSevenId}`)).json() expect(level.goals).toEqual([expect.objectContaining({ key:'barricelli.inventor-proof',status:'complete',newlyCompleted:false })]) - expect(level.exhibits.filter((exhibit: { type:string }) => exhibit.type === 'document')).toEqual([ - expect.objectContaining({ captureKind:'clipping',width:210,height:194 }), + const savedDocuments=level.exhibits.filter((exhibit: { type:string }) => exhibit.type === 'document') + expect(savedDocuments).toEqual([ + expect.objectContaining({ title:'Google Patents · GB695913A',captureKind:'clipping',width:230,height:290 }), ]) + expect(savedDocuments[0].rotation).toBeGreaterThanOrEqual(-10) + expect(savedDocuments[0].rotation).toBeLessThanOrEqual(10) expect(level.report).toMatchObject({ status:'accepted',investigatorName:'Player' }) }) diff --git a/migrations/037_note_presentation.sql b/migrations/037_note_presentation.sql new file mode 100644 index 0000000..1137cad --- /dev/null +++ b/migrations/037_note_presentation.sql @@ -0,0 +1,6 @@ +ALTER TABLE osint.note_exhibits + ADD COLUMN presentation_kind TEXT NOT NULL DEFAULT 'luggage' + CHECK (presentation_kind IN ('luggage', 'lined_sheet')); + +COMMENT ON COLUMN osint.note_exhibits.presentation_kind IS + 'Visual presentation of a note exhibit. Notebook tear-outs use lined_sheet; ordinary working notes use luggage.'; diff --git a/migrations/038_expand_clipping_cards.sql b/migrations/038_expand_clipping_cards.sql new file mode 100644 index 0000000..cb65935 --- /dev/null +++ b/migrations/038_expand_clipping_cards.sql @@ -0,0 +1,9 @@ +UPDATE osint.exhibits AS exhibit +SET width = 220, + height = 272, + updated_at = NOW() +FROM osint.document_exhibits AS document +WHERE document.exhibit_id = exhibit.id + AND document.capture_kind_id = 'clipping' + AND exhibit.width = 210 + AND exhibit.height = 194; diff --git a/migrations/039_clipping_titles_from_source.sql b/migrations/039_clipping_titles_from_source.sql new file mode 100644 index 0000000..3dbf1fd --- /dev/null +++ b/migrations/039_clipping_titles_from_source.sql @@ -0,0 +1,13 @@ +UPDATE osint.document_exhibits AS document +SET title = document.citation_text +WHERE document.capture_kind_id = 'clipping' + AND BTRIM(document.citation_text) <> '' + AND ( + BTRIM(document.title) = '' + OR EXISTS ( + SELECT 1 + FROM osint.assets AS asset + WHERE asset.id = document.asset_id + AND document.title = asset.original_name + ) + ); diff --git a/migrations/040_edge_to_edge_clipping_cards.sql b/migrations/040_edge_to_edge_clipping_cards.sql new file mode 100644 index 0000000..d8a451e --- /dev/null +++ b/migrations/040_edge_to_edge_clipping_cards.sql @@ -0,0 +1,8 @@ +UPDATE osint.exhibits AS exhibit +SET height = 220, + updated_at = NOW() +FROM osint.document_exhibits AS document +WHERE document.exhibit_id = exhibit.id + AND document.capture_kind_id = 'clipping' + AND exhibit.width = 220 + AND exhibit.height = 272; diff --git a/migrations/041_canonical_clipping_cards.sql b/migrations/041_canonical_clipping_cards.sql new file mode 100644 index 0000000..07c6da6 --- /dev/null +++ b/migrations/041_canonical_clipping_cards.sql @@ -0,0 +1,22 @@ +UPDATE osint.exhibits AS exhibit +SET width = 220, + height = 220, + updated_at = NOW() +FROM osint.document_exhibits AS document +WHERE document.exhibit_id = exhibit.id + AND document.capture_kind_id = 'clipping' + AND (exhibit.width <> 220 OR exhibit.height <> 220); + +UPDATE osint.document_exhibits AS document +SET title = document.citation_text +WHERE document.capture_kind_id = 'clipping' + AND BTRIM(document.citation_text) <> '' + AND ( + BTRIM(document.title) = '' + OR EXISTS ( + SELECT 1 + FROM osint.assets AS asset + WHERE asset.id = document.asset_id + AND document.title = asset.original_name + ) + ); diff --git a/migrations/042_mounted_clipping_cards.sql b/migrations/042_mounted_clipping_cards.sql new file mode 100644 index 0000000..d2a6b38 --- /dev/null +++ b/migrations/042_mounted_clipping_cards.sql @@ -0,0 +1,8 @@ +UPDATE osint.exhibits AS exhibit +SET width = 230, + height = 290, + updated_at = NOW() +FROM osint.document_exhibits AS document +WHERE document.exhibit_id = exhibit.id + AND document.capture_kind_id = 'clipping' + AND (exhibit.width <> 230 OR exhibit.height <> 290); diff --git a/server/api.integration.test.ts b/server/api.integration.test.ts index 659667b..131f10a 100644 --- a/server/api.integration.test.ts +++ b/server/api.integration.test.ts @@ -104,7 +104,7 @@ suite('normalized level persistence API', () => { const document: DocumentExhibit = { id: randomUUID(), type: 'document', title: 'Evidence', publishedAt: '2021-04-17T12:00:00Z', body: ['Extracted body'], regions: [{ id: 'stamp', label: 'Date stamp', excerpt: '17 April 2021', date: '2021-04-17T09:00:00Z' }], fileType: 'image', captureKind:'full_page',metadata: {}, ...placed(1051, 417, 205, 282, 2) } const gatedDocument: DocumentExhibit = { id: randomUUID(), type: 'document', title: 'Later tip', body: [], regions: [], fileType: 'image', captureKind:'clipping',metadata: {}, requiredFlags: ['tip.received'], ...placed(1260, 417, 210, 178, 3) } const folder: FolderExhibit = { id: randomUUID(), type: 'folder', title: 'Folder', content: 'Evidence folder', isOpen: true, ...placed(685, 417, 260, 166) } - const note: NoteExhibit = { id: randomUUID(), type: 'note', title: 'Extract', content: 'Date matters', ...placed(420, 300, 108, 154) } + const note: NoteExhibit = { id:randomUUID(),type:'note',title:'Extract',content:'Date matters',presentation:'lined_sheet',...placed(420,300,220,270) } const event: EventExhibit = { id: randomUUID(), type: 'event', title: 'The meeting occurred', content: 'The evidence places the meeting on 18 April.', eventDate: '2021-04-18T14:30:00Z', ...placed(520, 610, 270, 174) } const party: PartyExhibit = { id: randomUUID(), type: 'party', partyKind: 'person', title: 'Ada Lovelace', content: 'Named as correspondent.', aliases: ['A. A. L.'], ...placed(720, 250, 280, 190) } state.exhibits = [document, gatedDocument, folder, note, event, party] @@ -123,6 +123,7 @@ suite('normalized level persistence API', () => { const loaded = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState expect(loaded.views[0]).toMatchObject({ type: 'timeline', rangeMode: 'fixed', range: timeline.range }) expect(loaded.exhibits.find(item => item.id === folder.id)).toMatchObject({ x: 685, y: 417, isOpen: true }) + expect(loaded.exhibits.find(item => item.id === note.id)).toMatchObject({ presentation:'lined_sheet',width:220,height:270 }) expect(loaded.exhibits.find(item => item.id === document.id)).toMatchObject({ captureKind:'full_page',width:205,height:282 }) expect(loaded.relations).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'supports', fromExhibitId: event.id, toExhibitId: note.id })])) expect(loaded.connections[0]).toMatchObject({ fromExhibitId: folder.id, toExhibitId: document.id, label: 'Primary source' }) @@ -234,6 +235,7 @@ suite('normalized level persistence API', () => { expect(clone.exhibits.find(item => item.type === 'folder')).toMatchObject({ x: 685, y: 417 }) expect(clone.relations.filter(relation => relation.type === 'supports')).toHaveLength(2) expect(clone.connections[0]).toMatchObject({ label: 'Primary source', tightness: 85 }) + expect(clone.exhibits.find(item => item.type === 'note')).toMatchObject({ presentation:'lined_sheet',width:220,height:270 }) expect(clone.brief.concepts[0].resolvedPartyExhibitId).not.toBe(party.id) const authoredClone = await (await adminFetch(`${baseUrl}/api/levels/${clone.id}?edit=1`)).json() as CaseState expect(authoredClone.exhibits.find(item => item.type === 'document' && item.title === 'Later tip')).toMatchObject({ captureKind:'clipping',requiredFlags: ['tip.received'] }) @@ -330,7 +332,7 @@ suite('normalized level persistence API', () => { relatedState.connections.push({ id:relatedConnectionId,fromExhibitId:relatedClaim.id,toExhibitId:fatherDocument.id,label:'Proof that the Barricelli family included an inventor.',tightness:65,tagStyle:'luggage',tagPosition:50,tagOffset:0 }) expect((await fetch(`${baseUrl}/api/levels/${relatedState.id}`,{ method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(relatedState) })).status).toBe(200) const relatedSubmission=await (await fetch(`${baseUrl}/api/levels/${relatedState.id}/report/submissions`,{ method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ - investigatorName:'Test Player',evidence:[{ connectionId:relatedConnectionId,documentExhibitId:fatherDocument.id,relationText:'Proof that the Barricelli family included an inventor.' }], + investigatorName:'Test Player', }) })).json() expect(relatedSubmission).toMatchObject({ status:'evidence_insufficient',issues:expect.arrayContaining(['missing_accepted_evidence','connected_evidence_unverified']), feedback:expect.stringContaining('related person rather than the claim subject'),claims:[expect.objectContaining({ evidence:[expect.objectContaining({ @@ -350,14 +352,24 @@ suite('normalized level persistence API', () => { 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…' }], + investigatorName:'Test Player', + // Legacy/forged report fields must not mutate board-owned provenance. + evidence:[{ connectionId,documentExhibitId:targetDocument.id,relationText:'Forged complete statement',publishedAt:'1953-08-19',sourceCitation:'Forged source' }], }) }) 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 targetOnBoard=reportState.exhibits.find(exhibit => exhibit.type === 'document' && exhibit.id === targetDocument.id) + if (!targetOnBoard || targetOnBoard.type !== 'document') throw new Error('Target document missing from board') + targetOnBoard.publishedAt='1953-08-19T00:00:00.000Z' + targetOnBoard.sourceCitation='Google Patents · GB695913A' + targetOnBoard.sourceUri='https://patents.google.com/patent/GB695913A/en' + const targetConnection=reportState.connections.find(connection => connection.id === connectionId) + if (!targetConnection) throw new Error('Target connection missing from board') + targetConnection.label='Proof that Barricelli is named as the inventor on patent GB695913A.' + expect((await fetch(`${baseUrl}/api/levels/${reportState.id}`,{ method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(reportState) })).status).toBe(200) 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' }], + investigatorName:'Test Player', }) }) expect(accepted.status).toBe(201) const acceptedBody=await accepted.json() @@ -376,10 +388,14 @@ suite('normalized level persistence API', () => { const repeatedState=await (await fetch(`${baseUrl}/api/levels/${reportState.id}`)).json() as CaseState const repeatedConnectionId=randomUUID() repeatedState.connections.push({ id:repeatedConnectionId,fromExhibitId:claim.id,toExhibitId:repeatedDocument.id,label:'Proof that Barricelli is named as the inventor on patent GB695913A.',tightness:65,tagStyle:'luggage',tagPosition:50,tagOffset:0 }) + const repeatedOnBoard=repeatedState.exhibits.find(exhibit => exhibit.type === 'document' && exhibit.id === repeatedDocument.id) + if (!repeatedOnBoard || repeatedOnBoard.type !== 'document') throw new Error('Repeated document missing from board') + repeatedOnBoard.publishedAt='1953-08-19T00:00:00.000Z' + repeatedOnBoard.sourceCitation='Google Patents · GB695913A' + repeatedOnBoard.sourceUri='https://patents.google.com/patent/GB695913A/en' expect((await fetch(`${baseUrl}/api/levels/${repeatedState.id}`,{ method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(repeatedState) })).status).toBe(200) const repeatedReport=await (await fetch(`${baseUrl}/api/levels/${repeatedState.id}/report/submissions`,{ method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ - investigatorName:'Test Player',evidence:[{ connectionId:repeatedConnectionId,documentExhibitId:repeatedDocument.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' }], + investigatorName:'Test Player', }) })).json() expect(repeatedReport).toMatchObject({ status:'accepted',claims:[expect.objectContaining({ evidence:expect.arrayContaining([expect.objectContaining({ documentExhibitId:repeatedDocument.id,evidenceAccepted:true,verification:expect.objectContaining({ status:'accepted' }), diff --git a/server/boardClone.ts b/server/boardClone.ts index de6d326..a4f4afd 100644 --- a/server/boardClone.ts +++ b/server/boardClone.ts @@ -140,10 +140,11 @@ export async function cloneBoard(client: PoolClient, sourceBoardId: string, targ 'INSERT INTO osint.image_documents (exhibit_id,pixel_width,pixel_height,alt_text) VALUES ($1,$2,$3,$4)', [mapped(exhibitIds, row.exhibit_id, 'image'), row.pixel_width, row.pixel_height, row.alt_text]) - const notes = await client.query<{ exhibit_id: string; title: string; note_text: string }>( + const notes = await client.query<{ exhibit_id: string; title: string; note_text: string; presentation_kind:'luggage'|'lined_sheet' }>( `SELECT n.* FROM osint.note_exhibits n JOIN osint.exhibits e ON e.id=n.exhibit_id WHERE e.board_id=$1`, [sourceBoardId]) - 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]) + for (const row of notes.rows) await client.query( + 'INSERT INTO osint.note_exhibits (exhibit_id,title,note_text,presentation_kind) VALUES ($1,$2,$3,$4)', + [mapped(exhibitIds,row.exhibit_id,'note'),row.title,row.note_text,row.presentation_kind]) const claims = await client.query<{ exhibit_id:string;statement:string }>( `SELECT claim.exhibit_id,claim.statement FROM osint.claim_exhibits claim diff --git a/server/caseReports.ts b/server/caseReports.ts index cac9bc2..13d97cd 100644 --- a/server/caseReports.ts +++ b/server/caseReports.ts @@ -5,18 +5,6 @@ import type { CaseReport, CaseReportEvidence, CaseReportSubmissionInput, CaseRep 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()) } @@ -130,29 +118,6 @@ export async function submitCaseReport(pool: Pool, levelSlug: string, rawInput: 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 diff --git a/server/index.ts b/server/index.ts index 8c4fc0e..e9a29e2 100644 --- a/server/index.ts +++ b/server/index.ts @@ -213,7 +213,6 @@ 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) } diff --git a/server/levelRepository.ts b/server/levelRepository.ts index 54ba2ce..e12dd58 100644 --- a/server/levelRepository.ts +++ b/server/levelRepository.ts @@ -87,6 +87,7 @@ type ExhibitRow = { 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 + note_presentation_kind: 'luggage' | 'lined_sheet' | null party_kind: PartyKind | null; organization_kind: OrganizationKind | null } @@ -352,6 +353,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec 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.capture_kind_id, d.asset_id, d.published_at, d.captured_at, d.source_uri,d.citation_text,citation.display_number, + n.presentation_kind AS note_presentation_kind, ev.occurred_at,claim.statement, p.party_kind, op.organization_kind, a.original_name, a.mime_type, a.byte_size, @@ -448,7 +450,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec 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 if (row.exhibit_type_id === 'note') evidence.push({ ...common, type:'note', presentation:row.note_presentation_kind || 'luggage' }) else throw new Error(`Unsupported exhibit type ${row.exhibit_type_id}`) } const views: BoardView[] = viewsResult.rows.map(row => ({ id: row.id, type: 'timeline', visible: row.visible, zIndex: row.z_index, @@ -525,18 +527,21 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec else await client.query('DELETE FROM osint.exhibits WHERE board_id=$1', [level.board_id]) for (const exhibit of state.exhibits) { + const clipping=exhibit.type === 'document' && documentCaptureKind(exhibit.captureKind) === 'clipping' await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,rotation,z_index,hidden) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) 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]) + [exhibit.id,level.board_id,exhibit.type,exhibit.x,exhibit.y,clipping ? 230 : exhibit.width,clipping ? 290 : 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) { + const clippingTitle=document.captureKind === 'clipping' && document.sourceCitation?.trim() + && (!document.title.trim() || document.title === document.fileName) ? document.sourceCitation.trim() : document.title await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,capture_kind_id,asset_id,title,published_at,captured_at,source_uri,citation_text) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) 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, capture_kind_id=EXCLUDED.capture_kind_id,captured_at=EXCLUDED.captured_at,source_uri=EXCLUDED.source_uri,citation_text=EXCLUDED.citation_text`, - [document.id, documentType(document), documentCaptureKind(document.captureKind), document.assetId || null, document.title, + [document.id, documentType(document), documentCaptureKind(document.captureKind), document.assetId || null, clippingTitle, 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]) { @@ -560,7 +565,9 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec if (isFolderExhibit(exhibit)) await client.query( 'INSERT INTO osint.folder_exhibits (exhibit_id,title,label_text,is_open) VALUES ($1,$2,$3,$4)', [exhibit.id, exhibit.title, exhibit.content, exhibit.isOpen]) - if (exhibit.type === 'note') await client.query('INSERT INTO osint.note_exhibits (exhibit_id,title,note_text) VALUES ($1,$2,$3)', [exhibit.id, exhibit.title, exhibit.content]) + if (exhibit.type === 'note') await client.query( + 'INSERT INTO osint.note_exhibits (exhibit_id,title,note_text,presentation_kind) VALUES ($1,$2,$3,$4)', + [exhibit.id,exhibit.title,exhibit.content,exhibit.presentation === 'lined_sheet' ? 'lined_sheet' : 'luggage']) if (isEventExhibit(exhibit)) await client.query( 'INSERT INTO osint.event_exhibits (exhibit_id,title,narrative_text,occurred_at) VALUES ($1,$2,$3,$4)', [exhibit.id, exhibit.title, exhibit.content, timestamp(exhibit.eventDate)]) diff --git a/server/levelVisibility.test.ts b/server/levelVisibility.test.ts index fe608ec..1231260 100644 --- a/server/levelVisibility.test.ts +++ b/server/levelVisibility.test.ts @@ -5,7 +5,7 @@ import { filterLevelVisibility, mergePlayerStateForPersistence } from './levelVi const placed = { x: 10, y: 20, width: 174, height: 145, rotation: 0, zIndex: 1, hidden: false } const open: DocumentExhibit = { id: 'open', type: 'document', title: 'Open', body: [], regions: [], fileType: 'image', captureKind:'unclassified',metadata: {}, ...placed } const gated: DocumentExhibit = { id: 'gated', type: 'document', title: 'Gated', body: [], regions: [], fileType: 'image', captureKind:'unclassified',metadata: {}, requiredFlags: ['tip.received'], ...placed } -const note: NoteExhibit = { id: 'note', type: 'note', title: 'Note', content: '', ...placed } +const note: NoteExhibit = { id:'note',type:'note',title:'Note',content:'',presentation:'luggage',...placed } const state: CaseState = { id: 'demo', title: 'Demo', subtitle: '', exhibits: [open, gated, note], viewport: { x: 0, y: 0, zoom: 1 }, relations: [{ id: 'source', type: 'source', fromExhibitId: note.id, toExhibitId: gated.id, sortOrder: 0 }], diff --git a/src/App.tsx b/src/App.tsx index a5650cf..e7c8d8a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { BookOpen, Building2, CalendarClock, Camera, Check, ChevronRight, CircleHelp, ClipboardCheck, FileText, FolderOpen, Hand, Image as ImageIcon, Images, Info, Link2, Minus, MousePointer2, Network, Newspaper, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react' -import type { BriefConcept, CaseDocument, CaseReport, CaseReportSubmissionInput, CaseState, Connection, DocumentCaptureKind, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types' +import type { BriefConcept, CaseDocument, CaseReport, CaseReportSubmissionInput, CaseState, Connection, DocumentCaptureKind, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, NotePresentation, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types' import { AdminPanel } from './admin' import { audio } from './audio' import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain' @@ -28,6 +28,12 @@ function documentSearchText(document: CaseDocument) { ...document.body, ...document.regions.flatMap(region => [region.label, region.excerpt, region.date]), ...Object.entries(document.metadata).flatMap(([key, value]) => [key, value])].filter(Boolean).join('\n').toLocaleLowerCase() } +function clippingInsetRotation(id:string) { + let hash=0 + for (const character of id) hash=(hash * 31 + character.charCodeAt(0)) >>> 0 + const degrees=((hash % 49) - 24) / 10 + return degrees === 0 ? .7 : degrees +} function connectionPoint(item: Exhibit) { return exhibitWidget(item.type).connectionPorts(item)[0] } @@ -214,12 +220,14 @@ export function App() { setOpenDoc(null); setSelected(ev.id); setRecentlyCreatedExhibitId(ev.id); setStatus('EVIDENCE EXTRACTED · PROVENANCE ATTACHED') } - const addNote = (preset?: string) => { + const addNote = (preset?: string, presentation:NotePresentation = 'luggage') => { const content = typeof preset === 'string' ? preset : window.prompt('What do you think this evidence means?')?.trim() if (!content || !caseState) return const { viewport } = caseState - const position = nextOpenBoardPosition(caseState.exhibits, { x: Math.max(100, (500 - viewport.x) / viewport.zoom), y: Math.max(100, (330 - viewport.y) / viewport.zoom) }, { width: 108 }) - const note: Evidence = { id: uid('note'), type: 'note', title: 'WORKING NOTE', content, ...placement(position.x, position.y, 108, 154) } + const size = presentation === 'lined_sheet' ? { width:220,height:270 } : { width:108,height:154 } + const position = nextOpenBoardPosition(caseState.exhibits, { x: Math.max(100, (500 - viewport.x) / viewport.zoom), y: Math.max(100, (330 - viewport.y) / viewport.zoom) }, { width:size.width }) + const note: Evidence = { id: uid('note'), type:'note', title:presentation === 'lined_sheet' ? 'FIELD NOTE' : 'WORKING NOTE', content,presentation, + ...placement(position.x,position.y,size.width,size.height) } update(s => ({ ...s, exhibits: [...s.exhibits, note] })); setSelected(note.id); setRecentlyCreatedExhibitId(note.id) } @@ -439,7 +447,10 @@ export function App() { const classifyDocument = (documentId:string,captureKind:DocumentCaptureKind) => { const presentation=documentCapture(captureKind) - const classify = (document:CaseDocument):CaseDocument => ({ ...document,captureKind,width:presentation.defaultSize.width,height:presentation.defaultSize.height }) + const initialClipRotation=Math.round((Math.random() * 20 - 10) * 10) / 10 + const classify = (document:CaseDocument):CaseDocument => ({ ...document,captureKind,width:presentation.defaultSize.width,height:presentation.defaultSize.height, + title:captureKind === 'clipping' && document.captureKind === 'unclassified' && document.title === document.fileName ? '' : document.title, + rotation:captureKind === 'clipping' && document.captureKind === 'unclassified' ? initialClipRotation : document.rotation }) update(state => ({ ...state,exhibits:state.exhibits.map(exhibit => exhibit.id === documentId && exhibit.type === 'document' ? classify(exhibit) : exhibit) })) @@ -529,12 +540,16 @@ export function App() { const reportAttentionCount = caseState.report?.requiredForCompletion && caseState.report.status !== 'accepted' ? 1 : 0 const activeGoal = caseState.goals.find(goal => goal.status === 'pending') || caseState.goals[0] const canAuthor = isAdmin && requestedEditMode && Boolean(caseState.editingAllowed) - const temporalItems: TemporalItem[] = caseState.exhibits.flatMap(exhibit => exhibitWidget(exhibit.type).temporalFacts(exhibit).map(fact => { + const temporalItems: TemporalItem[] = caseState.exhibits.flatMap(exhibit => { + const widget=exhibitWidget(exhibit.type) + if (!widget) throw new Error(`No widget is registered for exhibit type “${String(exhibit.type)}”`) + return widget.temporalFacts(exhibit).map(fact => { const membership = exhibit.type === 'document' ? caseState.relations.find(relation => relation.type === 'contains' && relation.toExhibitId === exhibit.id) : undefined const folder = membership ? caseState.exhibits.find(candidate => candidate.id === membership.fromExhibitId && candidate.type === 'folder') as FolderExhibit | undefined : undefined const sourceTemporalId = folder && !folder.isOpen ? `widget:${folder.id}` : `widget:${exhibit.id}` return { id: fact.id, sourceTemporalId, date: fact.start, label: fact.label, kind: exhibit.type === 'document' ? 'document' as const : 'widget' as const, exhibitId: exhibit.id } - })).sort((a, b) => dateValue(a.date) - dateValue(b.date)) + }) + }).sort((a, b) => dateValue(a.date) - dateValue(b.date)) const storyEvents = evidence.filter((item): item is EventExhibit => item.type === 'event').sort((a, b) => { if (!a.eventDate) return b.eventDate ? 1 : 0 if (!b.eventDate) return -1 @@ -544,7 +559,7 @@ export function App() { return
{inventoryOpen && activePlaythroughId && window.location.assign('/?resume=1') }} - onTearToBoard={text => { addNote(text); setInventoryOpen(false) }} onClose={() => setInventoryOpen(false)} /> + onTearToBoard={text => { addNote(text,'lined_sheet'); setInventoryOpen(false) }} onClose={() => setInventoryOpen(false)} /> }
GUOSINT BOARD / {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}
@@ -949,20 +964,21 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx {containmentRelations.map(relation => { const folder = byId.get(relation.fromExhibitId), document = byId.get(relation.toExhibitId); if (folder?.type !== 'folder' || document?.type !== 'document') return null; const origin = { x: folder.x + folder.width / 2, y: folder.y + 78 }; return })} - {evidenceExhibits(state.exhibits).filter(exhibit => !exhibit.hidden).map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = byId.get(id); return document?.type === 'document' ? [document] : [] }); const locatedDocumentId = ev.type === 'folder' && !ev.isOpen && containedDocuments.some(document => document.id === locatorDocumentId) ? locatorDocumentId : undefined; const definition = exhibitWidget(ev.type); const Widget = definition.Component; const containsArrival = ev.type === 'folder' && containedDocuments.some(document => arrivingExhibitIds.includes(document.id)); return
!exhibit.hidden).map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = byId.get(id); return document?.type === 'document' ? [document] : [] }); const locatedDocumentId = ev.type === 'folder' && !ev.isOpen && containedDocuments.some(document => document.id === locatorDocumentId) ? locatorDocumentId : undefined; const definition = exhibitWidget(ev.type); const Widget = definition.Component; const containsArrival = ev.type === 'folder' && containedDocuments.some(document => arrivingExhibitIds.includes(document.id)); const notePresentation=ev.type === 'note' ? ev.presentation === 'lined_sheet' ? 'lined-sheet' : 'luggage-tag' : ''; return
{ e.stopPropagation(); if (linkFrom && e.button === 0) return; if (ev.type === 'folder') startFolderLongPress(e, ev.id); if (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } else if (e.button === 0) pointerDown(e, { kind: 'widget', id: ev.id }) }} onPointerMove={e => { e.stopPropagation(); pointerMove(e) }} onPointerUp={e => { e.stopPropagation(); finishDrag(e) }} onPointerCancel={e => { e.stopPropagation(); finishDrag(e) }} onAuxClick={e => { if (e.button === 1) e.preventDefault() }} onClick={e => { e.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (ev.type === 'folder' && e.detail > 1) return; if (tool === 'move') onCardClick(ev.id) }} onDoubleClick={e => { e.stopPropagation(); if (ev.type === 'folder' && tool === 'move' && !linkFrom && !(e.target as HTMLElement).closest('button')) toggleFolder(ev.id) }} onKeyDown={e => { if (ev.type === 'folder' && e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); toggleFolder(ev.id) } }}>
{definition.heading(ev, widgetContext)}{String(i + 1).padStart(3, '0')}
})} - {documentExhibits(state.exhibits).filter(document => !document.hidden).map(document => { const membership = containmentRelations.find(relation => relation.toExhibitId === document.id); const folder = membership ? byId.get(membership.fromExhibitId) : undefined; const open = folder?.type !== 'folder' || folder.isOpen; const located = open && locatorDocumentId === document.id; const left = open ? document.x : folder.x + folder.width / 2 - document.width / 2, top = open ? document.y : folder.y + 45; const definition = documentWidget(document.fileType); const presentation=documentCapture(document.captureKind); const identification=mugshotIdentification(document,state.exhibits,state.connections); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return
!document.hidden).map(document => { const membership = containmentRelations.find(relation => relation.toExhibitId === document.id); const folder = membership ? byId.get(membership.fromExhibitId) : undefined; const open = folder?.type !== 'folder' || folder.isOpen; const located = open && locatorDocumentId === document.id; const left = open ? document.x : folder.x + folder.width / 2 - document.width / 2, top = open ? document.y : folder.y + 45; const definition = documentWidget(document.fileType); const presentation=documentCapture(document.captureKind); const identification=mugshotIdentification(document,state.exhibits,state.connections); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return
{ event.stopPropagation(); if (linkFrom && event.button === 0) return; if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'widget', id: document.id }) }} onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag(event) }} onPointerCancel={event => { event.stopPropagation(); finishDrag(event) }} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!open) return; if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}>
{(document.captureKind === 'unclassified' ? definition.label : presentation.label).toUpperCase()}{String(document.displayNumber || (membership?.sortOrder || 0) + 1).padStart(2, '0')}
onUpdateDocumentCue(document.id, cue)}/>
- {document.captureKind === 'photo' ? : {document.title}} -
+ {document.captureKind === 'photo' ? : document.captureKind !== 'clipping' ? {document.title} : null} +
+
})} @@ -1088,6 +1104,15 @@ function localDateTime(value?: string) { return local.toISOString().slice(0, 16) } +function publishedDateParts(value?: string) { + if (!value) return { date:'',time:'' } + const parsed=new Date(value) + if (!Number.isFinite(parsed.getTime())) return { date:'',time:'' } + const iso=parsed.toISOString() + const time=iso.slice(11,16) + return { date:iso.slice(0,10),time:time === '00:00' ? '' : time } +} + function TimelineRangeEditor({ range, dates, onClose, onSave }: { range?: TimelineRange | null; dates: string[]; onClose: () => void; onSave: (range: TimelineRange | null) => void }) { const dated = dates.map(date => date.slice(0, 10)).filter(Boolean).sort() const [start, setStart] = useState(range?.start || dated[0] || '') @@ -1106,21 +1131,15 @@ function CaseReportPanel({ report, defaultInvestigator, hasNext, onClose, onSubm report:CaseReport;defaultInvestigator:string;hasNext:boolean;onClose:()=>void onSubmit:(input:CaseReportSubmissionInput)=>Promise;onContinue:()=>void }) { - const [investigatorName,setInvestigatorName]=useState(report.investigatorName || defaultInvestigator) - const [evidence,setEvidence]=useState(() => report.claims.flatMap(claim => claim.evidence.map(item => ({ - connectionId:item.connectionId,documentExhibitId:item.documentExhibitId,relationText:item.relationText || 'Proof that…', - publishedAt:item.publishedAt?.slice(0,10),sourceCitation:item.sourceCitation,sourceUri:item.sourceUri, - })))) + const investigatorName=report.investigatorName || defaultInvestigator const [busy,setBusy]=useState(false) const [error,setError]=useState('') - const updateEvidence=(connectionId:string,patch:Partial) => setEvidence(items => items.map(item => item.connectionId === connectionId ? { ...item,...patch } : item)) const submit=async (event:React.FormEvent) => { event.preventDefault();setBusy(true);setError('') - try { await onSubmit({ investigatorName,evidence }) } + try { await onSubmit({ investigatorName }) } catch (reason) { setError(reason instanceof Error ? reason.message : 'Report submission failed') } finally { setBusy(false) } } - const draftByConnection=new Map(evidence.map(item => [item.connectionId,item])) return
void submit(event)}>
{report.title}
GLITCH UNIVERSITY · PRINCIPAL INVESTIGATOR PROGRAMME

CASE REPORT

FORM GUPI–7 / EVIDENTIARY FINDING
@@ -1128,16 +1147,16 @@ function CaseReportPanel({ report, defaultInvestigator, hasNext, onClose, onSubm

CLAIM {claimIndex + 1}{claim.statement}

EVIDENCE

{claim.evidence.length === 0 ?
No source evidence is connected to this claim. Return to the board and use red thread to attach a document.
- : claim.evidence.map(item => { const draft=draftByConnection.get(item.connectionId)!; const rejected=report.status !== 'draft' && !item.evidenceAccepted; return
+ : claim.evidence.map(item => { const rejected=report.status !== 'draft' && !item.evidenceAccepted; return
Exhibit {item.displayNumber}{item.fileType.replaceAll('_',' ')} · {item.documentTitle}{item.evidenceAccepted ? CONTENT VERIFIED : rejected ? NOT VERIFIED : null}
{rejected &&

{item.verification.detail}

} -