Architektúra Übrig: začať v Berne, rozšíriť bez prerábania

Výskum a plán, 25. 9. 2026. Podložené aktuálnou schémou (db/001_schema.sql), aplikáciou (app/index.html) a 55 stránkami dokumentácie Supabase, ktoré boli v tejto session čitateľné cez nástroj vyhľadávania v dokumentácii. Všetko ostatné je označené experience, verify a zoradené v zozname na doverenie.

Odporúčanie v jednej vete: jeden Supabase projekt pre Švajčiarsko, jedna zdieľaná schéma, a na každom riadku dva stĺpce – org_id (bezpečnostná hranica) a city_id (prevádzková hranica) – s hierarchiou mesto → organizácia → miesto → členstvo; pravidlá (teploty, alergény, lehoty) v tabuľke rulesets verzovanej podľa krajiny a mesta, nie v kóde; protokol odovzdania ako nemenný záznam udalostí s hash-reťazou; frontend ostáva bez build kroku, ale prejde na ES moduly, PWA a push notifikácie; druhé mesto sa pridá riadkom v tabuľke, druhá krajina riadkom v rulesets a rozhodnutím o regióne – nikdy prepisom.

🟥 Dôležité – tri diery v dnešnej živej schéme, nájdené pri čítaní (nie domnienky): (1) politika offers_update_kitchen pripína len reserved_by, takže kuchyňa môže priamym UPDATE nastaviť status='picked' alebo 'expired' a obísť RPC; (2) profiles_select_counterpart volá vnorený EXISTS na tabuľku s vlastným RLS – vzor, ktorý dokumentácia Supabase odporúča nahradiť SECURITY DEFINER helperom; (3) helpery my_role() / i_am_approved() nie sú v politikách obalené (select …), čo Advisor lint 0003 označí a pri raste dát stojí výkon. Oprava je migrácia 002_hardening.sql vo Fáze 0 (aditívna, bez zmeny aplikácie). Nespúšťam ju, kým nepovieš – dnes sa nič nestavia.

Obsah

  1. Desať rozhodnutí
  2. Model nájomcov
  3. Pravidlá podľa jurisdikcie
  4. Protokol ako záznam udalostí
  5. Frontend, PWA, notifikácie
  6. Platforma, región, plán
  7. Náklady
  8. Plán vo fázach
  9. Čo doplniť s odblokovanou sieťou
  10. Technická príloha (EN, úplné poznámky)

1 · Desať rozhodnutí

#RozhodnutiePrečoČo zamietame
1Jeden projekt, zdieľaná schéma, tenant stĺpceJediný vzor, ktorý dokumentácia Supabase podporuje a benchmarkuje; používateľ aktívny v dvoch mestách je jeden účet.Schéma na mesto, projekt na mesto (násobí fixné náklady, láme krížové štatistiky).
2Hierarchia mesto → organizácia → miesto → členstvoReštauračná skupina má viac kuchýň, NGO viac výdajní; dnes je používateľ = profil = kuchyňa, čo neškáluje.„Osobný“ režim – každý koná za organizáciu (jednotlivec = org druhu individual).
3Pravidlá v tabuľke rulesets, verzované, s platnosťou od–do, riešené mesto → krajina → globalCH 65 °C vs. UK 63 °C vs. US 60 °C; EU-14 = CH-14, US má 9 alergénov. Snapshot ruleset_id na každej udalosti povie v roku 2027, čo platilo.Prahy a zoznam alergénov v kóde (dnes allergens <@ 1..14, lang in (…)).
4Protokol odovzdania = append-only handover_events s hash-reťazou a JSON-schema validáciouObe strany sú Lebensmittelbetrieb; stav ponuky sa odvodzuje z udalostí, záznam sa nedá tichým UPDATE zmeniť.Stav ako jediná pravda v offers.status.
5RLS cez dva helpery v schéme private obalené (select …), index na každom tenant stĺpciDokumentácia meria 2–24 ms na 1 mil. riadkov s indexom oproti timeoutom bez obalenia.Vnorené RLS podotázky v politikách (dnes profiles_select_counterpart).
6Frontend bez build kroku, ES moduly + import map, JSDoc + tsc --noEmit na generovaných typochJeden vývojár, ~10 pohľadov; typová kontrola názvov stĺpcov zadarmo (pravidlo 9: nikdy nepísať názov, ktorý si nečítal).React/Tailwind/bundler; framework až pri > 15 previazaných reaktívnych stavoch.
7Realtime cez Broadcast z DB na privátnych kanáloch city:<id>, Web Push cez Edge Function pre zavretú stránkupostgres_changes robí jednu RLS kontrolu na odberateľa a zmenu a je jednovláknový – dokumentácia ho pri škále neodporúča.Nefiltrované postgres_changes (dnes).
8Fronta pgmq + pg_cron + Edge Function dispatch pre e-mail, push, SMS, WhatsAppOdosielanie oddelené od transakcie, opakovanie a dead-letter zadarmo v Postgrese.Vystavenie pgmq_public prehliadaču (tabuľky front nemajú RLS).
9Pro plán v deň, keď má prvý cudzí používateľ dostať magic link; región rozhodnúť pred prvým skutočným protokolom (Zürich eu-central-2 odporúčaný pre live, Paríž ostáva staging)Predvolený SMTP doručuje len členom tímu; Free pauzuje po 7 dňoch; región sa nedá zmeniť na mieste.PITR, log drains, read replicas pre pilot (až pri > 4 GB alebo požiadavke regulátora).
10Druhé mesto = riadok v cities (téma, moderátori, manifest), druhá krajina = riadok v rulesets + rozhodnutie o projekte podľa právneho regiónuTest architektúry: onboarding mesta cez UI s nulou commitov.Fork kódu na mesto/krajinu.

