Database Schema

Volledig PostgreSQL schema met Row Level Security (RLS) policies.

Tabellen

profielen

CREATE TABLE public.profielen (
  id              UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
  voornaam        TEXT NOT NULL,
  achternaam      TEXT NOT NULL,
  email           TEXT NOT NULL UNIQUE,
  telefoon        TEXT NOT NULL,           -- 06-nummer (E.164: +316...)
  wa_opt_in       BOOLEAN DEFAULT FALSE,   -- expliciete WhatsApp toestemming
  email_opt_in    BOOLEAN DEFAULT TRUE,
  aangemaakt_op   TIMESTAMPTZ DEFAULT NOW(),
  verwijder_op    TIMESTAMPTZ,             -- ingesteld op opening + 30d
  CONSTRAINT telefoon_formaat CHECK (telefoon ~ '^\+316[0-9]{8}$')
);

Relatie: Uitbreiding op Supabase auth.users RLS: Gebruiker ziet alleen zichzelf

cirkels

CREATE TABLE public.cirkels (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  naam            TEXT NOT NULL,
  beschrijving    TEXT,
  mentor_id       UUID NOT NULL REFERENCES public.profielen(id),
  uitnodigings_code TEXT UNIQUE NOT NULL,  -- 8-karakter random code
  opening_datum   DATE NOT NULL,
  opening_verzonden BOOLEAN DEFAULT FALSE,
  cirkel_sleutel  TEXT,                   -- AES-256 sleutel, alleen zichtbaar NA opening_datum
  aangemaakt_op   TIMESTAMPTZ DEFAULT NOW(),
  gesloten_op     TIMESTAMPTZ,            -- na vernietiging
  max_leden       INT DEFAULT 50,
  CONSTRAINT opening_in_toekomst CHECK (opening_datum > aangemaakt_op::DATE)
);

RLS:

  • SELECT: alleen voor leden
  • INSERT: iedereen (wordt mentor)
  • UPDATE: alleen mentor
  • cirkel_sleutel: NULL totdat opening_datum voorbij

lidmaatschappen

CREATE TABLE public.lidmaatschappen (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  cirkel_id    UUID NOT NULL REFERENCES public.cirkels(id) ON DELETE CASCADE,
  gebruiker_id UUID NOT NULL REFERENCES public.profielen(id) ON DELETE CASCADE,
  rol          TEXT NOT NULL DEFAULT 'lid' CHECK (rol IN ('mentor', 'lid')),
  toegevoegd_op TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE (cirkel_id, gebruiker_id)
);

Relatie: Joins cirkels ↔ profielen RLS: Leden zien alleen hun eigen lidmaatschappen

notities

CREATE TABLE public.notities (
  id                UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  cirkel_id         UUID NOT NULL REFERENCES public.cirkels(id) ON DELETE CASCADE,
  schrijver_id      UUID NOT NULL REFERENCES public.profielen(id),
  ontvanger_id      UUID NOT NULL REFERENCES public.profielen(id),
  versleutelde_tekst TEXT NOT NULL,        -- AES-256 encrypted, base64
  iv                TEXT NOT NULL,         -- initialisatievector, base64
  geschreven_op     TIMESTAMPTZ DEFAULT NOW(),
  gelezen_op        TIMESTAMPTZ,           -- null totdat blik open is
  CONSTRAINT niet_aan_zichzelf CHECK (schrijver_id != ontvanger_id)
);

Encryptie: versleutelde_tekst en iv zijn base64-encoded RLS:

  • Schrijver mag metadata zien (voor wie, wanneer)
  • Ontvanger mag inhoud lezen NA opening
  • Schrijver mag eigen notities zien (NIET de versleutelde tekst)

uitnodigingen

CREATE TABLE public.uitnodigingen (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  cirkel_id     UUID NOT NULL REFERENCES public.cirkels(id),
  uitgenodigd_email TEXT NOT NULL,
  uitgenodigd_door  UUID NOT NULL REFERENCES public.profielen(id),
  token         TEXT UNIQUE NOT NULL,      -- éénmalig gebruik
  verlopen_op   TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '7 days',
  gebruikt_op   TIMESTAMPTZ,
  aangemaakt_op TIMESTAMPTZ DEFAULT NOW()
);

Validatie: token moet uniek zijn, verlopen_op standaard 7 dagen RLS: Uitgenodigd-door-persoon mag zien/wijzigen

verwijderlogs

CREATE TABLE public.verwijderlogs (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  cirkel_id   UUID,                        -- kan null zijn na verwijdering
  actie       TEXT NOT NULL,
  details     JSONB,
  uitgevoerd_op TIMESTAMPTZ DEFAULT NOW()
);

