Ga naar inhoud

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.

  • Primary keys: UUID.
  • Elke tabel heeft created_at en updated_at (timestamptz).
  • Tabelnamen meervoud, snake_case. Kolomnamen snake_case.
  • Gereserveerde woorden in Postgres vermijden. Daarom testimonials in plaats van references, en sort_order in plaats van order. 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.

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

id, slug (uniek), title, excerpt, blocks (jsonb), image_idmedia, sort_order (int), status (enum).

id, slug (uniek), title, excerpt, blocks (jsonb), image_idmedia, status (enum), published_at.

id, client_name, quote, logo_idmedia, 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).

id, label, url, parent_id (self-referentieel, null), sort_order, location (enum: header / footer).

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

id, form_type, payload (jsonb), is_read (bool).

id, actor_id, action, entity, entity_id, metadata (jsonb). Gevuld door het CMS bij elke mutatie. Voedt later de activiteitenweergave in het master dashboard.

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.

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_idusers, impersonated_by
accounts id, account_id, provider_id, user_idusers, 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.

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

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.

Een blocks-waarde is een array van blokobjecten:

[
{ "id": "…", "type": "hero", "props": { … } },
{ "id": "…", "type": "text", "props": { … } },
{ "id": "…", "type": "cardGrid", "props": { … } }
]
  • id is stabiel per blok-instantie, zodat de pagebuilder later kan herordenen zonder identiteit te verliezen.
  • type is de discriminator van de union in @platform/contract.
  • props wordt 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.

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.