Data & Architectuur
Datamodellen
Section titled “Datamodellen”Auth & Gebruikers
Section titled “Auth & Gebruikers”auth.users (Supabase managed)
Section titled “auth.users (Supabase managed)”| Kolom | Type | Verplicht | Toelichting |
|---|---|---|---|
id | UUID | PK | Supabase auth identifier |
email | text | Ja | Login email |
created_at | timestamptz | Ja | Registratiedatum |
profiles
Section titled “profiles”| Kolom | Type | Verplicht | Toelichting |
|---|---|---|---|
id | UUID | PK, FK → auth.users | 1:1 met auth user. CASCADE DELETE |
email | text | Ja | Gedupliceerd voor snelle lookups |
first_name | text | Nee | Voornaam |
last_name | text | Nee | Achternaam |
avatar_url | text | Nee | URL naar Supabase Storage (avatars bucket) |
created_at | timestamptz | Ja | Auto-generated |
updated_at | timestamptz | Ja | Auto-updated via trigger |
Team Management
Section titled “Team Management”team_members
Section titled “team_members”| Kolom | Type | Verplicht | Toelichting |
|---|---|---|---|
id | UUID | PK | — |
site_id | UUID | FK → sites | Site waartoe dit lidmaatschap behoort |
user_id | UUID | FK → auth.users | Het teamlid |
role | text | Ja | owner | admin | editor | viewer |
created_at | timestamptz | Ja | — |
Constraints: UNIQUE(site_id, user_id) — een user kan maar 1x lid zijn per site.
Site-scoped: Teams zijn per site, niet per owner. Een user kan editor zijn op site A en viewer op site B.
Bootstrap: bootstrap_new_user() trigger maakt automatisch een team_members record (role: owner) aan voor de default site. add_owner_to_site_team() trigger voegt de owner toe bij elke nieuwe site.
team_invitations
Section titled “team_invitations”| Kolom | Type | Verplicht | Toelichting |
|---|---|---|---|
id | UUID | PK | — |
site_id | UUID | FK → sites | Site waarvoor uitgenodigd |
email | text | Ja | Lowercase email van genodigde |
role | text | Ja | admin | editor | viewer (niet owner) |
status | text | Ja | pending | accepted | revoked | expired |
token | UUID | Ja, UNIQUE | Magic link token |
expires_at | timestamptz | Ja | 7 dagen na creatie |
accepted_by | UUID | Nee | FK → auth.users. Gezet bij acceptatie |
responded_at | timestamptz | Nee | Datum van accept/decline |
created_at | timestamptz | Ja | — |
updated_at | timestamptz | Ja | — |
Sites & Domeinen
Section titled “Sites & Domeinen”| Kolom | Type | Verplicht | Toelichting |
|---|---|---|---|
id | UUID | PK | — |
owner_id | UUID | FK → auth.users | Meerdere sites per owner (max 10). CASCADE DELETE |
name | text | Ja | Default: “Mijn website” |
slug | text | UNIQUE | Auto-gegenereerd uit naam (lowercase, hyphens) |
deleted_at | timestamptz | Nee | Soft delete timestamp. NULL = actief, gevuld = verwijderd |
created_at | timestamptz | Ja | — |
updated_at | timestamptz | Ja | — |
Bootstrap: bootstrap_new_user() trigger maakt automatisch een site aan bij registratie — behalve wanneer er een open team_invitations rij bestaat voor dezelfde email (sinds migratie 090). In dat geval verloopt de site-creatie en wordt de user pas team-lid via de invite-accept flow. Voorkomt dat uitgenodigde gebruikers een “ghost” eigen site krijgen die ze nooit gebruiken.
Quota trigger: check_site_quota() — blokkeert INSERT als de owner al 10 actieve sites heeft (sites waar deleted_at IS NULL).
Soft delete: Sites worden niet direct verwijderd maar krijgen een deleted_at timestamp. Na 14 dagen worden ze definitief verwijderd door de hardDeleteExpiredSites() cron (R2 bestanden, CF custom domains en DB records).
site_domains
Section titled “site_domains”| Kolom | Type | Verplicht | Toelichting |
|---|---|---|---|
id | UUID | PK | — |
site_id | UUID | FK → sites | — |
domain | text | UNIQUE | Volledig domein (bijv. example.com) |
is_primary | boolean | Ja | Max 1 per site (trigger enforced) |
status | text | Ja | pending | active | error |
cloudflare_hostname_id | text | Nee | CF Pages Custom Domain ID |
created_at | timestamptz | Ja | — |
updated_at | timestamptz | Ja | — |
Trigger: enforce_single_primary_domain() — voorkomt meerdere primary domeinen per site.
site_settings
Section titled “site_settings”| Kolom | Type | Verplicht | Toelichting |
|---|---|---|---|
id | UUID | PK | — |
user_id | UUID | FK → auth.users | Owner |
site_id | UUID | FK → sites | Site scope |
homepage_page_id | UUID | Nee | FK → pages. Default homepage |
header_menu_id | UUID | Nee | FK → menus |
footer_menu_id | UUID | Nee | FK → menus |
header_nav_type | text | Nee | logo-menu-button (Logo—Menu—CTA) of menu-logo-menu (Menu—Logo—Menu). Default: logo-menu-button. CHECK constraint |
header_menu_left_id | UUID | Nee | FK → menus. Linker menu bij menu-logo-menu layout |
header_button_text | text | Nee | CTA button tekst in header |
header_button_url | text | Nee | CTA button URL in header |
header_design | JSONB | Nee | Header design config — zie HeaderDesign interface hieronder |
favicon_url | text | Nee | — |
apple_touch_icon_url | text | Nee | — |
og_image_url | text | Nee | Default social image |
site_name | text | Nee | — |
site_title | text | Nee | — |
site_description | text | Nee | — |
site_language | text | Ja | Default: nl |
block_search_engines | boolean | Ja | Default: false. Zet noindex op alle pagina’s |
Constraints: UNIQUE(site_id) — maximaal 1 settings record per site. Voorkomt duplicaat rijen bij concurrent inserts.
header_design JSONB structuur (HeaderDesign interface in @beam/shared):
| Veld | Type | Default | Beschrijving |
|---|---|---|---|
bgColor | string | #ffffff | Achtergrondkleur (hex) |
bgOpacity | number | 100 | Achtergrond transparantie (0-100) |
bgBlur | boolean | false | Glassmorphism blur effect |
borderEnabled | boolean | true | Onderrand tonen |
borderColor | string | #e5e7eb | Rand kleur (hex) |
shadow | string | none | none / subtle / medium |
position | string | sticky | sticky / static / fixed |
height | string | default | compact (48px) / default (64px) / tall (80px) |
hideOnScroll | boolean | false | Verberg bij scrollen, toon bij omhoog scrollen |
transparentOnHero | boolean | false | Transparant bovenaan als eerste block een bg afbeelding heeft |
selectedLogoId | string | — | ID van geselecteerd logo uit brand_profiles.logos |
buttonStyle | string | solid | CTA button stijl (solid / outline / transparent) |
buttonColor | string | #111827 | CTA button kleur |
buttonColorToken | string | — | Brand kleur token (bijv. primary, accent) |
menuFontSize | number | 14 | Menu items font-size (px) |
menuFontWeight | number | 500 | Menu items font-weight |
menuLetterSpacing | number | 0 | Menu items letter-spacing (em) |
logoFontSize | number | 18 | Logo tekst font-size (px) |
logoFontWeight | number | 600 | Logo tekst font-weight |
buttonFontSize | number | 14 | CTA button font-size (px) |
buttonFontWeight | number | 500 | CTA button font-weight |
brand_profiles
Section titled “brand_profiles”| Kolom | Type | Verplicht | Toelichting |
|---|---|---|---|
id | UUID | PK | — |
site_id | UUID | FK → sites, UNIQUE | 1 profiel per site. CASCADE DELETE |
onboarding_data | JSONB | Nee | Brand story data (BrandStory interface). Bevat bedrijfsinfo, doelgroep, tone of voice, missie/visie, contactgegevens. Wordt als AI-context meegestuurd bij content generatie. Gevalideerd via parseBrandStory(). |
design_tokens | JSONB | Ja | Kleuren, fonts, radius, spacing, shadow. Default: {} |
mood_preset | text | Nee | Actieve sfeer-preset ID (bijv. warm-aards) |
logo_url | text | Nee | URL naar het merklogo (R2 of extern) |
created_at | timestamptz | Ja | — |
updated_at | timestamptz | Ja | Auto-updated via trigger |
Index: idx_brand_profiles_site op site_id.
RLS: Site team members kunnen lezen, admins+ kunnen inserteren en updaten via get_user_role(auth.uid(), site_id).
onboarding_data velden (BrandStory interface in packages/shared/src/types.ts):
| Veld | Type | Doel |
|---|---|---|
companyName | string | Bedrijfsnaam |
slogan | string | Tagline |
description | string | Korte bedrijfsbeschrijving |
industry | string | Branche |
targetAudience | string | Doelgroep beschrijving |
differentiator | string | Onderscheidend vermogen |
primaryCta | string | Primaire call-to-action |
secondaryCta | string | Secundaire call-to-action |
businessType | 'b2b' | 'b2c' | 'both' | Type klant |
region | string | Regio/markt |
foundedYear | string | Oprichtingsjaar |
mission | string | Missie |
vision | string | Visie |
coreValues | string[] (max 6) | Kernwaarden |
addressForm | 'je' | 'u' | 'jullie' | Aanspreekvorm |
toneFormal | number (0-1) | Slider: informeel ↔ formeel |
toneSerious | number (0-1) | Slider: speels ↔ serieus |
toneTechnical | number (0-1) | Slider: toegankelijk ↔ technisch |
toneDescription | string | Vrije tone beschrijving |
forbiddenWords | string[] (max 10) | Woorden die AI niet mag gebruiken |
email | string | Contactgegevens |
phone | string | Contactgegevens |
address | string | Contactgegevens |
Alle velden zijn optioneel. Lege velden worden overgeslagen in de AI-context. Runtime validatie via parseBrandStory() in @beam/shared.
token_versions
Section titled “token_versions”| Kolom | Type | Verplicht | Toelichting |
|---|---|---|---|
id | UUID | PK | — |
site_id | UUID | FK → sites | CASCADE DELETE |
design_tokens | JSONB | Ja | Snapshot van design_tokens op dat moment |
label | text | Nee | Naam voor handmatige snapshots |
auto | boolean | Ja | TRUE = auto-save bij wijziging, FALSE = named snapshot |
created_by | UUID | FK → auth.users | Wie de wijziging maakte |
created_at | timestamptz | Ja | — |
Indexes: idx_token_versions_site op site_id, idx_token_versions_site_created op (site_id, created_at DESC).
Trigger: snapshot_tokens_before_update maakt automatisch een snapshot van de huidige design_tokens voor elke update op brand_profiles. Houdt max 50 auto-snapshots per site.
RLS: Site team members kunnen lezen, admins+ kunnen inserteren en verwijderen via get_user_role(auth.uid(), site_id).
Content
Section titled “Content”| Kolom | Type | Verplicht | Toelichting |
|---|---|---|---|
id | UUID | PK | — |
site_id | UUID | FK → sites, NOT NULL | Team-scope |
user_id | UUID | FK → auth.users | Creator |
title | text | Ja | Paginatitel |
slug | text | UNIQUE(site_id, slug) | URL path. Regex: ^[a-z0-9]([a-z0-9-]{1,61}[a-z0-9])?$. Uniek per site |
blocks | JSONB | Ja | Array van Block objecten. Default: [] |
block_count | integer | Generated | Automatisch berekend uit blocks array |
status | text | Ja | draft | published |
meta_title | text | Nee | SEO title (overschrijft title) |
meta_description | text | Nee | SEO description |
og_image | text | Nee | Social media preview image |
hide_header | boolean | Ja | Default: false |
hide_footer | boolean | Ja | Default: false |
no_index | boolean | Ja | Default: false. Verbergt pagina voor zoekmachines |
created_at | timestamptz | Ja | — |
updated_at | timestamptz | Ja | — |
Indexes: pages_slug_idx, pages_user_id_idx, pages_status_idx, pages_site_id_idx, pages_site_id_status_idx, idx_pages_blocks_gin (GIN op JSONB voor pattern queries).
| Kolom | Type | Verplicht | Toelichting |
|---|---|---|---|
id | UUID | PK | — |
name | text | Ja | Menu naam (bijv. “Header”, “Footer”) |
user_id | UUID | FK → auth.users | Owner |
site_id | UUID | FK → sites, NOT NULL | Site scope |
menu_items
Section titled “menu_items”| Kolom | Type | Verplicht | Toelichting |
|---|---|---|---|
id | UUID | PK | — |
menu_id | UUID | FK → menus | Parent menu |
page_id | UUID | Nee | FK → pages. Linkt naar interne pagina |
label | text | Ja | Weergavenaam |
url | text | Nee | Externe URL (als page_id null) |
parent_id | UUID | Nee | FK → menu_items (self-referencing tree) |
position | integer | Ja | Volgorde binnen parent |
Patterns
Section titled “Patterns”patterns
Section titled “patterns”| Kolom | Type | Verplicht | Toelichting |
|---|---|---|---|
id | UUID | PK | — |
site_id | UUID | FK → sites | Team-scope |
name | text | Ja | Pattern naam |
description | text | Nee | — |
scope | text | Nee | Scope type |
scope_ref_id | UUID | Nee | Referentie naar scope object |
blocks_data | JSONB | Ja | Array van Block objecten |
is_synced | boolean | Ja | Of propagatie actief is |
category | text | Nee | Categorisering |
status | text | Nee | — |
created_by | UUID | FK → auth.users | Creator |
block_count | integer | Nee | Aantal blocks |
created_at | timestamptz | Ja | — |
updated_at | timestamptz | Ja | — |
pattern_instances
Section titled “pattern_instances”| Kolom | Type | Verplicht | Toelichting |
|---|---|---|---|
id | UUID | PK | — |
pattern_id | UUID | FK → patterns | Bron pattern |
page_id | UUID | FK → pages | Pagina waar pattern gebruikt wordt |
position | integer | Nee | Positie in pagina |
overrides | JSONB | Nee | Lokale aanpassingen |
pattern_previews
Section titled “pattern_previews”| Kolom | Type | Verplicht | Toelichting |
|---|---|---|---|
id | UUID | PK | — |
pattern_id | UUID | FK → patterns, UNIQUE | 1 preview per pattern |
thumbnail_url | text | Ja | URL naar Supabase Storage |
generated_at | timestamptz | Ja | Laatst gegenereerd |
| Kolom | Type | Verplicht | Toelichting |
|---|---|---|---|
id | UUID | PK | — |
site_id | UUID | FK → sites, NOT NULL | Team-scope. Telt mee voor quota |
type | text | Ja | image | document |
filename | text | Ja | Originele bestandsnaam |
mime_type | text | Ja | MIME type (bijv. image/jpeg) |
file_size | integer | Ja | Grootte in bytes |
storage_path | text | Ja | R2 pad: {siteId}/originals/{fileId}.{ext} |
thumbnail_path | text | Nee | R2 pad: {siteId}/thumbnails/{fileId}.webp |
width | integer | Nee | Alleen voor afbeeldingen |
height | integer | Nee | Alleen voor afbeeldingen |
alt_text | text | Nee | Toegankelijkheid |
title | text | Nee | — |
source | text | Ja | upload | external |
external_url | text | Nee | Alleen bij source: external |
file_hash | text | Nee | SHA-256 hash voor deduplicatie |
blur_data_url | text | Nee | Base64 LQIP (20px blur) |
uploaded_by | UUID | FK → auth.users | Uploader |
bunny_video_id | text | Nee | Bunny Stream video ID |
bunny_library_id | text | Nee | Bunny Stream library ID |
bunny_status | integer | Nee | 0-5 encoding status |
duration | float | Nee | Video duur in seconden |
created_at | timestamptz | Ja | — |
updated_at | timestamptz | Ja | — |
Quota trigger: check_media_quota() — blokkeert INSERT als get_site_storage_used(site_id) + file_size > 500MB.
media_tags
Section titled “media_tags”| Kolom | Type | Verplicht | Toelichting |
|---|---|---|---|
id | UUID | PK | — |
site_id | UUID | FK → sites | Team-scope |
name | text | Ja | Tag naam |
Index: idx_media_tags_site_name — UNIQUE op (site_id, LOWER(name)). Case-insensitive per site.
media_tag_assignments
Section titled “media_tag_assignments”| Kolom | Type | Verplicht | Toelichting |
|---|---|---|---|
media_id | UUID | FK → media | — |
tag_id | UUID | FK → media_tags | — |
PK: (media_id, tag_id) — composite primary key.
Addons
Section titled “Addons”addons
Section titled “addons”| Kolom | Type | Verplicht | Toelichting |
|---|---|---|---|
id | UUID | PK | — |
site_id | UUID | FK → sites, NOT NULL | Per-site activatie |
addon_id | text | Ja | Stringid uit code-registry (bv. 'events'). Geen FK — registry is source of truth |
enabled | boolean | Ja | true = actief, false = soft-deactivated |
installed_via | text | Nee | Hoe is geactiveerd ('manual', 'recommendation', etc.) |
specialization | text | Nee | Optionele variant (bv. 'yoga' voor events) |
config | jsonb | Nee | Runtime instellingen, addon-specifiek |
created_at | timestamptz | Ja | — |
updated_at | timestamptz | Ja | — |
Unique index: (site_id, addon_id) — één row per addon per site.
RLS: Site members read-only; owner/admin INSERT/UPDATE/DELETE. Platform_admin bypass via mig 070.
Events (Addon)
Section titled “Events (Addon)”events
Section titled “events”| Kolom | Type | Verplicht | Toelichting |
|---|---|---|---|
id | UUID | PK | — |
site_id | UUID | FK → sites, NOT NULL | Site-scope |
title | text | Ja | max 200 chars |
slug | text | Ja | unique per site, lowercase + hyphens (regex check) |
description | text | Nee | max 500 chars, plain text — fungeert als lead-tekst intro op de detail-pagina |
content_html | text | Nee | DOMPurify-sanitized HTML uit Tiptap WYSIWYG (mig 083). Lange omschrijving, geen length-cap. Lege editor wordt als null opgeslagen. |
price | text | Nee | Canonieke decimale prijs als string — '1234.56' (decimale punt, geen scheidingstekens) of null. UI maskeert input naar NL-notatie (1.234,56) via maskPriceInputNL, rendert via eventPriceDisplay (@beam/shared/events). null bij donatiebasis. |
price_donation | boolean | Ja | NOT NULL DEFAULT false (mig 093). true = “Op donatiebasis” tonen i.p.v. een bedrag; price is dan null. |
price_from | boolean | Ja | NOT NULL DEFAULT false (mig 093). true = bedrag als “Vanaf €X” tonen. Alleen relevant bij een numerieke price. |
start_at | timestamptz | Ja | ISO 8601 UTC |
end_at | timestamptz | Nee | Optioneel; geen DB-constraint dat eind > start (frontend valideert) |
location | jsonb | Ja | {type: 'physical'|'online'|'hybrid', venue_name?, address?, online_url?} |
status | text | Ja | draft|published|sold_out|completed (CHECK constraint) |
featured_image_url | text | Nee | max 2048 chars (CHECK constraint, mig 072) |
cta_url | text | Nee | max 2048 chars, extern (mig 079) |
cta_label | text | Nee | max 60 chars (mig 079) |
created_by | UUID | FK → auth.users, NOT NULL | Maker |
created_at | timestamptz | Ja | — |
updated_at | timestamptz | Ja | Auto via trigger |
Unique index: (site_id, slug) — slug uniek per site.
Indexes: (site_id, start_at DESC), partial (site_id, status) WHERE status='published'.
RLS:
- Site members (team) — alle CRUD met role checks (owner/admin/editor)
- Platform_admin bypass — alle CRUD (mig 071)
- Anon — geen directe toegang; loopt via SECURITY DEFINER RPCs (mig 078)
Relaties & ERD
Section titled “Relaties & ERD”auth.users |── 1:1 → profiles (CASCADE DELETE) |── 1:N → sites (owner_id — max 10 actieve, CASCADE DELETE) |── 1:N → team_members (als owner_id — team scope) |── 1:N → team_members (als user_id — lidmaatschap) └── 1:N → pages (user_id — creator)
sites |── 1:N → pages (site_id — CASCADE DELETE) |── 1:N → menus (site_id) |── 1:1 → site_settings (site_id — UNIQUE(site_id)) |── 1:N → media (site_id — SET NULL bij delete) |── 1:N → patterns (scope_ref_id) |── 1:N → media_tags (site_id) └── 1:N → site_domains (site_id — CASCADE DELETE)
pages |── N:1 → sites (site_id) |── 1:N → pattern_instances (page_id) └── [JSONB] → media_id refs, page_id refs (buttons), pattern_id refs
menus |── N:1 → auth.users (user_id) |── N:1 → sites (site_id) └── 1:N → menu_items (menu_id) |── N:1 → pages (page_id, nullable) └── N:1 → menu_items (parent_id — self-referencing tree)SQL Helper Functies
Section titled “SQL Helper Functies”| Functie | Returns | Doel |
|---|---|---|
get_user_site_ids(user_id) | SETOF UUID | Alle site_ids waar een user lid van is. Kern van RLS policies |
get_user_role(user_id, site_id) | TEXT | Rol op specifieke site: owner/admin/editor/viewer/NULL |
get_team_owner_id(user_id) | UUID | Deprecated. Backward compat: owner_id via JOIN. Vervangen door get_user_site_ids() voor site-scoped queries |
are_teammates(user_a, user_b) | BOOLEAN | Delen twee users een site? (via site_id join) |
is_reserved_slug(slug) | BOOLEAN | Check tegen reserved slugs (app, admin, www, api, etc.) |
ensure_owner_team_member(site_id) | VOID | Safety net. SECURITY DEFINER: maakt owner team_members rij aan als die ontbreekt. Valideert sites.owner_id = auth.uid(). Idempotent (ON CONFLICT DO NOTHING). Aanroepbaar via supabase.rpc() |
bootstrap_new_user() | TRIGGER | Auto-create team_members (owner) + default site bij sign-up. Skipt site-creatie wanneer een pending, niet-verlopen team_invitations rij bestaat voor het email (mig 090) — invited users krijgen pas team-toegang via de accept-flow. |
add_owner_to_site_team() | TRIGGER | Auto-add owner als team member bij nieuwe site creatie |
enforce_single_primary_domain() | TRIGGER | Max 1 primary domain per site |
get_site_storage_used(site_id) | BIGINT | Totaal opslag in bytes (excl. video) |
check_site_quota() | TRIGGER | Enforce max 10 actieve sites per owner op INSERT |
check_media_quota() | TRIGGER | Enforce 500 MB quota op INSERT |
count_media_references(media_id) | TABLE | Telt usage in pages + patterns (page_count, pattern_count) |
clear_media_references(media_id) | TABLE | Verwijdert media refs uit JSONB blocks |
clear_media_references_batch(ids[]) | TABLE | Batch variant |
delete_ghost_user(email) | VOID | Verwijdert ongebruikte Supabase auth users |
Public-site resolver RPCs (mig 074-078)
Section titled “Public-site resolver RPCs (mig 074-078)”Doel: anon Supabase clients kunnen sinds mig 074-078 niet meer direct sites, site_domains, site_settings, brand_profiles, menus, menu_items en events SELECT’en — voorheen kon iedereen via REST alle slugs en branding van klanten enumereren. Vervangen door SECURITY DEFINER functies die per call alleen het minimum aan velden teruggeven. Authenticated users vallen op team-policies, platform_admins op bypass policies (mig 080), anon krijgt alleen RPC-toegang.
Alle resolvers hebben SECURITY DEFINER, SET search_path = public, STABLE, LIMIT 1 (of N), filter op sites.deleted_at IS NULL. Granted aan anon + authenticated (na REVOKE ALL FROM public).
| Functie | Returns | Doel |
|---|---|---|
public_resolve_site_by_slug(text) | (site_id, slug) | Subdomain → site context (host middleware) |
public_resolve_site_by_domain(text) | (site_id, slug, is_primary) | Custom domain → site context |
public_primary_domain_for_slug(text) | text | Canonical URL voor <link rel="canonical"> |
public_resolve_site_chrome(uuid) | settings + homepage_slug (incl. block_ai_crawlers mig 082; site_name valt terug op sites.name via COALESCE sinds mig 085) | Site config voor public-site rendering |
public_resolve_brand(uuid) | (logo_url, logos jsonb) | Brand logo voor SiteHeader |
public_resolve_menu_items(uuid) | items + page joins | Header/footer menu rendering |
public_resolve_events_for_site(uuid) | events list (incl. price, price_donation, price_from mig 093) | Events archive (status IN published/sold_out/completed) |
public_resolve_event_by_slug(uuid, text) | single event + content_html (mig 084) + price-velden (mig 093) | Events detail page |
Pattern documentatie: .claude/rules/security.md — “Anon-SELECT via SECURITY DEFINER RPC only”.
RLS Policies
Section titled “RLS Policies”Alle tabellen hebben Row Level Security. Het kernpatroon is site-scoped isolatie via team_members.site_id:
-- Performance pattern: subquery wordt gecached per SQL statement(SELECT auth.uid())
-- Site isolation: alle queries zijn scoped naar sites waar de user lid van issite_id IN (SELECT get_user_site_ids((SELECT auth.uid())))
-- Rol-check per site(SELECT get_user_role((SELECT auth.uid()), site_id)) IN ('owner', 'admin', 'editor')Policies per tabel
Section titled “Policies per tabel”-- SELECT: published pagina's zijn publiek, rest alleen voor site teamCREATE POLICY "pages_select" ON pages FOR SELECT USING ( status = 'published' OR site_id IN (SELECT get_user_site_ids((SELECT auth.uid()))));
-- INSERT: editor+ op deze siteCREATE POLICY "pages_insert" ON pages FOR INSERT WITH CHECK ( (SELECT get_user_role((SELECT auth.uid()), site_id)) IN ('owner', 'admin', 'editor'));
-- UPDATE: editor+ op deze site-- DELETE: admin+ op deze site-- SELECT: site team membersCREATE POLICY "media_select" ON media FOR SELECT USING ( site_id IN (SELECT get_user_site_ids((SELECT auth.uid()))));
-- INSERT: editor+ op deze site-- UPDATE: editor+ op deze site-- DELETE: admin+ op deze siteOverzichtstabel
Section titled “Overzichtstabel”| Tabel | SELECT | INSERT | UPDATE | DELETE |
|---|---|---|---|---|
pages | Published: publiek. Rest: team | Editor+ (user_id = auth.uid) | Editor+ | Admin+ |
media | Team members | Editor+ (uploaded_by = auth.uid) | Editor+ | Admin+ |
patterns | Team members | Editor+ | Editor+ | Admin+ |
menus | Publiek (site-scoped, deleted_at IS NULL filter) + team members | Editor+ | Editor+ | Admin+ |
team_members | Team members | Owner/admin | Owner/admin | Owner/admin |
site_settings | Publiek (nodig voor homepage routing) | Team | Team | Team |
sites | Team members (deleted_at IS NULL) | — (trigger) | Owner/admin | Owner only (soft delete via API) |
brand_profiles | Team members | Team members | Team members | — |
token_versions | Team members | Team members | — | Team members |
site_domains | Team members | Owner/admin | Owner/admin | Owner/admin |
Bestandsopslag
Section titled “Bestandsopslag”Cloudflare R2 (MEDIA_BUCKET)
Section titled “Cloudflare R2 (MEDIA_BUCKET)”Primaire media opslag. Directe binding vanuit Workers (geen S3 API overhead).
{siteId}/ originals/{fileId}.{ext} # Origineel bestand thumbnails/{fileId}.webp # 400px WebP thumbnailSupabase Storage
Section titled “Supabase Storage”| Bucket | Zichtbaarheid | Doel |
|---|---|---|
avatars | Publiek | Profielfoto’s |
media | Publiek (10 MB limit) | Legacy — R2 is primair |
pattern-previews | Publiek | Pattern thumbnail screenshots |
Bunny Stream
Section titled “Bunny Stream”Video hosting extern via TUS resumable upload met presigned headers. Metadata in media tabel (bunny_video_id, bunny_library_id, bunny_status, duration). Niet meegeteld in site quota. Zie Bunny Stream integratie voor upload flow en encoding polling.