Skip to content

Data & Architectuur

KolomTypeVerplichtToelichting
idUUIDPKSupabase auth identifier
emailtextJaLogin email
created_attimestamptzJaRegistratiedatum
KolomTypeVerplichtToelichting
idUUIDPK, FK → auth.users1:1 met auth user. CASCADE DELETE
emailtextJaGedupliceerd voor snelle lookups
first_nametextNeeVoornaam
last_nametextNeeAchternaam
avatar_urltextNeeURL naar Supabase Storage (avatars bucket)
created_attimestamptzJaAuto-generated
updated_attimestamptzJaAuto-updated via trigger
KolomTypeVerplichtToelichting
idUUIDPK
site_idUUIDFK → sitesSite waartoe dit lidmaatschap behoort
user_idUUIDFK → auth.usersHet teamlid
roletextJaowner | admin | editor | viewer
created_attimestamptzJa

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.

KolomTypeVerplichtToelichting
idUUIDPK
site_idUUIDFK → sitesSite waarvoor uitgenodigd
emailtextJaLowercase email van genodigde
roletextJaadmin | editor | viewer (niet owner)
statustextJapending | accepted | revoked | expired
tokenUUIDJa, UNIQUEMagic link token
expires_attimestamptzJa7 dagen na creatie
accepted_byUUIDNeeFK → auth.users. Gezet bij acceptatie
responded_attimestamptzNeeDatum van accept/decline
created_attimestamptzJa
updated_attimestamptzJa
KolomTypeVerplichtToelichting
idUUIDPK
owner_idUUIDFK → auth.usersMeerdere sites per owner (max 10). CASCADE DELETE
nametextJaDefault: “Mijn website”
slugtextUNIQUEAuto-gegenereerd uit naam (lowercase, hyphens)
deleted_attimestamptzNeeSoft delete timestamp. NULL = actief, gevuld = verwijderd
created_attimestamptzJa
updated_attimestamptzJa

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

KolomTypeVerplichtToelichting
idUUIDPK
site_idUUIDFK → sites
domaintextUNIQUEVolledig domein (bijv. example.com)
is_primarybooleanJaMax 1 per site (trigger enforced)
statustextJapending | active | error
cloudflare_hostname_idtextNeeCF Pages Custom Domain ID
created_attimestamptzJa
updated_attimestamptzJa

Trigger: enforce_single_primary_domain() — voorkomt meerdere primary domeinen per site.

KolomTypeVerplichtToelichting
idUUIDPK
user_idUUIDFK → auth.usersOwner
site_idUUIDFK → sitesSite scope
homepage_page_idUUIDNeeFK → pages. Default homepage
header_menu_idUUIDNeeFK → menus
footer_menu_idUUIDNeeFK → menus
header_nav_typetextNeelogo-menu-button (Logo—Menu—CTA) of menu-logo-menu (Menu—Logo—Menu). Default: logo-menu-button. CHECK constraint
header_menu_left_idUUIDNeeFK → menus. Linker menu bij menu-logo-menu layout
header_button_texttextNeeCTA button tekst in header
header_button_urltextNeeCTA button URL in header
header_designJSONBNeeHeader design config — zie HeaderDesign interface hieronder
favicon_urltextNee
apple_touch_icon_urltextNee
og_image_urltextNeeDefault social image
site_nametextNee
site_titletextNee
site_descriptiontextNee
site_languagetextJaDefault: nl
block_search_enginesbooleanJaDefault: 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):

