Datamodel
Status: levend document. Laatst bijgewerkt: augustus 2026. Schema-definities leven in
packages/db/src/schema.ts. Dit document beschrijft de bedoeling; bij afwijking wint de code, maar werk dan dit document bij.
Conventies
Section titled “Conventies”- Primary keys: UUID.
- Elke tabel heeft
created_atenupdated_at(timestamptz). - Tabelnamen meervoud, snake_case. Kolomnamen snake_case.
- Gereserveerde woorden in Postgres vermijden. Daarom
testimonialsin plaats vanreferences, ensort_orderin plaats vanorder. Quoten is geen oplossing — hernoemen. - Soft delete alleen waar het echt nodig is; standaard hard delete met audit-log-entry.
- Alle jsonb-kolommen worden gevalideerd met een Zod-schema uit
@platform/contract, bij schrijven én bij lezen.
Tenant-database (één per klant)
Section titled “Tenant-database (één per klant)”Singleton: precies één rij per database.
| Kolom | Type | Toelichting |
|---|---|---|
id |
uuid | |
tenant_id |
text | Komt overeen met de slug in de master-database |
name |
text | Bedrijfsnaam van de klant |
default_locale |
text | Nu altijd nl |
theme |
jsonb | Design tokens, zie onder |
seo_defaults |
jsonb | Titel-template, standaard beschrijving, OG-afbeelding |
| Kolom | Type | Toelichting |
|---|---|---|
id |
uuid | |
slug |
text, uniek | home, over-ons, contact |
title |
text | |
status |
enum | draft / published |
blocks |
jsonb | Array van blokken |
seo |
jsonb | Overschrijft seo_defaults |
published_at |
timestamptz, null |
services
Section titled “services”id, slug (uniek), title, excerpt, blocks (jsonb), image_id → media, sort_order (int), status (enum).
id, slug (uniek), title, excerpt, blocks (jsonb), image_id → media, status (enum), published_at.
testimonials
Section titled “testimonials”id, client_name, quote, logo_id → media, rating (int, null), sort_order.
id, storage_key (pad in R2/B2), mime, width, height, alt, size_bytes.
alt is verplicht bij upload/bewerken in het CMS (mediaAltSchema / mediaWriteSchema) — afdwingen in de CMS-laag, niet in de database. Publieke URL = S3_PUBLIC_URL + / + storage_key (site-relatieve keys zoals /demo/... blijven pad-URL’s voor seed-assets).
navigation
Section titled “navigation”id, label, url, parent_id (self-referentieel, null), sort_order, location (enum: header / footer).
settings
Section titled “settings”id, key (uniek), value (jsonb). Voor losse configuratie die geen eigen tabel rechtvaardigt: contactgegevens, social links, formulier-ontvanger.
CMS-instellingen (taak 1.8) bewerken zowel de site-singleton (name, theme, seo_defaults) als bekende settings-keys (nu: form_recipient).
form_submissions
Section titled “form_submissions”id, form_type, payload (jsonb), is_read (bool).
audit_log
Section titled “audit_log”id, actor_id, action, entity, entity_id, metadata (jsonb). Gevuld door het CMS bij elke mutatie. Voedt later de activiteitenweergave in het master dashboard.
webhook_outbox
Section titled “webhook_outbox”id, event_id (uniek), envelope (jsonb, PlatformEventEnvelope), attempt_count, next_attempt_at, last_error. Uitgaande HMAC-webhooks naar de master. De rij blijft tot een 2xx of 4xx, of tot hij ouder is dan 24 uur. Retry herberekent timestamp en HMAC.
Auth-tabellen (Better Auth)
Section titled “Auth-tabellen (Better Auth)”Meervoudige tabelnamen via usePlural in de Drizzle-adapter. Kolommen snake_case.
| Tabel | Kolommen |
|---|---|
users |
id, name, email, email_verified, image, created_at, updated_at, plus admin-plugin: role (admin | editor), banned, ban_reason, ban_expires |
sessions |
id, expires_at, token, created_at, updated_at, ip_address, user_agent, user_id → users, impersonated_by |
accounts |
id, account_id, provider_id, user_id → users, tokens, password, timestamps |
verifications |
id, identifier, value, expires_at, timestamps |
Rollen: cmsRoleSchema in @platform/contract (admin = beheerder, editor = redacteur). Publieke signup staat uit; users komen uit seed of later CMS.
Master-database (één centraal)
Section titled “Master-database (één centraal)”Schema in packages/db/src/master/schema.ts, migraties in
packages/db/drizzle-master/. Aparte drizzle-config (drizzle.master.config.ts)
en aparte connectiestring (MASTER_DATABASE_URL), zodat een tenant-migratie
nooit master-tabellen kan bevatten en omgekeerd. Import via
@platform/db/master — niet via @platform/db, want de auth-tabellen heten aan
beide kanten hetzelfde.
| Tabel | Kolommen |
|---|---|
tenants |
id, slug (uniek), name, status, coolify_app_uuid, database_url_encrypted, webhook_secret_encrypted, platform_api_key_encrypted, better_auth_secret_encrypted, timestamps |
domains |
id, tenant_id → tenants, hostname (uniek), type (preview/custom), cf_hostname_id, ssl_status, verified_at, cf_verification (jsonb, nullable) |
deployments |
id, tenant_id, status, commit_sha, triggered_at, finished_at |
activity |
id, tenant_id (nullable), type, payload (jsonb), occurred_at |
site_stats |
id, tenant_id (uniek), page_count, last_activity_at, umami_website_id, collected_at |
webhook_receipts |
event_id (pk, envelope-UUID), tenant_id (nullable FK tenants), received_at |
migration_runs |
id, started_at, finished_at, target_tag, target_count, triggered_by_email, applied_count, up_to_date_count, skipped_count, failed_count, results (jsonb) |
| Auth | users, sessions, accounts, verifications (Better Auth) |
cf_verification is het laatst bekende Cloudflare-snapshot in
platformvocabulaire (domainVerificationSchema: records + errors).
Alleen Cloudflare-afkomstige data — de routing-CNAME komt uit
NUXT_CF_CNAME_TARGET bij lezen. Null zolang cf_hostname_id null is
(preview, of custom nog niet bij Cloudflare).
Enums: tenant_status (provisioning/active/suspended/failed),
domain_type, ssl_status (pending/active/failed), deployment_status
(queued/running/success/failed). Zod-equivalenten in
@platform/contract (platform.ts); dat blijft de bron voor validatie.
Geen admin_users-tabel. De Better Auth users-tabel is de
beheerderstabel. Ook geen role-kolom en geen admin-plugin: iedereen met een
account in de master is beheerder, dus er is geen tweede rol om tegen te
vergelijken. Een read-only rol later is één migratie.
database_url_encrypted en de andere *_encrypted kolommen bevatten
ciphertext, nooit een bruikbare waarde — de naam dwingt dat af. AES-256-GCM
met een sleutel uit NUXT_SECRETS_KEY, formaat v1.<iv>.<tag>.<ciphertext>
(base64url). Versleutelen en ontsleutelen gebeurt in
apps/master-dashboard/server/utils/secrets.ts, niet in @platform/db: dat
package levert schema, connectie en migraties en verder niets. Null op
webhook_secret_encrypted betekent dat de webhook-ontvanger nog het gedeelde
NUXT_PLATFORM_WEBHOOK_SECRET gebruikt.
activity.type is text en geen enum: die waardes komen uit de webhook-events
van taak 2.2/2.3. tenant_id is daar nullable voor gebeurtenissen zonder tenant.
Indexen op (tenant_id, occurred_at desc) en (tenant_id, triggered_at desc)
voor de activiteiten- en deployweergave.
webhook_receipts is de idempotentie-tabel voor POST /api/webhooks/site.
event_id is de UUID uit de envelope; een tweede POST met dezelfde id schrijft
geen tweede activity-rij. tenant_id wordt gezet als tenants.slug bestaat,
anders null. Als een tenant later via het dashboard wordt geregistreerd, koppelt
dezelfde transactie eerdere activity- en webhook_receipts-rijen via
activity.payload.tenantSlug en vult site_stats.last_activity_at met het
nieuwste gekoppelde event.
migration_runs is de historie van de migratie-runner (2.8): één rij per run,
geschreven ná de laatste tenant, dus nooit een blijvende “running”-rij.
results bevat per tenant slug, outcome (applied / up_to_date /
skipped / failed), appliedTags, reason, detail, error en
durationMs, gevalideerd met tenantMigrationResultSchema bij schrijven en
lezen. De schemastand zelf staat niet in de master: die wordt live gelezen uit
drizzle.__drizzle_migrations van elke tenant, zodat een docker exec migrate.mjs buiten het dashboard om geen drift veroorzaakt.
Theme (jsonb op site.theme)
Section titled “Theme (jsonb op site.theme)”Zod-schema themeSchema in @platform/contract. Elk veld heeft een default, zodat een nieuwe tenant zonder configuratie een neutrale, werkende huisstijl heeft.
theme = { colors: { primary, secondary, accent, background, foreground, muted, border, primaryForeground, secondaryForeground, accentForeground, mutedForeground } typography: { fontHeading, fontBody, fontHeadingUrl?, fontBodyUrl?, scale } radii: { sm, md, lg, full } spacing: { base } logo: { mediaId, mediaIdDark? }}Afgeleide *Foreground-tokens zijn verplicht (met defaults), zodat knoppen/vlakken geen hardcoded text-white nodig hebben. Optionele webfont-URL’s wijzen naar externe stylesheets; self-hosted fonts volgen later. Wordt via themeToCssVars() omgezet naar CSS custom properties. Zie regel 1 in Architectuur.
Blokken (jsonb in blocks-kolommen)
Section titled “Blokken (jsonb in blocks-kolommen)”Een blocks-waarde is een array van blokobjecten:
[ { "id": "…", "type": "hero", "props": { … } }, { "id": "…", "type": "text", "props": { … } }, { "id": "…", "type": "cardGrid", "props": { … } }]idis stabiel per blok-instantie, zodat de pagebuilder later kan herordenen zonder identiteit te verliezen.typeis de discriminator van de union in@platform/contract.propswordt gevalideerd tegen het Zod-schema van dat bloktype.
Een nieuw blok toevoegen betekent altijd drie dingen tegelijk: een Zod-schema, een renderer, en een registry-entry. Nooit een van de drie los.
Blokken zijn stijl-agnostisch: ze bevatten geen kleur- of fontwaarden en halen hun vormgeving uit de tokens van de tenant.
Migraties
Section titled “Migraties”Gegenereerd met Drizzle Kit en gecommit in de repo. Nooit schema-wijzigingen op runtime afleiden.
Bij database-per-tenant itereert de migratie-runner in het master dashboard (/migrations, taak 2.8) over alle tenant-databases waarvan de DSN in tenants.database_url_encrypted staat. Hij rapporteert per database (up_to_date, pending, blocked, unreachable, no_database), past sequentieel toe en stopt niet bij één fout. Tenant-migraties zijn additief (nieuwe tabellen, nullable kolommen): ze draaien vóór de site-uitrol, dus de draaiende site-versie moet ermee werken. Drops en renames pas in een volgende release, nadat alle sites bij zijn.
Backups: per tenant-database én media-prefix, weggeschreven naar object storage. Procedure en bewaartermijn: Backups + Operations. Restore-rehearsal vóór de eerste echte klant: zie de restore-rehearsal van 12 augustus 2026.