agriflow-api / db /schema.sql
masterAAA123's picture
Space deploy v5: orphan snapshot of main, zero binary files
b81a86b
Raw
History Blame Contribute Delete
22.3 kB
-- =============================================================================
-- AgriFlow — Supabase / Postgres Schema
-- Apply with: psql $SUPABASE_DB_URL -f db/schema.sql
-- =============================================================================
--
-- TABLE INVENTORY
-- 1. kabupaten — mirror of sample_data/kabupaten_jatim.csv
-- 2. commodity — mirror of sample_data/komoditas_constraints.csv
-- 3. surplus_deficit — mirror of sample_data/surplus_deficit.csv
-- 4. weather_forecast — mirror of sample_data/weather_forecast.csv
-- 5. historical_prices — mirror of sample_data/historical_price_stats.csv
-- 6. commodity_code_map — Bapanas integer ID ↔ canonical code mapping
-- 7. policy_docs — RAG document store (pgvector embeddings)
-- 8. price_history — TimesFM INPUT (daily price time series per city)
-- 9. forecasts — TimesFM OUTPUT (per-commodity per-city forecasts)
-- 10. subscriber — WhatsApp identity (hashed) + plan state
-- 11. wa_usage_daily — per-day metered query counter (free-tier quota)
-- 12. payment_order — upgrade orders + settlement status
-- =============================================================================
-- Required extension for pgvector (RAG embeddings)
CREATE EXTENSION IF NOT EXISTS vector;
-- =============================================================================
-- 1. KABUPATEN
-- =============================================================================
CREATE TABLE IF NOT EXISTS kabupaten (
kab_id VARCHAR(10) PRIMARY KEY, -- BPS wilayah code, e.g. "3578"
nama VARCHAR(100) NOT NULL, -- e.g. "Kota Surabaya"
latitude DOUBLE PRECISION NOT NULL,
longitude DOUBLE PRECISION NOT NULL,
ipm_2024 DOUBLE PRECISION NOT NULL, -- IPM BPS 2024
population_2024 INTEGER NOT NULL DEFAULT 0,
tier VARCHAR(20) NOT NULL -- 'TIER_1_HIGH' | 'TIER_2_MEDIUM'
CHECK (tier IN ('TIER_1_HIGH', 'TIER_2_MEDIUM')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_kabupaten_tier ON kabupaten(tier);
-- =============================================================================
-- 2. COMMODITY
-- =============================================================================
CREATE TABLE IF NOT EXISTS commodity (
code VARCHAR(50) PRIMARY KEY, -- canonical code, e.g. "cabai_merah"
nama VARCHAR(100) NOT NULL,
max_distance_km DOUBLE PRECISION NOT NULL,
min_viable_tons DOUBLE PRECISION NOT NULL,
max_fresh_age_days INTEGER NOT NULL,
bulog_priority BOOLEAN NOT NULL DEFAULT FALSE,
is_imported BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- =============================================================================
-- 3. SURPLUS_DEFICIT
-- =============================================================================
CREATE TABLE IF NOT EXISTS surplus_deficit (
id BIGSERIAL PRIMARY KEY,
kab_id VARCHAR(10) NOT NULL REFERENCES kabupaten(kab_id),
commodity_code VARCHAR(50) NOT NULL REFERENCES commodity(code),
role VARCHAR(10) NOT NULL
CHECK (role IN ('SURPLUS', 'DEFICIT')),
volume_tons DOUBLE PRECISION NOT NULL,
price_idr_per_kg DOUBLE PRECISION NOT NULL,
harvest_age_days INTEGER NOT NULL DEFAULT 0,
data_source VARCHAR(20) NOT NULL DEFAULT 'PIHPS',
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_surdef_kab ON surplus_deficit(kab_id);
CREATE INDEX IF NOT EXISTS idx_surdef_commodity ON surplus_deficit(commodity_code);
CREATE INDEX IF NOT EXISTS idx_surdef_role ON surplus_deficit(role);
CREATE INDEX IF NOT EXISTS idx_surdef_recorded_at ON surplus_deficit(recorded_at DESC);
-- =============================================================================
-- 4. WEATHER_FORECAST
-- =============================================================================
CREATE TABLE IF NOT EXISTS weather_forecast (
id BIGSERIAL PRIMARY KEY,
origin_kab_id VARCHAR(10) NOT NULL REFERENCES kabupaten(kab_id),
dest_kab_id VARCHAR(10) NOT NULL REFERENCES kabupaten(kab_id),
max_rain_mm DOUBLE PRECISION NOT NULL,
transit_window_days INTEGER NOT NULL DEFAULT 1,
source VARCHAR(20) NOT NULL DEFAULT 'BMKG',
valid_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (origin_kab_id, dest_kab_id, valid_at)
);
CREATE INDEX IF NOT EXISTS idx_weather_origin ON weather_forecast(origin_kab_id);
CREATE INDEX IF NOT EXISTS idx_weather_dest ON weather_forecast(dest_kab_id);
-- =============================================================================
-- 5. HISTORICAL_PRICES
-- (aggregate stats per commodity — used by engine for fairness scoring)
-- =============================================================================
CREATE TABLE IF NOT EXISTS historical_prices (
commodity_code VARCHAR(50) PRIMARY KEY REFERENCES commodity(code),
median_idr_per_kg DOUBLE PRECISION NOT NULL,
std_idr_per_kg DOUBLE PRECISION NOT NULL,
sample_size INTEGER NOT NULL DEFAULT 0,
period_start DATE,
period_end DATE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- =============================================================================
-- 6. COMMODITY_CODE_MAP
-- Maps Bapanas integer IDs ↔ AgriFlow canonical codes.
-- Explicit in DB — NOT hardcoded in application logic.
-- Mitigates risk if Bapanas API changes its numbering scheme.
-- =============================================================================
CREATE TABLE IF NOT EXISTS commodity_code_map (
mapping_id INTEGER PRIMARY KEY, -- Bapanas integer commodity ID
canonical_code VARCHAR(50) NOT NULL REFERENCES commodity(code),
bapanas_name VARCHAR(100) NOT NULL, -- name as returned by Bapanas API
pihps_code VARCHAR(50), -- PIHPS code if different
notes TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_ccmap_canonical ON commodity_code_map(canonical_code);
CREATE INDEX IF NOT EXISTS idx_ccmap_active ON commodity_code_map(is_active);
-- Seed: known Bapanas commodity IDs at time of AgriFlow v9 build (2026-05)
-- Update this table when Bapanas changes their numbering — never change canonical_code.
--
-- SELECT ... WHERE EXISTS, not a plain VALUES insert. canonical_code is a
-- foreign key into commodity, and commodity is populated by the application
-- loader, NOT by this file. A plain INSERT therefore aborts on a fresh
-- database with:
-- violates foreign key constraint "commodity_code_map_canonical_code_fkey"
-- which kills the rest of the script -- including the RLS section at the end,
-- silently leaving every table world-readable through PostgREST.
--
-- Filtering on EXISTS makes this a no-op on an empty database instead. Because
-- the whole file is IF NOT EXISTS / ON CONFLICT idempotent, re-running it after
-- the loader has populated commodity backfills these mappings.
INSERT INTO commodity_code_map (mapping_id, canonical_code, bapanas_name)
SELECT v.mapping_id, v.canonical_code, v.bapanas_name
FROM (VALUES
(1, 'beras_premium', 'Beras Premium'),
(2, 'beras_medium', 'Beras Medium'),
(3, 'beras_ir64', 'Beras IR 64'),
(4, 'jagung', 'Jagung Pipilan Kering'),
(5, 'kedelai', 'Kedelai Biji Kering (Impor)'),
(6, 'cabai_merah', 'Cabai Merah Besar'),
(7, 'cabai_rawit', 'Cabai Rawit Merah'),
(8, 'bawang_merah', 'Bawang Merah'),
(9, 'bawang_putih', 'Bawang Putih (Bonggol)'),
(10, 'daging_sapi', 'Daging Sapi Murni'),
(11, 'daging_ayam', 'Daging Ayam Ras'),
(12, 'telur_ayam', 'Telur Ayam Ras'),
(13, 'minyak_goreng', 'Minyak Goreng Curah'),
(14, 'gula_pasir', 'Gula Pasir Lokal'),
(15, 'tepung_terigu', 'Tepung Terigu (Curah)'),
(16, 'tomat', 'Tomat Sayur'),
(17, 'kentang', 'Kentang'),
(18, 'wortel', 'Wortel'),
(19, 'kacang_tanah', 'Kacang Tanah Kupas')
) AS v(mapping_id, canonical_code, bapanas_name)
WHERE EXISTS (SELECT 1 FROM commodity c WHERE c.code = v.canonical_code)
ON CONFLICT (mapping_id) DO NOTHING;
-- =============================================================================
-- 7. POLICY_DOCS
-- Supabase pgvector store for RAG (Gemini embeddings, 768 dims).
-- =============================================================================
CREATE TABLE IF NOT EXISTS policy_docs (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
source VARCHAR(100), -- e.g. 'Permendag 2024', 'BPOM No.5/2024'
content TEXT NOT NULL, -- full text chunk
embedding vector(768), -- text-embedding-004 output
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- IVFFlat index — good enough for < 1M rows; tune lists param at scale.
-- Build AFTER initial bulk insert for speed.
CREATE INDEX IF NOT EXISTS idx_policy_docs_embedding
ON policy_docs
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
CREATE INDEX IF NOT EXISTS idx_policy_docs_source ON policy_docs(source);
-- =============================================================================
-- 8. PRICE_HISTORY — TimesFM INPUT
--
-- One row per (date, city_id, commodity_code).
-- Fed into google/timesfm-2.0-500m-pytorch for forecasting.
--
-- DATA CONTRACT (shared with TimesFM repo):
-- date DATE — observation date (YYYY-MM-DD), no time zone
-- city_id VARCHAR(10) — matches kabupaten.kab_id (BPS wilayah code)
-- commodity_code VARCHAR(50) — matches commodity.code (AgriFlow canonical)
-- price_per_kg NUMERIC(12,2) — observed price in IDR per kg
-- =============================================================================
CREATE TABLE IF NOT EXISTS price_history (
id BIGSERIAL PRIMARY KEY,
date DATE NOT NULL,
city_id VARCHAR(10) NOT NULL REFERENCES kabupaten(kab_id),
commodity_code VARCHAR(50) NOT NULL REFERENCES commodity(code),
price_per_kg NUMERIC(12, 2) NOT NULL,
data_source VARCHAR(30) NOT NULL DEFAULT 'PIHPS', -- 'PIHPS'|'BAPANAS'|'MANUAL'
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (date, city_id, commodity_code)
);
CREATE INDEX IF NOT EXISTS idx_ph_date ON price_history(date DESC);
CREATE INDEX IF NOT EXISTS idx_ph_city ON price_history(city_id);
CREATE INDEX IF NOT EXISTS idx_ph_commodity ON price_history(commodity_code);
-- Composite for the primary TimesFM query pattern: time series per city+commodity
CREATE INDEX IF NOT EXISTS idx_ph_city_commodity_date
ON price_history(city_id, commodity_code, date DESC);
-- =============================================================================
-- 9. FORECASTS — TimesFM OUTPUT
--
-- One row per (commodity, city_id, date) per model run.
-- Written by the TimesFM inference pipeline; read by the AgriFlow dashboard.
--
-- DATA CONTRACT (shared with TimesFM repo):
-- commodity VARCHAR(50) — matches commodity.code
-- city_id VARCHAR(10) — matches kabupaten.kab_id
-- city_name VARCHAR(100) — denormalized for dashboard convenience
-- date DATE — forecast date (YYYY-MM-DD)
-- price_forecast NUMERIC(12,2) — point forecast, IDR per kg
-- price_lower NUMERIC(12,2) — lower bound (e.g. 10th percentile)
-- price_upper NUMERIC(12,2) — upper bound (e.g. 90th percentile)
-- model VARCHAR(100) — model identifier, e.g. 'google/timesfm-2.0-500m-pytorch'
-- generated_at TIMESTAMPTZ — when this forecast was produced
-- =============================================================================
CREATE TABLE IF NOT EXISTS forecasts (
id BIGSERIAL PRIMARY KEY,
commodity VARCHAR(50) NOT NULL REFERENCES commodity(code),
city_id VARCHAR(10) NOT NULL REFERENCES kabupaten(kab_id),
city_name VARCHAR(100) NOT NULL,
date DATE NOT NULL,
price_forecast NUMERIC(12, 2) NOT NULL,
price_lower NUMERIC(12, 2),
price_upper NUMERIC(12, 2),
model VARCHAR(100) NOT NULL DEFAULT 'google/timesfm-2.0-500m-pytorch',
generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (commodity, city_id, date, model)
);
CREATE INDEX IF NOT EXISTS idx_forecasts_commodity ON forecasts(commodity);
CREATE INDEX IF NOT EXISTS idx_forecasts_city ON forecasts(city_id);
CREATE INDEX IF NOT EXISTS idx_forecasts_date ON forecasts(date DESC);
CREATE INDEX IF NOT EXISTS idx_forecasts_generated_at ON forecasts(generated_at DESC);
-- Composite for the primary dashboard query: latest forecast per city+commodity
CREATE INDEX IF NOT EXISTS idx_forecasts_city_commodity_date
ON forecasts(city_id, commodity, date DESC);
-- =============================================================================
-- 10. SUBSCRIBER — WhatsApp identity + plan
--
-- PRIVACY CONTRACT: phone_hash is a salted SHA-256 digest of the normalized
-- number (see whatsapp_bot/subscription.py::hash_phone). The raw number is
-- NEVER written to this database. The salt lives in PHONE_HASH_SALT, outside
-- the database, so a dump of this table alone cannot be reversed into a
-- contact list even by brute force.
--
-- dashboard_user_id links a WhatsApp identity to a Supabase Auth user once the
-- two channels are tied together. Nullable: most WhatsApp users never sign in
-- to the dashboard, and most dashboard users are government staff with no
-- WhatsApp subscription.
-- =============================================================================
CREATE TABLE IF NOT EXISTS subscriber (
phone_hash CHAR(64) PRIMARY KEY, -- hex SHA-256, salted
plan VARCHAR(10) NOT NULL DEFAULT 'FREE'
CHECK (plan IN ('FREE', 'PRO')),
plan_expires_at TIMESTAMPTZ, -- NULL = perpetual (or FREE)
dashboard_user_id UUID, -- Supabase auth.users.id
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_subscriber_plan ON subscriber(plan);
CREATE INDEX IF NOT EXISTS idx_subscriber_expires ON subscriber(plan_expires_at);
CREATE INDEX IF NOT EXISTS idx_subscriber_dash_user ON subscriber(dashboard_user_id);
-- =============================================================================
-- 11. WA_USAGE_DAILY — free-tier counter
--
-- One row per (phone_hash, day). usage_date is a WIB (UTC+7) calendar date,
-- computed by the application, NOT by the database — the server may run in UTC
-- and CURRENT_DATE would then roll the quota over at 07:00 local time.
--
-- Incremented via INSERT .. ON CONFLICT DO UPDATE so concurrent workers cannot
-- lose a count and hand out queries beyond the limit.
-- =============================================================================
CREATE TABLE IF NOT EXISTS wa_usage_daily (
phone_hash CHAR(64) NOT NULL,
usage_date DATE NOT NULL,
query_count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (phone_hash, usage_date)
);
CREATE INDEX IF NOT EXISTS idx_wa_usage_date ON wa_usage_daily(usage_date DESC);
-- =============================================================================
-- 12. PAYMENT_ORDER — upgrade orders
--
-- No foreign key to subscriber: an order is created the moment a user asks to
-- upgrade, which may be before any subscriber row exists for them.
-- =============================================================================
CREATE TABLE IF NOT EXISTS payment_order (
order_id VARCHAR(20) PRIMARY KEY, -- e.g. 'AF-1A2B3C4D'
phone_hash CHAR(64) NOT NULL,
plan VARCHAR(10) NOT NULL DEFAULT 'PRO'
CHECK (plan IN ('FREE', 'PRO')),
amount_idr INTEGER NOT NULL,
status VARCHAR(10) NOT NULL DEFAULT 'PENDING'
CHECK (status IN ('PENDING', 'PAID', 'EXPIRED')),
provider VARCHAR(30) NOT NULL DEFAULT 'MOCK',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
paid_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_order_phone ON payment_order(phone_hash);
CREATE INDEX IF NOT EXISTS idx_order_status ON payment_order(status);
-- =============================================================================
-- 13. ROW LEVEL SECURITY — lock the PostgREST surface
-- =============================================================================
--
-- WHY THIS SECTION EXISTS
-- -----------------------
-- Supabase automatically exposes every table in the `public` schema over
-- PostgREST, and the `anon` API key is PUBLIC BY DESIGN: it ships to every
-- visitor's browser inside NEXT_PUBLIC_SUPABASE_ANON_KEY. Without RLS, anyone
-- who opens devtools can lift that key and talk to this database directly:
--
-- GET /rest/v1/subscriber -> read every subscriber row
-- PATCH /rest/v1/subscriber -> set their own plan to 'PRO'
--
-- That would bypass the JWT verification in whatsapp_bot/auth.py entirely.
-- Locking the API layer while leaving PostgREST open is a locked front door
-- next to an open back one.
--
-- THE MODEL: DENY BY DEFAULT
-- --------------------------
-- Enabling RLS with NO policies denies all access to non-owner roles. Since
-- AgriFlow's dashboard never queries Supabase tables directly (it reads
-- everything through the FastAPI service, and uses Supabase only to sign users
-- in), nothing legitimate needs the PostgREST path. So we grant nothing.
--
-- The FastAPI backend is unaffected: it connects over SUPABASE_DB_URL as the
-- table owner, and in Postgres the owner bypasses RLS. PostgREST connects as
-- `anon` or `authenticated`, which do not.
--
-- ⚠ DO NOT ADD `FORCE ROW LEVEL SECURITY`.
-- FORCE additionally subjects the table OWNER to RLS. With no policies
-- defined, an owner that lacks the BYPASSRLS attribute then sees zero rows
-- and cannot insert -- an outage that looks exactly like "the database is
-- empty". Enabling RLS is correct here; forcing it buys nothing.
--
-- Measured on PostgreSQL 17.2 against a NOSUPERUSER NOBYPASSRLS owner:
-- RLS enabled, no FORCE -> owner SELECT returns its row
-- RLS enabled + FORCE -> owner SELECT returns 0 rows,
-- INSERT fails "violates row-level security"
-- A superuser or BYPASSRLS role is immune and will not reproduce this, which
-- is exactly why the mistake survives a casual test and bites in production.
--
-- IF YOU LATER WANT BROWSER-DIRECT READS
-- --------------------------------------
-- Add a narrow policy per table rather than disabling RLS, e.g. to let any
-- signed-in user read reference data:
--
-- CREATE POLICY kabupaten_read_authenticated ON kabupaten
-- FOR SELECT TO authenticated USING (true);
--
-- Never write a policy `USING (true)` for the `anon` role on subscriber,
-- wa_usage_daily, or payment_order. Those are per-person records.
-- =============================================================================
ALTER TABLE kabupaten ENABLE ROW LEVEL SECURITY;
ALTER TABLE commodity ENABLE ROW LEVEL SECURITY;
ALTER TABLE surplus_deficit ENABLE ROW LEVEL SECURITY;
ALTER TABLE weather_forecast ENABLE ROW LEVEL SECURITY;
ALTER TABLE historical_prices ENABLE ROW LEVEL SECURITY;
ALTER TABLE commodity_code_map ENABLE ROW LEVEL SECURITY;
ALTER TABLE policy_docs ENABLE ROW LEVEL SECURITY;
ALTER TABLE price_history ENABLE ROW LEVEL SECURITY;
ALTER TABLE forecasts ENABLE ROW LEVEL SECURITY;
ALTER TABLE subscriber ENABLE ROW LEVEL SECURITY;
ALTER TABLE wa_usage_daily ENABLE ROW LEVEL SECURITY;
ALTER TABLE payment_order ENABLE ROW LEVEL SECURITY;
-- Defence in depth: revoke table privileges from the PostgREST roles outright,
-- so a carelessly-added policy later cannot by itself open a table up.
--
-- Wrapped in a guard because `anon` and `authenticated` are Supabase-specific
-- roles. This file should stay runnable against a plain Postgres instance (a
-- local test database, CI), where those roles do not exist and an unguarded
-- REVOKE would abort the script.
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN
REVOKE ALL ON ALL TABLES IN SCHEMA public FROM anon;
REVOKE ALL ON ALL SEQUENCES IN SCHEMA public FROM anon;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN
REVOKE ALL ON ALL TABLES IN SCHEMA public FROM authenticated;
REVOKE ALL ON ALL SEQUENCES IN SCHEMA public FROM authenticated;
END IF;
END
$$;
-- Verification — run after applying. Every row must show rowsecurity = true.
-- Any 'f' is a table still readable with the public anon key.
--
-- SELECT tablename, rowsecurity
-- FROM pg_tables
-- WHERE schemaname = 'public'
-- ORDER BY rowsecurity, tablename;