VeldTypeDefaultBeschrijving
bgColorstring#ffffffAchtergrondkleur (hex)
bgOpacitynumber100Achtergrond transparantie (0-100)
bgBlurbooleanfalseGlassmorphism blur effect
borderEnabledbooleantrueOnderrand tonen
borderColorstring#e5e7ebRand kleur (hex)
shadowstringnonenone / subtle / medium
positionstringstickysticky / static / fixed
heightstringdefaultcompact (48px) / default (64px) / tall (80px)
hideOnScrollbooleanfalseVerberg bij scrollen, toon bij omhoog scrollen
transparentOnHerobooleanfalseTransparant bovenaan als eerste block een bg afbeelding heeft
selectedLogoIdstringID van geselecteerd logo uit brand_profiles.logos
buttonStylestringsolidCTA button stijl (solid / outline / transparent)
buttonColorstring#111827CTA button kleur
buttonColorTokenstringBrand kleur token (bijv. primary, accent)
menuFontSizenumber14Menu items font-size (px)
menuFontWeightnumber500Menu items font-weight
menuLetterSpacingnumber0Menu items letter-spacing (em)
logoFontSizenumber18Logo tekst font-size (px)
logoFontWeightnumber600Logo tekst font-weight
buttonFontSizenumber14CTA button font-size (px)
buttonFontWeightnumber500CTA button font-weight
KolomTypeVerplichtToelichting
idUUIDPK
site_idUUIDFK → sites, UNIQUE1 profiel per site. CASCADE DELETE
onboarding_dataJSONBNeeBrand 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_tokensJSONBJaKleuren, fonts, radius, spacing, shadow. Default: {}
mood_presettextNeeActieve sfeer-preset ID (bijv. warm-aards)
logo_urltextNeeURL naar het merklogo (R2 of extern)
created_attimestamptzJa
updated_attimestamptzJaAuto-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):

VeldTypeDoel
companyNamestringBedrijfsnaam
sloganstringTagline
descriptionstringKorte bedrijfsbeschrijving
industrystringBranche
targetAudiencestringDoelgroep beschrijving
differentiatorstringOnderscheidend vermogen
primaryCtastringPrimaire call-to-action
secondaryCtastringSecundaire call-to-action
businessType'b2b' | 'b2c' | 'both'Type klant
regionstringRegio/markt
foundedYearstringOprichtingsjaar
missionstringMissie
visionstringVisie
coreValuesstring[] (max 6)Kernwaarden
addressForm'je' | 'u' | 'jullie'Aanspreekvorm
toneFormalnumber (0-1)Slider: informeel ↔ formeel
toneSeriousnumber (0-1)Slider: speels ↔ serieus
toneTechnicalnumber (0-1)Slider: toegankelijk ↔ technisch
toneDescriptionstringVrije tone beschrijving
forbiddenWordsstring[] (max 10)Woorden die AI niet mag gebruiken
emailstringContactgegevens
phonestringContactgegevens
addressstringContactgegevens

Alle velden zijn optioneel. Lege velden worden overgeslagen in de AI-context. Runtime validatie via parseBrandStory() in @beam/shared.

KolomTypeVerplichtToelichting
idUUIDPK
site_idUUIDFK → sitesCASCADE DELETE
design_tokensJSONBJaSnapshot van design_tokens op dat moment
labeltextNeeNaam voor handmatige snapshots
autobooleanJaTRUE = auto-save bij wijziging, FALSE = named snapshot
created_byUUIDFK → auth.usersWie de wijziging maakte
created_attimestamptzJa

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

KolomTypeVerplichtToelichting
idUUIDPK
site_idUUIDFK → sites, NOT NULLTeam-scope
user_idUUIDFK → auth.usersCreator
titletextJaPaginatitel
slugtextUNIQUE(site_id, slug)URL path. Regex: ^[a-z0-9]([a-z0-9-]{1,61}[a-z0-9])?$. Uniek per site
blocksJSONBJaArray van Block objecten. Default: []
block_countintegerGeneratedAutomatisch berekend uit blocks array
statustextJadraft | published
meta_titletextNeeSEO title (overschrijft title)
meta_descriptiontextNeeSEO description
og_imagetextNeeSocial media preview image
hide_headerbooleanJaDefault: false
hide_footerbooleanJaDefault: false
no_indexbooleanJaDefault: false. Verbergt pagina voor zoekmachines
created_attimestamptzJa
updated_attimestamptzJa

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

