Architectuur en multi-tenancy

Overzicht

                 ┌───────────────────────── Eén Next.js-applicatie (Vercel) ─────────────────────────┐
 bezoeker ──►    │ proxy.ts: domein → klant                                                           │
 klantdomein     │   noordlicht-installatie.nl ──► /sites/<klant>/…   (publieke website, blokken)      │
                 │                                                                                    │
 ondernemer ──►  │ /dashboard  (klantomgeving)   /m  (mobiele PWA)   /editor-preview  (bouwer)        │
 jij (eigenaar)► │ /admin      (platformbeheer – aparte rol, aparte layout)                            │
                 │                                                                                    │
                 │ Server actions ──► auth.ts (sessie, rol, pakket) ──► repo.ts scoped(tenantId)       │
                 │                    agent/ (beleid → runtime → Claude / demo)                         │
                 │                    integrations/ (connector-contract, kluis, retries)               │
                 └───────────────┬──────────────────────┬──────────────────────┬──────────────────────┘
                                 │                      │                      │
                      Supabase Postgres + RLS    Supabase Storage       Externe diensten
                      (één database, tenant_id)  (map per tenant)       Claude, Resend, HubSpot, Exact, …

Multi-tenant: één database, gedeelde tabellen, tenant_id overal

Onderzochte opties:

Model Voordelen Nadelen Oordeel
Eén database, gedeelde tabellen + tenant_id + RLS Goedkoop, één migratie voor iedereen, eenvoudig rapporteren Scheiding moet streng afgedwongen worden Gekozen voor alle standaardklanten
Schema per klant Iets sterkere scheiding Migraties × aantal klanten, lastig in Supabase Niet nodig
Database/project per klant Maximale scheiding, eigen regio/back-up Duur (Supabase rekent compute per project), beheer × N Alleen voor enterprise-klant die het contractueel eist

Hybride route: standaard gedeeld; een grote klant kan later een eigen Supabase-project krijgen. Omdat alle code via repo.ts loopt, verandert alleen de verbinding.

Hoe klantgegevens strikt gescheiden blijven

Laag Maatregel In de demo
Database Elke tabel heeft tenant_id; Row Level Security staat alleen rijen toe waar tenant_id = tenant van de ingelogde gebruiker (supabase/migrations/0001_init.sql). Nagebootst in scoped()
Queries Eén toegangspunt: scoped(tenantId). Lezen filtert, wijzigen/verwijderen weigert records van anderen (NotFound), invoegen stempelt de tenant. ✅ + tests
API / server actions tenantId komt uit de server-side sessie, nooit uit formulier of URL. Elke actie controleert rol en pakket opnieuw. ✅
Bestanden Upload-map per tenant; pad gevalideerd; productie: Storage-policy op tenant_id/…. ✅
Achtergrondtaken Elke taak krijgt expliciet een tenantId mee en werkt via scoped(); nooit "alle klanten tegelijk" zonder platformrol. ✅ (after() bij formulieren)
AI-context Agent-tools zijn gesloten over de scope van de klant; het model kan geen tenantId kiezen. Alleen goedgekeurde kennisbronnen van díe klant gaan mee. Promptcache per klant. ✅
Integraties Tokens versleuteld per koppeling (AES-256-GCM, vault.ts), nooit naar browser of AI. Code klaar, demo zonder echte tokens
Platformbeheer Aparte rol platform_admin, aparte layout en aparte functies (platform.ts); kan geen tokens inzien. ✅
Logging Audit-log met tenant_id; platformbeheer ziet alles, klant alleen eigen regels. ✅

Websitebouwer

  • Een pagina = een lijst blokken ({ id, type, props }) als JSON; draft en published gescheiden.
  • Publiceren kopieert draft → published en bewaart een versie (max. 20); terugzetten zet een versie terug als concept.
  • Bloktypes staan in één schema (lib/blocks.ts); de editor bouwt zijn formulieren daaruit op. Nieuw bloktype = schema + render-component.
  • De preview draait in een iframe met dezelfde componenten als de live site, zodat responsive gedrag klopt; wijzigingen gaan live via postMessage (alleen same-origin).
  • Server-side validatie: alleen bekende bloktypes, geen javascript:-links, alleen geüploade of https-afbeeldingen.

Uitbreidingspad: kolommen/secties met kinderen (geneste blokken), globale blokken (header/footer per klant), meertaligheid (props.nl, props.en), A/B-varianten, gedeelde blokbibliotheek per branche, en eventueel een vrij canvas met @dnd-kit voor drag-and-drop binnen kolommen.

Overstappen, exporteren, loskoppelen

  • Exporteren: /api/export levert alle gegevens van de klant als JSON (pagina's, diensten, aanvragen, offertes, klanten, berichten, AI-log, koppelingstatus) – zonder tokens. Uitbreiding: CSV per tabel en een statische HTML-export van de website.
  • Loskoppelen: koppeling verbreken verwijdert tokens; bij de leverancier trekt de klant de toestemming in (OAuth-app). Logregel wordt bewaard.
  • Opzeggen: status opgezegd → site offline, data 30 dagen bewaard voor export, daarna definitief verwijderd (vastleggen in verwerkersovereenkomst).
  • Domein: blijft eigendom van de klant; alleen een CNAME-record wijst naar het platform.

Eigen domein koppelen (productie)

  1. Klant (of jij) voegt het domein toe aan het Vercel-project (later automatisch via de Vercel API).
  2. DNS: www → CNAME cname.vercel-dns.com; apex-domein → A-record volgens Vercel.
  3. Vercel regelt SSL automatisch.
  4. tenants.domain invullen; proxy.ts herkent het domein en toont de juiste site.
  5. Voorbeeldlink vóór livegang: <slug>.<NEXT_PUBLIC_ROOT_DOMAIN> (wildcard-domein op Vercel).