Skip to content

Verbeterrapport

Dit rapport bundelt de bevindingen van 4 parallelle audits:

  • Schema design audit (datamodel-nieuw)
  • UX journey audit (user-journeys)
  • Migratie architectuur audit (migratie-stack)
  • WP broncode performance audit (huidige implementatie)

Elke bevinding is beoordeeld op ernst en gegroepeerd per thema.

CRITICAL: assigned_to UUID[] moet junction table worden

Section titled “CRITICAL: assigned_to UUID[] moet junction table worden”

Het huidige schema gebruikt assigned_to UUID[] (Postgres array) op de jobs tabel. Dit is problematisch:

  • Geen foreign key constraint mogelijk op array elementen
  • Geen index op individuele array waarden (alleen GIN, langzaam)
  • Geen RLS mogelijk per toewijzing
  • Niet queryable: “geef mij alle klussen van monteur X” vereist @> operator

Fix: Aparte job_assignments tabel:

CREATE TABLE job_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
job_id UUID NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
role TEXT DEFAULT 'assigned' CHECK (role IN ('assigned', 'lead')),
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
assigned_by UUID REFERENCES auth.users(id),
UNIQUE (job_id, user_id)
);
CREATE INDEX idx_assignments_user ON job_assignments(user_id);
CREATE INDEX idx_assignments_job ON job_assignments(job_id);

Voordelen:

  • “Mijn klussen vandaag”: WHERE user_id = auth.uid() AND job.scheduled_date = today
  • Wie heeft toegewezen en wanneer (audit trail)
  • Lead monteur vs helper onderscheid

CRITICAL: work_items en materials als JSONB — validatie ontbreekt

Section titled “CRITICAL: work_items en materials als JSONB — validatie ontbreekt”

JSONB is flexibel maar er is geen database-level validatie. Een lege string of ongeldige structuur wordt geaccepteerd.

Fix: Voeg CHECK constraint toe:

ALTER TABLE work_orders ADD CONSTRAINT work_items_valid
CHECK (jsonb_typeof(work_items) = 'array');
ALTER TABLE work_orders ADD CONSTRAINT materials_valid
CHECK (jsonb_typeof(materials) = 'array');

Overweging: Voor materiaal zou een lookup tabel beter zijn:

CREATE TABLE material_catalog (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
site_id UUID NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
name TEXT NOT NULL,
category TEXT,
unit TEXT DEFAULT 'stuk',
active BOOLEAN DEFAULT true,
UNIQUE (site_id, name)
);
-- work_order_materials als junction met qty
CREATE TABLE work_order_materials (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
work_order_id UUID NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
material_id UUID NOT NULL REFERENCES material_catalog(id),
quantity NUMERIC(10,2) NOT NULL DEFAULT 1,
note TEXT
);

Voordelen: materiaal namen consistent, rapportage op materiaalverbruik, voorraad tracking (v3).

De activity_log groeit onbeperkt. Bij 100 klussen/dag met gemiddeld 5 events = 500 rows/dag = 180.000/jaar.

Fix: Time-based partitioning:

CREATE TABLE activity_log (
...
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
) PARTITION BY RANGE (created_at);
CREATE TABLE activity_log_2026 PARTITION OF activity_log
FOR VALUES FROM ('2026-01-01') TO ('2027-01-01');

Of: archive naar cold storage na 90 dagen.

IMPORTANT: Soft-delete op contacts conflicteert met FK RESTRICT

Section titled “IMPORTANT: Soft-delete op contacts conflicteert met FK RESTRICT”

reports en jobs hebben REFERENCES contacts(id) ON DELETE RESTRICT. Maar als een contact soft-deleted wordt (deleted_at IS NOT NULL), bestaan de FK referenties nog. Het contact kan dan niet hard-deleted worden.

Fix: Gebruik ON DELETE SET NULL i.p.v. RESTRICT, of voeg een check toe:

-- Voorkom soft-delete als er actieve jobs/reports zijn
CREATE OR REPLACE FUNCTION prevent_contact_soft_delete()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.deleted_at IS NOT NULL AND OLD.deleted_at IS NULL THEN
IF EXISTS (SELECT 1 FROM jobs WHERE contact_id = NEW.id AND status NOT IN ('completed', 'cancelled')) THEN
RAISE EXCEPTION 'Contact heeft actieve klussen';
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

IMPORTANT: RLS user_site_ids() performance

Section titled “IMPORTANT: RLS user_site_ids() performance”

user_site_ids() wordt op elke query aangeroepen via RLS. Dit is een subquery die sites + team_members joint.

