File size: 22,340 Bytes
b81a86b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
-- =============================================================================
-- 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;