Ga naar inhoud

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.

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. degraded staat niet in die enum.
  • POST /api/webhooks/site schrijft activity en, als tenants.slug bestaat, tenant_id plus site_stats.last_activity_at. Zonder rij blijft activity.tenant_id null; payload.tenantSlug blijft bewaard zodat 2.4 kan backfillen.
  • Auth: requireAdminSession op data-routes. Vue-middleware is navigatie. POST /api/webhooks/site blijft HMAC-only.
  • UI: Tailwind 4, geen @nuxt/ui (TS-pin, besloten in 2.1). index.vue is een placeholder. Harde regel 1 geldt hier niet: gewone utilities (bg-slate-900, …).
  • tenants/*.json blijft de bootstrap-input voor @platform/ops tot 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.

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.

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[] via platformDomainSchema (leeg tot 2.6/2.7)
  • deployments[] via platformDeploymentSchema, nieuwste eerst, cap 20 (leeg tot 2.5)
  • activity[] via platformActivitySchema, nieuwste eerst, cap 50
  • stats: platformSiteStatsSchema of 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.

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:

  1. update activity set tenant_id = :id where tenant_id is null and payload->>'tenantSlug' = :slug
  2. update webhook_receipts hetzelfde op tenant_id
  3. max(occurred_at) van die rijen → upsert site_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. provisioning en suspended
  • quietstatus is active of failed, en lastActivityAt is 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. provisioningactive 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.

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) en platformTenantListItemSchema = platformTenantSchema + lastActivityAt + attention. Detail: platformTenantDetailSchema met domains, deployments, activity, stats.
  • platformTenantStatusPatchSchema: status in active | suspended | failed; optioneel name (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 --test ernaast: 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 databaseUrlEncrypted in het object.
  • index.get.ts, index.post.ts, [slug].get.ts, [slug].patch.ts. Elk roept requireAdminSession aan 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_changed in activity als status wijzigt.
  • Cache-Control: no-store.
  • Unique slug → 409. Ongeldige body → 400.
  • app/layouts/default.vue: header “Platform”, e-mail, uitloggen, nav-link naar overzicht. Zelfde visuele taal als huidige index.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 met type, tijdstip, beknopte payload (entity/slug of formType; nooit een dump van het hele JSON als de feed lang wordt — wél type + een regel metadata).
  • 404-pagina of createError(404) als de slug niet bestaat.
  • Contract: master heeft nu REST voor tenants; degraded uit de foutafhandelingszin splitsen in “quiet (2.4)” vs “degraded na health (2.5+)”.
  • Datamodel: zin “lokaal tot 2.4” bij orphan tenant_id schrappen; 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).
  • pnpm turbo run typecheck en pnpm turbo run build groen.
  • node --test op attentionFrom: de drie gevallen hierboven.
  • Zonder sessie: GET /api/tenants en POST /api/tenants → 401.
  • Met sessie: lege lijst 200 []. POST geldige body → 201, rij in tenants, status=provisioning, GET lijst bevat hem, GET detail ook. database_url_encrypted komt 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 toont active. PATCH naar provisioning → 400.
  • Webhook mét bestaande slug: activity.tenant_id gezet, detail toont content.published (of welk type de fixture stuurt). Tweede POST zelfde eventId → nog één activity-rij (2.3-regressie).
  • Orphan-pad: webhook op onbekende slug (tenant_id null), daarna POST tenant met die slug → detail toont het eerdere event, stats lastActivityAt gezet.
  • attention=quiet voor een active tenant zonder activity of met lastActivityAt > 24 u (fixture met oude timestamp). provisioning zonder 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-admin weigert nog een tenant-database. pnpm --filter @platform/db exec drizzle-kit check op de tenant-config blijft groen (geen schemawijziging verwacht).
  • 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_encrypted en X-Platform-Key — 2.5. Shared NUXT_PLATFORM_WEBHOOK_SECRET blijft.
  • Healthcheck-poll en echte degraded — zodra er een URL en key zijn. 2.4 toont alleen quiet.
  • /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_master op de backuplijst — losse punten in Taken.
  • Verwijderen van een tenant — bewust niet; cascade vs live database is 2.5-materiaal.
  • Automatisch importeren van tenants/*.json of live Coolify-apps.
  • @nuxt/ui.