Add Scene 7 claim report workflow
This commit is contained in:
+13
-14
@@ -65,27 +65,27 @@ The narrative layer — campaigns, NPC cutscenes, the admin authoring panel, and
|
|||||||
|
|
||||||
## Milestone 5: claim-driven case report
|
## Milestone 5: claim-driven case report
|
||||||
|
|
||||||
The purpose of this milestone is the gameplay loop, not a knowledge graph: the player connects two exhibits, explains that one specific thread with a luggage-tag Claim, and later discovers that their accumulated explanations have become a nearly complete case report.
|
The purpose of this milestone is the gameplay loop, not a knowledge graph: a Claim is a pinned proposition, source exhibits connect to it with red thread, and each thread's luggage-tag text explains how that source supports the Claim. Those explanations become the evidence section of the Case Report.
|
||||||
|
|
||||||
### 5.1 Lock the gameplay and temporal rules
|
### 5.1 Lock the gameplay and temporal rules
|
||||||
- [ ] Define a Claim as an entity owned by exactly one investigative thread; a thread has zero or one Claim. (The text associated with a claim can prove multiple points, handed by the text.
|
- [x] Define a Claim as an independent pinned Exhibit which can receive one or more supporting document threads.
|
||||||
- [ ] Keep untagged threads as ordinary connections that do not appear in the report.
|
- [x] Treat a claim-to-document thread label as an evidentiary statement; new threads begin with `Proof that…`.
|
||||||
- [ ] Use the same Claim text on the luggage tag and in the report. Editing either presentation updates the same database value.
|
- [x] Use the same persisted connection statement on the luggage tag and in the report.
|
||||||
- [ ] Derive the Claim date from the earliest non-null temporal date of its two endpoint exhibits; never use `created_at` or the current time.
|
- [ ] Derive the Claim date from the earliest non-null temporal date of its two endpoint exhibits; never use `created_at` or the current time.
|
||||||
- [ ] Use Event `occurred_at` and a Document's primary timeline date as direct endpoint dates. For a Folder, use the earliest dated contained Document. Leave a Claim undated when neither endpoint supplies a date.
|
- [ ] Use Event `occurred_at` and a Document's primary timeline date as direct endpoint dates. For a Folder, use the earliest dated contained Document. Leave a Claim undated when neither endpoint supplies a date.
|
||||||
- [ ] Place undated Claims after dated Claims in the initial report order while keeping them fully editable and reorderable.
|
- [ ] Place undated Claims after dated Claims in the initial report order while keeping them fully editable and reorderable.
|
||||||
- [ ] Treat the derived date as the initial chronological suggestion only. Manual report ordering must not rewrite exhibit or Claim dates.
|
- [ ] Treat the derived date as the initial chronological suggestion only. Manual report ordering must not rewrite exhibit or Claim dates.
|
||||||
### 5.2 Add normalized persistence
|
### 5.2 Add normalized persistence
|
||||||
|
|
||||||
- [ ] Add a `claims` table with a unique foreign key to `exhibit_connections`, text, tag style, position percentage, lateral offset, and timestamps.
|
- [x] Add normalized `claim_exhibits` as an Exhibit subtype; Claims are not encoded as connection labels.
|
||||||
- [ ] Move luggage-tag-specific text and placement fields out of `exhibit_connections`; retain curve tightness and endpoints on the connection.
|
- [ ] Move luggage-tag presentation fields out of `exhibit_connections`; retain evidentiary statement, curve tightness, and endpoints on the connection.
|
||||||
- [ ] Add one level-owned `case_report` and normalized `case_report_claims` rows with explicit `sort_order`.
|
- [x] Add one board-owned `case_report` plus immutable level-owned submissions and normalized submission issues.
|
||||||
- [ ] Assign stable, level-local display numbers to cite exhibits as `Exhibit 3` independently of board position, z-index, or report order.
|
- [x] Assign stable, board-local display numbers to cite exhibits as `Exhibit 3` independently of board position, z-index, or report order.
|
||||||
- [ ] Enforce same-board ownership for the Claim's connection, both endpoint exhibits, report, and report membership.
|
- [ ] Enforce same-board ownership for the Claim's connection, both endpoint exhibits, report, and report membership.
|
||||||
- [ ] Delete a Claim and its report membership transactionally when its luggage tag is removed, while retaining the now-untagged thread.
|
- [ ] Delete a Claim and its report membership transactionally when its luggage tag is removed, while retaining the now-untagged thread.
|
||||||
- [ ] Delete both the Claim and connection when the thread itself is removed.
|
- [ ] Delete both the Claim and connection when the thread itself is removed.
|
||||||
- [ ] Clone board-owned Claims with fresh IDs during template creation and instantiation; rebuild level report membership against the cloned Claim IDs.
|
- [x] Clone board-owned Claims with fresh IDs during template creation and instantiation.
|
||||||
- [ ] Make reset discard player-created Claims and restore exactly the Claims present in the source template version.
|
- [x] Make reset discard player-created Claims/report submissions and restore exactly the source template report configuration.
|
||||||
- [ ] Keep uploaded binary evidence in MinIO. Reports and Claims reference Document exhibits and asset metadata; they never duplicate or embed asset bytes.
|
- [ ] Keep uploaded binary evidence in MinIO. Reports and Claims reference Document exhibits and asset metadata; they never duplicate or embed asset bytes.
|
||||||
|
|
||||||
### 5.3 Expose a focused API contract
|
### 5.3 Expose a focused API contract
|
||||||
@@ -109,16 +109,16 @@ The purpose of this milestone is the gameplay loop, not a knowledge graph: the p
|
|||||||
### 5.5 Build the typewriter case report
|
### 5.5 Build the typewriter case report
|
||||||
The end goal is that a case report is prepopulated by the claims the player made during the investigation so that a skeleon of the case solution is present. The player must simply edit the case report and submit it.
|
The end goal is that a case report is prepopulated by the claims the player made during the investigation so that a skeleon of the case solution is present. The player must simply edit the case report and submit it.
|
||||||
|
|
||||||
- [ ] Add **Case Report** as a primary menu item and implement it as a persistent board view, separate from exhibits and the timeline.
|
- [x] Add **Case Report** as a primary menu item and implement its first persistent report surface, separate from exhibits and the timeline.
|
||||||
- [ ] Provide an empty state that explains that explaining red threads will create the report, without revealing a solution or forcing a tutorial.
|
- [ ] Provide an empty state that explains that explaining red threads will create the report, without revealing a solution or forcing a tutorial.
|
||||||
- [ ] Initially arrange Claim rows chronologically by derived date, using the order parameters on the claim as tie-breaker and undated Claims last, below a horizontal rule that says (missing date)
|
- [ ] Initially arrange Claim rows chronologically by derived date, using the order parameters on the claim as tie-breaker and undated Claims last, below a horizontal rule that says (missing date)
|
||||||
- [ ] Render each row as a typewritten Claim with its date and endpoint citations, for example: `14.10.1987 — Maria Voss redirected the shipment. Exhibits 4 and 7.`
|
- [x] Render the Scene 7 Claim with typewritten, stable Exhibit citations and editable date/source provenance.
|
||||||
- [ ] It needs to be possible to add free text before and after the claims. Coloured inline text (use span elements) have a specific class and id can be edited
|
- [ ] It needs to be possible to add free text before and after the claims. Coloured inline text (use span elements) have a specific class and id can be edited
|
||||||
- [ ] -Make Claim text editable inline. Persist through the Claim API so the luggage tag updates immediately.
|
- [ ] -Make Claim text editable inline. Persist through the Claim API so the luggage tag updates immediately.
|
||||||
- [ ] Support pointer and keyboard reordering of Claim rows and persist the resulting explicit report order into order column of the claim.
|
- [ ] Support pointer and keyboard reordering of Claim rows and persist the resulting explicit report order into order column of the claim.
|
||||||
- [ ] Clicking a Claim must minimize the report as appropriate, center its thread, and briefly illuminate the curve and luggage tag.
|
- [ ] Clicking a Claim must minimize the report as appropriate, center its thread, and briefly illuminate the curve and luggage tag.
|
||||||
- [ ] Clicking an exhibit citation must locate and highlight that exhibit using the existing tray/board locator treatment.
|
- [ ] Clicking an exhibit citation must locate and highlight that exhibit using the existing tray/board locator treatment.
|
||||||
- [ ] Ensure the report is legible and operable on portrait mobile layouts as well as desktop.
|
- [x] Ensure the first Case Report surface is legible and operable on portrait mobile layouts as well as desktop.
|
||||||
- [ ] Add restrained typewriter, paper, and ink feedback while respecting reduced-motion preferences.
|
- [ ] Add restrained typewriter, paper, and ink feedback while respecting reduced-motion preferences.
|
||||||
|
|
||||||
### 5.6 Test the reasoning loop
|
### 5.6 Test the reasoning loop
|
||||||
@@ -140,4 +140,3 @@ The end goal is that a case report is prepopulated by the claims the player made
|
|||||||
### Milestone 5 definition of done
|
### Milestone 5 definition of done
|
||||||
A player can connect exhibits, explain each connection with a luggage-tag Claim, discover those exact words in a chronological typewriter report, improve and reorder the Claims, follow every citation back to the board, reload without loss, and reset safely to the template. No LLM is required for this experience to work.
|
A player can connect exhibits, explain each connection with a luggage-tag Claim, discover those exact words in a chronological typewriter report, improve and reorder the Claims, follow every citation back to the board, reload without loss, and reset safely to the template. No LLM is required for this experience to work.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -128,7 +128,8 @@ CREATE TABLE osint.document_exhibits (
|
|||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
published_at TIMESTAMPTZ,
|
published_at TIMESTAMPTZ,
|
||||||
captured_at TIMESTAMPTZ,
|
captured_at TIMESTAMPTZ,
|
||||||
source_uri TEXT
|
source_uri TEXT,
|
||||||
|
citation_text TEXT NOT NULL DEFAULT ''
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE osint.image_documents (
|
CREATE TABLE osint.image_documents (
|
||||||
@@ -154,6 +155,24 @@ CREATE TABLE osint.event_exhibits (
|
|||||||
|
|
||||||
The service validates that every base exhibit has exactly one subtype row matching `exhibit_type_id`.
|
The service validates that every base exhibit has exactly one subtype row matching `exhibit_type_id`.
|
||||||
|
|
||||||
|
### Claim and report semantics
|
||||||
|
|
||||||
|
A Claim is a pinned proposition the investigator is asked to establish, such as
|
||||||
|
**“Nils Aall Barricelli was an inventor.”** It is an Exhibit subtype rather than
|
||||||
|
text hidden in a widget or a connection. One Claim can therefore receive multiple
|
||||||
|
supporting red threads without becoming a knowledge-graph hub.
|
||||||
|
|
||||||
|
For a Claim-to-Document connection, the connection label is the evidentiary
|
||||||
|
statement—initially `Proof that…`—and the Document supplies the source date,
|
||||||
|
human-readable `citation_text`, and optional `source_uri`. Stable board-local
|
||||||
|
numbers live in `exhibit_citations`; they do not depend on z-order or position.
|
||||||
|
|
||||||
|
`case_reports` holds clonable board configuration. Mutable, server-evaluated
|
||||||
|
filings live in `case_report_submissions`, with individual deficiencies in
|
||||||
|
`case_report_submission_issues`. A report can therefore record the useful state
|
||||||
|
“evidence accepted, report incomplete” without weakening the evidence match or
|
||||||
|
pretending the level has been fully accepted.
|
||||||
|
|
||||||
### Event semantics
|
### Event semantics
|
||||||
|
|
||||||
An event is an investigator-authored assertion: **“this happened.”** It is not source evidence and must not silently inherit a document's publication time.
|
An event is an investigator-authored assertion: **“this happened.”** It is not source evidence and must not silently inherit a document's publication time.
|
||||||
|
|||||||
+30
-16
@@ -5,23 +5,25 @@ Demo: **GUPI Demo 1 — The Barricelli Files**
|
|||||||
|
|
||||||
## Outcome
|
## Outcome
|
||||||
|
|
||||||
Scene 7 teaches one idea: a screenshot found during an OSINT search can become
|
Scene 7 teaches two linked ideas: a screenshot found during an OSINT search can
|
||||||
source evidence.
|
become source evidence, and a finding is only as useful as the report that cites
|
||||||
|
and explains that evidence.
|
||||||
|
|
||||||
The player opens a minimal OSINT board, reads **“Demonstrate OSINT skill: prove
|
The player opens a minimal OSINT board, reads **“Demonstrate OSINT skill: prove
|
||||||
Nils Aall Barricelli was an inventor,”** finds the relevant Google Patents result,
|
Nils Aall Barricelli was an inventor,”** finds the relevant Google Patents result,
|
||||||
and pastes or uploads one screenshot. The board creates a document, extracts its
|
and pastes or uploads one screenshot. The board creates a document, extracts its
|
||||||
text, recognizes the source, and clears the level. A URL and written report are
|
text, and recognizes the source. The player connects that document to the authored
|
||||||
not required.
|
Claim, completes the evidentiary statement, and files the generated Case Report.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
paste screenshot -> document appears -> text is read -> source is verified
|
paste screenshot -> document appears -> source is verified -> connect to Claim
|
||||||
-> Scene 7 complete -> continue to Scene 8
|
-> submit thin report -> provenance feedback -> accepted -> Scene 8
|
||||||
```
|
```
|
||||||
|
|
||||||
## Product decisions
|
## Product decisions
|
||||||
|
|
||||||
- One suitable Google Patents screenshot is sufficient.
|
- One suitable Google Patents screenshot is sufficient evidence, but not by itself
|
||||||
|
a complete investigation.
|
||||||
- Clipboard paste and file upload use the same server path.
|
- Clipboard paste and file upload use the same server path.
|
||||||
- The image and OCR remain a real source document on the player's board.
|
- The image and OCR remain a real source document on the player's board.
|
||||||
- A known-source fuzzy match is the fast, deterministic victory path.
|
- A known-source fuzzy match is the fast, deterministic victory path.
|
||||||
@@ -33,6 +35,16 @@ paste screenshot -> document appears -> text is read -> source is verified
|
|||||||
- Scene 7 records `scene7.nils_inventor_proved`. Scene 8 owns the ceremony and
|
- Scene 7 records `scene7.nils_inventor_proved`. Scene 8 owns the ceremony and
|
||||||
awards `barricelli_luggage`.
|
awards `barricelli_luggage`.
|
||||||
- Inconclusive evaluation never deletes or penalizes uploaded evidence.
|
- Inconclusive evaluation never deletes or penalizes uploaded evidence.
|
||||||
|
- Scene 7 begins with one normalized, pinned Claim exhibit: **“Nils Aall
|
||||||
|
Barricelli was an inventor.”**
|
||||||
|
- A new thread begins with **“Proof that…”**. This placeholder intentionally
|
||||||
|
produces a report that must be improved.
|
||||||
|
- An accepted source connected to the Claim can earn a qualified pass while the
|
||||||
|
report is returned for missing date/source provenance.
|
||||||
|
- The source link is encouraged but optional; date, source citation, investigator,
|
||||||
|
and a completed evidentiary statement are required.
|
||||||
|
- Story advancement requires an accepted report when the template marks its report
|
||||||
|
as required.
|
||||||
|
|
||||||
## Shared identifiers
|
## Shared identifiers
|
||||||
|
|
||||||
@@ -56,6 +68,8 @@ conditional in React or server business logic.
|
|||||||
- `029_semantic_evidence_judging.sql`: clonable semantic rules, level-owned
|
- `029_semantic_evidence_judging.sql`: clonable semantic rules, level-owned
|
||||||
evaluations, and semantic flag provenance.
|
evaluations, and semantic flag provenance.
|
||||||
- `030_evidence_match_source_metadata.sql`: author-only canonical source metadata.
|
- `030_evidence_match_source_metadata.sql`: author-only canonical source metadata.
|
||||||
|
- `031_claim_case_reports.sql`: Claim exhibits, stable exhibit citations, report
|
||||||
|
configuration, immutable submissions, and normalized submission issues.
|
||||||
- `server/ocr.ts`: plain-text extraction and Tesseract.
|
- `server/ocr.ts`: plain-text extraction and Tesseract.
|
||||||
- `server/evidenceMatching.ts`: OCR-tolerant fuzzy passage matching.
|
- `server/evidenceMatching.ts`: OCR-tolerant fuzzy passage matching.
|
||||||
- `POST /api/levels/:id/documents`: persistent upload plus OCR and matching.
|
- `POST /api/levels/:id/documents`: persistent upload plus OCR and matching.
|
||||||
@@ -237,8 +251,7 @@ type DocumentAnalysis = {
|
|||||||
## Out of scope
|
## Out of scope
|
||||||
|
|
||||||
- Scenes 1–6, Scene 8's ceremony/3D model, and Scene 9's fire mystery.
|
- Scenes 1–6, Scene 8's ceremony/3D model, and Scene 9's fire mystery.
|
||||||
- Knowledge graph, Case Report, claims, red-thread reasoning, or multi-document
|
- Knowledge graph, general-purpose claim ontology, or multi-document synthesis.
|
||||||
synthesis.
|
|
||||||
- Web crawling, URL fetching, or requiring a URL for victory.
|
- Web crawling, URL fetching, or requiring a URL for victory.
|
||||||
- Custom model training or the general story graph `llm_gate`.
|
- Custom model training or the general story graph `llm_gate`.
|
||||||
- Deleting irrelevant evidence.
|
- Deleting irrelevant evidence.
|
||||||
@@ -255,10 +268,11 @@ parallel schema work and never renumber an already-shared migration silently.
|
|||||||
|
|
||||||
## Definition of done
|
## Definition of done
|
||||||
|
|
||||||
From a fresh playthrough, the player reaches Scene 7 and pastes one accepted
|
From a fresh playthrough, the player reaches Scene 7 and sees one authored Claim.
|
||||||
Google Patents screenshot. One source document appears; MinIO asset, OCR, match
|
They paste one accepted Google Patents screenshot, connect Exhibit 1 to the Claim,
|
||||||
evaluation, and `scene7.nils_inventor_proved` are persisted; the goal completes;
|
and see **“Proof that…”** appear in the typewriter report. The first thin submission
|
||||||
the UI explains what was proved and offers Continue; and the story enters Scene 8.
|
passes the evidence but is returned for provenance; adding the date, source citation,
|
||||||
Reload and duplicate evaluation are idempotent, another player's level is
|
and a proper evidentiary statement produces an accepted report and enables Continue.
|
||||||
unaffected, no URL was required, and `barricelli_luggage` is not awarded until
|
MinIO asset, OCR, match provenance, stable exhibit number, connection, report
|
||||||
Scene 8.
|
submissions, and `scene7.nils_inventor_proved` persist. A URL improves the report
|
||||||
|
but remains optional, and `barricelli_luggage` is not awarded until Scene 8.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { expect, test } from '@playwright/test'
|
import { expect, test } from '@playwright/test'
|
||||||
|
|
||||||
test('pasting one Google Patents screenshot verifies the Scene 7 objective', async ({ page, request }) => {
|
test('pasting, connecting, and citing one patent screenshot completes the Scene 7 report lesson', async ({ page, request }) => {
|
||||||
test.setTimeout(60_000)
|
test.setTimeout(60_000)
|
||||||
const levels = await (await request.get('/api/levels')).json() as { id:string }[]
|
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 sceneSeven = levels.find(level => level.id.startsWith('barricelli-inventor-proof-case-'))
|
||||||
@@ -44,12 +44,33 @@ test('pasting one Google Patents screenshot verifies the Scene 7 objective', asy
|
|||||||
|
|
||||||
await expect(page.locator('.source-file-widget')).toHaveCount(1)
|
await expect(page.locator('.source-file-widget')).toHaveCount(1)
|
||||||
await expect(page.locator('.source-file-widget')).toHaveClass(/\barriving\b/)
|
await expect(page.locator('.source-file-widget')).toHaveClass(/\barriving\b/)
|
||||||
const complete = page.locator('.goal-complete-card')
|
await expect(page.locator('.goal-complete-card')).toHaveCount(0)
|
||||||
await expect(complete).toBeVisible({ timeout:20_000 })
|
await expect(page.locator('.evidence-card.claim')).toContainText('Nils Aall Barricelli was an inventor')
|
||||||
await expect(complete).toContainText('SOURCE VERIFIED — NILS AALL BARRICELLI: INVENTOR')
|
|
||||||
await expect(complete.getByRole('button', { name:/RETURN TO BOARD/ })).toBeVisible()
|
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.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 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/${sceneSeven.id}`)).json()
|
||||||
expect(level.goals).toEqual([expect.objectContaining({ key:'barricelli.inventor-proof',status:'complete',newlyCompleted:false })])
|
expect(level.goals).toEqual([expect.objectContaining({ key:'barricelli.inventor-proof',status:'complete',newlyCompleted:false })])
|
||||||
expect(level.exhibits.filter((exhibit: { type:string }) => exhibit.type === 'document')).toHaveLength(1)
|
expect(level.exhibits.filter((exhibit: { type:string }) => exhibit.type === 'document')).toHaveLength(1)
|
||||||
|
expect(level.report).toMatchObject({ status:'accepted',investigatorName:'Player' })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
-- Claim-centred case reports. A Claim is a pinned board exhibit; red-thread
|
||||||
|
-- labels describe how connected source documents support it. Report acceptance
|
||||||
|
-- is level-owned history, while authored report configuration clones with boards.
|
||||||
|
|
||||||
|
INSERT INTO osint.exhibit_types (id,name,is_spatial)
|
||||||
|
VALUES ('claim','Claim',TRUE)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
CREATE TABLE osint.claim_exhibits (
|
||||||
|
exhibit_id UUID PRIMARY KEY REFERENCES osint.exhibits(id) ON DELETE CASCADE,
|
||||||
|
statement TEXT NOT NULL CHECK (char_length(btrim(statement)) BETWEEN 1 AND 2000)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE osint.document_exhibits
|
||||||
|
ADD COLUMN citation_text TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
CREATE TABLE osint.exhibit_citations (
|
||||||
|
board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE,
|
||||||
|
exhibit_id UUID NOT NULL REFERENCES osint.exhibits(id) ON DELETE CASCADE,
|
||||||
|
display_number INTEGER NOT NULL CHECK (display_number > 0),
|
||||||
|
PRIMARY KEY (board_id,exhibit_id),
|
||||||
|
UNIQUE (board_id,display_number),
|
||||||
|
FOREIGN KEY (board_id,exhibit_id) REFERENCES osint.exhibits(board_id,id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO osint.exhibit_citations (board_id,exhibit_id,display_number)
|
||||||
|
SELECT board_id,id,ROW_NUMBER() OVER (PARTITION BY board_id ORDER BY created_at,id)::integer
|
||||||
|
FROM osint.exhibits
|
||||||
|
WHERE exhibit_type_id='document';
|
||||||
|
|
||||||
|
CREATE TABLE osint.case_reports (
|
||||||
|
board_id UUID PRIMARY KEY REFERENCES osint.boards(id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL DEFAULT 'Case Report' CHECK (char_length(btrim(title)) BETWEEN 1 AND 200),
|
||||||
|
investigator_name TEXT NOT NULL DEFAULT '' CHECK (char_length(investigator_name) <= 300),
|
||||||
|
required_for_completion BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE osint.case_report_submissions (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
level_id UUID NOT NULL,
|
||||||
|
board_id UUID NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('evidence_insufficient','evidence_accepted_report_incomplete','accepted')),
|
||||||
|
investigator_name TEXT NOT NULL CHECK (char_length(btrim(investigator_name)) BETWEEN 1 AND 300),
|
||||||
|
feedback TEXT NOT NULL,
|
||||||
|
submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
FOREIGN KEY (level_id,board_id) REFERENCES osint.levels(id,board_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX case_report_submissions_level_idx
|
||||||
|
ON osint.case_report_submissions (level_id,submitted_at DESC,id);
|
||||||
|
|
||||||
|
CREATE TABLE osint.case_report_submission_issues (
|
||||||
|
submission_id UUID NOT NULL REFERENCES osint.case_report_submissions(id) ON DELETE CASCADE,
|
||||||
|
issue_key TEXT NOT NULL CHECK (issue_key ~ '^[a-z][a-z0-9_.-]{0,63}$'),
|
||||||
|
PRIMARY KEY (submission_id,issue_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE osint.claim_exhibits IS 'Pinned propositions which can receive one or more supporting red-thread connections.';
|
||||||
|
COMMENT ON TABLE osint.exhibit_citations IS 'Stable board-local exhibit numbers used in reports independently of spatial or z-order.';
|
||||||
|
COMMENT ON TABLE osint.case_reports IS 'Clonable report configuration plus the mutable investigator byline for one board.';
|
||||||
|
COMMENT ON TABLE osint.case_report_submissions IS 'Immutable server evaluations of a mutable level report.';
|
||||||
|
COMMENT ON COLUMN osint.document_exhibits.citation_text IS 'Player-authored source/publication citation, distinct from the document title and optional source URI.';
|
||||||
@@ -4,17 +4,31 @@
|
|||||||
"title": "The Barricelli Files",
|
"title": "The Barricelli Files",
|
||||||
"subtitle": "Scene 7 · Demonstrate OSINT skill",
|
"subtitle": "Scene 7 · Demonstrate OSINT skill",
|
||||||
"brief": {
|
"brief": {
|
||||||
"body": "DEMONSTRATE OSINT SKILL\n\nProve that Nils Aall Barricelli was an inventor. Find a reliable source online, take a screenshot, then paste it directly onto this board. One decisive source is enough. You do not need to submit a URL or write a report.",
|
"body": "DEMONSTRATE OSINT SKILL\n\nProve that Nils Aall Barricelli was an inventor. Find a reliable source online, take a screenshot, and paste it directly onto this board. Connect the source to the authored claim with red thread, explain what the evidence proves, and submit the Case Report.",
|
||||||
"concepts": []
|
"concepts": []
|
||||||
},
|
},
|
||||||
"documents": [],
|
"documents": [],
|
||||||
"folders": [],
|
"folders": [],
|
||||||
|
"claims": [
|
||||||
|
{
|
||||||
|
"key": "nils-inventor",
|
||||||
|
"statement": "Nils Aall Barricelli was an inventor.",
|
||||||
|
"x": 940,
|
||||||
|
"y": 360,
|
||||||
|
"width": 330,
|
||||||
|
"height": 190
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"report": {
|
||||||
|
"title": "Barricelli Inventor Finding",
|
||||||
|
"requiredForCompletion": true
|
||||||
|
},
|
||||||
"goals": [
|
"goals": [
|
||||||
{
|
{
|
||||||
"key": "barricelli.inventor-proof",
|
"key": "barricelli.inventor-proof",
|
||||||
"title": "Prove Nils Aall Barricelli was an inventor",
|
"title": "Prove Nils Aall Barricelli was an inventor",
|
||||||
"instructions": "Find a reliable source online and paste a screenshot onto the board.",
|
"instructions": "Paste a reliable screenshot, connect it to the claim, and submit a properly cited Case Report.",
|
||||||
"completionMessage": "SOURCE VERIFIED — NILS AALL BARRICELLI: INVENTOR",
|
"completionMessage": "CASE REPORT ACCEPTED — NILS AALL BARRICELLI: INVENTOR",
|
||||||
"requiredFlags": ["scene7.nils_inventor_proved"]
|
"requiredFlags": ["scene7.nils_inventor_proved"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { defineConfig } from '@playwright/test'
|
|||||||
import jwt from 'jsonwebtoken'
|
import jwt from 'jsonwebtoken'
|
||||||
|
|
||||||
const port = 18788
|
const port = 18788
|
||||||
const adminToken = jwt.sign({ sub: 'e2e-admin', role: 'admin' }, 'osint-e2e-jwt-secret')
|
const adminToken = jwt.sign({ sub: 'e2e-admin', role: 'admin', name: 'Player' }, 'osint-e2e-jwt-secret')
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './e2e',
|
testDir: './e2e',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'
|
|||||||
import { readFile } from 'node:fs/promises'
|
import { readFile } from 'node:fs/promises'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import type { CaseDocument, CaseState, PartyKind, SourceFileType } from '../src/types.js'
|
import type { CaseDocument, CaseState, ClaimExhibit, PartyKind, SourceFileType } from '../src/types.js'
|
||||||
|
|
||||||
type MysteryDocument = {
|
type MysteryDocument = {
|
||||||
key: string
|
key: string
|
||||||
@@ -45,6 +45,8 @@ type MysteryManifest = {
|
|||||||
brief: { body: string; concepts: { label: string; context: string; expectedPartyKind: PartyKind }[] }
|
brief: { body: string; concepts: { label: string; context: string; expectedPartyKind: PartyKind }[] }
|
||||||
documents: MysteryDocument[]
|
documents: MysteryDocument[]
|
||||||
folders: { key: string; title: string; content: string; x: number; y: number; width: number; members: string[] }[]
|
folders: { key: string; title: string; content: string; x: number; y: number; width: number; members: string[] }[]
|
||||||
|
claims?: { key:string;statement:string;x:number;y:number;width?:number;height?:number }[]
|
||||||
|
report?: { title?:string;requiredForCompletion?:boolean }
|
||||||
goals?: MysteryGoal[]
|
goals?: MysteryGoal[]
|
||||||
evidenceMatchRules?: MysteryEvidenceMatchRule[]
|
evidenceMatchRules?: MysteryEvidenceMatchRule[]
|
||||||
evidenceSemanticRules?: MysterySemanticRule[]
|
evidenceSemanticRules?: MysterySemanticRule[]
|
||||||
@@ -107,7 +109,9 @@ export async function importMysteryTemplate(manifestPath: string, baseUrl = 'htt
|
|||||||
id: folderIds.get(folder.key)!, type: 'folder', title: folder.title, content: folder.content,
|
id: folderIds.get(folder.key)!, type: 'folder', title: folder.title, content: folder.content,
|
||||||
x: folder.x, y: folder.y, width: folder.width, height: 166, rotation: 0, zIndex: 1, hidden: false, isOpen: false,
|
x: folder.x, y: folder.y, width: folder.width, height: 166, rotation: 0, zIndex: 1, hidden: false, isOpen: false,
|
||||||
} as const))
|
} as const))
|
||||||
state.exhibits = [...documents.values(), ...folders]
|
const claims:ClaimExhibit[]=(manifest.claims || []).map(claim => ({ id:randomUUID(),type:'claim',title:claim.statement,statement:claim.statement,
|
||||||
|
x:claim.x,y:claim.y,width:claim.width || 310,height:claim.height || 180,rotation:0,zIndex:2,hidden:false }))
|
||||||
|
state.exhibits = [...documents.values(), ...folders,...claims]
|
||||||
state.relations = manifest.folders.flatMap(folder => folder.members.map((key, memberIndex) => {
|
state.relations = manifest.folders.flatMap(folder => folder.members.map((key, memberIndex) => {
|
||||||
const document = documents.get(key)
|
const document = documents.get(key)
|
||||||
if (!document) throw new Error(`Folder ${folder.key} refers to unknown document ${key}`)
|
if (!document) throw new Error(`Folder ${folder.key} refers to unknown document ${key}`)
|
||||||
@@ -117,6 +121,8 @@ export async function importMysteryTemplate(manifestPath: string, baseUrl = 'htt
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
state.connections = []
|
state.connections = []
|
||||||
|
state.report=manifest.report ? { title:manifest.report.title || 'Case Report',investigatorName:'',requiredForCompletion:manifest.report.requiredForCompletion !== false,
|
||||||
|
status:'draft',issues:[],claims:[] } : undefined
|
||||||
state.viewport = { x: 0, y: 28, zoom: 0.7 }
|
state.viewport = { x: 0, y: 28, zoom: 0.7 }
|
||||||
|
|
||||||
await requireOk(await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
await requireOk(await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ suite('normalized level persistence API', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('round-trips exhibits, relations, board views, private objects, and template clones', async () => {
|
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`, {
|
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' }),
|
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 { importMysteryTemplate } = await import('../scripts/importMysteryTemplate.js')
|
||||||
const manifestPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'mysteries', 'barricelli-scene-7', 'mystery.json')
|
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 /, ''))
|
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,
|
key:'barricelli.inventor-proof',status:'pending',newlyCompleted:false,
|
||||||
})] })
|
})] })
|
||||||
expect(await (await adminFetch(`${baseUrl}/api/levels/${imported.playableLevel.id}/evidence-match-rules`)).json()).toEqual([
|
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')
|
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 })
|
const targetResponse = await fetch(`${baseUrl}/api/levels/${imported.playableLevel.id}/documents`, { method:'POST',body:targetUpload })
|
||||||
expect(targetResponse.status).toBe(201)
|
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 })] } })
|
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 }
|
judgeVerdict = { subject:'target',supports_claim:true,evidence_excerpt:'Ada Example patented a pocket telescope',confidence:.96 }
|
||||||
judgeHttpStatus = 200
|
judgeHttpStatus = 200
|
||||||
})
|
})
|
||||||
|
|||||||
+6
-1
@@ -1,7 +1,7 @@
|
|||||||
import type { NextFunction, Request, Response } from 'express'
|
import type { NextFunction, Request, Response } from 'express'
|
||||||
import jwt, { type JwtPayload } from 'jsonwebtoken'
|
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 {
|
declare global {
|
||||||
namespace Express {
|
namespace Express {
|
||||||
@@ -39,6 +39,11 @@ export function resolveUserId(req: Request): string {
|
|||||||
return typeof sub === 'string' && sub.length > 0 ? sub : DEVELOPMENT_TEST_USER_ID
|
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) {
|
export function requireAdmin(req: Request, res: Response, next: NextFunction) {
|
||||||
if (!hasAdminClaim(req)) return res.status(403).json({ error: 'Administrator claim required' })
|
if (!hasAdminClaim(req)) return res.status(403).json({ error: 'Administrator claim required' })
|
||||||
next()
|
next()
|
||||||
|
|||||||
+22
-3
@@ -10,6 +10,8 @@ function mapped(ids: IdMap, sourceId: string, label: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function clearBoard(client: PoolClient, boardId: 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.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.brief_concepts WHERE board_id=$1', [boardId])
|
||||||
await client.query('DELETE FROM osint.level_briefs 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<{
|
const documents = await client.query<{
|
||||||
exhibit_id: string; document_type_id: string; asset_id: string | null; title: string
|
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])
|
}>(`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
|
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)`,
|
(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])
|
[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 }>(
|
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])
|
'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)',
|
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])
|
[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 }>(
|
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])
|
`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(
|
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,
|
[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])
|
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
|
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 multer from 'multer'
|
||||||
import pg from 'pg'
|
import pg from 'pg'
|
||||||
import type { CaseState } from '../src/types.js'
|
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 { createLevelRepository } from './levelRepository.js'
|
||||||
import { createEvidenceJudgeFromEnv } from './evidenceJudge.js'
|
import { createEvidenceJudgeFromEnv } from './evidenceJudge.js'
|
||||||
import { createNarrativeRepository } from './narrativeRepository.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 }) }
|
evidenceJudge: evidenceJudge.enabled ? evidenceJudge.provider : 'disabled', schema: 'osint', editingEnabled }) }
|
||||||
catch { res.status(503).json({ ok: false, database: 'unavailable' }) }
|
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) => {
|
if (process.env.NODE_ENV !== 'production') app.get('/api/dev/admin-session', (req, res) => {
|
||||||
const requestedReturn = String(req.query.returnTo || '/')
|
const requestedReturn = String(req.query.returnTo || '/')
|
||||||
const returnTo = requestedReturn.startsWith('/') && !requestedReturn.startsWith('//') ? requestedReturn : '/'
|
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' })
|
level ? res.json(level) : res.status(404).json({ error: 'Level not found' })
|
||||||
} catch (error) { next(error) }
|
} 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 authoring panel: NPC template library and mystery listing. Reads require an
|
||||||
// admin claim; writes additionally require editing to be enabled on this deployment.
|
// 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) => {
|
app.post('/api/playthroughs/:id/advance', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const result = await narrative.advancePlaythrough(resolveUserId(req), String(req.params.id), req.body?.terminalKey)
|
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 })
|
.json({ error: result.error, errorCode: result.errorCode, pendingGoals: result.pendingGoals })
|
||||||
} catch (error) { next(error) }
|
} catch (error) { next(error) }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { createHash, randomUUID } from 'node:crypto'
|
|||||||
import { Readable } from 'node:stream'
|
import { Readable } from 'node:stream'
|
||||||
import type { Pool, PoolClient } from 'pg'
|
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 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 { clearBoard, cloneBoard } from './boardClone.js'
|
||||||
|
import { loadCaseReport } from './caseReports.js'
|
||||||
import { evaluateEvidenceRules, type EvidenceMatchRule } from './evidenceMatching.js'
|
import { evaluateEvidenceRules, type EvidenceMatchRule } from './evidenceMatching.js'
|
||||||
import { EvidenceJudgeError, type EvidenceJudge } from './evidenceJudge.js'
|
import { EvidenceJudgeError, type EvidenceJudge } from './evidenceJudge.js'
|
||||||
import { filterLevelVisibility, mergePlayerStateForPersistence } from './levelVisibility.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
|
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
|
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
|
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
|
original_name: string | null; mime_type: string | null; byte_size: string | null
|
||||||
source_document_id: string | null; source_region_key: string | null
|
source_document_id: string | null; source_region_key: string | null
|
||||||
party_kind: PartyKind | null; organization_kind: OrganizationKind | 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,
|
const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult, eventEvidenceResult,
|
||||||
aliasesResult, partyEvidenceResult, briefResult, conceptsResult, viewsResult, requirementsResult, flagsResult, seenResult] = await Promise.all([
|
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,
|
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,
|
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,
|
p.party_kind, op.organization_kind,
|
||||||
a.original_name, a.mime_type, a.byte_size,
|
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
|
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.event_exhibits ev ON ev.exhibit_id = e.id
|
||||||
LEFT JOIN osint.party_exhibits p ON p.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.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.assets a ON a.id = d.asset_id
|
||||||
LEFT JOIN osint.exhibit_sources s ON s.exhibit_id = e.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
|
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 documents: CaseDocument[] = exhibitsResult.rows.filter(row => row.exhibit_type_id === 'document').map(row => {
|
||||||
const type = row.document_type_id || 'file'
|
const type = row.document_type_id || 'file'
|
||||||
const publishedAt = row.published_at?.toISOString()
|
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,
|
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,
|
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) || {} }
|
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) })
|
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 === '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 === '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' })
|
||||||
else throw new Error(`Unsupported exhibit type ${row.exhibit_type_id}`)
|
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,
|
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 }))
|
...(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 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,
|
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,
|
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,
|
label: row.label || undefined, tightness: row.tightness, tagStyle: row.tag_style,
|
||||||
tagPosition: row.tag_position_percent, tagOffset: row.tag_lateral_offset })),
|
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(),
|
viewport: { x: level.viewport_x, y: level.viewport_y, zoom: level.viewport_zoom }, updatedAt: level.updated_at.toISOString(),
|
||||||
views, revision: Number(level.revision),
|
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 }
|
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)
|
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.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.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])
|
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])
|
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])
|
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])
|
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()`,
|
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, 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) {
|
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)
|
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)`, [document.id, documentType(document), document.assetId || null, document.title,
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT (exhibit_id) DO UPDATE SET
|
||||||
timestamp(document.publishedAt), timestamp(document.capturedAt), document.sourceUri || null])
|
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])
|
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(
|
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])
|
'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(
|
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])
|
'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) {
|
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)`,
|
(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])
|
[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 {
|
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])
|
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)`,
|
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])
|
[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 (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(
|
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()])
|
'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('UPDATE osint.boards SET revision=revision+1,updated_at=NOW() WHERE id=$1', [level.board_id])
|
||||||
await client.query('COMMIT')
|
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,
|
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,
|
fileType,metadata:{},body:extraction.status === 'succeeded' && extraction.text.trim() ? [extraction.text.trim()] : [],regions:[],assetId,
|
||||||
fileName:file.originalname,mimeType:file.mimetype,fileSize:file.size,
|
fileName:file.originalname,mimeType:file.mimetype,fileSize:file.size,
|
||||||
analysis:{ extractionStatus:extraction.status, matchedFlags:[...new Set(matchedFlags)], awardedFlags:[...new Set(awardedFlags)], goals } }
|
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)
|
const touchesUnavailable = (item: ExhibitRelation | Connection) => unavailableIds.has(item.fromExhibitId) || unavailableIds.has(item.toExhibitId)
|
||||||
return {
|
return {
|
||||||
...submitted,
|
...submitted,
|
||||||
|
report: full.report,
|
||||||
exhibits: appendMissingById(submittedExhibits, preservedExhibits),
|
exhibits: appendMissingById(submittedExhibits, preservedExhibits),
|
||||||
relations: appendMissingById(submitted.relations, full.relations.filter(touchesUnavailable)),
|
relations: appendMissingById(submitted.relations, full.relations.filter(touchesUnavailable)),
|
||||||
connections: appendMissingById(submitted.connections, full.connections.filter(touchesUnavailable)),
|
connections: appendMissingById(submitted.connections, full.connections.filter(touchesUnavailable)),
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ suite('PostgreSQL migrations', () => {
|
|||||||
'evidence_match_evaluations', 'evidence_match_anchor_evaluations',
|
'evidence_match_evaluations', 'evidence_match_anchor_evaluations',
|
||||||
'level_goals', 'level_goal_flag_requirements',
|
'level_goals', 'level_goal_flag_requirements',
|
||||||
'evidence_semantic_rules', 'evidence_semantic_evaluations',
|
'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']))
|
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')
|
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
|
ok: boolean
|
||||||
state?: PlaythroughState
|
state?: PlaythroughState
|
||||||
error?: string
|
error?: string
|
||||||
errorCode?: 'goals_incomplete'
|
errorCode?: 'goals_incomplete' | 'report_incomplete'
|
||||||
pendingGoals?: { key: string; title: string }[]
|
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',
|
return { ok:false,error:'Complete the level objective before continuing',errorCode:'goals_incomplete',
|
||||||
pendingGoals:pendingGoals.map(goal => ({ key:goal.goal_key,title:goal.title })) }
|
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)
|
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
|
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
|
JOIN osint.level_goals goal ON goal.board_id=level.board_id AND goal.enabled
|
||||||
|
|||||||
+108
-15
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
|
import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, ClipboardCheck, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
|
||||||
import type { BriefConcept, CaseDocument, CaseState, Connection, 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, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types'
|
||||||
import { AdminPanel } from './admin'
|
import { AdminPanel } from './admin'
|
||||||
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
|
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
|
||||||
import { documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
|
import { documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
|
||||||
@@ -20,7 +20,7 @@ function screenshotFile(file: File, index: number) {
|
|||||||
}
|
}
|
||||||
function briefAcknowledgementKey(levelId: string) { return `gupi-osint-board:brief-acknowledged:${levelId}` }
|
function briefAcknowledgementKey(levelId: string) { return `gupi-osint-board:brief-acknowledged:${levelId}` }
|
||||||
function documentSearchText(document: CaseDocument) {
|
function documentSearchText(document: CaseDocument) {
|
||||||
return [document.title, document.fileType, document.publishedAt, document.capturedAt, document.fileName, document.mimeType,
|
return [document.title, document.fileType, document.publishedAt, document.capturedAt, document.sourceCitation,document.sourceUri,document.fileName, document.mimeType,
|
||||||
...document.body, ...document.regions.flatMap(region => [region.label, region.excerpt, region.date]),
|
...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()
|
...Object.entries(document.metadata).flatMap(([key, value]) => [key, value])].filter(Boolean).join('\n').toLocaleLowerCase()
|
||||||
}
|
}
|
||||||
@@ -62,6 +62,8 @@ export function App() {
|
|||||||
const [editingPartyId, setEditingPartyId] = useState<string | null>(null)
|
const [editingPartyId, setEditingPartyId] = useState<string | null>(null)
|
||||||
const [newPartyDraft, setNewPartyDraft] = useState<PartyExhibit | null>(null)
|
const [newPartyDraft, setNewPartyDraft] = useState<PartyExhibit | null>(null)
|
||||||
const [briefOpen, setBriefOpen] = useState(false)
|
const [briefOpen, setBriefOpen] = useState(false)
|
||||||
|
const [reportOpen, setReportOpen] = useState(false)
|
||||||
|
const [playerName, setPlayerName] = useState('Player')
|
||||||
const [editingBrief, setEditingBrief] = useState(false)
|
const [editingBrief, setEditingBrief] = useState(false)
|
||||||
const [editingTimeline, setEditingTimeline] = useState(false)
|
const [editingTimeline, setEditingTimeline] = useState(false)
|
||||||
const [recentlyCreatedExhibitId, setRecentlyCreatedExhibitId] = useState<string | null>(null)
|
const [recentlyCreatedExhibitId, setRecentlyCreatedExhibitId] = useState<string | null>(null)
|
||||||
@@ -98,7 +100,9 @@ export function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (adminRoute) return
|
if (adminRoute) return
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
fetch('/api/session').then(response => response.ok ? response.json() : null).then(session => setIsAdmin(Boolean(session?.isAdmin))).catch(() => setIsAdmin(false))
|
fetch('/api/session').then(response => response.ok ? response.json() : null).then(session => {
|
||||||
|
setIsAdmin(Boolean(session?.isAdmin)); setPlayerName(String(session?.playerName || 'Player'))
|
||||||
|
}).catch(() => setIsAdmin(false))
|
||||||
|
|
||||||
const pathLevel = window.location.pathname.match(/^\/level\/(.+)$/)
|
const pathLevel = window.location.pathname.match(/^\/level\/(.+)$/)
|
||||||
const deepLinkLevel = params.get('level') || (pathLevel ? decodeURIComponent(pathLevel[1]) : null)
|
const deepLinkLevel = params.get('level') || (pathLevel ? decodeURIComponent(pathLevel[1]) : null)
|
||||||
@@ -271,7 +275,7 @@ export function App() {
|
|||||||
setLinkFrom(null); setThreadDraft(existing); setStatus('THREAD ALREADY EXISTS · EDITING TAG')
|
setLinkFrom(null); setThreadDraft(existing); setStatus('THREAD ALREADY EXISTS · EDITING TAG')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setThreadDraft({ id: uid('connection'), fromExhibitId: linkFrom, toExhibitId: targetId, tightness: 65, tagStyle: 'luggage', tagPosition: 50, tagOffset: 0 })
|
setThreadDraft({ id: uid('connection'), fromExhibitId: linkFrom, toExhibitId: targetId, label:'Proof that…',tightness: 65, tagStyle: 'luggage', tagPosition: 50, tagOffset: 0 })
|
||||||
setLinkFrom(null)
|
setLinkFrom(null)
|
||||||
if (caseState.exhibits.some(item => item.id === targetId)) setSelected(targetId)
|
if (caseState.exhibits.some(item => item.id === targetId)) setSelected(targetId)
|
||||||
}
|
}
|
||||||
@@ -386,13 +390,13 @@ export function App() {
|
|||||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ documentIds: [document.id] }),
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ documentIds: [document.id] }),
|
||||||
})
|
})
|
||||||
const deterministicCompletion = analysis.goals.find(goal => goal.newlyCompleted)
|
const deterministicCompletion = analysis.goals.find(goal => goal.newlyCompleted)
|
||||||
if (deterministicCompletion) setCompletedGoal(deterministicCompletion)
|
if (deterministicCompletion && !caseState.report?.requiredForCompletion) setCompletedGoal(deterministicCompletion)
|
||||||
if (analysis.awardedFlags.length) {
|
if (analysis.awardedFlags.length) {
|
||||||
window.clearTimeout(saveTimer.current)
|
window.clearTimeout(saveTimer.current)
|
||||||
const editQuery = requestedEditMode && caseState.editingAllowed ? '?edit=1' : ''
|
const editQuery = requestedEditMode && caseState.editingAllowed ? '?edit=1' : ''
|
||||||
await loadLevelBySlug(caseState.id, editQuery)
|
await loadLevelBySlug(caseState.id, editQuery)
|
||||||
setSelected(document.id)
|
setSelected(document.id)
|
||||||
setStatus(deterministicCompletion ? 'SOURCE VERIFIED · OBJECTIVE COMPLETE' : `EVIDENCE MATCHED · ${analysis.awardedFlags.join(', ').toUpperCase()} · NEW MATERIAL UNLOCKED`)
|
setStatus(deterministicCompletion ? caseState.report?.requiredForCompletion ? 'SOURCE VERIFIED · CONNECT IT TO THE CLAIM AND FILE YOUR REPORT' : 'SOURCE VERIFIED · OBJECTIVE COMPLETE' : `EVIDENCE MATCHED · ${analysis.awardedFlags.join(', ').toUpperCase()} · NEW MATERIAL UNLOCKED`)
|
||||||
} else if (analysis.matchedFlags.length) setStatus('EVIDENCE MATCHED · ACHIEVEMENT ALREADY RECORDED')
|
} else if (analysis.matchedFlags.length) setStatus('EVIDENCE MATCHED · ACHIEVEMENT ALREADY RECORDED')
|
||||||
else if (analysis.extractionStatus === 'succeeded' && analysis.goals.some(goal => goal.status === 'pending')) {
|
else if (analysis.extractionStatus === 'succeeded' && analysis.goals.some(goal => goal.status === 'pending')) {
|
||||||
setStatus('TEXT EXTRACTED · CHECKING SOURCE CLAIM…')
|
setStatus('TEXT EXTRACTED · CHECKING SOURCE CLAIM…')
|
||||||
@@ -401,13 +405,13 @@ export function App() {
|
|||||||
const semantic = await judgeResponse.json() as DocumentSemanticAnalysis
|
const semantic = await judgeResponse.json() as DocumentSemanticAnalysis
|
||||||
const semanticCompletion = semantic.goals.find(goal => goal.newlyCompleted)
|
const semanticCompletion = semantic.goals.find(goal => goal.newlyCompleted)
|
||||||
if (semanticCompletion || semantic.awardedFlags.length) {
|
if (semanticCompletion || semantic.awardedFlags.length) {
|
||||||
if (semanticCompletion) setCompletedGoal(semanticCompletion)
|
if (semanticCompletion && !caseState.report?.requiredForCompletion) setCompletedGoal(semanticCompletion)
|
||||||
window.clearTimeout(saveTimer.current)
|
window.clearTimeout(saveTimer.current)
|
||||||
const editQuery = requestedEditMode && caseState.editingAllowed ? '?edit=1' : ''
|
const editQuery = requestedEditMode && caseState.editingAllowed ? '?edit=1' : ''
|
||||||
await loadLevelBySlug(caseState.id, editQuery)
|
await loadLevelBySlug(caseState.id, editQuery)
|
||||||
setSelected(document.id)
|
setSelected(document.id)
|
||||||
}
|
}
|
||||||
if (semanticCompletion) setStatus('SOURCE VERIFIED · OBJECTIVE COMPLETE')
|
if (semanticCompletion) setStatus(caseState.report?.requiredForCompletion ? 'SOURCE VERIFIED · CONNECT IT TO THE CLAIM AND FILE YOUR REPORT' : 'SOURCE VERIFIED · OBJECTIVE COMPLETE')
|
||||||
else if (semantic.subject === 'related' && semantic.supportsClaim) setStatus('RELATED DISCOVERY FOUND · STILL NEED PROOF ABOUT NILS')
|
else if (semantic.subject === 'related' && semantic.supportsClaim) setStatus('RELATED DISCOVERY FOUND · STILL NEED PROOF ABOUT NILS')
|
||||||
else if (semantic.status === 'unavailable') setStatus('SCREENSHOT SAVED · AUTOMATIC CLAIM REVIEW UNAVAILABLE')
|
else if (semantic.status === 'unavailable') setStatus('SCREENSHOT SAVED · AUTOMATIC CLAIM REVIEW UNAVAILABLE')
|
||||||
else if (semantic.status === 'pending') setStatus('SCREENSHOT SAVED · CLAIM REVIEW STILL RUNNING')
|
else if (semantic.status === 'pending') setStatus('SCREENSHOT SAVED · CLAIM REVIEW STILL RUNNING')
|
||||||
@@ -437,6 +441,42 @@ export function App() {
|
|||||||
} finally { setAdvancing(false) }
|
} finally { setAdvancing(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openCaseReport = async () => {
|
||||||
|
if (!caseState?.report) return
|
||||||
|
window.clearTimeout(saveTimer.current)
|
||||||
|
const editQuery = requestedEditMode && caseState.editingAllowed ? '?edit=1' : ''
|
||||||
|
setStatus('COMPILING CASE REPORT…')
|
||||||
|
const saved = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}${editQuery}`, {
|
||||||
|
method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(caseState),
|
||||||
|
})
|
||||||
|
if (saved.ok) await loadLevelBySlug(caseState.id,editQuery)
|
||||||
|
setReportOpen(true)
|
||||||
|
setStatus(saved.ok ? 'CASE REPORT COMPILED FROM BOARD' : 'CASE REPORT OPEN · LATEST SERVER COPY')
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitReport = async (input:CaseReportSubmissionInput) => {
|
||||||
|
if (!caseState?.report) throw new Error('Case report unavailable')
|
||||||
|
setStatus('SUBMITTING CASE REPORT…')
|
||||||
|
const response=await fetch(`/api/levels/${encodeURIComponent(caseState.id)}/report/submissions`,{
|
||||||
|
method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(input),
|
||||||
|
})
|
||||||
|
if (!response.ok) { const body=await response.json().catch(() => ({})); throw new Error(body.error || 'Report submission failed') }
|
||||||
|
const report=await response.json() as CaseReport
|
||||||
|
const reportEvidence=report.claims.flatMap(claim => claim.evidence)
|
||||||
|
const byDocument=new Map(reportEvidence.map(item => [item.documentExhibitId,item]))
|
||||||
|
const byConnection=new Map(reportEvidence.map(item => [item.connectionId,item]))
|
||||||
|
setCaseState(current => current ? {
|
||||||
|
...current,report,
|
||||||
|
exhibits:current.exhibits.map(exhibit => exhibit.type === 'document' && byDocument.has(exhibit.id)
|
||||||
|
? { ...exhibit,publishedAt:byDocument.get(exhibit.id)!.publishedAt,sourceCitation:byDocument.get(exhibit.id)!.sourceCitation,sourceUri:byDocument.get(exhibit.id)!.sourceUri,displayNumber:byDocument.get(exhibit.id)!.displayNumber }
|
||||||
|
: exhibit),
|
||||||
|
connections:current.connections.map(connection => byConnection.has(connection.id)
|
||||||
|
? { ...connection,label:byConnection.get(connection.id)!.relationText || undefined } : connection),
|
||||||
|
} : current)
|
||||||
|
setStatus(report.status === 'accepted' ? 'CASE REPORT ACCEPTED' : report.status === 'evidence_accepted_report_incomplete' ? 'EVIDENCE PASSED · REPORT RETURNED FOR REVISION' : 'REPORT NEEDS SUPPORTING EVIDENCE')
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!caseState) return
|
if (!caseState) return
|
||||||
const handlePaste = (event: ClipboardEvent) => {
|
const handlePaste = (event: ClipboardEvent) => {
|
||||||
@@ -464,6 +504,7 @@ export function App() {
|
|||||||
const unresolvedConceptCount = caseState.brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
|
const unresolvedConceptCount = caseState.brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
|
||||||
const pendingGoalCount = caseState.goals.filter(goal => goal.status === 'pending').length
|
const pendingGoalCount = caseState.goals.filter(goal => goal.status === 'pending').length
|
||||||
const briefAttentionCount = unresolvedConceptCount + pendingGoalCount
|
const briefAttentionCount = unresolvedConceptCount + pendingGoalCount
|
||||||
|
const reportAttentionCount = caseState.report?.requiredForCompletion && caseState.report.status !== 'accepted' ? 1 : 0
|
||||||
const activeGoal = caseState.goals.find(goal => goal.status === 'pending') || caseState.goals[0]
|
const activeGoal = caseState.goals.find(goal => goal.status === 'pending') || caseState.goals[0]
|
||||||
const canAuthor = isAdmin && requestedEditMode && Boolean(caseState.editingAllowed)
|
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 => exhibitWidget(exhibit.type).temporalFacts(exhibit).map(fact => {
|
||||||
@@ -484,6 +525,7 @@ export function App() {
|
|||||||
<nav>
|
<nav>
|
||||||
<button className={docsOpen ? 'active' : ''} onClick={() => setDocsOpen(true)}>EVIDENCE</button>
|
<button className={docsOpen ? 'active' : ''} onClick={() => setDocsOpen(true)}>EVIDENCE</button>
|
||||||
<button className={briefOpen ? 'active' : ''} aria-label="Case brief" onClick={() => briefOpen ? closeBrief() : setBriefOpen(true)}>CASE BRIEF{briefAttentionCount > 0 && <b className="brief-count">{briefAttentionCount}</b>}</button>
|
<button className={briefOpen ? 'active' : ''} aria-label="Case brief" onClick={() => briefOpen ? closeBrief() : setBriefOpen(true)}>CASE BRIEF{briefAttentionCount > 0 && <b className="brief-count">{briefAttentionCount}</b>}</button>
|
||||||
|
{caseState.report && <button className={reportOpen ? 'active' : ''} aria-label="Case report" onClick={() => reportOpen ? setReportOpen(false) : void openCaseReport()}>CASE REPORT{reportAttentionCount > 0 && <b className="brief-count">{reportAttentionCount}</b>}</button>}
|
||||||
<button onClick={() => setEditingTimeline(true)}>TIMELINE</button>
|
<button onClick={() => setEditingTimeline(true)}>TIMELINE</button>
|
||||||
<button onClick={() => setHelpOpen(true)}>HELP</button>
|
<button onClick={() => setHelpOpen(true)}>HELP</button>
|
||||||
{isAdmin && <div className="admin-menu" ref={adminMenuRef}>
|
{isAdmin && <div className="admin-menu" ref={adminMenuRef}>
|
||||||
@@ -511,7 +553,7 @@ export function App() {
|
|||||||
<div className="doc-list">
|
<div className="doc-list">
|
||||||
{filteredDocuments.map((doc, index) => <button className={`doc-row ${selected === doc.id ? 'selected' : ''} ${arrivingExhibitIds.includes(doc.id) ? 'arriving' : ''}`} data-document-row-id={doc.id} data-temporal-id={`document:${doc.id}`} key={doc.id} title="Click to locate on board · double-click to open" onDoubleClick={() => setOpenDoc(doc)} onClick={() => setSelected(current => current === doc.id ? null : doc.id)}>
|
{filteredDocuments.map((doc, index) => <button className={`doc-row ${selected === doc.id ? 'selected' : ''} ${arrivingExhibitIds.includes(doc.id) ? 'arriving' : ''}`} data-document-row-id={doc.id} data-temporal-id={`document:${doc.id}`} key={doc.id} title="Click to locate on board · double-click to open" onDoubleClick={() => setOpenDoc(doc)} onClick={() => setSelected(current => current === doc.id ? null : doc.id)}>
|
||||||
<div className={`doc-icon tint-${index % 3}`}><FileText size={24}/><b>{doc.fileType.slice(0, 3)}</b></div>
|
<div className={`doc-icon tint-${index % 3}`}><FileText size={24}/><b>{doc.fileType.slice(0, 3)}</b></div>
|
||||||
<div><strong>{doc.title}</strong><span>{documentWidget(doc.fileType).label} · {doc.publishedAt?.slice(0, 10) || 'UNDATED'}</span></div><ChevronRight size={16}/>
|
<div><strong>{doc.title}</strong><span>EXHIBIT {doc.displayNumber || index + 1} · {documentWidget(doc.fileType).label} · {doc.publishedAt?.slice(0, 10) || 'UNDATED'}</span></div><ChevronRight size={16}/>
|
||||||
</button>)}
|
</button>)}
|
||||||
{normalizedDocumentQuery && filteredDocuments.length === 0 && <div className="no-document-results"><Search size={20}/><b>NO MATCHING DOCUMENTS</b><span>Searches titles, contents, extracts, and metadata.</span></div>}
|
{normalizedDocumentQuery && filteredDocuments.length === 0 && <div className="no-document-results"><Search size={20}/><b>NO MATCHING DOCUMENTS</b><span>Searches titles, contents, extracts, and metadata.</span></div>}
|
||||||
</div>
|
</div>
|
||||||
@@ -519,7 +561,7 @@ export function App() {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className="board-shell" onDragEnter={e => { if (e.dataTransfer.types.includes('Files')) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) void uploadFiles(e.dataTransfer.files) }}>
|
<div className="board-shell" onDragEnter={e => { if (e.dataTransfer.types.includes('Files')) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) void uploadFiles(e.dataTransfer.files) }}>
|
||||||
<div className="case-heading"><div><small>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}</small><h1>{caseState.title}</h1><p>{caseState.subtitle || caseState.id.toUpperCase()}</p>{activeGoal && <button className={`active-goal ${activeGoal.status}`} onClick={() => setBriefOpen(true)}><span>{activeGoal.status === 'complete' ? 'OBJECTIVE VERIFIED' : 'CURRENT OBJECTIVE'}</span><b>{activeGoal.title}</b></button>}</div><div className="case-number">{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}<br/><b>{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}</b></div></div>
|
<div className="case-heading"><div><small>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}</small><h1>{caseState.title}</h1><p>{caseState.subtitle || caseState.id.toUpperCase()}</p>{activeGoal && <button className={`active-goal ${activeGoal.status}`} onClick={() => reportAttentionCount ? void openCaseReport() : setBriefOpen(true)}><span>{activeGoal.status === 'complete' ? reportAttentionCount ? 'SOURCE VERIFIED · REPORT REQUIRED' : 'OBJECTIVE VERIFIED' : 'CURRENT OBJECTIVE'}</span><b>{activeGoal.title}</b></button>}</div><div className="case-number">{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}<br/><b>{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}</b></div></div>
|
||||||
<Board state={caseState} selected={selected} locatorDocumentId={docsOpen && documents.some(document => document.id === selected) ? selected : null} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} arrivingExhibitIds={arrivingExhibitIds} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onDiscardExhibit={removeExhibit} onOpenSource={id => setOpenDoc(documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, exhibits: state.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'document' ? { ...exhibit, metadata: { ...exhibit.metadata, memory_cue: cue } } : exhibit) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
|
<Board state={caseState} selected={selected} locatorDocumentId={docsOpen && documents.some(document => document.id === selected) ? selected : null} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} arrivingExhibitIds={arrivingExhibitIds} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onDiscardExhibit={removeExhibit} onOpenSource={id => setOpenDoc(documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, exhibits: state.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'document' ? { ...exhibit, metadata: { ...exhibit.metadata, memory_cue: cue } } : exhibit) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
|
||||||
{briefOpen && <BriefPanel
|
{briefOpen && <BriefPanel
|
||||||
brief={caseState.brief}
|
brief={caseState.brief}
|
||||||
@@ -659,6 +701,10 @@ export function App() {
|
|||||||
onSave={saveThread}
|
onSave={saveThread}
|
||||||
onRemove={() => removeThread(threadDraft.id)}
|
onRemove={() => removeThread(threadDraft.id)}
|
||||||
/>}
|
/>}
|
||||||
|
{reportOpen && caseState.report && <CaseReportPanel key={`${caseState.id}:${caseState.report.status}:${caseState.report.claims.flatMap(claim => claim.evidence).length}`}
|
||||||
|
report={caseState.report} defaultInvestigator={caseState.report.investigatorName || playerName} hasNext={Boolean(activePlaythroughId)}
|
||||||
|
onClose={() => setReportOpen(false)} onSubmit={submitReport}
|
||||||
|
onContinue={() => activePlaythroughId ? void continueAfterGoal() : setReportOpen(false)}/>}
|
||||||
{helpOpen && <Help onClose={() => setHelpOpen(false)}/>}
|
{helpOpen && <Help onClose={() => setHelpOpen(false)}/>}
|
||||||
{flagsOpen && <LevelFlagsEditor levelId={caseState.id} onClose={() => setFlagsOpen(false)} onChanged={async () => { await loadLevelBySlug(caseState.id) }} />}
|
{flagsOpen && <LevelFlagsEditor levelId={caseState.id} onClose={() => setFlagsOpen(false)} onChanged={async () => { await loadLevelBySlug(caseState.id) }} />}
|
||||||
{matchRulesOpen && <EvidenceMatchRulesEditor levelId={caseState.id} onClose={() => setMatchRulesOpen(false)}/>}
|
{matchRulesOpen && <EvidenceMatchRulesEditor levelId={caseState.id} onClose={() => setMatchRulesOpen(false)}/>}
|
||||||
@@ -812,7 +858,10 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx
|
|||||||
setDraggingThreadTagId(null)
|
setDraggingThreadTagId(null)
|
||||||
setDraggingWidget(false)
|
setDraggingWidget(false)
|
||||||
setTrashActive(false)
|
setTrashActive(false)
|
||||||
if (completedDrag?.kind === 'widget' && completedDrag.id && completedDrag.moved && trashTarget.current) onDiscardExhibit(completedDrag.id)
|
if (completedDrag?.kind === 'widget' && completedDrag.id && completedDrag.moved && trashTarget.current) {
|
||||||
|
const exhibit=byId.get(completedDrag.id)
|
||||||
|
if (exhibit && exhibitWidget(exhibit.type).capabilities.discardable) onDiscardExhibit(completedDrag.id)
|
||||||
|
}
|
||||||
trashTarget.current = false
|
trashTarget.current = false
|
||||||
}
|
}
|
||||||
const toggleFolder = (id: string) => update(s => ({ ...s, exhibits: s.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'folder' ? { ...exhibit, isOpen: !exhibit.isOpen } : exhibit) }))
|
const toggleFolder = (id: string) => update(s => ({ ...s, exhibits: s.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'folder' ? { ...exhibit, isOpen: !exhibit.isOpen } : exhibit) }))
|
||||||
@@ -879,7 +928,7 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx
|
|||||||
{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 Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return <article key={document.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`widget:${document.id}`} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} ${selected === document.id ? 'selected' : ''} ${located ? 'document-located' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''} ${arrivingExhibitIds.includes(document.id) ? 'arriving' : ''}`} style={{ left, top, width: document.width, height: document.height, rotate: `${document.rotation}deg`, zIndex: document.zIndex }}
|
{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 Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return <article key={document.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`widget:${document.id}`} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} ${selected === document.id ? 'selected' : ''} ${located ? 'document-located' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''} ${arrivingExhibitIds.includes(document.id) ? 'arriving' : ''}`} style={{ left, top, width: document.width, height: document.height, rotate: `${document.rotation}deg`, zIndex: document.zIndex }}
|
||||||
onPointerDown={event => { 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 }) }}
|
onPointerDown={event => { 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)}>
|
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)}>
|
||||||
<header><span>{definition.label.toUpperCase()}</span><i>{String((membership?.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
|
<header><span>{definition.label.toUpperCase()}</span><i>{String(document.displayNumber || (membership?.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
|
||||||
<div className="source-file-preview"><Preview document={document} source={source} onMemoryCue={cue => onUpdateDocumentCue(document.id, cue)}/></div>
|
<div className="source-file-preview"><Preview document={document} source={source} onMemoryCue={cue => onUpdateDocumentCue(document.id, cue)}/></div>
|
||||||
<strong>{document.title}</strong><time>{document.publishedAt?.slice(0, 10) || 'UNDATED'}</time>
|
<strong>{document.title}</strong><time>{document.publishedAt?.slice(0, 10) || 'UNDATED'}</time>
|
||||||
<div className="source-file-actions"><button onClick={event => { event.stopPropagation(); onOpenSource(document.id) }}><BookOpen size={12}/> OPEN</button><button onClick={event => { event.stopPropagation(); onEditFile(document.id) }}><Pencil size={12}/> METADATA</button></div>
|
<div className="source-file-actions"><button onClick={event => { event.stopPropagation(); onOpenSource(document.id) }}><BookOpen size={12}/> OPEN</button><button onClick={event => { event.stopPropagation(); onEditFile(document.id) }}><Pencil size={12}/> METADATA</button></div>
|
||||||
@@ -999,6 +1048,50 @@ function TimelineRangeEditor({ range, dates, onClose, onSave }: { range?: Timeli
|
|||||||
</form></div>
|
</form></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CaseReportPanel({ report, defaultInvestigator, hasNext, onClose, onSubmit, onContinue }: {
|
||||||
|
report:CaseReport;defaultInvestigator:string;hasNext:boolean;onClose:()=>void
|
||||||
|
onSubmit:(input:CaseReportSubmissionInput)=>Promise<CaseReport>;onContinue:()=>void
|
||||||
|
}) {
|
||||||
|
const [investigatorName,setInvestigatorName]=useState(report.investigatorName || defaultInvestigator)
|
||||||
|
const [evidence,setEvidence]=useState<CaseReportSubmissionInput['evidence']>(() => 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 [busy,setBusy]=useState(false)
|
||||||
|
const [error,setError]=useState('')
|
||||||
|
const updateEvidence=(connectionId:string,patch:Partial<CaseReportSubmissionInput['evidence'][number]>) => 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 }) }
|
||||||
|
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 <div className="modal-shade case-report-shade"><form className="window case-report" onSubmit={event => void submit(event)}>
|
||||||
|
<header><ClipboardCheck size={16}/><b>{report.title}</b><span/><button type="button" aria-label="Close case report" onClick={onClose}><X size={14}/></button></header>
|
||||||
|
<div className="case-report-paper"><div className="case-report-letterhead"><small>GLITCH UNIVERSITY · PRINCIPAL INVESTIGATOR PROGRAMME</small><h2>CASE REPORT</h2><span>FORM GUPI–7 / EVIDENTIARY FINDING</span></div>
|
||||||
|
{report.claims.map((claim,claimIndex) => <section className="report-claim" key={claim.claimExhibitId}>
|
||||||
|
<h3><span>CLAIM {claimIndex + 1}</span>{claim.statement}</h3>
|
||||||
|
<h4>EVIDENCE</h4>
|
||||||
|
{claim.evidence.length === 0 ? <div className="report-empty-evidence">No source evidence is connected to this claim. Return to the board and use red thread to attach a document.</div>
|
||||||
|
: claim.evidence.map(item => { const draft=draftByConnection.get(item.connectionId)!; return <article className={`report-evidence ${item.evidenceAccepted ? 'verified' : ''}`} key={item.connectionId}>
|
||||||
|
<div className="report-evidence-heading"><b>Exhibit {item.displayNumber}</b><span>{item.fileType.replaceAll('_',' ')} · {item.documentTitle}</span>{item.evidenceAccepted && <em>CONTENT VERIFIED</em>}</div>
|
||||||
|
<label><span>EVIDENTIARY STATEMENT</span><textarea aria-label={`Evidence statement for Exhibit ${item.displayNumber}`} rows={2} value={draft.relationText} onChange={event => updateEvidence(item.connectionId,{ relationText:event.target.value })}/></label>
|
||||||
|
<div className="report-fields"><label><span>DATED</span><input aria-label={`Date for Exhibit ${item.displayNumber}`} type="date" value={draft.publishedAt || ''} onChange={event => updateEvidence(item.connectionId,{ publishedAt:event.target.value || undefined })}/></label>
|
||||||
|
<label><span>SOURCE / PUBLICATION</span><input aria-label={`Source for Exhibit ${item.displayNumber}`} value={draft.sourceCitation || ''} placeholder="e.g. Google Patents · GB695913A" onChange={event => updateEvidence(item.connectionId,{ sourceCitation:event.target.value })}/></label></div>
|
||||||
|
<label><span>SOURCE LINK · IF AVAILABLE</span><input aria-label={`Source link for Exhibit ${item.displayNumber}`} type="url" value={draft.sourceUri || ''} placeholder="https://…" onChange={event => updateEvidence(item.connectionId,{ sourceUri:event.target.value })}/></label>
|
||||||
|
</article> })}
|
||||||
|
</section>)}
|
||||||
|
<label className="report-investigator"><span>INVESTIGATOR</span><input aria-label="Investigator name" value={investigatorName} maxLength={300} onChange={event => setInvestigatorName(event.target.value)}/></label>
|
||||||
|
{report.status !== 'draft' && <section className={`report-verdict ${report.status}`} role="status"><small>{report.status === 'accepted' ? 'REPORT ACCEPTED' : report.status === 'evidence_accepted_report_incomplete' ? 'EVIDENCE PASSED · REPORT RETURNED' : 'EVIDENCE NOT ESTABLISHED'}</small><p>{report.feedback}</p></section>}
|
||||||
|
{error && <p className="flag-error">{error}</p>}
|
||||||
|
<div className="case-report-actions"><button type="button" onClick={onClose}>RETURN TO BOARD</button>{report.status === 'accepted'
|
||||||
|
? <button className="primary" type="button" onClick={onContinue}>{hasNext ? 'CONTINUE' : 'CLOSE CASE'} <span>▸</span></button>
|
||||||
|
: <button className="primary" type="submit" disabled={busy}>{busy ? 'FILING…' : 'SUBMIT CASE REPORT'}</button>}</div>
|
||||||
|
</div>
|
||||||
|
</form></div>
|
||||||
|
}
|
||||||
|
|
||||||
function ThreadEditor({ connection, sourceName, targetName, isNew, onClose, onSave, onRemove }: { connection: Connection; sourceName: string; targetName: string; isNew: boolean; onClose: () => void; onSave: (connection: Connection) => void; onRemove: () => void }) {
|
function ThreadEditor({ connection, sourceName, targetName, isNew, onClose, onSave, onRemove }: { connection: Connection; sourceName: string; targetName: string; isNew: boolean; onClose: () => void; onSave: (connection: Connection) => void; onRemove: () => void }) {
|
||||||
const [label, setLabel] = useState(connection.label || '')
|
const [label, setLabel] = useState(connection.label || '')
|
||||||
const [tightness, setTightness] = useState(connection.tightness ?? 65)
|
const [tightness, setTightness] = useState(connection.tightness ?? 65)
|
||||||
@@ -1012,8 +1105,8 @@ function ThreadEditor({ connection, sourceName, targetName, isNew, onClose, onSa
|
|||||||
return <div className="modal-shade"><form className="window thread-editor" onSubmit={event => { event.preventDefault(); save() }}>
|
return <div className="modal-shade"><form className="window thread-editor" onSubmit={event => { event.preventDefault(); save() }}>
|
||||||
<header><Link2 size={16}/><b>{isNew ? 'Add relation tag' : 'Edit red thread'}</b><span/><button type="button" aria-label="Close thread editor" onClick={onClose}><X size={14}/></button></header>
|
<header><Link2 size={16}/><b>{isNew ? 'Add relation tag' : 'Edit red thread'}</b><span/><button type="button" aria-label="Close thread editor" onClick={onClose}><X size={14}/></button></header>
|
||||||
<div><small>RED THREAD · INVESTIGATOR RELATION</small><div className="thread-endpoints"><b>{sourceName}</b><i/><b>{targetName}</b></div>
|
<div><small>RED THREAD · INVESTIGATOR RELATION</small><div className="thread-endpoints"><b>{sourceName}</b><i/><b>{targetName}</b></div>
|
||||||
<p>What does this connection mean? Add a short tag if the thread represents a specific claim.</p>
|
<p>What does this connection prove? Complete the sentence on the luggage tag; it will also appear in the Case Report.</p>
|
||||||
<label className="field"><span>RELATION TAG · OPTIONAL</span><input aria-label="Thread tag" autoFocus placeholder="e.g. Proof Elias is the driver" value={label} onChange={event => setLabel(event.target.value)}/></label>
|
<label className="field"><span>EVIDENTIARY STATEMENT · OPTIONAL</span><input aria-label="Thread tag" autoFocus placeholder="Proof that…" value={label} onChange={event => setLabel(event.target.value)}/></label>
|
||||||
<fieldset className="tag-style-picker"><legend>TAG PRESENTATION</legend><label className={tagStyle === 'luggage' ? 'selected' : ''}><input type="radio" name="tag-style" value="luggage" checked={tagStyle === 'luggage'} onChange={() => setTagStyle('luggage')}/><span className="tag-style-luggage"><i/>LUGGAGE</span><small>Expressive · rotates to read</small></label><label className={tagStyle === 'compact' ? 'selected' : ''}><input type="radio" name="tag-style" value="compact" checked={tagStyle === 'compact'} onChange={() => setTagStyle('compact')}/><span className="tag-style-compact"><i/>COMPACT</span><small>Quiet · less board clutter</small></label></fieldset>
|
<fieldset className="tag-style-picker"><legend>TAG PRESENTATION</legend><label className={tagStyle === 'luggage' ? 'selected' : ''}><input type="radio" name="tag-style" value="luggage" checked={tagStyle === 'luggage'} onChange={() => setTagStyle('luggage')}/><span className="tag-style-luggage"><i/>LUGGAGE</span><small>Expressive · rotates to read</small></label><label className={tagStyle === 'compact' ? 'selected' : ''}><input type="radio" name="tag-style" value="compact" checked={tagStyle === 'compact'} onChange={() => setTagStyle('compact')}/><span className="tag-style-compact"><i/>COMPACT</span><small>Quiet · less board clutter</small></label></fieldset>
|
||||||
<label className="field thread-position-control"><span>TAG POSITION <output>{tagPosition}%</output></span><input aria-label="Tag position" type="range" min="5" max="95" step="1" value={tagPosition} onChange={event => setTagPosition(Number(event.target.value))}/><small>Drag the tag on the board for along-thread position and tension-limited lateral play.</small></label>
|
<label className="field thread-position-control"><span>TAG POSITION <output>{tagPosition}%</output></span><input aria-label="Tag position" type="range" min="5" max="95" step="1" value={tagPosition} onChange={event => setTagPosition(Number(event.target.value))}/><small>Drag the tag on the board for along-thread position and tension-limited lateral play.</small></label>
|
||||||
<label className="field thread-tightness"><span>THREAD TIGHTNESS <output>{tightness}%</output></span><input aria-label="Thread tightness" type="range" min="0" max="100" step="5" value={tightness} onChange={event => setTightness(Number(event.target.value))}/><small><span>SLACK</span><span>TAUT</span></small></label>
|
<label className="field thread-tightness"><span>THREAD TIGHTNESS <output>{tightness}%</output></span><input aria-label="Thread tightness" type="range" min="0" max="100" step="5" value={tightness} onChange={event => setTightness(Number(event.target.value))}/><small><span>SLACK</span><span>TAUT</span></small></label>
|
||||||
|
|||||||
+4
-1
@@ -206,6 +206,7 @@ type LegacyCaseState = {
|
|||||||
viewport: Viewport
|
viewport: Viewport
|
||||||
brief?: CaseState['brief']
|
brief?: CaseState['brief']
|
||||||
goals?: CaseState['goals']
|
goals?: CaseState['goals']
|
||||||
|
report?: CaseState['report']
|
||||||
updatedAt?: string
|
updatedAt?: string
|
||||||
levelStatus?: string
|
levelStatus?: string
|
||||||
sourceTemplateVersionId?: string
|
sourceTemplateVersionId?: string
|
||||||
@@ -240,6 +241,7 @@ export function normalizeCase(input: CaseState | LegacyCaseState): CaseState {
|
|||||||
relations: (state.relations || []) as unknown as ExhibitRelation[], connections: (state.connections || []) as unknown as Connection[],
|
relations: (state.relations || []) as unknown as ExhibitRelation[], connections: (state.connections || []) as unknown as Connection[],
|
||||||
revision: Number(state.revision || 0), brief: state.brief || { body: '', concepts: [] },
|
revision: Number(state.revision || 0), brief: state.brief || { body: '', concepts: [] },
|
||||||
goals: state.goals || [],
|
goals: state.goals || [],
|
||||||
|
report: state.report,
|
||||||
views: Array.isArray(state.views) && state.views.length ? state.views : [defaultTimelineView(state.timelineRange)],
|
views: Array.isArray(state.views) && state.views.length ? state.views : [defaultTimelineView(state.timelineRange)],
|
||||||
exhibits: state.exhibits.map((exhibit, index) => ({ ...exhibit, ...placement(exhibit as unknown as Record<string, unknown>, { width: exhibit.type === 'document' ? 174 : 240, height: exhibit.type === 'document' ? 145 : 160 }, index) })),
|
exhibits: state.exhibits.map((exhibit, index) => ({ ...exhibit, ...placement(exhibit as unknown as Record<string, unknown>, { width: exhibit.type === 'document' ? 174 : 240, height: exhibit.type === 'document' ? 145 : 160 }, index) })),
|
||||||
updatedAt: state.updatedAt, levelStatus: state.levelStatus, sourceTemplateVersionId: state.sourceTemplateVersionId, editingAllowed: state.editingAllowed,
|
updatedAt: state.updatedAt, levelStatus: state.levelStatus, sourceTemplateVersionId: state.sourceTemplateVersionId, editingAllowed: state.editingAllowed,
|
||||||
@@ -257,6 +259,7 @@ export function normalizeCase(input: CaseState | LegacyCaseState): CaseState {
|
|||||||
...placement({ ...document, ...(documentPositions.get(String(document.id)) || {}) }, { width: 174, height: 145 }, index),
|
...placement({ ...document, ...(documentPositions.get(String(document.id)) || {}) }, { width: 174, height: 145 }, index),
|
||||||
publishedAt: String(document.publishedAt || document.date || '') || undefined, capturedAt: String(document.capturedAt || '') || undefined,
|
publishedAt: String(document.publishedAt || document.date || '') || undefined, capturedAt: String(document.capturedAt || '') || undefined,
|
||||||
sourceUri: String(document.sourceUri || '') || undefined, body: Array.isArray(document.body) ? document.body.map(String) : [],
|
sourceUri: String(document.sourceUri || '') || undefined, body: Array.isArray(document.body) ? document.body.map(String) : [],
|
||||||
|
sourceCitation: String(document.sourceCitation || '') || undefined,
|
||||||
regions: Array.isArray(document.regions) ? document.regions as never[] : [], assetId: String(document.assetId || '') || undefined,
|
regions: Array.isArray(document.regions) ? document.regions as never[] : [], assetId: String(document.assetId || '') || undefined,
|
||||||
fileName: String(document.fileName || '') || undefined, mimeType: String(document.mimeType || '') || undefined,
|
fileName: String(document.fileName || '') || undefined, mimeType: String(document.mimeType || '') || undefined,
|
||||||
fileSize: document.fileSize === undefined ? undefined : Number(document.fileSize),
|
fileSize: document.fileSize === undefined ? undefined : Number(document.fileSize),
|
||||||
@@ -280,7 +283,7 @@ export function normalizeCase(input: CaseState | LegacyCaseState): CaseState {
|
|||||||
const connections: Connection[] = (state.connections || []).map(connection => ({ ...connection, id: String(connection.id),
|
const connections: Connection[] = (state.connections || []).map(connection => ({ ...connection, id: String(connection.id),
|
||||||
fromExhibitId: String(connection.fromExhibitId || connection.fromEvidenceId), toExhibitId: String(connection.toExhibitId || connection.toEvidenceId) } as Connection))
|
fromExhibitId: String(connection.fromExhibitId || connection.fromEvidenceId), toExhibitId: String(connection.toExhibitId || connection.toEvidenceId) } as Connection))
|
||||||
return { id: state.id, title: state.title, subtitle: state.subtitle, exhibits: [...documents, ...evidence], relations: derivedRelations, connections,
|
return { id: state.id, title: state.title, subtitle: state.subtitle, exhibits: [...documents, ...evidence], relations: derivedRelations, connections,
|
||||||
views: [defaultTimelineView(state.timelineRange)], viewport: state.viewport, brief: state.brief || { body: '', concepts: [] }, goals: state.goals || [], revision: Number(state.revision || 0),
|
views: [defaultTimelineView(state.timelineRange)], viewport: state.viewport, brief: state.brief || { body: '', concepts: [] }, goals: state.goals || [], report: state.report, revision: Number(state.revision || 0),
|
||||||
updatedAt: state.updatedAt, levelStatus: state.levelStatus, sourceTemplateVersionId: state.sourceTemplateVersionId, editingAllowed: state.editingAllowed,
|
updatedAt: state.updatedAt, levelStatus: state.levelStatus, sourceTemplateVersionId: state.sourceTemplateVersionId, editingAllowed: state.editingAllowed,
|
||||||
newlyVisibleDocumentIds: state.newlyVisibleDocumentIds || [] }
|
newlyVisibleDocumentIds: state.newlyVisibleDocumentIds || [] }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { documentWidget, documentWidgetRegistry, exhibitWidget, exhibitWidgetReg
|
|||||||
|
|
||||||
describe('frontend exhibit registry', () => {
|
describe('frontend exhibit registry', () => {
|
||||||
it('registers every normalized exhibit type', () => {
|
it('registers every normalized exhibit type', () => {
|
||||||
const types = ['folder', 'document', 'note', 'event', 'party'] as const
|
const types = ['folder', 'document', 'note', 'event', 'party', 'claim'] as const
|
||||||
expect(Object.keys(exhibitWidgetRegistry).sort()).toEqual([...types].sort())
|
expect(Object.keys(exhibitWidgetRegistry).sort()).toEqual([...types].sort())
|
||||||
expect(exhibitWidget('event').heading({} as never, { exhibits: [], relations: [], dispatch: () => {} })).toContain('THIS HAPPENED')
|
expect(exhibitWidget('event').heading({} as never, { exhibits: [], relations: [], dispatch: () => {} })).toContain('THIS HAPPENED')
|
||||||
})
|
})
|
||||||
|
|||||||
+11
-1
@@ -1,5 +1,5 @@
|
|||||||
import type { ComponentType } from 'react'
|
import type { ComponentType } from 'react'
|
||||||
import { BookOpen, Building2, CalendarClock, FileText, Image as ImageIcon, Pencil, UserRound } from 'lucide-react'
|
import { BadgeCheck, BookOpen, Building2, CalendarClock, FileText, Image as ImageIcon, Pencil, UserRound } from 'lucide-react'
|
||||||
import type { CaseDocument, DocumentExhibit, Evidence, Exhibit, ExhibitRelation, ExhibitType, SourceFileType, TemporalFact } from './types'
|
import type { CaseDocument, DocumentExhibit, Evidence, Exhibit, ExhibitRelation, ExhibitType, SourceFileType, TemporalFact } from './types'
|
||||||
|
|
||||||
export type WidgetCommand =
|
export type WidgetCommand =
|
||||||
@@ -72,6 +72,13 @@ function PartyWidget({ exhibit, context }: ExhibitWidgetProps) {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ClaimWidget({ exhibit }: ExhibitWidgetProps) {
|
||||||
|
if (exhibit.type !== 'claim') return null
|
||||||
|
return <div className="card-content claim-content"><div className="claim-heading"><BadgeCheck size={18}/><span>PROPOSITION TO PROVE</span></div>
|
||||||
|
<blockquote>{exhibit.statement}</blockquote><small>CONNECT SOURCE EVIDENCE WITH RED THREAD</small>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
function DocumentWidget({ exhibit }: ExhibitWidgetProps) {
|
function DocumentWidget({ exhibit }: ExhibitWidgetProps) {
|
||||||
if (exhibit.type !== 'document') return null
|
if (exhibit.type !== 'document') return null
|
||||||
return <strong>{exhibit.title}</strong>
|
return <strong>{exhibit.title}</strong>
|
||||||
@@ -82,6 +89,7 @@ const notePorts = (exhibit: Exhibit) => [{ id:'knot',x:exhibit.x + exhibit.width
|
|||||||
const standardCapabilities: WidgetCapabilities = { movable:true,resizable:false,connectable:true,discardable:true,dockable:false }
|
const standardCapabilities: WidgetCapabilities = { movable:true,resizable:false,connectable:true,discardable:true,dockable:false }
|
||||||
const searchable = (exhibit: Exhibit) => exhibit.type === 'document'
|
const searchable = (exhibit: Exhibit) => exhibit.type === 'document'
|
||||||
? [exhibit.title,...exhibit.body,...Object.values(exhibit.metadata)].join('\n').toLocaleLowerCase()
|
? [exhibit.title,...exhibit.body,...Object.values(exhibit.metadata)].join('\n').toLocaleLowerCase()
|
||||||
|
: exhibit.type === 'claim' ? [exhibit.title,exhibit.statement].join('\n').toLocaleLowerCase()
|
||||||
: [exhibit.title,exhibit.content].join('\n').toLocaleLowerCase()
|
: [exhibit.title,exhibit.content].join('\n').toLocaleLowerCase()
|
||||||
|
|
||||||
export const exhibitWidgetRegistry: Record<ExhibitType, ExhibitWidgetDefinition> = {
|
export const exhibitWidgetRegistry: Record<ExhibitType, ExhibitWidgetDefinition> = {
|
||||||
@@ -101,6 +109,8 @@ export const exhibitWidgetRegistry: Record<ExhibitType, ExhibitWidgetDefinition>
|
|||||||
? [{ id:`${exhibit.id}:occurred`,exhibitId:exhibit.id,kind:'occurred',start:exhibit.eventDate,label:exhibit.content }] : [],searchText:searchable,Component:EventWidget },
|
? [{ id:`${exhibit.id}:occurred`,exhibitId:exhibit.id,kind:'occurred',start:exhibit.eventDate,label:exhibit.content }] : [],searchText:searchable,Component:EventWidget },
|
||||||
party: { modelKind:'exhibit',visualType:'party',shell:'card',defaultSize:{width:280,height:190},capabilities:standardCapabilities,
|
party: { modelKind:'exhibit',visualType:'party',shell:'card',defaultSize:{width:280,height:190},capabilities:standardCapabilities,
|
||||||
heading:exhibit => exhibit.type === 'party' && exhibit.partyKind === 'person' ? 'PARTY / PERSON DOSSIER' : 'PARTY / ORGANIZATION DOSSIER',connectionPorts:standardPorts,temporalFacts:() => [],searchText:searchable,Component:PartyWidget },
|
heading:exhibit => exhibit.type === 'party' && exhibit.partyKind === 'person' ? 'PARTY / PERSON DOSSIER' : 'PARTY / ORGANIZATION DOSSIER',connectionPorts:standardPorts,temporalFacts:() => [],searchText:searchable,Component:PartyWidget },
|
||||||
|
claim: { modelKind:'exhibit',visualType:'claim',shell:'card',defaultSize:{width:310,height:180},capabilities:{...standardCapabilities,discardable:false},
|
||||||
|
heading:() => 'CASE CLAIM / UNPROVEN',connectionPorts:standardPorts,temporalFacts:() => [],searchText:searchable,Component:ClaimWidget },
|
||||||
}
|
}
|
||||||
|
|
||||||
export function exhibitWidget(type: ExhibitType) { return exhibitWidgetRegistry[type] }
|
export function exhibitWidget(type: ExhibitType) { return exhibitWidgetRegistry[type] }
|
||||||
|
|||||||
@@ -141,6 +141,11 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.party-identity small { color: #6a746e; font: 7px IBM Plex Mono; }
|
.party-identity small { color: #6a746e; font: 7px IBM Plex Mono; }
|
||||||
.evidence-card.party p { margin-top: 9px; font-size: 13px; }
|
.evidence-card.party p { margin-top: 9px; font-size: 13px; }
|
||||||
.party-aliases { padding: 5px 0; color: #76552f; font: 7px IBM Plex Mono; }
|
.party-aliases { padding: 5px 0; color: #76552f; font: 7px IBM Plex Mono; }
|
||||||
|
.evidence-card.claim { min-height: 180px; padding: 15px 18px; background: linear-gradient(105deg,#e2dfcf,#d3d1c2); border: 2px solid #b99a68; box-shadow: 8px 10px 0 #020b0980,0 0 0 2px #526059; }
|
||||||
|
.evidence-card.claim::before { content:''; position:absolute; z-index:2; top:-7px; left:50%; width:13px; height:13px; translate:-50% 0; border-radius:50%; background:#9e392f; border:2px solid #5d1e1b; box-shadow:0 2px 2px #0006; }
|
||||||
|
.evidence-card.claim::after { background:#827d6e; }
|
||||||
|
.claim-content { padding-top:12px; }.claim-heading { display:flex; align-items:center; gap:7px; color:#89502b; font:600 7px IBM Plex Mono; letter-spacing:.13em; }
|
||||||
|
.claim-content blockquote { margin:14px 0 16px; padding:0; color:#202a26; font:22px/1.25 Special Elite; }.claim-content > small { color:#66716b; font:7px IBM Plex Mono; letter-spacing:.08em; }
|
||||||
.folder-actions { position: absolute; right: 13px; bottom: 10px; }
|
.folder-actions { position: absolute; right: 13px; bottom: 10px; }
|
||||||
.folder-actions button { float: none; min-height: 29px; padding: 6px 9px; gap: 6px; border: 1px solid #81633d; background: #d9bb82; color: #563617; font-size: 9px; box-shadow: 2px 2px #75552f66; }
|
.folder-actions button { float: none; min-height: 29px; padding: 6px 9px; gap: 6px; border: 1px solid #81633d; background: #d9bb82; color: #563617; font-size: 9px; box-shadow: 2px 2px #75552f66; }
|
||||||
.folder-actions button:hover { background: #e5ca96; color: #321f0f; }
|
.folder-actions button:hover { background: #e5ca96; color: #321f0f; }
|
||||||
@@ -291,6 +296,17 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.match-rule-layout { display: grid; grid-template-columns: minmax(250px, .75fr) minmax(390px, 1.25fr); gap: 13px; align-items: start; }.match-rule-list { max-height: 560px; overflow: auto; border: 1px solid #8b938d; background: #d3d4cc; }
|
.match-rule-layout { display: grid; grid-template-columns: minmax(250px, .75fr) minmax(390px, 1.25fr); gap: 13px; align-items: start; }.match-rule-list { max-height: 560px; overflow: auto; border: 1px solid #8b938d; background: #d3d4cc; }
|
||||||
.match-rule-row { min-height: 58px; display: grid; grid-template-columns: minmax(0, 1fr) auto 28px; align-items: center; gap: 6px; padding: 7px; border-bottom: 1px solid #a0a69f; }.match-rule-row.disabled { opacity: .55; }.match-rule-row > div { min-width: 0; display: grid; gap: 4px; }.match-rule-row b, .match-rule-row small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.match-rule-row b { color: #273d36; font: 600 9px IBM Plex Mono; }.match-rule-row small { color: #737d77; font: 7px IBM Plex Mono; }.match-rule-row button, .match-rule-form button { min-height: 27px; border: 1px outset #89938d; background: #c6cbc4; color: #30463f; cursor: pointer; font: 8px IBM Plex Mono; }.match-rule-row button:last-child { display: grid; place-items: center; color: #783f2e; }
|
.match-rule-row { min-height: 58px; display: grid; grid-template-columns: minmax(0, 1fr) auto 28px; align-items: center; gap: 6px; padding: 7px; border-bottom: 1px solid #a0a69f; }.match-rule-row.disabled { opacity: .55; }.match-rule-row > div { min-width: 0; display: grid; gap: 4px; }.match-rule-row b, .match-rule-row small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.match-rule-row b { color: #273d36; font: 600 9px IBM Plex Mono; }.match-rule-row small { color: #737d77; font: 7px IBM Plex Mono; }.match-rule-row button, .match-rule-form button { min-height: 27px; border: 1px outset #89938d; background: #c6cbc4; color: #30463f; cursor: pointer; font: 8px IBM Plex Mono; }.match-rule-row button:last-child { display: grid; place-items: center; color: #783f2e; }
|
||||||
.match-rule-form { padding: 13px; border: 1px solid #8b938d; background: #cecfc7; }.match-rule-form-heading, .anchor-heading { display: flex; justify-content: space-between; align-items: center; margin-bottom: 11px; color: #44504c; font: 8px IBM Plex Mono; letter-spacing: .08em; }.match-rule-form-heading button, .anchor-heading button { display: flex; align-items: center; gap: 4px; padding: 5px 8px; }.match-rule-fields { display: grid; grid-template-columns: minmax(0, 1fr) 110px; gap: 9px; }.match-rule-enabled { display: flex; align-items: center; gap: 7px; margin: 10px 0 15px; color: #59645e; font: 8px IBM Plex Mono; }.anchor-heading { margin: 0; padding: 8px 0; border-bottom: 2px solid #59625d; }.anchor-list { max-height: 295px; overflow: auto; border: 1px solid #929991; border-top: 0; background: #d7d8d0; }.anchor-row { display: grid; grid-template-columns: minmax(0, 1fr) 88px 28px; align-items: end; gap: 7px; padding: 8px; border-bottom: 1px solid #a1a69f; }.anchor-row > div, .anchor-row label { display: grid; gap: 4px; }.anchor-row small, .anchor-row label span { color: #6b756f; font: 7px IBM Plex Mono; }.anchor-row textarea { min-width: 0; resize: vertical; padding: 7px; background: #eeeadd; font: 9px/1.4 IBM Plex Mono; }.anchor-row input { min-width: 0; padding: 7px 4px; font: 8px IBM Plex Mono; }.anchor-row > button { display: grid; place-items: center; color: #783f2e; }.match-rule-form .folder-editor-actions { margin-top: 12px; }.match-rule-form .folder-editor-actions button { padding: 8px 11px; }.match-rule-form .folder-editor-actions button:disabled, .match-rule-row button:disabled, .anchor-row > button:disabled { opacity: .45; cursor: default; }.match-rule-form .flag-error { margin: 9px 0 0; color: #8a342e; font: 8px IBM Plex Mono; }
|
.match-rule-form { padding: 13px; border: 1px solid #8b938d; background: #cecfc7; }.match-rule-form-heading, .anchor-heading { display: flex; justify-content: space-between; align-items: center; margin-bottom: 11px; color: #44504c; font: 8px IBM Plex Mono; letter-spacing: .08em; }.match-rule-form-heading button, .anchor-heading button { display: flex; align-items: center; gap: 4px; padding: 5px 8px; }.match-rule-fields { display: grid; grid-template-columns: minmax(0, 1fr) 110px; gap: 9px; }.match-rule-enabled { display: flex; align-items: center; gap: 7px; margin: 10px 0 15px; color: #59645e; font: 8px IBM Plex Mono; }.anchor-heading { margin: 0; padding: 8px 0; border-bottom: 2px solid #59625d; }.anchor-list { max-height: 295px; overflow: auto; border: 1px solid #929991; border-top: 0; background: #d7d8d0; }.anchor-row { display: grid; grid-template-columns: minmax(0, 1fr) 88px 28px; align-items: end; gap: 7px; padding: 8px; border-bottom: 1px solid #a1a69f; }.anchor-row > div, .anchor-row label { display: grid; gap: 4px; }.anchor-row small, .anchor-row label span { color: #6b756f; font: 7px IBM Plex Mono; }.anchor-row textarea { min-width: 0; resize: vertical; padding: 7px; background: #eeeadd; font: 9px/1.4 IBM Plex Mono; }.anchor-row input { min-width: 0; padding: 7px 4px; font: 8px IBM Plex Mono; }.anchor-row > button { display: grid; place-items: center; color: #783f2e; }.match-rule-form .folder-editor-actions { margin-top: 12px; }.match-rule-form .folder-editor-actions button { padding: 8px 11px; }.match-rule-form .folder-editor-actions button:disabled, .match-rule-row button:disabled, .anchor-row > button:disabled { opacity: .45; cursor: default; }.match-rule-form .flag-error { margin: 9px 0 0; color: #8a342e; font: 8px IBM Plex Mono; }
|
||||||
|
.case-report-shade { padding:24px; }.case-report { width:min(900px,94vw); height:min(860px,94vh); display:grid; grid-template-rows:31px minmax(0,1fr); overflow:hidden; }
|
||||||
|
.case-report > header { cursor:default; }.case-report-paper { overflow:auto; padding:42px clamp(26px,7vw,74px) 34px; color:#202622; background-color:#ebe7d8; background-image:linear-gradient(#7e897e18 1px,transparent 1px); background-size:100% 28px; box-shadow:inset 0 0 46px #8d877466; font-family:Special Elite,serif; }
|
||||||
|
.case-report-letterhead { position:relative; padding-bottom:22px; border-bottom:3px double #343b37; text-align:center; }.case-report-letterhead small,.case-report-letterhead span { display:block; color:#59625d; font:7px IBM Plex Mono; letter-spacing:.16em; }.case-report-letterhead h2 { margin:9px 0 7px; color:#1d2622; font:30px Special Elite; letter-spacing:.11em; }
|
||||||
|
.report-claim { margin-top:30px; }.report-claim h3 { margin:0 0 22px; font:21px/1.45 Special Elite; }.report-claim h3 span { display:block; margin-bottom:5px; color:#915c30; font:7px IBM Plex Mono; letter-spacing:.14em; }.report-claim h4 { margin:0; padding-bottom:6px; border-bottom:1px solid #4c554f; font:600 8px IBM Plex Mono; letter-spacing:.15em; }
|
||||||
|
.report-empty-evidence { margin-top:13px; padding:17px; border:1px dashed #8a6550; color:#76513c; background:#d9d1bd; font:10px/1.5 IBM Plex Mono; }
|
||||||
|
.report-evidence { margin-top:14px; padding:14px 16px 16px; border-left:4px solid #8b5b38; background:#e1ddcfcc; box-shadow:0 1px #fff8; }.report-evidence.verified { border-left-color:#46705c; }
|
||||||
|
.report-evidence-heading { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:10px; margin-bottom:12px; }.report-evidence-heading b { color:#25332d; font:14px Special Elite; }.report-evidence-heading span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#646c67; font:8px IBM Plex Mono; text-transform:uppercase; }.report-evidence-heading em { padding:3px 5px; border:1px solid #577665; color:#436753; font:600 6px IBM Plex Mono; letter-spacing:.08em; font-style:normal; }
|
||||||
|
.report-evidence label,.report-investigator { display:grid; gap:4px; margin-top:9px; }.report-evidence label > span,.report-investigator > span { color:#5c655f; font:600 7px IBM Plex Mono; letter-spacing:.11em; }.report-evidence input,.report-evidence textarea,.report-investigator input { width:100%; min-width:0; padding:7px 8px; border:0; border-bottom:1px solid #747b75; outline:0; background:#f2eee0a8; color:#1d2622; font:12px/1.45 Special Elite; resize:vertical; }.report-evidence input:focus,.report-evidence textarea:focus,.report-investigator input:focus { border-bottom-color:#9b582d; background:#f6f0dc; box-shadow:inset 3px 0 #b2784a; }
|
||||||
|
.report-fields { display:grid; grid-template-columns:155px minmax(0,1fr); gap:13px; }.report-investigator { margin-top:30px; padding-top:13px; border-top:1px solid #4c554f; }.report-investigator input { max-width:390px; }
|
||||||
|
.report-verdict { margin-top:28px; padding:17px 19px; border:2px solid #874339; background:#e0c7b9; transform:rotate(-.25deg); }.report-verdict small { color:#7c352f; font:700 8px IBM Plex Mono; letter-spacing:.13em; }.report-verdict p { margin:9px 0 0; font:15px/1.5 Special Elite; }.report-verdict.accepted { border-color:#47705c; background:#d2ddcf; }.report-verdict.accepted small { color:#315b48; }
|
||||||
|
.case-report-actions { position:sticky; bottom:-34px; display:flex; justify-content:flex-end; gap:9px; margin:30px -12px -20px; padding:14px 12px 20px; background:linear-gradient(transparent,#ebe7d8 25%); }.case-report-actions button { border:1px outset #8c948e; padding:10px 13px; color:#34443e; background:#c8cbc4; cursor:pointer; font:8px IBM Plex Mono; letter-spacing:.06em; }.case-report-actions .primary { float:none; color:#fff; background:#174337; }.case-report-actions button:disabled { opacity:.55;cursor:progress; }
|
||||||
.boot { height: 100vh; background: #071916; display: grid; place-content: center; justify-items: center; color: #819b93; font: 11px IBM Plex Mono; letter-spacing: .15em; }.boot .seal { width: 70px; height: 70px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; margin-bottom: 24px; font-weight: 600; }.boot small { color: #4e6a62; }
|
.boot { height: 100vh; background: #071916; display: grid; place-content: center; justify-items: center; color: #819b93; font: 11px IBM Plex Mono; letter-spacing: .15em; }.boot .seal { width: 70px; height: 70px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; margin-bottom: 24px; font-weight: 600; }.boot small { color: #4e6a62; }
|
||||||
.empty-archive { height: 100vh; display: grid; place-content: center; justify-items: center; text-align: center; background: radial-gradient(circle, #123029 0, #071916 65%); color: #9bb0a9; }.empty-archive .seal { width: 72px; height: 72px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; font: 600 14px IBM Plex Mono; margin-bottom: 25px; }.empty-archive small { font: 9px IBM Plex Mono; letter-spacing: .18em; color: #68837b; }.empty-archive h1 { margin: 12px 0 5px; color: #e0e5e1; font: 27px Special Elite; }.empty-archive p { font-size: 12px; }.empty-archive button { margin-top: 18px; display: flex; align-items: center; gap: 8px; background: #1a493d; border: 1px solid #6f8f85; padding: 11px 16px; font: 10px IBM Plex Mono; cursor: pointer; }.empty-archive .hint { margin-top: 20px; color: #718a83; }.empty-archive code { color: #d59450; }
|
.empty-archive { height: 100vh; display: grid; place-content: center; justify-items: center; text-align: center; background: radial-gradient(circle, #123029 0, #071916 65%); color: #9bb0a9; }.empty-archive .seal { width: 72px; height: 72px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; font: 600 14px IBM Plex Mono; margin-bottom: 25px; }.empty-archive small { font: 9px IBM Plex Mono; letter-spacing: .18em; color: #68837b; }.empty-archive h1 { margin: 12px 0 5px; color: #e0e5e1; font: 27px Special Elite; }.empty-archive p { font-size: 12px; }.empty-archive button { margin-top: 18px; display: flex; align-items: center; gap: 8px; background: #1a493d; border: 1px solid #6f8f85; padding: 11px 16px; font: 10px IBM Plex Mono; cursor: pointer; }.empty-archive .hint { margin-top: 20px; color: #718a83; }.empty-archive code { color: #d59450; }
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
@@ -319,6 +335,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.brief-panel.minimized { inset: 82px 8px auto; width: auto; height: 48px; max-height: 48px; border: 2px solid #d8dbd4; box-shadow: 5px 6px 0 #020a08; }
|
.brief-panel.minimized { inset: 82px 8px auto; width: auto; height: 48px; max-height: 48px; border: 2px solid #d8dbd4; box-shadow: 5px 6px 0 #020a08; }
|
||||||
.folder-member, .file-editor-grid { grid-template-columns: 1fr; gap: 7px; }
|
.folder-member, .file-editor-grid { grid-template-columns: 1fr; gap: 7px; }
|
||||||
.match-rules-editor { width: 100vw; height: 100dvh; max-height: none; border: 0; }.match-rules-body { padding: 16px; }.match-rule-layout { grid-template-columns: 1fr; }.match-rule-list { max-height: 180px; }.match-rule-fields { grid-template-columns: 1fr 100px; }
|
.match-rules-editor { width: 100vw; height: 100dvh; max-height: none; border: 0; }.match-rules-body { padding: 16px; }.match-rule-layout { grid-template-columns: 1fr; }.match-rule-list { max-height: 180px; }.match-rule-fields { grid-template-columns: 1fr 100px; }
|
||||||
|
.case-report-shade { padding:0; }.case-report { width:100vw;height:100dvh;border:0; }.case-report-paper { padding:28px 17px 24px; }.report-fields { grid-template-columns:1fr;gap:0; }.report-evidence-heading { grid-template-columns:auto 1fr; }.report-evidence-heading em { grid-column:1/-1;width:max-content; }.case-report-actions { bottom:-24px;margin-left:-7px;margin-right:-7px; }
|
||||||
.board-actions button { width: 38px; padding: 0; justify-content: center; gap: 0; font-size: 0; }
|
.board-actions button { width: 38px; padding: 0; justify-content: center; gap: 0; font-size: 0; }
|
||||||
.board-actions > b { display: none; }
|
.board-actions > b { display: none; }
|
||||||
.board-actions > span { margin: 0 2px; }
|
.board-actions > span { margin: 0 2px; }
|
||||||
|
|||||||
+48
-2
@@ -1,4 +1,4 @@
|
|||||||
export type ExhibitType = 'folder' | 'document' | 'note' | 'event' | 'party'
|
export type ExhibitType = 'folder' | 'document' | 'note' | 'event' | 'party' | 'claim'
|
||||||
export type PartyKind = 'person' | 'organization'
|
export type PartyKind = 'person' | 'organization'
|
||||||
export type OrganizationKind = 'business' | 'public_body' | 'association' | 'informal_group' | 'other'
|
export type OrganizationKind = 'business' | 'public_body' | 'association' | 'informal_group' | 'other'
|
||||||
export type SourceFileType = 'image' | 'pdf' | 'web_capture' | 'email' | 'article' | 'filing' | 'price_list' | 'text' | 'file'
|
export type SourceFileType = 'image' | 'pdf' | 'web_capture' | 'email' | 'article' | 'filing' | 'price_list' | 'text' | 'file'
|
||||||
@@ -34,11 +34,14 @@ export interface FolderExhibit extends ExhibitBase {
|
|||||||
|
|
||||||
export interface DocumentExhibit extends ExhibitBase {
|
export interface DocumentExhibit extends ExhibitBase {
|
||||||
type: 'document'
|
type: 'document'
|
||||||
|
/** Stable, board-local number used when citing this exhibit. */
|
||||||
|
displayNumber?: number
|
||||||
/** Author-mode reveal requirements. Omitted from play-mode payloads. */
|
/** Author-mode reveal requirements. Omitted from play-mode payloads. */
|
||||||
requiredFlags?: string[]
|
requiredFlags?: string[]
|
||||||
publishedAt?: string
|
publishedAt?: string
|
||||||
capturedAt?: string
|
capturedAt?: string
|
||||||
sourceUri?: string
|
sourceUri?: string
|
||||||
|
sourceCitation?: string
|
||||||
body: string[]
|
body: string[]
|
||||||
regions: DocumentRegion[]
|
regions: DocumentRegion[]
|
||||||
assetId?: string
|
assetId?: string
|
||||||
@@ -80,7 +83,12 @@ export interface PartyExhibit extends ExhibitBase {
|
|||||||
aliases: string[]
|
aliases: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Exhibit = FolderExhibit | DocumentExhibit | NoteExhibit | EventExhibit | PartyExhibit
|
export interface ClaimExhibit extends ExhibitBase {
|
||||||
|
type: 'claim'
|
||||||
|
statement: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Exhibit = FolderExhibit | DocumentExhibit | NoteExhibit | EventExhibit | PartyExhibit | ClaimExhibit
|
||||||
export type Evidence = Exclude<Exhibit, DocumentExhibit>
|
export type Evidence = Exclude<Exhibit, DocumentExhibit>
|
||||||
export type CaseDocument = DocumentExhibit
|
export type CaseDocument = DocumentExhibit
|
||||||
export type EvidenceType = Evidence['type']
|
export type EvidenceType = Evidence['type']
|
||||||
@@ -167,6 +175,42 @@ export interface LevelGoal {
|
|||||||
newlyCompleted: boolean
|
newlyCompleted: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CaseReportSubmissionStatus = 'evidence_insufficient' | 'evidence_accepted_report_incomplete' | 'accepted'
|
||||||
|
|
||||||
|
export interface CaseReportEvidence {
|
||||||
|
connectionId: string
|
||||||
|
documentExhibitId: string
|
||||||
|
displayNumber: number
|
||||||
|
documentTitle: string
|
||||||
|
fileType: SourceFileType
|
||||||
|
relationText: string
|
||||||
|
publishedAt?: string
|
||||||
|
sourceCitation?: string
|
||||||
|
sourceUri?: string
|
||||||
|
evidenceAccepted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CaseReportClaim {
|
||||||
|
claimExhibitId: string
|
||||||
|
statement: string
|
||||||
|
evidence: CaseReportEvidence[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CaseReport {
|
||||||
|
title: string
|
||||||
|
investigatorName: string
|
||||||
|
requiredForCompletion: boolean
|
||||||
|
status: 'draft' | CaseReportSubmissionStatus
|
||||||
|
feedback?: string
|
||||||
|
issues: string[]
|
||||||
|
claims: CaseReportClaim[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CaseReportSubmissionInput {
|
||||||
|
investigatorName: string
|
||||||
|
evidence: Array<Pick<CaseReportEvidence, 'connectionId' | 'documentExhibitId' | 'relationText' | 'publishedAt' | 'sourceCitation' | 'sourceUri'>>
|
||||||
|
}
|
||||||
|
|
||||||
export interface CaseState {
|
export interface CaseState {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
@@ -178,6 +222,7 @@ export interface CaseState {
|
|||||||
viewport: Viewport
|
viewport: Viewport
|
||||||
brief: LevelBrief
|
brief: LevelBrief
|
||||||
goals: LevelGoal[]
|
goals: LevelGoal[]
|
||||||
|
report?: CaseReport
|
||||||
revision: number
|
revision: number
|
||||||
updatedAt?: string
|
updatedAt?: string
|
||||||
levelStatus?: string
|
levelStatus?: string
|
||||||
@@ -243,3 +288,4 @@ export function isEvidenceExhibit(exhibit: Exhibit): exhibit is Evidence { retur
|
|||||||
export function isFolderExhibit(exhibit: Exhibit): exhibit is FolderExhibit { return exhibit.type === 'folder' }
|
export function isFolderExhibit(exhibit: Exhibit): exhibit is FolderExhibit { return exhibit.type === 'folder' }
|
||||||
export function isEventExhibit(exhibit: Exhibit): exhibit is EventExhibit { return exhibit.type === 'event' }
|
export function isEventExhibit(exhibit: Exhibit): exhibit is EventExhibit { return exhibit.type === 'event' }
|
||||||
export function isPartyExhibit(exhibit: Exhibit): exhibit is PartyExhibit { return exhibit.type === 'party' }
|
export function isPartyExhibit(exhibit: Exhibit): exhibit is PartyExhibit { return exhibit.type === 'party' }
|
||||||
|
export function isClaimExhibit(exhibit: Exhibit): exhibit is ClaimExhibit { return exhibit.type === 'claim' }
|
||||||
|
|||||||
Reference in New Issue
Block a user