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.

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.

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

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.