Fix: Markeer als STABLE (al gedaan) maar overweeg ook:

-- Materialized in een session variabele bij login
SET app.current_site_id = 'uuid-here';
-- RLS check wordt dan:
CREATE POLICY "..." ON jobs
FOR SELECT USING (site_id = current_setting('app.current_site_id')::uuid);

Dit elimineert de subquery volledig. De Hono middleware zet de session variabele na auth.

Momenteel kan een klus van ‘completed’ terug naar ‘open’ gezet worden. Dat is waarschijnlijk ongewenst.

CREATE OR REPLACE FUNCTION validate_job_status_transition()
RETURNS TRIGGER AS $$
DECLARE
valid_transitions jsonb := '{
"open": ["assigned", "cancelled"],
"assigned": ["scheduled", "open", "cancelled"],
"scheduled": ["in_progress", "assigned", "cancelled"],
"in_progress": ["completed", "on_hold", "cancelled"],
"on_hold": ["in_progress", "cancelled"],
"completed": [],
"cancelled": []
}'::jsonb;
BEGIN
IF OLD.status = NEW.status THEN RETURN NEW; END IF;
IF NOT valid_transitions->OLD.status ? NEW.status THEN
RAISE EXCEPTION 'Ongeldige status transitie: % → %', OLD.status, NEW.status;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

MUST-FIX: Offline/slechte connectie voor monteur

Section titled “MUST-FIX: Offline/slechte connectie voor monteur”

Monteurs werken in kelders, tunnels, landelijk gebied. De 30-seconden werkbon is zinloos als de app niet laadt.

v1 minimum: Optimistic UI + retry queue. Form state lokaal opslaan (localStorage), sync wanneer online.

v2: Service Worker met offline cache voor:

  • Materiaal catalogus (static data)
  • Huidige dagplanning (pre-fetched)
  • Werkbon draft opslaan offline → sync bij verbinding

MUST-FIX: Monteur wordt gestoord tijdens werkbon

Section titled “MUST-FIX: Monteur wordt gestoord tijdens werkbon”

Telefoon gaat, klant vraagt iets, monteur moet weg. Huidige WP: alles kwijt.

Oplossing: Auto-save is al in het schema (status: 'draft'). Implementatie:

  • Debounced save elke 5 seconden bij form changes
  • “Je hebt een onafgeronde werkbon” banner op de homepage
  • Draft lijst in het monteur menu

SHOULD-FIX: Bijkomend werk (“kun je ook even naar X kijken”)

Section titled “SHOULD-FIX: Bijkomend werk (“kun je ook even naar X kijken”)”

Monteur is bij klant voor lekkage, klant vraagt: “De kraan in de keuken lekt ook, kun je dat meteen meenemen?”

Opties:

  1. Extra werkregel toevoegen aan huidige werkbon (eenvoudigst)
  2. Tweede werkbon aanmaken op dezelfde klus
  3. Nieuwe melding + klus aanmaken ter plaatse (te veel overhead)

Aanbeveling: Optie 1 voor v1 (gewoon een extra work_item), optie 2 als het apart gefactureerd moet worden.

SHOULD-FIX: Bulk triage voor woningcorporaties

Section titled “SHOULD-FIX: Bulk triage voor woningcorporaties”

Maasdelta stuurt soms 20 meldingen tegelijk (renovatieproject). 1-voor-1 triageren duurt te lang.

Fix: Bulk acties op de meldingen pagina:

  • Selecteer meerdere → “Accepteren als klussen”
  • Optioneel: zelfde monteur toewijzen, zelfde datum
  • Scheelt 80% van de klikken

Monteur is ziek, 5 klussen moeten herplant. Nu: 1-voor-1 opnieuw toewijzen.

Fix: “Herplannen” actie op monteur niveau:

  • Selecteer monteur → “Alle klussen van vandaag herplannen”
  • Kies nieuwe monteur of datum
  • Bulk update

NICE-TO-HAVE: Klant status communicatie (v2)

Section titled “NICE-TO-HAVE: Klant status communicatie (v2)”

In v1 geen klantportaal. Maar minimaal:

  • Email bij inplanning: “Uw monteur komt [datum] tussen [tijd]”
  • Email na werkbon goedkeuring: “Het werk is afgerond” + factuur

Twee klussen op hetzelfde tijdslot bij dezelfde monteur. Nu: geen waarschuwing.

Fix: Soft warning bij inplannen: “Piet heeft al een klus op dinsdag 9:00-11:00. Toch inplannen?”

NICE-TO-HAVE: Kleurenblind-vriendelijke statussen

