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.
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.
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 |
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 |
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.