2 · Model nájomcov

cities (Standort)Bern → Zürich → Wien · config, pravidlá, téma organisationsreštaurácia, skupina, NGO · status schválenia siteskuchyňa / výdajné miesto · PostGIS bod membershipspoužívateľ × org × rola (owner, staff) offers · handover_eventskaždý riadok nesie org_id + city_id rulesets · allergen_listsglobal → krajina → mesto, verzované 1 : n1 : n RLS: org_id = any(array(select private.my_org_ids())) · moderátori mesta cez city_roles · nikdy schéma alebo projekt na mesto

Migrácia z dnešného stavu bez výpadku (dual-write): 1) aditívna migrácia vytvorí cities (seed bern), organisations, sites, memberships, city_roles a pridá nullable org_id/site_id/city_id na offers; 2) backfill v tej istej transakcii – jedna org na existujúci profil, jedno členstvo owner, jedno miesto na kuchyňu, admini → city_roles(bern, moderator); 3) trigger offers_fill_tenant doplní tenant stĺpce, keď starý klient vloží len kitchen_id – stará aplikácia beží ďalej; 4) nové politiky žijú popri starých (permisívne politiky sa spájajú OR), pgTAP dokáže, že oba tvary vidia to isté, potom sa staré zahodia; 5) feature flag cities.features->>'orgs_ui' zapne nové UI; 6) až nakoniec DESTRUKTÍVNE set not null a zahodenie profiles.org/address – s tvojím výslovným áno.

3 · Pravidlá podľa jurisdikcie

Tabuľka rulesets(scope, country, city_id, version, effective_from, effective_to, rules jsonb, source) + allergen_lists(code, version, items). Resolver private.active_ruleset(city, at) zlúči global ← krajina ← mesto. Klient si pri štarte zavolá app_bootstrap(city_slug) (anon, bez PII) a dostane {city, ruleset, allergens, locales, theme, features} – sedem otázok Freigabe-Checku sa renderuje z pravidiel. Príklad pre CH:

{ "hot_min_c": 65, "cold_max_c": 5, "cool_down_max_minutes": 120, "reheat_core_min_c": 72,
  "allergen_list": "EU14", "max_hours_after_made": 24, "require_temp_at_handover": true,
  "require_receiver_signature": true, "label_fields": ["dish","made_at","use_by","allergens","kitchen"] }

Pozor: výskum právneho rámca našiel dve sady prahov – BLV-Spendenleitfaden 2021 (≥ 60 °C, < 10 °C do 2 h) a GVG/HyV prax (65 °C / 5 °C). Ktorá platí pre odovzdanie, rozhodne telefonát s Kantonales Laboratorium (zoznam na doverenie, položka A1) – a zapíše sa do rulesets.source, nie do kódu.

4 · Protokol ako záznam udalostí

handover_events(offer_id, org_id, city_id, event_type, actor_id, actor_org_id, occurred_at, payload jsonb, ruleset_id, prev_hash, hash); typy published, reserved, released, handed_over, received, temp_checked, cancelled, expired, disputed, note. UPDATE/DELETE odobraté rolám a blokované triggerom; payload validovaný pg_jsonschema podľa (event_type, version). Podpisy a fotky v privátnom Storage buckete protocols s RLS cez storage.foldername. Odovzdanie je „úplné“, až keď kuchyňa zapíše handed_over a odberateľ received – obe strany si vedia dokázať, čo prešlo. Export CSV/PDF pre inšpektora, mesto a fundraising.

Audit v troch vrstvách: obchodný (handover_events), správcovský (audit_log trigger na org/členstvá/pravidlá) a pgaudit object-mode na dvoch citlivých tabuľkách. Log drains až na žiadosť regulátora.

5 · Frontend, PWA, notifikácie

6 · Platforma, región, plán