Section titled “NICE-TO-HAVE: Kleurenblind-vriendelijke statussen”

De status kleuren (rood/oranje/groen) zijn niet kleurenblind-vriendelijk.

Fix: Voeg iconen toe naast kleuren:

  • Open: blauw cirkel
  • Assigned: persoon icoon
  • Scheduled: kalender icoon
  • In progress: spinner/pijl
  • Completed: vinkje
  • Cancelled: kruis

Twee gelijktijdige webhooks van de marketingsite met dezelfde klant → twee contacten aangemaakt.

Fix: Database-level UNIQUE constraint + upsert:

-- Unieke index op genormaliseerde email (als email niet leeg is)
CREATE UNIQUE INDEX idx_contacts_unique_email
ON contacts(site_id, lower(email))
WHERE email IS NOT NULL AND email != '' AND deleted_at IS NULL;
-- Upsert in de API
INSERT INTO contacts (site_id, email, firstname, ...)
VALUES ($1, $2, $3, ...)
ON CONFLICT (site_id, lower(email))
WHERE email IS NOT NULL AND email != '' AND deleted_at IS NULL
DO UPDATE SET
firstname = COALESCE(NULLIF(EXCLUDED.firstname, ''), contacts.firstname),
updated_at = NOW()
RETURNING *;

CRITICAL: Webhook betrouwbaarheid (outgoing)

Section titled “CRITICAL: Webhook betrouwbaarheid (outgoing)”

Huidige WP: fire-and-forget wp_remote_post(). Als Make.com down is → data verloren.

Fix: Cloudflare Queue:

// In de API route (na werkbon goedkeuring):
await c.env.WEBHOOK_QUEUE.send({
type: 'moneybird_invoice',
work_order_id: workOrder.id,
attempt: 1,
});
// Queue consumer (apart Worker):
export default {
async queue(batch, env) {
for (const msg of batch.messages) {
try {
await sendToMakeWebhook(msg.body);
msg.ack();
} catch {
msg.retry({ delaySeconds: 60 * msg.body.attempt });
}
}
}
};

Voordelen: automatic retry, dead letter queue, geen data verlies.

IMPORTANT: Site_id patroon — single vs multi-tenant

Section titled “IMPORTANT: Site_id patroon — single vs multi-tenant”

Het Beam site_id patroon is ontworpen voor multi-tenant (meerdere sites per account). Werkbon is single-company.

Aanbeveling: Houd site_id maar maak het transparant:

  • Bij registratie: automatisch 1 site aanmaken
  • In de UI: geen site switcher tonen
  • In de API: site_id automatisch uit auth context
  • Later: multi-tenant is gratis (al ingebouwd)

De addresses tabel is statisch (verandert alleen bij CSV import). Elke autocomplete query hit de database.

Fix: Cache in KV:

// Bij import: cache naar KV
await env.WERKBON_CACHE.put(
`addresses:${siteId}`,
JSON.stringify(allAddresses),
{ expirationTtl: 86400 * 30 } // 30 dagen
);
// Bij autocomplete: lees uit KV, filter client-side
const cached = await env.WERKBON_CACHE.get(`addresses:${siteId}`, 'json');
if (cached) return filterAddresses(cached, searchTerm);

Of: stuur de hele tabel naar de client bij login (< 1MB voor ~10.000 adressen) en doe alle filtering client-side. Elimineert AJAX volledig.

IMPORTANT: Data migratie strategie ontbreekt

Section titled “IMPORTANT: Data migratie strategie ontbreekt”

Er is geen concreet migratieplan voor de data.

Nodig:

  1. WP export script (PHP): dump contacts, jobs, files naar JSON
  2. Transform script (Node): WP post_meta → flat columns, deduplicate contacts
  3. Supabase import script: insert met conflict handling
  4. Validatie: tellingen vergelijken, steekproef controle
  5. Parallel draai: 2 weken WP en nieuwe app naast elkaar
  6. Rollback: als iets misgaat, terug naar WP

OPTIMIZATION: Direct Moneybird API i.p.v. Make.com

Section titled “OPTIMIZATION: Direct Moneybird API i.p.v. Make.com”

Make.com is een extra hop (en kosten) voor wat een simpele API call is.

Overweeg: Direct Moneybird API aanroepen vanuit de Hono Worker:

  • Moneybird API is goed gedocumenteerd
  • Scheelt Make.com abonnement
  • Minder latency (1 hop minder)
  • Meer controle over error handling

Nadeel: zelf OAuth token management bouwen. Maar met Cloudflare KV voor token opslag is dat beheersbaar.

