Initial commit

This commit is contained in:
2026-08-14 12:43:11 +02:00
commit fa78640227
25 changed files with 6003 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
CREATE TABLE osint.cases (
id TEXT PRIMARY KEY,
state_json JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON SCHEMA osint IS 'GUPI OSINT Board application data';
COMMENT ON TABLE osint.cases IS 'Server-authoritative investigation board state';
+130
View File
@@ -0,0 +1,130 @@
CREATE TABLE osint.levels (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
subtitle TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'archived')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE osint.widgets (
id TEXT PRIMARY KEY,
level_id TEXT NOT NULL REFERENCES osint.levels(id) ON DELETE CASCADE,
widget_type TEXT NOT NULL CHECK (widget_type IN ('document', 'evidence', 'note', 'event')),
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
config JSONB NOT NULL DEFAULT '{}'::jsonb,
source_widget_id TEXT REFERENCES osint.widgets(id) ON DELETE SET NULL,
source_region_key TEXT,
event_date DATE,
x DOUBLE PRECISION,
y DOUBLE PRECISION,
width DOUBLE PRECISION,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (level_id, id)
);
CREATE INDEX widgets_level_type_idx ON osint.widgets(level_id, widget_type, sort_order);
CREATE TABLE osint.widget_regions (
id TEXT PRIMARY KEY,
document_widget_id TEXT NOT NULL REFERENCES osint.widgets(id) ON DELETE CASCADE,
region_key TEXT NOT NULL,
label TEXT NOT NULL,
excerpt TEXT NOT NULL,
event_date DATE,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
sort_order INTEGER NOT NULL DEFAULT 0,
UNIQUE (document_widget_id, region_key)
);
CREATE TABLE osint.level_connections (
id TEXT PRIMARY KEY,
level_id TEXT NOT NULL REFERENCES osint.levels(id) ON DELETE CASCADE,
from_widget_id TEXT NOT NULL REFERENCES osint.widgets(id) ON DELETE CASCADE,
to_widget_id TEXT NOT NULL REFERENCES osint.widgets(id) ON DELETE CASCADE,
config JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE TABLE osint.playthroughs (
id TEXT PRIMARY KEY,
level_id TEXT NOT NULL REFERENCES osint.levels(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'completed', 'abandoned')),
viewport JSONB NOT NULL DEFAULT '{"x":0,"y":28,"zoom":0.7}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE osint.playthrough_widget_state (
playthrough_id TEXT NOT NULL REFERENCES osint.playthroughs(id) ON DELETE CASCADE,
widget_id TEXT NOT NULL REFERENCES osint.widgets(id) ON DELETE CASCADE,
x DOUBLE PRECISION NOT NULL,
y DOUBLE PRECISION NOT NULL,
width DOUBLE PRECISION NOT NULL,
hidden BOOLEAN NOT NULL DEFAULT FALSE,
annotation TEXT,
PRIMARY KEY (playthrough_id, widget_id)
);
CREATE TABLE osint.playthrough_widgets (
id TEXT PRIMARY KEY,
playthrough_id TEXT NOT NULL REFERENCES osint.playthroughs(id) ON DELETE CASCADE,
widget_type TEXT NOT NULL CHECK (widget_type IN ('evidence', 'note', 'event')),
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
source_widget_id TEXT REFERENCES osint.widgets(id) ON DELETE SET NULL,
source_region_key TEXT,
event_date DATE,
x DOUBLE PRECISION NOT NULL,
y DOUBLE PRECISION NOT NULL,
width DOUBLE PRECISION NOT NULL,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE osint.playthrough_connections (
id TEXT PRIMARY KEY,
playthrough_id TEXT NOT NULL REFERENCES osint.playthroughs(id) ON DELETE CASCADE,
from_widget_id TEXT NOT NULL,
to_widget_id TEXT NOT NULL,
config JSONB NOT NULL DEFAULT '{}'::jsonb
);
-- Preserve levels saved by the pre-widget architecture, if any exist.
INSERT INTO osint.levels (id, title, subtitle, status, created_at, updated_at)
SELECT id, state_json->>'title', COALESCE(state_json->>'subtitle', ''), 'draft', created_at, updated_at
FROM osint.cases;
INSERT INTO osint.widgets (id, level_id, widget_type, title, content, config, event_date, sort_order)
SELECT doc->>'id', c.id, 'document', doc->>'title', '',
jsonb_build_object('kind', doc->>'kind', 'date', doc->>'date', 'body', COALESCE(doc->'body', '[]'::jsonb)),
NULLIF(doc->>'date', '')::date, ordinality::integer
FROM osint.cases c
CROSS JOIN LATERAL jsonb_array_elements(c.state_json->'documents') WITH ORDINALITY AS item(doc, ordinality);
INSERT INTO osint.widget_regions (id, document_widget_id, region_key, label, excerpt, event_date, sort_order)
SELECT (doc->>'id') || ':' || (region->>'id'), doc->>'id', region->>'id', region->>'label', region->>'excerpt',
NULLIF(region->>'date', '')::date, region_ordinality::integer
FROM osint.cases c
CROSS JOIN LATERAL jsonb_array_elements(c.state_json->'documents') AS docs(doc)
CROSS JOIN LATERAL jsonb_array_elements(doc->'regions') WITH ORDINALITY AS regions(region, region_ordinality);
INSERT INTO osint.widgets (id, level_id, widget_type, title, content, source_widget_id, source_region_key, event_date, x, y, width, sort_order)
SELECT ev->>'id', c.id, ev->>'type', ev->>'title', ev->>'content', NULLIF(ev->>'sourceDocumentId', ''),
NULLIF(ev->>'sourceRegionId', ''), NULLIF(ev->>'eventDate', '')::date,
(ev->>'x')::double precision, (ev->>'y')::double precision, (ev->>'width')::double precision, ordinality::integer
FROM osint.cases c
CROSS JOIN LATERAL jsonb_array_elements(c.state_json->'evidence') WITH ORDINALITY AS item(ev, ordinality);
INSERT INTO osint.level_connections (id, level_id, from_widget_id, to_widget_id)
SELECT conn->>'id', c.id, conn->>'fromEvidenceId', conn->>'toEvidenceId'
FROM osint.cases c
CROSS JOIN LATERAL jsonb_array_elements(c.state_json->'connections') AS connections(conn);
INSERT INTO osint.playthroughs (id, level_id, viewport)
SELECT 'default:' || id, id, COALESCE(state_json->'viewport', '{"x":0,"y":28,"zoom":0.7}'::jsonb)
FROM osint.cases;
DROP TABLE osint.cases;
+17
View File
@@ -0,0 +1,17 @@
CREATE TABLE osint.assets (
id TEXT PRIMARY KEY,
level_id TEXT NOT NULL REFERENCES osint.levels(id) ON DELETE CASCADE,
original_name TEXT NOT NULL,
mime_type TEXT NOT NULL,
byte_size BIGINT NOT NULL CHECK (byte_size >= 0),
content BYTEA NOT NULL,
checksum_sha256 TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX assets_level_idx ON osint.assets(level_id, created_at);
ALTER TABLE osint.widgets
ADD COLUMN asset_id TEXT REFERENCES osint.assets(id) ON DELETE SET NULL;
COMMENT ON TABLE osint.assets IS 'Uploaded source files used by database-backed document widgets';
@@ -0,0 +1,56 @@
-- Widget kinds are intentionally data-driven. Removing enum-like checks means
-- future widget types do not require a table rewrite merely to exist.
ALTER TABLE osint.widgets DROP CONSTRAINT IF EXISTS widgets_widget_type_check;
ALTER TABLE osint.playthrough_widgets DROP CONSTRAINT IF EXISTS playthrough_widgets_widget_type_check;
ALTER TABLE osint.widgets ADD COLUMN published_at TIMESTAMPTZ;
UPDATE osint.widgets
SET published_at = event_date::timestamp AT TIME ZONE 'UTC'
WHERE widget_type = 'document' AND event_date IS NOT NULL;
CREATE TABLE osint.widget_relations (
id TEXT PRIMARY KEY,
level_id TEXT NOT NULL REFERENCES osint.levels(id) ON DELETE CASCADE,
from_widget_id TEXT NOT NULL REFERENCES osint.widgets(id) ON DELETE CASCADE,
to_widget_id TEXT NOT NULL REFERENCES osint.widgets(id) ON DELETE CASCADE,
relation_type TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (from_widget_id, to_widget_id, relation_type)
);
CREATE INDEX widget_relations_from_idx ON osint.widget_relations(level_id, from_widget_id, relation_type);
CREATE INDEX widget_relations_to_idx ON osint.widget_relations(level_id, to_widget_id, relation_type);
CREATE TABLE osint.playthrough_widget_relations (
id TEXT PRIMARY KEY,
playthrough_id TEXT NOT NULL REFERENCES osint.playthroughs(id) ON DELETE CASCADE,
from_widget_id TEXT NOT NULL,
to_widget_id TEXT NOT NULL,
relation_type TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
UNIQUE (playthrough_id, from_widget_id, to_widget_id, relation_type)
);
UPDATE osint.widgets SET widget_type = 'folder' WHERE widget_type = 'evidence';
UPDATE osint.playthrough_widgets SET widget_type = 'folder' WHERE widget_type = 'evidence';
INSERT INTO osint.widget_relations (id, level_id, from_widget_id, to_widget_id, relation_type)
SELECT 'contains:' || id || ':' || source_widget_id, level_id, id, source_widget_id, 'contains'
FROM osint.widgets
WHERE widget_type = 'folder' AND source_widget_id IS NOT NULL
ON CONFLICT (from_widget_id, to_widget_id, relation_type) DO NOTHING;
INSERT INTO osint.playthrough_widget_relations
(id, playthrough_id, from_widget_id, to_widget_id, relation_type)
SELECT 'contains:' || playthrough_id || ':' || id || ':' || source_widget_id,
playthrough_id, id, source_widget_id, 'contains'
FROM osint.playthrough_widgets
WHERE widget_type = 'folder' AND source_widget_id IS NOT NULL
ON CONFLICT (playthrough_id, from_widget_id, to_widget_id, relation_type) DO NOTHING;
COMMENT ON COLUMN osint.widgets.published_at IS 'Publication/creation time of a source document; chronology belongs to documents, not folders';
COMMENT ON TABLE osint.widget_relations IS 'Generic authored widget graph; contains is the first relation type';
+15
View File
@@ -0,0 +1,15 @@
-- Runtime state is generic JSON so future widget and relation renderers can
-- persist interaction-specific state without adding a column per widget kind.
ALTER TABLE osint.playthrough_widget_state
ADD COLUMN config JSONB NOT NULL DEFAULT '{}'::jsonb;
CREATE TABLE osint.playthrough_widget_relation_state (
playthrough_id TEXT NOT NULL REFERENCES osint.playthroughs(id) ON DELETE CASCADE,
relation_id TEXT NOT NULL REFERENCES osint.widget_relations(id) ON DELETE CASCADE,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (playthrough_id, relation_id)
);
COMMENT ON COLUMN osint.playthrough_widget_state.config IS 'Player-owned runtime and metadata overrides for any authored widget type';
COMMENT ON TABLE osint.playthrough_widget_relation_state IS 'Player-owned layout/state overrides for authored widget relations';