Audit trail: Geanonimiseerde log voor data-vernietiging RLS: Alleen service-role key mag schrijven (cron jobs)


Row Level Security (RLS) Policies

Alle tabellen hebben RLS ingeschakeld:

ALTER TABLE profielen      ENABLE ROW LEVEL SECURITY;
ALTER TABLE cirkels        ENABLE ROW LEVEL SECURITY;
ALTER TABLE lidmaatschappen ENABLE ROW LEVEL SECURITY;
ALTER TABLE notities       ENABLE ROW LEVEL SECURITY;
ALTER TABLE uitnodigingen  ENABLE ROW LEVEL SECURITY;

PROFIELEN

-- Gebruiker ziet alleen zichzelf
CREATE POLICY "eigen_profiel" ON profielen
  FOR ALL USING (auth.uid() = id);

CIRKELS

-- Zichtbaar voor leden
CREATE POLICY "cirkel_voor_leden" ON cirkels
  FOR SELECT USING (
    EXISTS (
      SELECT 1 FROM lidmaatschappen
      WHERE cirkel_id = cirkels.id
        AND gebruiker_id = auth.uid()
    )
  );

-- Aanmaken: iedereen (wordt mentor)
CREATE POLICY "cirkel_aanmaken" ON cirkels
  FOR INSERT WITH CHECK (mentor_id = auth.uid());

-- Bewerken: alleen mentor
CREATE POLICY "cirkel_bewerken_mentor" ON cirkels
  FOR UPDATE USING (mentor_id = auth.uid());

KRITISCH: Cirkel-Sleutel Timing

-- cirkel_sleutel is NULL totdat opening_datum verstreken is
-- Edge Function onthult sleutel pas op openingsdatum

CREATE POLICY "sleutel_pas_na_opening" ON cirkels
  FOR SELECT USING (
    CASE
      WHEN opening_datum <= CURRENT_DATE THEN TRUE
      ELSE (cirkel_sleutel IS NULL)   -- geeft NULL terug, nooit vroegtijdig
    END
  );

NOTITIES

-- Schrijven: alleen leden, vóór opening
CREATE POLICY "notitie_schrijven" ON notities
  FOR INSERT WITH CHECK (
    schrijver_id = auth.uid()
    AND EXISTS (
      SELECT 1 FROM lidmaatschappen
      WHERE cirkel_id = notities.cirkel_id
        AND gebruiker_id = auth.uid()
    )
    AND EXISTS (
      SELECT 1 FROM cirkels
      WHERE id = notities.cirkel_id
        AND opening_datum > CURRENT_DATE  -- blik is nog dicht
    )
  );

-- Lezen: ontvanger NA opening; schrijver mag metadata zien
CREATE POLICY "notitie_lezen_na_opening" ON notities
  FOR SELECT USING (
    ontvanger_id = auth.uid()
    AND EXISTS (
      SELECT 1 FROM cirkels
      WHERE id = notities.cirkel_id
        AND opening_datum <= CURRENT_DATE
    )
  );

-- Schrijver: metadata zien, NIET versleutelde tekst
CREATE POLICY "notitie_meta_schrijver" ON notities
  FOR SELECT USING (schrijver_id = auth.uid());

UITNODIGINGEN

CREATE POLICY "uitnodiging_mentor" ON uitnodigingen
  FOR ALL USING (
    uitgenodigd_door = auth.uid()
    AND EXISTS (
      SELECT 1 FROM cirkels
      WHERE id = uitnodigingen.cirkel_id
        AND mentor_id = auth.uid()
    )
  );

Indexes (Performance)

CREATE INDEX idx_lidmaatschappen_cirkel_id ON lidmaatschappen(cirkel_id);
CREATE INDEX idx_lidmaatschappen_gebruiker_id ON lidmaatschappen(gebruiker_id);
CREATE INDEX idx_notities_cirkel_id ON notities(cirkel_id);
CREATE INDEX idx_notities_ontvanger_id ON notities(ontvanger_id);
CREATE INDEX idx_cirkels_mentor_id ON cirkels(mentor_id);
CREATE INDEX idx_profielen_email ON profielen(email);

Migrations

Alle tabellen worden via migrations aangemaakt:

  • supabase/migrations/001_schema.sql — Tabellen
  • supabase/migrations/002_rls.sql — RLS policies
  • supabase/migrations/003_functions.sql — Stored procedures & triggers

Zie: Deployment voor hoe migrations uit te voeren.


Opmerking over veiligheid: RLS is je laatste verdedigingslinie. Test altijd dat RLS juist werkt voordat je deployt!