4. WP Performance Anti-Patterns (NIET repliceren)

Section titled “4. WP Performance Anti-Patterns (NIET repliceren)”
OperatieQueriesProbleem
Jobs lijst (50 items)4-5 + 1.250 meta lookupsIndividuele get_post_meta in loop
Reports lijst (50 items)5-7 + full table scansDynamic JOINs op postmeta
Adres autocomplete (1 toets)2-3 per keystrokeGeen debounce, geen cache
Job aanmaken (API)15-255 LEFT JOINs voor contact matching
Contact upsert (worst case)520+N+1 loop: 50 kandidaten x 10 meta reads
WP Anti-PatternSupabase Best Practice
get_post_meta() in loop.select('*, contacts(*)') met JOIN
Dynamic JOIN constructionSupabase .or() / .in() filters
Geen transactiessupabase.rpc() met PL/pgSQL functie
Fire-and-forget webhookCloudflare Queue met retry
Geen debounce op AJAXClient-side debounce (300ms) + KV cache
Hardcoded posts_per_page=50Cursor-based pagination
String processing in SQLGenormaliseerde kolommen + index
Static $cache in PHPSWR cache (30s dedup)
#BevindingErnstStatus
1assigned_to UUID[] → junction tableCriticalVerwerkt: job_assignments tabel
2Contact matching race conditionCriticalVerwerkt: UNIQUE index op email
3origin mist ‘website’ op contactsCriticalVerwerkt: CHECK constraint bijgewerkt
4Attachments tabel ontbreektImportantVerwerkt: attachments tabel toegevoegd
5Activity log UUID → BIGINT PKImportantVerwerkt: BIGINT IDENTITY
6Contacts mist identity constraintImportantVerwerkt: CHECK (company OR firstname)
7Correlated subqueries in job_details viewImportantVerwerkt: LATERAL join
8work_order_details mist contactgegevensImportantVerwerkt: JOIN op effective contact
9Material catalog tabelImportantVerwerkt: material_catalog tabel
10RLS user_site_ids() performanceImportantGedocumenteerd: session variabele aanbeveling
11Status transitie validatieNice-to-haveGedocumenteerd: trigger voorbeeld
12Activity log partitioningNice-to-haveGedocumenteerd: kwartaal partities
#BevindingErnstStatus
1revision_requested status op werkbonMust-fixVerwerkt in schema + status model
2Dagplanning monteur → v1 (was v2)Must-fixVerwerkt in roadmap
3Klant notificaties → v1 (was v2)Must-fixVerwerkt in roadmap
4Klant status-check URL (public_token)Must-fixVerwerkt in schema (reports tabel)
5Email of phone verplicht op webhookMust-fixVerwerkt in Zod schema
6started_at/completed_at op jobsImportantVerwerkt in schema
7Offline: lokale opslag + sync indicatorMust-fixVerwerkt in v1 scope
8Duplicate-detectie meldingenMust-fixVerwerkt in v1 scope
9Gestoord tijdens werkbonMust-fixAuto-save al in schema
10Bijkomend werk op locatieShould-fixGedocumenteerd in edge cases
11Bulk triage woningcorporatieShould-fixGedocumenteerd in edge cases
12Herplanning bij ziekteShould-fixGedocumenteerd in edge cases
13In-app comments beheerder↔monteurShould-fixToegevoegd aan v2 roadmap
14Touch targets 48px minimumShould-fixGedocumenteerd
15Kleurenblind: iconen naast kleurenMust-fixGedocumenteerd
16i18n framework vanaf beginNice-to-haveGedocumenteerd
#BevindingErnstStatus
1Blue-green deployment strategieCriticalVerwerkt: week-voor-week plan
2Data validatie queries na migratieCriticalVerwerkt: 4 validatie queries
3Rollback planCriticalVerwerkt: 30-dagen strategie
4Incoming webhook buiten auth middlewareCriticalVerwerkt: route ordering
5Moneybird ID continuïteitCriticalVerwerkt: fase 1 migratie
6Database seeding (addresses)CriticalVerwerkt: fase 5 migratie
7Webhook reliability (Queue)ImportantVerwerkt: Cloudflare Queue
8Service layer documenterenImportantVerwerkt: services/ structuur
9Feature parity checklistImportantVerwerkt: 15-item checklist
10Kosten schattingImportantVerwerkt: ~$5/maand
11Telefoon normalisatie portenImportantVerwerkt: phone.service.ts
12Adres cache (KV)OptimizationGedocumenteerd
13Direct Moneybird API (fase 2)OptimizationGedocumenteerd