← back to Ca Donations
db/schema.sql
122 lines
-- ca_donations — California public-records donations data product
-- Two record families: political contributions (donor-level, above the $100 CA
-- itemization threshold) and charitable records (org + grant, NOT person-level).
-- See ~/.claude/plans/use-high-codex-deep-lovely-sparrow.md for the source map.
-- ---------------------------------------------------------------------------
-- provenance: every row traces to a source + ingest run
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS sources (
id SERIAL PRIMARY KEY,
slug TEXT UNIQUE NOT NULL, -- e.g. 'calaccess', 'fec_bulk', 'propublica', 'irs_990', 'ca_ag'
name TEXT NOT NULL,
url TEXT,
jurisdiction TEXT, -- state | federal | local
family TEXT NOT NULL, -- political | charitable
granularity TEXT, -- donor | org | grant | aggregate
access TEXT, -- bulk | api | csv | scrape
notes TEXT
);
CREATE TABLE IF NOT EXISTS ingest_runs (
id SERIAL PRIMARY KEY,
source_slug TEXT NOT NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
finished_at TIMESTAMPTZ,
rows_in INTEGER,
rows_upsert INTEGER,
status TEXT NOT NULL DEFAULT 'running', -- running | ok | error
detail TEXT
);
-- ---------------------------------------------------------------------------
-- POLITICAL — donor-level contributions (CAL-ACCESS RCPT + Forms 461/496/497,
-- FEC itcont filtered to CA, local NetFile/Socrata). Above the $100 CA
-- itemization threshold; sub-threshold stays aggregate (not stored here).
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS political_contributions (
id BIGSERIAL PRIMARY KEY,
source_slug TEXT NOT NULL,
external_id TEXT, -- source's own tran/record id (for idempotent upsert)
donor_name TEXT,
donor_employer TEXT,
donor_occupation TEXT,
donor_city TEXT,
donor_state TEXT,
donor_zip TEXT,
amount NUMERIC(14,2),
contribution_date DATE,
recipient_name TEXT, -- committee / candidate / PAC
recipient_id TEXT,
office TEXT,
jurisdiction TEXT NOT NULL, -- state | federal | local
form TEXT, -- 460A | 461 | 496 | 497 | FEC_A | local
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (source_slug, external_id)
);
CREATE INDEX IF NOT EXISTS pc_donor_name_idx ON political_contributions USING gin (to_tsvector('simple', coalesce(donor_name,'')));
CREATE INDEX IF NOT EXISTS pc_recipient_idx ON political_contributions (recipient_name);
CREATE INDEX IF NOT EXISTS pc_employer_idx ON political_contributions (donor_employer);
CREATE INDEX IF NOT EXISTS pc_date_idx ON political_contributions (contribution_date);
CREATE INDEX IF NOT EXISTS pc_juris_idx ON political_contributions (jurisdiction);
-- ---------------------------------------------------------------------------
-- CHARITABLE — organizations (registration/status) + grant lines
-- (990-PF Part XV outbound, 990 Schedule I / F, 990-PF Sched B foundation
-- contributors where public). NOT individual public-charity donors.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS charitable_orgs (
id BIGSERIAL PRIMARY KEY,
ein TEXT UNIQUE,
name TEXT NOT NULL,
city TEXT,
state TEXT,
ntee_code TEXT,
subsection TEXT, -- e.g. 501(c)(3), 990-PF private foundation
ca_ag_status TEXT, -- may-operate | may-not-operate | undetermined | revoked | (null)
source_slug TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS co_name_idx ON charitable_orgs USING gin (to_tsvector('simple', coalesce(name,'')));
CREATE INDEX IF NOT EXISTS co_status_idx ON charitable_orgs (ca_ag_status);
CREATE INDEX IF NOT EXISTS co_ntee_idx ON charitable_orgs (ntee_code);
CREATE TABLE IF NOT EXISTS charitable_grants (
id BIGSERIAL PRIMARY KEY,
source_slug TEXT NOT NULL,
external_id TEXT,
grantor_ein TEXT,
grantor_name TEXT,
grantee_name TEXT,
grantee_city TEXT,
grantee_state TEXT,
amount NUMERIC(14,2),
purpose TEXT,
tax_year INTEGER,
grant_type TEXT, -- 990pf_partxv | 990_sched_i | 990_sched_f | 990pf_schedb
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (source_slug, external_id)
);
CREATE INDEX IF NOT EXISTS cg_grantor_idx ON charitable_grants (grantor_ein);
CREATE INDEX IF NOT EXISTS cg_grantee_idx ON charitable_grants USING gin (to_tsvector('simple', coalesce(grantee_name,'')));
CREATE INDEX IF NOT EXISTS cg_year_idx ON charitable_grants (tax_year);
-- ---------------------------------------------------------------------------
-- POLITICAL AGG — materialized k-anon rollup of political_contributions.
-- Precomputed so /api/political/agg reads an indexed table (sub-second) instead
-- of a 15.7M-row full-table GROUP BY (~174s, guarded to 503). refresh-political-agg.mjs
-- rebuilds it atomically on every ingest. The k-anon floor is BAKED IN: the
-- populate step enforces HAVING count(distinct donor_name) >= 5, so this table
-- can NEVER contain a group that singles out fewer than 5 donors.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS political_agg (
dimension TEXT NOT NULL, -- recipient | employer | city | jurisdiction
group_key TEXT NOT NULL,
total_amount NUMERIC(14,2),
contribution_count BIGINT,
distinct_donors INT,
refreshed_at TIMESTAMPTZ,
UNIQUE (dimension, group_key)
);
CREATE INDEX IF NOT EXISTS pa_dim_amount_idx ON political_agg (dimension, total_amount DESC);