OtázkaOdpoveďZdroj / status
Kedy Pro?V deň, keď má prvý ne-tímový používateľ dostať magic link (default SMTP doručuje len tímu; Free pauzuje po 7 dňoch; bez záloh na stiahnutie).docs Custom SMTP, Production checklist
RegiónParíž (EU) je pre CH pilot právne OK (adekvátnosť); Zürich eu-central-2 existuje a pre mestá je „Daten in der Schweiz“ predajný argument. Odporúčanie: Pro projekt v Zürichu pri go-live, Paríž ako staging. Edge Functions nemajú Zürich región (beh najbližšie k používateľovi).docs GDPR, PrivateLink, Regional invocations · verify zmena regiónu na mieste
PrihláseniePrejsť z magic linku na e-mail OTP kód (link z mailu otvára iný prehliadač než nainštalovaná PWA); shouldCreateUser:false + pozvánky.docs Passwordless
Plánované úlohypg_cron každých 5 min expiruje ponuky a emituje udalosť; nočne retencia a štatistiky; Edge Functions cez pg_net + Vault.docs Cron, Schedule functions
Správypgmq fronta, dispatcher každú minútu; e-mail + push pri štarte, SMS len pre zrušenie tesne pred vyzdvihnutím, WhatsApp len ak ho odberatelia už používajú (Business API – overiť ceny a schvaľovanie šablón).docs PGMQ · verify WhatsApp
IdentitaRoly v tabuľkách (okamžité odobratie), nie v JWT; výnimka: platform_admin claim cez Access Token Hook pre UI. Pozvánky s jednorazovým tokenom. Overenie UID cez Zefix. MFA povinné pre moderátorov mesta. SSO pre mestá až na žiadosť (Pro+).docs RBAC, Hooks, MFA, SSO
Bezpečnosť13-bodový RLS checklist (v prílohe §10) s pgTAP testom, ktorý prejde pg_policies a padne na neobalenom auth.uid(). Telefón kuchyne viditeľný len protistrane rezervovanej ponuky (dnes každému schválenému odberateľovi).docs RLS
Ochrana údajovrevDSG: Datenschutzerklärung pri prvom prihlásení, jednostranový register spracovaní, DPA so Supabase; retencia: ponuky 30 d → anonymizácia, 24 mes. → zmazanie; udalosti bez PII navždy (hash-reťaz). Nikdy alergie ľudí – len alergény jedla.docs GDPR · verify lehota Selbstkontrolle

7 · Náklady (odhad; USD podľa dokumentácie, CHF ≈ 0,85 × USD)

EtapaPlán a doplnkyUSD/mes.≈ CHF/mes.
Bern pilotFree, Nano, SMTP len pre tím00
Bern livePro 25 + Micro 10 − 10 kredit + vlastná doména 10 + Resend free≈ 35≈ 30
5 miest (~300 org, ~2 000 MAU)Pro + Small 15 + doména + Resend ~20 (+ PITR 7 d 100 voliteľne)≈ 60 (160 s PITR)≈ 50–135
50 miest, 2 krajiny (~3 000 org, ~20 000 MAU, 2 projekty)Pro/Team + Medium 60 + staging + 2 domény + PITR 14 d 200 + log drain 60 + egress + SMS/WhatsApp≈ 350–600 + správy≈ 300–500 + správy

Kvóty z dokumentácie: MAU 50 k Free / 100 k Pro; egress 5 GB / 250 GB; DB 500 MB / 8 GB; Realtime 2 mil. správ a 200 spojení / 5 mil. a 500; Edge 500 k / 2 mil. volaní. Ceny sa menia – overiť na pricing stránke.

8 · Plán vo fázach

Konvencie pre každú fázu: číslované migrácie v db/ zrkadlené do supabase/migrations/; ku každej pgTAP test; rollback napísaný pred nasadením; „hotové“ = videné na skutočnom telefóne proti živému projektu. Nič z toho sa nestavia dnes – je to plán na tvoje schválenie.

Fáza 0

Bern pilot – nič, čo rozbije

  • 002_hardening.sql: obaliť helpery (select …), pripnúť status v update politike, nahradiť vnorený EXISTS helperom, tabuľka cities s riadkom bern, offers.city_id, profiles von z realtime publikácie
  • pgTAP: odberateľ nenastaví picked; anon nič nevidí
  • Ops: rozhodnúť Zürich vs. Paríž; Pro + Resend ak sa hlási cudzí používateľ
  • Exit: Advisor čistý, pgTAP zelený, jedna kuchyňa + jeden odberateľ dokončili rezerváciu na telefónoch
Fáza 1

Organizácie, mestá, pravidlá

  • 003_orgs.sql (tabuľky, backfill, trigger, nové politiky popri starých), 004_rulesets.sql (seed CH + EU14, resolver, app_bootstrap), 005_drop_legacy_policies.sql po dvoch týždňoch
  • App: ES moduly (bez zmeny správania), prahy z bootstrapu, org switcher za flagom, i18n do JSON
  • Exit: každý profil má org + členstvo, nula prahov v kóde (grep guard), i18n 100 % de-CH
Fáza 2

Protokol, notifikácie, PWA

  • 006_events.sql, 007_storage.sql, 008_queue.sql (pgmq, push_subscriptions, cron), 009_realtime.sql (broadcast)
  • Edge Functions: dispatch, notify-offer, manifest
  • App: protokol z rulesetu, offline fronta, service worker, push opt-in
  • Exit: úplné odovzdanie s teplotou a oboma potvrdeniami exportované a ukázané kontaktu z Lebensmittelkontrolle; push doručený na iOS PWA aj Androide
Fáza 3

