Taak 2.4 — Klantenbeheer: overzicht, aanmaken, detail, activiteit
Status: uitgevoerd, 3 september 2026, na merge van 2.3. Scope: tenants-CRUD in het master dashboard, overzicht + detail met status en activiteitfeed. Geen Coolify/Postgres/env (2.5), geen preview-URL’s of DNS (2.6), geen custom domains (2.7), geen per-tenant webhook-secrets, geen health-poll naar
/api/platform/health, geen/stats-pull, geen Coolify-deploy van de master. Wijkt de uitvoering af, werk dit document bij en noteer de afwijking bij 2.4 in Taken.
Uitgangspunt op main
Section titled “Uitgangspunt op main”Dit is al gebouwd, niet opnieuw te beslissen:
- Master-schema:
tenants,domains,deployments,activity,site_stats,webhook_receipts. Geen extra tabel voor deze taak. - Contract:
platformTenantSchema(leesvorm, zonder encrypted database-URL),platformTenantWriteSchema(slug+name),tenantStatusSchema=provisioning|active|suspended|failed.degradedstaat niet in die enum. POST /api/webhooks/siteschrijftactivityen, alstenants.slugbestaat,tenant_idplussite_stats.last_activity_at. Zonder rij blijftactivity.tenant_idnull;payload.tenantSlugblijft bewaard zodat 2.4 kan backfillen.- Auth:
requireAdminSessionop data-routes. Vue-middleware is navigatie.POST /api/webhooks/siteblijft HMAC-only. - UI: Tailwind 4, geen
@nuxt/ui(TS-pin, besloten in 2.1).index.vueis een placeholder. Harde regel 1 geldt hier niet: gewone utilities (bg-slate-900, …). tenants/*.jsonblijft de bootstrap-input voor@platform/opstot 2.5 die uit de master-rij kan genereren. Deze taak verwijdert die files niet en importeert ze niet automatisch.
Contract v0 blijft richting. Waar hij botst met de code op main, wint de code en wordt het contract bijgewerkt.
Vastgestelde versies
Section titled “Vastgestelde versies”Geen nieuwe packages. Pins blijven die van 2.1–2.3, afgelezen uit de
package.json op main (niet opnieuw via npm view gezet, want er is
niets te scaffolden):
| Pakket | Pin | Waar |
|---|---|---|
nuxt |
4.5.2 | apps/master-dashboard |
vue / vue-router |
3.5.41 / 5.2.0 | master-dashboard |
better-auth |
1.6.26 | master + @platform/db |
drizzle-orm |
0.45.2 | @platform/db |
zod |
workspace @platform/contract |
write/read-schema’s |
tailwindcss |
4.3.3 | geen UI-kit erbij |
Testen zoals 2.2/2.3: node --test naast helpers, geen testrunner erbij.
Data shapes
Section titled “Data shapes”Drie vormen die de API teruggeeft. Encrypted URL, secrets en interne FKs naar ciphertext komen nooit in een response.
1. List item. platformTenantSchema plus afgeleide velden die 2.4
introduceert in het contract (één schema, niet lokaal in de app):
| Veld | Bron |
|---|---|
id, slug, name, status, coolifyAppUuid, createdAt |
tenants |
lastActivityAt |
site_stats.last_activity_at, anders null |
attention |
afgeleid, zie keuzes |
Geen pageCount op de lijst (die blijft 0 tot een /stats-pull of
fase 4). Geen domeinen op de lijst.
2. Detail. List item plus:
domains[]viaplatformDomainSchema(leeg tot 2.6/2.7)deployments[]viaplatformDeploymentSchema, nieuwste eerst, cap 20 (leeg tot 2.5)activity[]viaplatformActivitySchema, nieuwste eerst, cap 50stats:platformSiteStatsSchemaof null
3. Write. Bestaande platformTenantWriteSchema: slug + name.
Aanmaken zet status = provisioning, coolify_app_uuid null,
database_url_encrypted null. Optioneel later in dezelfde taak:
platformTenantStatusWriteSchema met alleen status in
{ active, suspended, failed } — niet provisioning (dat is de
startwaarde) en niet een verzonnen degraded.
Keuzes, met reden
Section titled “Keuzes, met reden”Aanmaken is een ledger-rij, geen provisioning. De taak heet
klantenbeheer, niet 2.5. Een POST maakt een tenants-rij. Er gaat geen
CREATE DATABASE, geen Coolify-app, geen env, geen bootstrap. Status
start op provisioning zodat 2.5 een ondubbelzinnige wachtrij heeft:
rijen zonder coolify_app_uuid in provisioning zijn het werk.
Verworpen: aanmaken blokkeren tot 2.5 (dan heeft 2.4 geen write-pad).
Verworpen: in 2.4 stiekem provision-tenant-db.sh aanroepen (andere
taak, andere secrets, andere failure-modes).
Bestaande live sites (pilot, rb-media) komen niet uit tenants/*.json
deze taak. Die JSON is content-bootstrap, geen master-ledger. De
operator maakt ze aan via het formulier (slug = TENANT_ID) of, voor
bewijs, via curl met sessiecookie. Geen seed-script, geen nieuwe
dependency.
Orphan-activity backfill bij insert. Webhooks van vóór de rij hebben
tenant_id null en payload.tenantSlug. In dezelfde transactie als de
insert:
update activity set tenant_id = :id where tenant_id is null and payload->>'tenantSlug' = :slugupdate webhook_receiptshetzelfde optenant_idmax(occurred_at)van die rijen → upsertsite_stats.last_activity_at
Zonder deze stap blijft de detailpagina leeg voor een slug die al events
stuurde. on conflict slug is 409, geen tweede rij.
degraded is UI-aandacht, geen vijfde enum-waarde. CONTRACT.md:
“geen events én geen geslaagde healthcheck gedurende 24 uur → degraded
in het dashboard.” Healthcheck naar de site vraagt een hostname (2.6)
én X-Platform-Key (2.5). Die poll hoort dus niet in 2.4.
Afgeleid veld attention:
none— default, o.a.provisioningensuspendedquiet—statusisactiveoffailed, enlastActivityAtis null of ouder dan 24 uur
Tonen als badge “geen activiteit > 24u”, niet als status=degraded
en niet als UPDATE op tenants.status. Een stille brochure-site
zonder healthcheck is “quiet”, niet “plat”. Echte degraded (stilte
én mislukte of ontbrekende health) komt wanneer 2.5/2.6 een URL en key
geven.
Verworpen: tenants.status naar degraded schrijven (enum-migratie,
vecht met suspended/failed, ongedaan maken bij het volgende event
is extra write-pad).
Verworpen: health-poll in 2.4 tegen pilot.okhema.studio (platform-key
staat niet in de master, en 2.4 mag geen tenant-DB lezen).
Operator mag status zetten, niet Coolify stoppen. PATCH
{ status: "suspended" | "active" | "failed" } schrijft alleen de
kolom. Geen container-stop. provisioning → active met de hand is
toegestaan zodat bestaande klanten in het overzicht “live” kunnen staan
vóór 2.5 bestaat. Weiger provisioning via PATCH (alleen de insert
zet dat). Geen DELETE: cascade zou activity/receipts/stats wissen
zonder dat de tenant-database verdwijnt.
Geen database_url_encrypted in JSON, ooit. platformTenantSchema
sluit het al uit. Selecteer kolommen expliciet; nooit select * from tenants in een handler die naar de browser gaat. Geen decryptie in
deze taak — secrets.ts blijft ongebruikt.
Platform-activity tenant.created / tenant.status_changed. Geen
site-webhook. Insert in activity vanuit de master-handler, tenant_id
gezet, payload zonder secrets. type mag een vrije string zijn
(contract: geen enum). Houd de prefix tenant. zodat de feed
site-events (content.published, …) van operator-events scheidt.
Overzicht op /, detail op /tenants/[slug]. Slug in de URL, niet
UUID: dat is wat operators en webhooks al gebruiken. Aanmaken via
/tenants/new (eigen pagina, geen modal-library). Gedeelde chrome in
app/layouts/default.vue (header + uitloggen uit de huidige
index.vue). Login blijft layout-loos of een minimale variant.
API onder /api/tenants, allemaal requireAdminSession.
| Methode | Pad | Doel |
|---|---|---|
| GET | /api/tenants |
lijst, nieuwste created_at eerst |
| POST | /api/tenants |
body = platformTenantWriteSchema |
| GET | /api/tenants/:slug |
detail + domains + deployments + activity + stats |
| PATCH | /api/tenants/:slug |
body = { status } zoals hierboven; optioneel { name } |
Geen aparte /activity-route: de eerste 50 zitten in GET detail.
Paginatie is 2.4-later als de feed groeit; cap is genoeg voor het
bewijs. Unique-violation (23505 op slug) → 409. Ontbrekende slug →
404. Validatiefout → 400 met Zod-flatten, geen 500.
Geen Nuxt UI, geen nieuwe CSS-variabelen. Tabellen en formulieren
in bestaande slate-utilities, in lijn met login.vue. Lege staat op
de lijst: korte tekst + link naar aanmaken.
Webhook-ontvanger niet wijzigen behalve wat backfill nodig heeft. Lookup-op-slug werkt zodra de rij er is; nieuwe events koppelen vanzelf. Geen shared-secret-kolom.
Stappen
Section titled “Stappen”Volgorde is drie verifieerbare eenheden: contract + helpers (unit), API (curl + sessie), UI (browser). Niet omgekeerd: de pagina’s lezen alleen de API.
1. Contract — afgeleide list/detail-vorm
Section titled “1. Contract — afgeleide list/detail-vorm”- In
packages/contract/src/platform.ts:tenantAttentionSchema(none|quiet) enplatformTenantListItemSchema=platformTenantSchema+lastActivityAt+attention. Detail:platformTenantDetailSchemametdomains,deployments,activity,stats. -
platformTenantStatusPatchSchema:statusinactive|suspended|failed; optioneelname(zelfde limiet als write). - Barrel exporteren. Geen duplicaat type in de Nuxt-app.
- Helper
attentionFrom(status, lastActivityAt, now)in de master (niet in contract: geen klok in dat package).node --testernaast: provisioning + oude activity →none; active + 25 u stilte →quiet; active + 1 u →none.
2. API — apps/master-dashboard/server/api/tenants/
Section titled “2. API — apps/master-dashboard/server/api/tenants/”- Shared mapper: DB-rij → list item, nooit
databaseUrlEncryptedin het object. -
index.get.ts,index.post.ts,[slug].get.ts,[slug].patch.ts. Elk roeptrequireAdminSessionaan vóór de query. - POST: transactie insert + backfill activity/receipts + stats-upsert
+
tenant.created-activity. Response 201 + list item. - PATCH: 404 als slug ontbreekt;
tenant.status_changedin activity als status wijzigt. -
Cache-Control: no-store. - Unique slug → 409. Ongeldige body → 400.
3. Layout en pagina’s
Section titled “3. Layout en pagina’s”-
app/layouts/default.vue: header “Platform”, e-mail, uitloggen, nav-link naar overzicht. Zelfde visuele taal als huidigeindex.vue. -
app/pages/index.vue: tabel (naam, slug, status, attention, laatste activiteit, aanmaakdatum), lege staat, knop “Nieuwe klant”. -
app/pages/tenants/new.vue: slug + naam, client-side dezelfde Zod-schema’s, serverfouten tonen (409 “slug in gebruik”). -
app/pages/tenants/[slug].vue: kop, status + attention, lijsten voor domeinen/deploys (lege staat: “nog geen … — volgt in 2.5/2.6”), activity-feed mettype, tijdstip, beknopte payload (entity/slug of formType; nooit een dump van het hele JSON als de feed lang wordt — wéltype+ een regel metadata). - 404-pagina of
createError(404)als de slug niet bestaat.
4. Documentatie
Section titled “4. Documentatie”- Contract: master heeft nu REST voor tenants;
degradeduit de foutafhandelingszin splitsen in “quiet (2.4)” vs “degraded na health (2.5+)”. - Datamodel: zin “lokaal tot 2.4” bij orphan
tenant_idschrappen; backfill beschrijven. - Architectuur:
tenants/-regel “ledger tot 2.4” bijwerken — JSON blijft bootstrap-input, de master-rij is vanaf 2.4 de bron voor “welke klanten bestaan”. - Taken: 2.4 afvinken met wat er gebouwd is en wat bewust is blijven liggen.
- Scoped rule: tenants-API noemen naast de webhook-uitzondering (sessie verplicht).
Klaar wanneer
Section titled “Klaar wanneer”-
pnpm turbo run typecheckenpnpm turbo run buildgroen. -
node --testopattentionFrom: de drie gevallen hierboven. - Zonder sessie:
GET /api/tenantsenPOST /api/tenants→ 401. - Met sessie: lege lijst 200
[]. POST geldige body → 201, rij intenants,status=provisioning, GET lijst bevat hem, GET detail ook.database_url_encryptedkomt niet voor in de JSON (grep op de response). - POST zelfde slug → 409, nog één rij.
- POST ongeldige slug (
Rb Media, lege naam) → 400, geen rij. - PATCH naar
active→ 200, detail toontactive. PATCH naarprovisioning→ 400. - Webhook mét bestaande slug:
activity.tenant_idgezet, detail toontcontent.published(of welk type de fixture stuurt). Tweede POST zelfdeeventId→ nog één activity-rij (2.3-regressie). - Orphan-pad: webhook op onbekende slug (
tenant_idnull), daarna POST tenant met die slug → detail toont het eerdere event, statslastActivityAtgezet. -
attention=quietvoor eenactivetenant zonder activity of metlastActivityAt> 24 u (fixture met oude timestamp).provisioningzonder activity →none. - Browser: inloggen, lege staat, aanmaken, overzicht, detail,
status naar active, activity zichtbaar. Uitloggen,
/→ login. - Regressie: Origin-loze webhook-curl blijft 401 zonder HMAC en
200 met HMAC.
create-adminweigert nog een tenant-database.pnpm --filter @platform/db exec drizzle-kit checkop de tenant-config blijft groen (geen schemawijziging verwacht).
Buiten scope, wel vastgelegd
Section titled “Buiten scope, wel vastgelegd”- Provisioning (database, migraties, Coolify-app, env, deploy) — taak 2.5. 2.4 levert de rij waar 2.5 op selecteert.
- Preview-URL, DNS-01, custom domains, SSL-status — 2.6 / 2.7. Detail toont lege lijsten, verzint geen hostname.
- Per-tenant
webhook_secret_encryptedenX-Platform-Key— 2.5. SharedNUXT_PLATFORM_WEBHOOK_SECRETblijft. - Healthcheck-poll en echte
degraded— zodra er een URL en key zijn. 2.4 toont alleenquiet. /stats-pull,pageCount— contractvangnet, niet nodig om de feed te tonen.site.error-emit — los punt; de feed toont het wél als het binnenkomt.- Coolify-deploy van de master en
platform_masterop de backuplijst — losse punten in Taken. - Verwijderen van een tenant — bewust niet; cascade vs live database is 2.5-materiaal.
- Automatisch importeren van
tenants/*.jsonof live Coolify-apps. @nuxt/ui.