Files
politics-tracking/backend/migrations/0001_schema.sql
T
simonandClaude Opus 5.5 583ccba31c Politrack: track Austrian politicians from public sources
Initial import of Politrack, a self-hosted service that tracks Austrian
politicians from federal to municipal level - roles, votes, money flows,
statements, meetings, social media and legal proceedings - with every claim
linked to its sources.

- backend: FastAPI + Postgres/pgvector; adapter framework (fetch -> parse ->
  load -> enrich) with robots.txt/TDM-aware fetching, a raw-response archive
  for reparsing, and a claim/evidence model with corroboration
- sources: Parliament, Wikidata, EU Parliament/Commission, Council of
  Ministers, Rechnungshof party reports and donations, procurement, RTR media
  transparency, political ad libraries, WKStA, news feeds, Wikipedia edits,
  Bluesky, Mastodon, public Telegram channels, YouTube and X (official API)
- web: Next.js frontend in German and English with typed API client
- mcp: MCP server for AI agents and RAG
- docs: architecture, data model, sources, legal notes, Unraid deployment and
  contributor guides; Docker Compose setup for Unraid

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:02:01 +01:00

501 lines
26 KiB
SQL

-- Politrack schema (v2). See docs/architecture/01-design-from-scratch.md and docs/data-model.md.
--
-- Every user-visible fact is a CLAIM backed by >= 1 EVIDENCE row pointing to a DOCUMENT (a URL)
-- published by a SOURCE. Typed facet tables (role, vote_position, money_flow, ...) add structure.
-- Entities carry any number of external IDENTIFIERS. Raw responses are indexed in FETCH.
-- All objects carry COMMENTs so agents can discover semantics from the catalog.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS unaccent;
-- ---------------------------------------------------------------------------
-- Sources, raw archive, documents
-- ---------------------------------------------------------------------------
CREATE TABLE source (
id SERIAL PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('official', 'ngo', 'media', 'social', 'aggregator', 'user', 'derived')),
homepage TEXT,
license TEXT,
access_method TEXT NOT NULL CHECK (access_method IN ('api', 'bulk', 'rss', 'scrape', 'manual', 'derived')),
store_fulltext BOOLEAN NOT NULL DEFAULT FALSE,
keep_raw BOOLEAN NOT NULL DEFAULT TRUE,
default_trust SMALLINT NOT NULL DEFAULT 50 CHECK (default_trust BETWEEN 0 AND 100),
tos_notes TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE source IS 'A publisher (parliament API, a newspaper, a social network...). Rows are synced from the SourceSpec objects in backend/politrack/sources (single source of truth).';
COMMENT ON COLUMN source.store_fulltext IS 'FALSE when licence/ToS or a TDM reservation forbids keeping body text: only title, short excerpt and link are stored.';
COMMENT ON COLUMN source.keep_raw IS 'FALSE when raw responses must not be archived (e.g. TDM-reserved publishers).';
CREATE TABLE fetch_log (
id BIGSERIAL PRIMARY KEY,
adapter TEXT NOT NULL,
source_id INT REFERENCES source(id),
request_fp TEXT NOT NULL,
url TEXT NOT NULL,
method TEXT NOT NULL DEFAULT 'GET',
request JSONB NOT NULL DEFAULT '{}'::jsonb,
status INT,
sha256 TEXT,
size INT,
content_type TEXT,
etag TEXT,
last_modified TEXT,
stored BOOLEAN NOT NULL DEFAULT FALSE,
tdm_reserved BOOLEAN NOT NULL DEFAULT FALSE,
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
parsed_sha256 TEXT,
parser_version INT,
error TEXT
);
CREATE INDEX fetch_fp_idx ON fetch_log (request_fp, fetched_at DESC);
CREATE INDEX fetch_adapter_idx ON fetch_log (adapter, fetched_at DESC);
COMMENT ON TABLE fetch_log IS 'Raw archive index: one row per HTTP fetch. Bodies live gzip-compressed under POLITRACK_RAW_DIR/<sha256[:2]>/<sha256>.gz when stored=TRUE. Used for conditional requests, change detection and re-parsing.';
CREATE TABLE story (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
first_seen TIMESTAMPTZ,
last_seen TIMESTAMPTZ,
document_count INT NOT NULL DEFAULT 0,
source_count INT NOT NULL DEFAULT 0
);
COMMENT ON TABLE story IS 'Cluster of news documents about the same event. Reported by several independent outlets = corroborated.';
CREATE TABLE document (
id BIGSERIAL PRIMARY KEY,
source_id INT NOT NULL REFERENCES source(id),
url TEXT NOT NULL UNIQUE,
title TEXT,
doc_type TEXT NOT NULL DEFAULT 'web_page',
lang TEXT DEFAULT 'de',
published_at TIMESTAMPTZ,
retrieved_at TIMESTAMPTZ NOT NULL DEFAULT now(),
excerpt TEXT,
full_text TEXT,
archive_url TEXT,
fetch_id BIGINT REFERENCES fetch_log(id) ON DELETE SET NULL,
story_id BIGINT REFERENCES story(id) ON DELETE SET NULL,
meta JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX document_source_idx ON document (source_id, published_at DESC);
CREATE INDEX document_published_idx ON document (published_at DESC);
CREATE INDEX document_story_idx ON document (story_id);
CREATE INDEX document_title_trgm ON document USING gin (title gin_trgm_ops);
COMMENT ON TABLE document IS 'One URL (article, API record, protocol, post, register entry). Evidence rows point here; url is what users click to verify a claim.';
COMMENT ON COLUMN document.doc_type IS 'article | press_release | api_record | protocol | social_post | register_entry | web_page | pdf | dataset_row';
-- ---------------------------------------------------------------------------
-- Entities
-- ---------------------------------------------------------------------------
CREATE TABLE person (
id SERIAL PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
full_name TEXT NOT NULL,
given_name TEXT,
family_name TEXT,
honorific_prefix TEXT,
honorific_suffix TEXT,
birth_date DATE,
death_date DATE,
gender TEXT,
photo_url TEXT,
summary_de TEXT,
summary_en TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX person_name_trgm ON person USING gin (full_name gin_trgm_ops);
COMMENT ON TABLE person IS 'A politician: anyone holding or having held a party, parliamentary, government or municipal function.';
CREATE TABLE organization (
id SERIAL PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
short_name TEXT,
kind TEXT NOT NULL CHECK (kind IN ('party', 'parliamentary_group', 'parliament', 'government', 'ministry',
'state_government', 'municipality', 'public_body', 'company', 'ngo',
'media', 'lobby', 'court', 'authority', 'eu_institution', 'other')),
parent_id INT REFERENCES organization(id),
color TEXT,
logo_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX organization_name_trgm ON organization USING gin (name gin_trgm_ops);
COMMENT ON TABLE organization IS 'Parties, parliamentary groups, chambers, ministries, public bodies, companies, media owners, lobby organisations.';
CREATE TABLE identifier (
entity_type TEXT NOT NULL CHECK (entity_type IN ('person', 'organization')),
entity_id INT NOT NULL,
scheme TEXT NOT NULL,
value TEXT NOT NULL,
source_slug TEXT,
verified BOOLEAN NOT NULL DEFAULT TRUE,
PRIMARY KEY (entity_type, scheme, value)
);
CREATE INDEX identifier_entity_idx ON identifier (entity_type, entity_id);
COMMENT ON TABLE identifier IS 'External ids: scheme parlament (PAD_INTERN), wikidata (QID), ep (MEP id), bluesky (DID), mastodon (acct), telegram (channel), youtube (channel id), uid (ATU...), firmenbuch, rtr (Rechtsträger id), eu_transparency (register id)...';
CREATE TABLE alias (
entity_type TEXT NOT NULL CHECK (entity_type IN ('person', 'organization')),
entity_id INT NOT NULL,
alias TEXT NOT NULL,
alias_norm TEXT NOT NULL,
PRIMARY KEY (entity_type, entity_id, alias_norm)
);
CREATE INDEX alias_norm_idx ON alias (entity_type, alias_norm);
COMMENT ON TABLE alias IS 'Name variants for entity resolution and linking (titles stripped, legal forms normalised for organisations).';
CREATE TABLE social_account (
id SERIAL PRIMARY KEY,
person_id INT REFERENCES person(id) ON DELETE CASCADE,
organization_id INT REFERENCES organization(id) ON DELETE CASCADE,
platform TEXT NOT NULL,
handle TEXT,
url TEXT NOT NULL,
verified BOOLEAN NOT NULL DEFAULT FALSE,
source_slug TEXT,
UNIQUE (platform, url)
);
COMMENT ON COLUMN social_account.verified IS 'TRUE when the account comes from an official source (parliament profile, party/official website, platform verification); Wikidata-only handles are FALSE until confirmed.';
-- ---------------------------------------------------------------------------
-- Topics, claims, evidence
-- ---------------------------------------------------------------------------
CREATE TABLE topic (
id SERIAL PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
name_de TEXT NOT NULL,
name_en TEXT NOT NULL,
keywords TEXT[] NOT NULL DEFAULT '{}'
);
COMMENT ON TABLE topic IS 'Bilingual topic taxonomy; the first 18 mirror the Austrian Parliament''s THEMEN.';
CREATE TABLE claim (
id BIGSERIAL PRIMARY KEY,
claim_type TEXT NOT NULL CHECK (claim_type IN (
'role', 'vote', 'donation', 'money_flow', 'statement', 'appearance', 'meeting', 'travel',
'side_job', 'legal_proceeding', 'lobbying', 'media_mention', 'career',
'parliamentary_question', 'government_decision', 'wiki_edit', 'declaration', 'ownership',
'affiliation', 'party_finance', 'other')),
fingerprint TEXT NOT NULL UNIQUE,
person_id INT REFERENCES person(id) ON DELETE CASCADE,
organization_id INT REFERENCES organization(id) ON DELETE CASCADE,
counterpart_person_id INT REFERENCES person(id),
counterpart_org_id INT REFERENCES organization(id),
occurred_at TIMESTAMPTZ,
valid_from DATE,
valid_to DATE,
summary_de TEXT NOT NULL,
summary_en TEXT,
data JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL DEFAULT 'reported' CHECK (status IN ('reported', 'corroborated', 'disputed', 'retracted')),
sensitive BOOLEAN NOT NULL DEFAULT FALSE,
review_status TEXT NOT NULL DEFAULT 'published' CHECK (review_status IN ('pending', 'published', 'rejected')),
reviewed_by TEXT,
reviewed_at TIMESTAMPTZ,
merged_into BIGINT REFERENCES claim(id) ON DELETE SET NULL,
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX claim_person_idx ON claim (person_id, claim_type, occurred_at DESC);
CREATE INDEX claim_org_idx ON claim (organization_id, claim_type, occurred_at DESC);
CREATE INDEX claim_cp_org_idx ON claim (counterpart_org_id) WHERE counterpart_org_id IS NOT NULL;
CREATE INDEX claim_type_time_idx ON claim (claim_type, occurred_at DESC);
CREATE INDEX claim_merged_idx ON claim (merged_into) WHERE merged_into IS NOT NULL;
CREATE INDEX claim_review_idx ON claim (review_status) WHERE review_status <> 'published';
COMMENT ON TABLE claim IS 'An atomic statement about a person and/or organization, never shown without evidence. Identity (fingerprint) is computed by the Loader from resolved entities and type-specific fields, so the same fact from several sources is one claim.';
COMMENT ON COLUMN claim.status IS 'reported = 1 source; corroborated = >= 2 independent sources; disputed = contradicting evidence; retracted.';
COMMENT ON COLUMN claim.sensitive IS 'Legal matters: UI and agents must show "Es gilt die Unschuldsvermutung" unless legal stage = convicted_final and not disputed.';
COMMENT ON COLUMN claim.merged_into IS 'Set when found to describe the same fact as another claim (corroboration); reads skip merged claims.';
CREATE TABLE claim_evidence (
id BIGSERIAL PRIMARY KEY,
claim_id BIGINT NOT NULL REFERENCES claim(id) ON DELETE CASCADE,
document_id BIGINT NOT NULL REFERENCES document(id) ON DELETE CASCADE,
stance TEXT NOT NULL DEFAULT 'supports' CHECK (stance IN ('supports', 'contradicts', 'mentions')),
quote TEXT,
locator TEXT NOT NULL DEFAULT '',
extracted_by TEXT NOT NULL,
confidence REAL NOT NULL DEFAULT 1.0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (claim_id, document_id, locator)
);
CREATE INDEX claim_evidence_doc_idx ON claim_evidence (document_id);
CREATE TABLE topic_assignment (
topic_id INT NOT NULL REFERENCES topic(id) ON DELETE CASCADE,
claim_id BIGINT NOT NULL REFERENCES claim(id) ON DELETE CASCADE,
confidence REAL NOT NULL DEFAULT 1.0,
method TEXT NOT NULL,
PRIMARY KEY (topic_id, claim_id)
);
CREATE INDEX topic_assignment_claim_idx ON topic_assignment (claim_id);
-- ---------------------------------------------------------------------------
-- Facets
-- ---------------------------------------------------------------------------
CREATE TABLE role (
claim_id BIGINT PRIMARY KEY REFERENCES claim(id) ON DELETE CASCADE,
person_id INT NOT NULL REFERENCES person(id) ON DELETE CASCADE,
organization_id INT REFERENCES organization(id),
party_id INT REFERENCES organization(id),
role TEXT NOT NULL,
level TEXT NOT NULL CHECK (level IN ('eu', 'federal', 'state', 'district', 'municipal', 'party', 'other')),
region TEXT,
start_date DATE,
end_date DATE
);
CREATE INDEX role_person_idx ON role (person_id);
CREATE INDEX role_party_idx ON role (party_id);
CREATE INDEX role_org_idx ON role (organization_id);
COMMENT ON TABLE role IS 'Mandates and functions. role = ''mp'' for members of a chamber (organization_id = chamber), otherwise the office title. end_date NULL = open.';
CREATE TABLE vote_event (
id BIGSERIAL PRIMARY KEY,
event_key TEXT NOT NULL UNIQUE,
chamber_id INT REFERENCES organization(id),
item_ref TEXT,
item_title TEXT NOT NULL,
stage TEXT,
voted_on DATE,
result TEXT CHECK (result IN ('accepted', 'rejected', 'unknown')),
roll_call BOOLEAN NOT NULL DEFAULT FALSE,
url TEXT,
protocol_url TEXT,
raw_text TEXT
);
CREATE INDEX vote_event_date_idx ON vote_event (voted_on DESC);
COMMENT ON TABLE vote_event IS 'A vote. Most Austrian votes are by standing up: only faction positions exist. roll_call = TRUE: individual positions exist.';
CREATE TABLE vote_position (
claim_id BIGINT PRIMARY KEY REFERENCES claim(id) ON DELETE CASCADE,
vote_event_id BIGINT NOT NULL REFERENCES vote_event(id) ON DELETE CASCADE,
organization_id INT REFERENCES organization(id),
person_id INT REFERENCES person(id),
party_id INT REFERENCES organization(id),
position TEXT NOT NULL CHECK (position IN ('for', 'against', 'abstain', 'absent')),
CHECK (organization_id IS NOT NULL OR person_id IS NOT NULL)
);
CREATE INDEX vote_position_event_idx ON vote_position (vote_event_id);
CREATE INDEX vote_position_person_idx ON vote_position (person_id) WHERE person_id IS NOT NULL;
COMMENT ON COLUMN vote_position.party_id IS 'For individual (roll-call) votes: the voter''s faction at the time.';
CREATE TABLE money_flow (
claim_id BIGINT PRIMARY KEY REFERENCES claim(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('donation', 'contract', 'government_ad', 'political_ad', 'subsidy',
'party_funding', 'party_ad', 'loan', 'side_income', 'sponsorship',
'other')),
payer_name TEXT,
payer_org_id INT REFERENCES organization(id),
payer_person_id INT REFERENCES person(id),
payee_name TEXT,
payee_org_id INT REFERENCES organization(id),
payee_person_id INT REFERENCES person(id),
amount NUMERIC(16, 2),
amount_category TEXT,
currency TEXT NOT NULL DEFAULT 'EUR',
paid_on DATE,
period TEXT,
reference TEXT,
category TEXT
);
CREATE INDEX money_flow_payer_idx ON money_flow (payer_org_id);
CREATE INDEX money_flow_payee_idx ON money_flow (payee_org_id);
CREATE INDEX money_flow_kind_idx ON money_flow (kind, paid_on DESC);
COMMENT ON TABLE money_flow IS 'Donations (Rechnungshof lists and party reports), public party funding, sponsoring, ads in party media (party_ad), loans, public contracts (BVergG core data), government advertising (RTR), political ads (Google/Meta archives). category = CPV code, ad medium category etc.';
CREATE TABLE event (
id BIGSERIAL PRIMARY KEY,
event_key TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL CHECK (kind IN ('parliament_session', 'committee', 'press_conference', 'tv_appearance',
'interview', 'party_event', 'public_event', 'foreign_trip', 'meeting',
'hearing', 'government_meeting', 'other')),
title TEXT NOT NULL,
starts_at TIMESTAMPTZ,
ends_at TIMESTAMPTZ,
location_name TEXT,
country TEXT,
url TEXT,
description TEXT,
organization_id INT REFERENCES organization(id),
source_id INT REFERENCES source(id)
);
CREATE INDEX event_time_idx ON event (starts_at);
COMMENT ON TABLE event IS 'Something with a time and place (sessions, hearings, trips, meetings). Participation is a claim with an event_participant facet.';
CREATE TABLE event_participant (
claim_id BIGINT PRIMARY KEY REFERENCES claim(id) ON DELETE CASCADE,
event_id BIGINT NOT NULL REFERENCES event(id) ON DELETE CASCADE,
person_id INT REFERENCES person(id),
organization_id INT REFERENCES organization(id),
participation TEXT NOT NULL DEFAULT 'participant'
);
CREATE INDEX event_participant_event_idx ON event_participant (event_id);
CREATE TABLE statement (
claim_id BIGINT PRIMARY KEY REFERENCES claim(id) ON DELETE CASCADE,
person_id INT REFERENCES person(id),
context TEXT NOT NULL CHECK (context IN ('parliament_speech', 'interview', 'tv', 'radio', 'social_post',
'press_release', 'event_speech', 'op_ed', 'testimony', 'other')),
platform TEXT,
medium TEXT,
title TEXT,
said_at TIMESTAMPTZ,
excerpt TEXT,
url TEXT NOT NULL
);
CREATE INDEX statement_person_idx ON statement (person_id, said_at DESC);
CREATE TABLE legal_proceeding (
claim_id BIGINT PRIMARY KEY REFERENCES claim(id) ON DELETE CASCADE,
person_id INT REFERENCES person(id),
organization_id INT REFERENCES organization(id),
stage TEXT NOT NULL CHECK (stage IN ('allegation_reported', 'investigation', 'immunity_waiver_requested',
'immunity_waived', 'immunity_refused', 'indicted', 'trial',
'convicted_not_final', 'convicted_final', 'acquitted',
'proceedings_dropped', 'diversion')),
offense TEXT,
authority TEXT,
case_ref TEXT,
stage_date DATE
);
COMMENT ON COLUMN legal_proceeding.stage IS 'Only convicted_final (and not disputed) is a settled finding of guilt; all other stages carry "Es gilt die Unschuldsvermutung".';
-- ---------------------------------------------------------------------------
-- Retrieval (RAG)
-- ---------------------------------------------------------------------------
CREATE TABLE chunk (
id BIGSERIAL PRIMARY KEY,
document_id BIGINT REFERENCES document(id) ON DELETE CASCADE,
claim_id BIGINT REFERENCES claim(id) ON DELETE CASCADE,
chunk_index INT NOT NULL DEFAULT 0,
text TEXT NOT NULL,
lang TEXT DEFAULT 'de',
tsv TSVECTOR GENERATED ALWAYS AS (to_tsvector('german', text)) STORED,
embedding vector(1024),
embedding_model TEXT,
CHECK (document_id IS NOT NULL OR claim_id IS NOT NULL)
);
CREATE UNIQUE INDEX chunk_uniq ON chunk (COALESCE(document_id, 0), COALESCE(claim_id, 0), chunk_index);
CREATE INDEX chunk_tsv_idx ON chunk USING gin (tsv);
CREATE INDEX chunk_embedding_idx ON chunk USING hnsw (embedding vector_cosine_ops);
COMMENT ON TABLE chunk IS 'Text units for hybrid search (German FTS + bge-m3 embeddings); each links to a claim and/or document for citations.';
-- ---------------------------------------------------------------------------
-- Operations
-- ---------------------------------------------------------------------------
CREATE TABLE run (
id BIGSERIAL PRIMARY KEY,
adapter TEXT NOT NULL,
mode TEXT NOT NULL DEFAULT 'fetch',
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
finished_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'running' CHECK (status IN ('running', 'ok', 'partial', 'error')),
stats JSONB NOT NULL DEFAULT '{}'::jsonb,
error TEXT
);
CREATE INDEX run_adapter_idx ON run (adapter, started_at DESC);
CREATE TABLE state (
adapter TEXT NOT NULL,
key TEXT NOT NULL,
value JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (adapter, key)
);
COMMENT ON TABLE state IS 'Cursors of incremental adapters (last date, last id...).';
CREATE TABLE app_user (
id SERIAL PRIMARY KEY,
external_subject TEXT NOT NULL UNIQUE,
email TEXT,
locale TEXT NOT NULL DEFAULT 'de',
excluded_source_ids INT[] NOT NULL DEFAULT '{}',
topic_ids INT[] NOT NULL DEFAULT '{}',
followed_person_ids INT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ---------------------------------------------------------------------------
-- Views
-- ---------------------------------------------------------------------------
CREATE VIEW person_identifier AS
SELECT p.id AS person_id,
max(i.value) FILTER (WHERE i.scheme = 'parlament') AS parlament_pad,
max(i.value) FILTER (WHERE i.scheme = 'wikidata') AS wikidata_qid,
max(i.value) FILTER (WHERE i.scheme = 'ep') AS ep_id
FROM person p LEFT JOIN identifier i ON i.entity_type = 'person' AND i.entity_id = p.id
GROUP BY p.id;
COMMENT ON VIEW person_identifier IS 'Convenience: the most common external ids of a person in columns.';
-- Activity status, derived from roles.
-- Parliament mandates/functions are authoritative (exact end dates) -> verified.
-- Other sources (mostly Wikidata) often lack end dates: an open-ended office of a living person
-- counts as current if it started within the last 15 years; flagged verified = false.
CREATE VIEW person_status AS
WITH current_roles AS (
SELECT r.person_id, r.level, r.role, r.party_id, r.start_date, c.created_by
FROM role r JOIN claim c ON c.id = r.claim_id
WHERE c.merged_into IS NULL AND (r.end_date IS NULL OR r.end_date >= current_date)
AND (c.created_by LIKE 'parlament%%' OR c.created_by LIKE 'europarl%%' OR r.end_date IS NOT NULL OR r.level = 'party'
OR (r.start_date IS NOT NULL AND r.start_date >= current_date - interval '15 years'))
)
SELECT p.id AS person_id,
CASE
WHEN p.death_date IS NOT NULL THEN 'deceased'
WHEN EXISTS (SELECT 1 FROM current_roles cr WHERE cr.person_id = p.id AND cr.level <> 'party') THEN 'active'
WHEN EXISTS (SELECT 1 FROM current_roles cr WHERE cr.person_id = p.id AND cr.level = 'party') THEN 'party_member'
ELSE 'former'
END AS status,
EXISTS (SELECT 1 FROM current_roles cr WHERE cr.person_id = p.id AND cr.level <> 'party'
AND (cr.created_by LIKE 'parlament%%' OR cr.created_by LIKE 'europarl%%')) AS verified,
(SELECT max(r.end_date) FROM role r JOIN claim c ON c.id = r.claim_id
WHERE r.person_id = p.id AND c.merged_into IS NULL AND r.level <> 'party') AS last_office_end,
(SELECT array_agg(DISTINCT cr.role) FROM current_roles cr WHERE cr.person_id = p.id AND cr.level <> 'party')
AS current_roles,
(SELECT array_agg(DISTINCT cr.party_id) FROM current_roles cr WHERE cr.person_id = p.id AND cr.party_id IS NOT NULL)
AS current_party_ids
FROM person p;
COMMENT ON VIEW person_status IS 'active = current office (verified = confirmed by Parliament/EP data); party_member = only party membership; former; deceased.';
CREATE VIEW claim_with_evidence AS
SELECT c.id AS claim_id, c.claim_type, c.summary_de, c.summary_en, c.occurred_at, c.valid_from, c.valid_to,
c.status, c.sensitive, p.full_name AS person_name, p.slug AS person_slug,
o.name AS organization_name, o.slug AS organization_slug,
jsonb_agg(jsonb_build_object('url', d.url, 'title', d.title, 'source', s.slug, 'source_name', s.name,
'published_at', d.published_at, 'quote', e.quote, 'stance', e.stance)
ORDER BY d.published_at) AS evidence
FROM claim c
LEFT JOIN person p ON p.id = c.person_id
LEFT JOIN organization o ON o.id = c.organization_id
JOIN claim_evidence e ON e.claim_id = c.id
JOIN document d ON d.id = e.document_id
JOIN source s ON s.id = d.source_id
WHERE c.merged_into IS NULL AND c.review_status = 'published'
GROUP BY c.id, p.id, o.id;
COMMENT ON VIEW claim_with_evidence IS 'Published claims with all evidence links - the easiest entry point for agents.';
-- Read-only role for agents / BI. Password set by `politrack migrate` from POLITRACK_READER_PASSWORD.
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'politrack_reader') THEN
CREATE ROLE politrack_reader LOGIN;
END IF;
END $$;
GRANT USAGE ON SCHEMA public TO politrack_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO politrack_reader;
REVOKE SELECT ON app_user FROM politrack_reader;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO politrack_reader;