Druhé mesto, white-label, štatistiky

  • 010_city2.sql – len INSERT, žiadna zmena schémy (to je test)
  • 011_stats.sql (api_v1 pohľady, k-anonymita ≥ 5 org), 012_webhooks.sql
  • App: rozlíšenie mesta (subdoména → param → default), tokeny témy pri štarte, manifest na mesto
  • Exit: druhé mesto onboardované moderátorom cez UI s nulou commitov; štatistiky konzumuje externá strana
Fáza 4

Druhá krajina

  • 013_country.sql (rulesets AT/DE, de-AT overlay), 014_legal_ids.sql
  • Rozhodnutie: ten istý projekt vs. nový podľa právneho regiónu (prenos dát, zmluva mesta, latencia)
  • Exit: pilotná ponuka vo Viedni s AT pravidlami a formátmi bez forku kódu; právna kontrola AT Datenschutzerklärung

9 · Čo doplniť s odblokovanou sieťou

Tieto veci sa v session nedali otvoriť; sú aj v zozname na doverenie, sekcia C. Kým nie sú overené, čísla v tomto dokumente sú odhady.

  1. Supabase pricing (Pro/Team, spend cap, egress/GB, compute tabuľka) a stránka regiónov (Zürich eu-central-2, medzery vo funkciách).
  2. Realtime limits (kanály na spojenie, veľkosť správy, joins/s podľa plánu).
  3. Custom SMTP na Free pláne – povolené alebo len Pro.
  4. Web Push na iOS PWA – minimálna verzia, nutnosť inštalácie, akcie/odznaky (stav 2026).
  5. Zefix API (endpoint, registrácia, podmienky, kvóty) a špecifikácia kontrolného čísla UID (eCH-0097).
  6. WhatsApp Business Cloud API (schvaľovanie šablón, cena za konverzáciu v CH/AT), Twilio CH sender ID, Signal (bez oficiálneho API).
  7. revDSG: adekvátnosť EU (EDÖB), ohlasovanie incidentov, prah registra spracovaní; lehota uchovávania Selbstkontrolle (HyV) a AT ekvivalent.
  8. Supabase DPA a zoznam subprocesorov.
  9. PostgREST CSV výstup a limity veľkosti.
  10. Import maps a Intl.RelativeTimeFormat – minimálne verzie Safari pre telefóny pilotu.
  11. Retencia Postgres logov podľa plánu (rozhodnutie o pgaudit).
  12. Či sa dá zmeniť región projektu bez nového projektu.

10 · Technická príloha – úplné výskumné poznámky (EN)

*Written 2026-09-25. Grounded on the current repo (`/home/user/uebrig/db/001_schema.sql`, 164 lines; `app/index.html`, 43 844 B / 392 lines; `app/config.js`; `README.md`) and on 26 Supabase documentation pages retrieved via `search_docs`. Every number that comes from a doc is cited inline. Anything not covered by a doc is marked **[experience, verify]**. Prices are quoted in USD as the docs give them; CHF conversions are estimates.*

0. Where we stand (measured, not remembered)


1. Tenancy model

Recommendation

Three-level hierarchy, one Supabase project, shared schema, tenant columns on every row:

cities (Standort)  1─n  organisations  1─n  sites (Küche / Abgabestelle)
                              1─n  memberships (user × org × role)
offers.site_id → sites;  offers.org_id, offers.city_id denormalised (trigger-filled)
handover_events.offer_id → offers;  + org_id, city_id denormalised
create function private.my_org_ids() returns uuid[]
language sql stable security definer set search_path = '' as $$
  select coalesce(array_agg(org_id), '{}') from public.memberships
  where user_id = (select auth.uid()) and status = 'active' $$;

create function private.my_city_roles() returns table(city_id uuid, role text)
language sql stable security definer set search_path = '' as $$
  select city_id, role from public.city_roles where user_id = (select auth.uid()) $$;

-- policy shape used everywhere:
create policy offers_select_org on public.offers for select to authenticated
  using ( org_id = any (array(select private.my_org_ids())) );

The = any(array(select fn())) form is the one the docs benchmark at 2–24 ms on a 1 M-row table with an index on the tenant column, versus timeouts without the wrap (RLS performance, section "Added example"). So: create index on offers (org_id), (city_id, status, pickup_to), (site_id). - Client queries always add the filter the policy already implies (.eq('city_id', …)), because "policies are implicit where clauses" and the planner needs the explicit filter (RLS guide → Add filters). - Policies always name to authenticated (or to anon explicitly for the public stats views) — stops anon evaluation before any function call (RLS guide → Specify roles).

Why not schema-per-tenant or project-per-city

Trade-offs

