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;