KolomTypeVerplichtToelichting
idUUIDPK
nametextJaMenu naam (bijv. “Header”, “Footer”)
user_idUUIDFK → auth.usersOwner
site_idUUIDFK → sites, NOT NULLSite scope
KolomTypeVerplichtToelichting
idUUIDPK
menu_idUUIDFK → menusParent menu
page_idUUIDNeeFK → pages. Linkt naar interne pagina
labeltextJaWeergavenaam
urltextNeeExterne URL (als page_id null)
parent_idUUIDNeeFK → menu_items (self-referencing tree)
positionintegerJaVolgorde binnen parent
KolomTypeVerplichtToelichting
idUUIDPK
site_idUUIDFK → sitesTeam-scope
nametextJaPattern naam
descriptiontextNee
scopetextNeeScope type
scope_ref_idUUIDNeeReferentie naar scope object
blocks_dataJSONBJaArray van Block objecten
is_syncedbooleanJaOf propagatie actief is
categorytextNeeCategorisering
statustextNee
created_byUUIDFK → auth.usersCreator
block_countintegerNeeAantal blocks
created_attimestamptzJa
updated_attimestamptzJa
KolomTypeVerplichtToelichting
idUUIDPK
pattern_idUUIDFK → patternsBron pattern
page_idUUIDFK → pagesPagina waar pattern gebruikt wordt
positionintegerNeePositie in pagina
overridesJSONBNeeLokale aanpassingen
KolomTypeVerplichtToelichting
idUUIDPK
pattern_idUUIDFK → patterns, UNIQUE1 preview per pattern
thumbnail_urltextJaURL naar Supabase Storage
generated_attimestamptzJaLaatst gegenereerd
KolomTypeVerplichtToelichting
idUUIDPK
site_idUUIDFK → sites, NOT NULLTeam-scope. Telt mee voor quota
typetextJaimage | document
filenametextJaOriginele bestandsnaam
mime_typetextJaMIME type (bijv. image/jpeg)
file_sizeintegerJaGrootte in bytes
storage_pathtextJaR2 pad: {siteId}/originals/{fileId}.{ext}
thumbnail_pathtextNeeR2 pad: {siteId}/thumbnails/{fileId}.webp
widthintegerNeeAlleen voor afbeeldingen
heightintegerNeeAlleen voor afbeeldingen
alt_texttextNeeToegankelijkheid
titletextNee
sourcetextJaupload | external
external_urltextNeeAlleen bij source: external
file_hashtextNeeSHA-256 hash voor deduplicatie
blur_data_urltextNeeBase64 LQIP (20px blur)
uploaded_byUUIDFK → auth.usersUploader
bunny_video_idtextNeeBunny Stream video ID
bunny_library_idtextNeeBunny Stream library ID
bunny_statusintegerNee0-5 encoding status
durationfloatNeeVideo duur in seconden
created_attimestamptzJa
updated_attimestamptzJa

Quota trigger: check_media_quota() — blokkeert INSERT als get_site_storage_used(site_id) + file_size > 500MB.

KolomTypeVerplichtToelichting
idUUIDPK
site_idUUIDFK → sitesTeam-scope
nametextJaTag naam

Index: idx_media_tags_site_name — UNIQUE op (site_id, LOWER(name)). Case-insensitive per site.

KolomTypeVerplichtToelichting
media_idUUIDFK → media
tag_idUUIDFK → media_tags

PK: (media_id, tag_id) — composite primary key.

KolomTypeVerplichtToelichting
idUUIDPK
site_idUUIDFK → sites, NOT NULLPer-site activatie
addon_idtextJaStringid uit code-registry (bv. 'events'). Geen FK — registry is source of truth
enabledbooleanJatrue = actief, false = soft-deactivated
installed_viatextNeeHoe is geactiveerd ('manual', 'recommendation', etc.)
specializationtextNeeOptionele variant (bv. 'yoga' voor events)
configjsonbNeeRuntime instellingen, addon-specifiek
created_attimestamptzJa
updated_attimestamptzJa

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.