Migration path from today's profiles/offers (zero-downtime, dual-write)

  1. Additive migration (002): create cities (seed bern), organisations, sites, memberships, city_roles. Add nullable org_id, site_id, city_id to offers. Add org_id to profiles (nullable).
  2. Backfill in the same transaction: one org per existing profile (name = profiles.org, kind = case role when 'kitchen' then 'kitchen' else 'taker' end, status = case approved when true then 'approved' else 'pending'), one membership (owner), one site per kitchen profile from profiles.address (geocode later, location nullable at first), offers.site_id/org_id/city_id from the kitchen's org; existing admin profiles → city_roles (bern, 'moderator').
  3. Compatibility layer: keep kitchen_id and reserved_by as-is (they are still the user who acted — useful in the event log). Trigger offers_fill_tenant derives org_id/site_id/city_id when a legacy client inserts with only kitchen_id (from the user's single membership). Old app keeps working unchanged.
  4. New policies live alongside old ones (permissive policies OR together). Add the org-based policies, run pgTAP proving old-shape and new-shape clients see identical rows, then drop the kitchen_id = auth.uid() policies in migration 004.
  5. Feature flag: cities.features->>'orgs_ui' read once at boot via RPC app_bootstrap(city_slug); the new UI (org switcher, site picker) renders only when true. Flip for Bern after pilot users are migrated.
  6. Finally (005, DESTRUCTIVE, explicit approval): set not null on offers.org_id/site_id/city_id, drop profiles.org/address, keep profiles.contact/phone/lang as the person record.

Sources

RLS guide · RLS performance troubleshooting · Lint 0003 · Compute usage · Advanced pgTAP (multi-tenant example)

Verify later


2. Data model additions

Recommendation

-- organisations
create table public.organisations (
  id uuid primary key default gen_random_uuid(),
  city_id uuid not null references public.cities(id),          -- home city
  kind text not null check (kind in ('kitchen','taker','both','individual')),
  name text not null check (length(name) between 2 and 120),
  legal_id text,                                               -- CHE-123.456.789 (UID) / FN / SIREN…
  legal_id_verified_at timestamptz, legal_id_source text,      -- 'zefix' | 'manual'
  status text not null default 'pending' check (status in ('pending','approved','blocked')),
  created_at timestamptz not null default now()
);
create table public.sites (
  id uuid primary key default gen_random_uuid(),
  org_id uuid not null references public.organisations(id) on delete cascade,
  city_id uuid not null references public.cities(id),
  name text not null, address_line text not null, postal_code text, locality text,
  country char(2) not null,
  location extensions.geography(Point, 4326),                  -- PostGIS
  pickup_notes text check (length(pickup_notes) <= 300),
  is_active boolean not null default true
);
create index sites_geo on public.sites using gist (location);
create table public.memberships (
  user_id uuid not null references auth.users(id) on delete cascade,
  org_id uuid not null references public.organisations(id) on delete cascade,
  role text not null check (role in ('owner','staff')),
  status text not null default 'active' check (status in ('invited','active','removed')),
  primary key (user_id, org_id)
);
create table public.city_roles (
  user_id uuid references auth.users(id) on delete cascade,
  city_id uuid references public.cities(id) on delete cascade,
  role text not null check (role in ('moderator','platform_admin')),
  primary key (user_id, city_id)
);

PostGIS: enable into the extensions schema, store geography(Point), GIST index, insert as 'POINT(lon lat)' (longitude first), expose nearest-neighbour via an RPC using <-> — the docs' nearby_restaurants pattern verbatim (PostGIS). Note the doc's warning: PostGIS ≥ 2.3 is not relocatable between schemas — pick extensions on first enable.

Offers: add site_id, org_id, city_id, ruleset_id (snapshot of the ruleset in force when published, §3), replace allergens smallint[] check with a FK-like check against the ruleset's allergen list (trigger), keep kitchen_id/reserved_by as actor columns.

Handover protocol as append-only event log:

create table public.handover_events (
  id bigint generated always as identity primary key,
  offer_id uuid not null references public.offers(id),
  org_id uuid not null, city_id uuid not null,                  -- trigger-filled
  event_type text not null check (event_type in
    ('published','reserved','released','handed_over','received','temp_checked',
     'cancelled','expired','disputed','note')),
  actor_id uuid not null default auth.uid(),
  actor_org_id uuid not null,                                   -- whose behalf
  occurred_at timestamptz not null default now(),
  payload jsonb not null default '{}',                          -- temp_c, container_count, signature_ref, allergens_confirmed, photo_path
  ruleset_id uuid not null,                                     -- rules in force at that moment
  prev_hash bytea, hash bytea not null,                         -- sha256(prev_hash || row) — tamper-evidence
  constraint payload_valid check (extensions.jsonb_matches_schema(
    '{"type":"object","properties":{"temp_c":{"type":"number"},"container_count":{"type":"integer","minimum":0}},"additionalProperties":true}', payload))
);
revoke update, delete, truncate on public.handover_events from authenticated, anon;
create trigger handover_events_immutable before update or delete on public.handover_events
  for each row execute function private.raise_immutable();

Audit trail — three layers, each for a different question:

Question Tool Notes
"What happened to this offer, legally?" handover_events (own table) Business audit, exported to the parties, retained per §10
"Who changed which row, including admins?" own audit_log table filled by generic trigger on organisations, memberships, city_roles, rulesets (old/new jsonb, auth.uid(), current_setting('request.headers')::json->>'x-forwarded-for') Cheap, queryable, RLS-protected (moderators only)
"Which role ran which SQL against sensitive objects?" pgaudit object logging via a dedicated no-login role granted select on profiles, handover_events Goes to Postgres logs, not a table; retention follows plan log retention (verify per-plan log retention) (PGAudit)
Long-term log archive Log Drains (Pro+, $60/mo per drain + events) Only when a funder/regulator asks; "public alpha" status (Log drain usage, Features status)

Do not use pgaudit session mode with all — the docs warn about volume; object mode on two tables is enough.

Trade-offs

Sources

PostGIS · pg_jsonschema · Storage access control · Storage helpers · Buckets · PGAudit · Log drains · Postgres log config

Verify later


3. Configuration per jurisdiction (rulesets)

Recommendation

Table-driven, versioned, effective-dated, resolved city → country → global:

create table public.rulesets (
  id uuid primary key default gen_random_uuid(),
  scope text not null check (scope in ('global','country','city')),
  country char(2), city_id uuid references public.cities(id),
  version int not null,
  effective_from date not null, effective_to date,
  rules jsonb not null,                       -- validated by pg_jsonschema against ruleset_schema v1
  source text, note text,                     -- "LMG/HyV Art. …", link
  created_by uuid, created_at timestamptz default now(),
  check ((scope='country') = (country is not null) or scope='city'),
  check ((scope='city') = (city_id is not null))
);
create unique index rulesets_active on public.rulesets (scope, coalesce(country,''), coalesce(city_id,'00000000-0000-0000-0000-000000000000'), version);

create table public.allergen_lists (
  code text not null,                          -- 'EU14' | 'US9' | 'CH14'
  version int not null,
  items jsonb not null,                        -- [{"id":1,"key":"gluten","labels":{"de-CH":"Glutenhaltiges Getreide","fr-CH":"…"}}]
  effective_from date not null, effective_to date,
  primary key (code, version)
);

rules example (CH):

{ "hot_min_c": 65, "cold_max_c": 5, "cool_down_max_minutes": 120, "reheat_core_min_c": 72,
  "allergen_list": "EU14", "max_hours_after_made": 24, "require_temp_at_handover": true,
  "require_receiver_signature": true, "label_fields": ["dish","made_at","use_by","allergens","kitchen"] }

Why

EU-14 = CH-14 today, US has 9 (sesame added 2023), Austria adds nothing but different inspection wording; temperature thresholds differ (CH HyV 65 °C hot vs. EU-common 63 °C in UK, 60 °C in parts of the US). Snapshotting ruleset_id on the event is what makes a 2027 export say "at the time, the rule was X".

Trade-offs

Sources

pg_jsonschema · Managing JSON

Verify later


4. i18n architecture

Recommendation

const pr = new Intl.PluralRules(locale);
function t(key, vars = {}) {
  let s = dict[key] ?? fallback[key] ?? key;
  if (typeof s === 'object') s = s[pr.select(vars.count)] ?? s.other;   // {"one":"{count} Portion","other":"{count} Portionen"}
  return s.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? '');
}
const fmtDT   = new Intl.DateTimeFormat(locale, { timeZone: city.timezone, dateStyle: 'short', timeStyle: 'short' });
const fmtRel  = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });   // "in 2 Std."
const fmtNum  = new Intl.NumberFormat(locale, { style: 'unit', unit: 'celsius' });

Intl.PluralRules, DateTimeFormat, RelativeTimeFormat, NumberFormat (unit style) and ListFormat are available in all evergreen browsers and iOS Safari; no library needed. experience, verify exact minimum iOS version for RelativeTimeFormat (Safari 14). - Time zones come from cities.timezone, never from the device: a Vienna moderator looking at Bern must see Bern's pickup window. All DB timestamps are timestamptz; formatting is the only place a zone appears. - Locale-bearing data in DB: allergen_lists.items[].labels and cities.name_i18n are jsonb maps keyed by BCP-47; the client picks labels[locale] ?? labels[base] ?? labels['de-CH']. User-generated text (dish names, notes) is not translated — it is shown as typed, with the offer's lang tag for lang= attributes. - Translation workflow: scripts/i18n_extract.py scans app/**/*.js for t('…') and data-i18n and fails CI if a key is missing in de-CH.json; missing keys in other locales are listed, not fatal, until release. Machine translation: an Edge Function translate (Claude via the existing proxy pattern, or DeepL) writes candidates into translations_review(key, locale, mt_text, status); a reviewer accepts in a tiny admin view; accepted rows are exported back into the JSON files by script and committed — the repo, not the DB, is the source of truth for UI strings (DB holds only per-city overrides such as the city's own greeting). - Coverage meter runs in pre-commit (lesson from Sautero: a meter that only runs at session close protects nothing). - RTL readiness now, cheaply: <html lang dir> set at boot from locale; CSS uses logical properties only (margin-inline-start, padding-inline, inset-inline-end, text-align: start); no left/right in the stylesheet (a lint grep). Icons that imply direction (arrows) get [dir=rtl] & { transform: scaleX(-1) }. Nothing else is needed until an RTL locale is actually planned.

Trade-offs

Sources

No Supabase doc applies; browser-platform knowledge experience, verify for Safari/iOS minimums.


5. Frontend architecture

Recommendation

Stay buildless. Leave the single file. Move to ES modules with an import map.

<script type="importmap">
{ "imports": {
  "@supabase/supabase-js": "https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2.86.0/+esm",
  "app/": "./js/" } }
</script>
<script type="module" src="./js/main.js"></script>

Trade-offs

Sources

Broadcast · Subscribing to database changes · Postgres Changes · Realtime authorization · Realtime pricing · Database webhooks · pg_net · Edge Function limits · Testing overview · Working with branches · Generating types

Verify later


6. Backend / platform

Recommendation

One project for Switzerland now; one project per legal region later (EU/EEA can share; a non-adequate country gets its own). Decide the region of the CH project before Pro, because a project cannot change region in place (experience, verify — docs describe "Restore to a new project"/duplicate, not in-place region moves).

Trade-offs

Sources

GDPR compliance · PrivateLink (region list) · Regional invocations · Custom SMTP · Production checklist · Rate limits · Passwordless · Project pausing · Backups · Compute · Cron quickstart · Scheduling Edge Functions · Vault · PGMQ · Queues quickstart · Before user created hook · Shared responsibility

Verify later


7. Identity & access

Recommendation

Action staff owner city moderator platform admin
publish/cancel offer for own org's sites ✓ ✓ – –
reserve as taker org ✓ ✓ – –
write handover events for own side ✓ ✓ – –
manage sites, invite/remove members – ✓ – –
approve/block orgs in city – – ✓ ✓
see protocols of any org in city – – ✓ (read) ✓
edit rulesets, cities, themes – – – ✓

Trade-offs

Sources

Custom claims & RBAC · Custom access token hook · SSO SAML · MFA · RLS guide

Verify later


8. Observability & ops

Recommendation

Stage Plan & add-ons USD/mo (list) ≈ CHF/mo
Bern pilot (Free) Free, Nano, default SMTP for team only 0 0
Bern live Pro 25 + Micro compute 10 − 10 credits + custom domain 10 + Resend free tier ≈ 35 ≈ 30
5 cities (~300 orgs, ~2 000 MAU) Pro 25 + Small 15 − 10 + custom domain 10 + PITR-7d 100 (optional) + Resend paid ~20 ≈ 60 (160 with PITR) ≈ 50–135
50 cities, 2 countries (~3 000 orgs, ~20 000 MAU, 2 projects) Pro/Team + Medium 60 + Micro staging 10 − 10 + 2 domains 20 + PITR-14d 200 + log drain 60 + egress overage + SMS/WhatsApp usage ≈ 350–600 + messaging ≈ 300–500 + messaging

Quota anchors from docs: MAU 50 k Free / 100 k Pro; egress 5 GB / 250 GB; DB 500 MB / 8 GB; Realtime 2 M msgs & 200 peak conns / 5 M & 500; Edge invocations 500 k / 2 M (Billing). Compute price table: Micro ≈ $10, Small ≈ $15, Medium ≈ $60, Large ≈ $111 (Compute); PITR 7/14/28 days ≈ $100/$200/$400 (PITR usage); custom domain ≈ $10 (Custom domain usage); log drain ≈ $60 + events (Log drain usage). Custom domain, PITR, log drains and compute are not covered by the Spend Cap.

Sources

Query logs with SQL · Postgres log config · Realtime reports · Backups · Billing · Compute · PITR usage · Custom domain usage · Log drain usage

Verify later


9. Public API & integrations

Recommendation

Trade-offs

Sources

RLS guide → Views · Custom domains · Buckets · Working with branches (webhook payload uses Standard Webhooks)

Verify later


10. Security & compliance

Recommendation

RLS review checklist (run by rls-guard or by hand; each item = a pgTAP test where possible): 1. Every table in an exposed schema has RLS enabled (event trigger rls_auto_enable from the docs installed so new tables cannot forget it) (RLS guide). 2. Every policy names to authenticated or to anon; none is to public. 3. Every auth.uid()/auth.jwt()/private.* call is wrapped in (select …); Advisor lint 0003 is clean. 4. Every tenant column used in a policy is indexed. 5. No policy on table A subqueries table B under RLS — use a private. SECURITY DEFINER helper with set search_path = ''. 6. SECURITY DEFINER functions live in private (not exposed), have EXECUTE revoked from anon/public, and never take row data as a parameter that would defeat the initPlan cache. 7. UPDATE policies have both USING and WITH CHECK; WITH CHECK pins every column the client must not change (today's offers_update_kitchen fails this — §0). 8. Views are security_invoker = true or live in an unexposed schema. 9. Storage: private by default; every storage.objects policy pins bucket_id; listing vs. download separated with storage.allow_only_operation('object.list') where needed (Storage helpers). 10. Realtime: "Allow public access" off; realtime.messages policies check realtime.topic() against tenant membership (Realtime authorization). 11. pgmq, net, vault, cron, private, stats schemas are not in the exposed list; vault.decrypted_secrets has no grant to authenticated. 12. Negative tests: anon sees zero rows everywhere except api_v1; staff of org A cannot read org B; a removed member (status removed) loses access without token refresh; a pending org sees no open offers. 13. Generic guard: a pgTAP test that iterates pg_policies and fails on any policy whose qual contains auth.uid() not preceded by (select.

Secrets: the publishable key in config.js is public by design; it grants nothing beyond RLS (RLS guide → Bypassing). The secret key exists only in Edge Function secrets and GitHub Actions secrets; third-party API keys (Resend, Twilio, VAPID, Zefix) in Edge Function secrets; keys needed inside SQL (cron → function auth) in Vault (Vault). Enable SSL enforcement and Network Restrictions on the DB; MFA on the Supabase org with two owners (Production checklist).

PII minimisation: - Phone numbers: move to contact_channels(org_id, kind, value, visible_after text) and reveal only to the counterparty of a reserved offer (today's profiles_select_counterpart reveals the kitchen's phone to every approved taker for every open offer — tighten to reserved-only, kitchens' pickup address stays visible since it is the offer). - Person names: the protocol needs "who handed over" — store actor_id; render the name from profiles at read time; after retention, the join returns "ehemaliges Mitglied". - Push endpoints, IP addresses in audit_log: 90-day retention. - Photos of dishes: optional, no faces, EXIF stripped client-side [experience].

Retention (pg_cron nightly, each step idempotent and logged): - offers: 30 days after pickup_to → note, address copy nulled (site remains), status kept; 24 months → row deleted, aggregate already in stats.city_daily. - handover_events: payload PII (signature_ref, free text) removed after 24 months; temperatures/times/counts kept for the statutory self-control retention — CH practice is commonly cited as 2 years [experience, verify with cantonal lab]; the row itself is never deleted (hash chain). - invitations 30 days after expiry; rate_events 7 days; message_log 90 days; auth.users without membership and without login for 12 months → deleted via admin API (their events keep actor_id as a dangling UUID, which is the point).

revDSG (CH, in force 1.9.2023): privacy notice at first sign-in (what, why, retention, Supabase as processor in FR/CH, Resend/Twilio as sub-processors, rights, FDPIC); a processing register (Verzeichnis der Bearbeitungstätigkeiten) — not mandatory below 250 employees unless high-risk, but a one-page register is cheap and funders ask; DPA with Supabase — Supabase provides one on request (GDPR compliance → DPA); Supabase is SOC 2 Type 2 (Security). Health data is not processed (allergens describe food, not people — keep it that way; never store a taker's allergies).

EU expansion notes: GDPR Art. 28 processor contract = the same Supabase DPA; data location Paris (EU) or Zurich (adequate third country, per the docs' own note that Zurich is not EU and needs a specific EU region if EU-only is required) (GDPR compliance); Art. 30 register becomes mandatory-in-practice; Art. 33 72-hour breach notice; cookie/consent: the app sets only functional storage (session, locale) — no consent banner needed, but state it in the notice [experience, verify per country].

Sources

RLS guide · Storage helpers · Realtime authorization · Vault · Production checklist · GDPR compliance · Security overview

Verify later


11. Phased build plan

Conventions for every phase: migrations are numbered files in db/ and mirrored to supabase/migrations/ so supabase db reset replays them; every migration ships with supabase/tests/database/NNN_*.test.sql (pgTAP); "rollback" = the forward migration that undoes it, written before the change ships; nothing is "done" until seen on a real phone against the live project.

Phase 0 — now: Bern pilot, nothing that breaks

Phase 1 — org/membership + cities + rulesets, dual-write, feature flags

Phase 2 — protocol event log + notifications + PWA

Phase 3 — second city, white-label, public stats

Phase 4 — second country: rulesets, locale, project/region decision

Things to verify with unblocked network

  1. Supabase pricing page — Pro/Team fees, spend cap, egress per GB, current compute table; and regions page — full list incl. eu-central-2 Zurich and any feature gaps.
  2. Realtime limits page (/docs/guides/realtime/limits) — channels per connection, message size, joins/sec per plan.
  3. Custom SMTP on Free plan — allowed or Pro-only.
  4. Web Push on iOS PWA — minimum iOS/Safari version, requirement to be installed to Home Screen, support for actions/badges (2026 state).
  5. Zefix API — public REST endpoint, registration, terms, quotas; UID checksum spec (eCH-0097).
  6. WhatsApp Business (Cloud API) terms — template approval, per-conversation pricing in CH/AT, opt-in wording; Twilio CH alphanumeric sender rules; Signal (no official API — confirm).
  7. revDSG specifics: FDPIC adequacy list (EU listed), breach-notification duty wording, processing-register threshold; CH HyV retention period for self-control records; AT LMSVG equivalents.
  8. Supabase DPA text and sub-processor list (needed for the privacy notice).
  9. PostgREST CSV support and size limits on the current Supabase version.
  10. Import maps / Intl.RelativeTimeFormat minimum Safari versions for the pilot's phones.
  11. Postgres log retention per plan (for the pgaudit decision).
  12. Whether project region can be changed without a new project (docs seen only describe restore/duplicate).

Scope declined