KolomTypeVerplichtToelichting
idUUIDPK
site_idUUIDFK → sites, NOT NULLSite-scope
titletextJamax 200 chars
slugtextJaunique per site, lowercase + hyphens (regex check)
descriptiontextNeemax 500 chars, plain text — fungeert als lead-tekst intro op de detail-pagina
content_htmltextNeeDOMPurify-sanitized HTML uit Tiptap WYSIWYG (mig 083). Lange omschrijving, geen length-cap. Lege editor wordt als null opgeslagen.
pricetextNeeCanonieke 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_donationbooleanJaNOT NULL DEFAULT false (mig 093). true = “Op donatiebasis” tonen i.p.v. een bedrag; price is dan null.
price_frombooleanJaNOT NULL DEFAULT false (mig 093). true = bedrag als “Vanaf €X” tonen. Alleen relevant bij een numerieke price.
start_attimestamptzJaISO 8601 UTC
end_attimestamptzNeeOptioneel; geen DB-constraint dat eind > start (frontend valideert)
locationjsonbJa{type: 'physical'|'online'|'hybrid', venue_name?, address?, online_url?}
statustextJadraft|published|sold_out|completed (CHECK constraint)
featured_image_urltextNeemax 2048 chars (CHECK constraint, mig 072)
cta_urltextNeemax 2048 chars, extern (mig 079)
cta_labeltextNeemax 60 chars (mig 079)
created_byUUIDFK → auth.users, NOT NULLMaker
created_attimestamptzJa
updated_attimestamptzJaAuto 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)
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)
FunctieReturnsDoel
get_user_site_ids(user_id)SETOF UUIDAlle site_ids waar een user lid van is. Kern van RLS policies
get_user_role(user_id, site_id)TEXTRol op specifieke site: owner/admin/editor/viewer/NULL
get_team_owner_id(user_id)UUIDDeprecated. Backward compat: owner_id via JOIN. Vervangen door get_user_site_ids() voor site-scoped queries
are_teammates(user_a, user_b)BOOLEANDelen twee users een site? (via site_id join)
is_reserved_slug(slug)BOOLEANCheck tegen reserved slugs (app, admin, www, api, etc.)
ensure_owner_team_member(site_id)VOIDSafety 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()TRIGGERAuto-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()TRIGGERAuto-add owner als team member bij nieuwe site creatie
enforce_single_primary_domain()TRIGGERMax 1 primary domain per site
get_site_storage_used(site_id)BIGINTTotaal opslag in bytes (excl. video)
check_site_quota()TRIGGEREnforce max 10 actieve sites per owner op INSERT
check_media_quota()TRIGGEREnforce 500 MB quota op INSERT
count_media_references(media_id)TABLETelt usage in pages + patterns (page_count, pattern_count)
clear_media_references(media_id)TABLEVerwijdert media refs uit JSONB blocks
clear_media_references_batch(ids[])TABLEBatch variant
delete_ghost_user(email)VOIDVerwijdert ongebruikte Supabase auth users

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

FunctieReturnsDoel
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)textCanonical 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 joinsHeader/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”.

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 is
site_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')
-- SELECT: published pagina's zijn publiek, rest alleen voor site team
CREATE 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 site
CREATE 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 members
CREATE 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 site
TabelSELECTINSERTUPDATEDELETE
pagesPublished: publiek. Rest: teamEditor+ (user_id = auth.uid)Editor+Admin+
mediaTeam membersEditor+ (uploaded_by = auth.uid)Editor+Admin+
patternsTeam membersEditor+Editor+Admin+
menusPubliek (site-scoped, deleted_at IS NULL filter) + team membersEditor+Editor+Admin+
team_membersTeam membersOwner/adminOwner/adminOwner/admin
site_settingsPubliek (nodig voor homepage routing)TeamTeamTeam
sitesTeam members (deleted_at IS NULL)— (trigger)Owner/adminOwner only (soft delete via API)
brand_profilesTeam membersTeam membersTeam members
token_versionsTeam membersTeam membersTeam members
site_domainsTeam membersOwner/adminOwner/adminOwner/admin

Primaire media opslag. Directe binding vanuit Workers (geen S3 API overhead).

{siteId}/
originals/{fileId}.{ext} # Origineel bestand
thumbnails/{fileId}.webp # 400px WebP thumbnail
BucketZichtbaarheidDoel
avatarsPubliekProfielfoto’s
mediaPubliek (10 MB limit)Legacy — R2 is primair
pattern-previewsPubliekPattern thumbnail screenshots

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.