Skip to content

Datamodel (Nieuw)

Dit document beschrijft het nieuwe datamodel voor de Werkbon migratie. Het model is gebaseerd op de User Journeys en lost de 7 structurele problemen van het huidige WP-model op. Zie Migratie Stack voor de technische implementatie.

EntiteitTabelWat is hetVoorbeeld
ContactcontactsKlant of opdrachtgever”Loodgieter BV”, “Jan de Vries”
MeldingreportsBinnenkomend signaal/verzoek”Lekkage gemeld op Hoofdstraat 42”
KlusjobsGeaccepteerd werk, inplanbaar”Lekkage Hoofdstraat 42 — ma 9:00, Piet”
Werkbonwork_ordersDocumentatie van uitgevoerd werk”Lekkage verholpen, 1.5 uur, 2x PVC buis”
┌──────────┐
│ CONTACT │
│ │
│ naam │
│ adres │
│ email │
│ telefoon │
│ type │
└────┬─────┘
│ contact_id
┌──────────┼──────────┐
▼ ▼ │
┌──────────┐ ┌──────────┐ │
│ MELDING │ │ KLUS │ │
│ │ │ │ │
│ bron │ │ status │ │
│ beschr. │ │ planning │ │
│ priorit. │ │ monteur │ │
└────┬─────┘ └────┬─────┘ │
│ │ │
│ report_id │ │ contact_id (optioneel override)
│ (optioneel) │ │
└──────┐ ┌────┘ │
▼ ▼ │
┌──────────┐ │
│ WERKBON │─────────┘
│ │
│ werk │
│ materiaal│
│ handtek. │
└──────────┘

Key relaties:

VanNaarKardinaliteitVerplichtToelichting
MeldingContactN:1JaElk signaal hoort bij een klant
KlusContactN:1JaElke klus heeft een klant
KlusMeldingN:1NeeKlus kan los bestaan of later gekoppeld
WerkbonKlusN:1JaWerkbon hoort altijd bij een klus
WerkbonContactN:1NeeAls NULL → erft van klus

Flexibiliteit:

  • Een melding kan 0 of meer klussen opleveren
  • Een klus kan direct aangemaakt worden zonder melding
  • Een klus kan later aan een melding gekoppeld worden
  • Een klus kan meerdere werkbonnen hebben (meerdere bezoeken)
  • Een werkbon kan optioneel een ander contactpersoon hebben dan de klus

Flow 1: Melding → Klus → Werkbon (standaard)

Section titled “Flow 1: Melding → Klus → Werkbon (standaard)”
1. Melding binnenkomt (API, telefoon, formulier)
└─ status: 'new'
└─ contact_id: bestaand of nieuw aangemaakt
2. Beheerder triageert melding
├─ Accepteren → status: 'accepted' → Klus aanmaken
├─ Afwijzen → status: 'rejected' (met reden)
├─ Samenvoegen → status: 'merged' + merged_into_id
└─ On hold → status: 'on_hold' (wacht op info)
3. Klus wordt ingepland
├─ Monteur toewijzen (assigned_to)
├─ Datum/tijd plannen (scheduled_date, scheduled_time_start/end)
└─ status: 'scheduled'
4. Monteur voert klus uit
├─ status: 'in_progress'
├─ Werkbon invullen (draft → submitted)
│ ├─ Werkzaamheden + materiaal
│ ├─ Handtekening
│ └─ Auto-save als draft
└─ Klaar? → Klus status: 'completed'
5. Werkbon triggert webhook → Moneybird factuur

Flow 2: Melding via marketingsite (webhook)

Section titled “Flow 2: Melding via marketingsite (webhook)”
1. Bezoeker vult formulier in op marketingsite
└─ POST naar Werkbon API: /api/reports/incoming
2. API ontvangt webhook
├─ Validatie: verplichte velden (naam, adres, beschrijving)
├─ Contact matching (zelfde algoritme als WP):
│ ├─ Email match → bestaand contact
│ ├─ Adres match → bestaand contact
│ └─ Geen match → nieuw contact aanmaken
│ └─ origin: 'website'
├─ Adres normalisatie via addresses tabel
│ └─ is_maasdelta? → type: 'maasdelta'
└─ Melding aanmaken
├─ source: 'website'
├─ status: 'new'
└─ Activity log: 'created'
3. Beheerder ziet nieuwe melding in dashboard
└─ Triage → accepteren → klus aanmaken

API Route:

apps/werkbon-api/src/routes/reports.ts
reports.post('/incoming', async (c) => {
// Geen auth nodig — webhook van marketingsite
// Wel: shared secret header voor verificatie
const secret = c.req.header('X-Werkbon-Secret')
if (!secret || secret !== c.env.WEBHOOK_SECRET) {
return c.json({ error: 'Unauthorized' }, 401)
}
const body = await c.req.json()
const parsed = incomingReportSchema.safeParse(body)
if (!parsed.success) {
return c.json({ error: 'Ongeldige invoer' }, 400)
}
const { data } = parsed
const adminSupabase = c.get('adminSupabase')
// 1. Contact matching of aanmaken
const contact = await findOrCreateContact(adminSupabase, {
email: data.email,
firstname: data.firstname,
lastname: data.lastname,
company: data.company,
phone: data.phone,
zipcode: data.zipcode,
house_number: data.house_number,
streetname: data.streetname,
city: data.city,
origin: 'website',
})
// 2. Adres normalisatie + Maasdelta detectie
const address = await lookupAddress(adminSupabase, data.zipcode, data.house_number, data.addition)
if (address?.is_maasdelta) {
await updateContactType(adminSupabase, contact.id, 'maasdelta')
}
// 3. Melding aanmaken
const { data: report } = await adminSupabase
.from('reports')
.insert({
site_id: siteId,
contact_id: contact.id,
source: 'website',
description: data.description,
priority: data.priority || 'normal',
})
.select()
.single()
return c.json({ success: true, report_id: report.id })
})

Zod Schema:

const incomingReportSchema = z.object({
// Contact (minstens email of phone verplicht)
firstname: z.string().min(1).max(100),
lastname: z.string().max(100).optional(),
company: z.string().max(200).optional(),
email: z.string().email().optional(),
phone: z.string().max(20).optional(),
// Adres
zipcode: z.string().max(10).optional(),
house_number: z.string().max(20).optional(),
addition: z.string().max(20).optional(),
streetname: z.string().max(255).optional(),
city: z.string().max(100).optional(),
// Melding
description: z.string().min(1).max(5000),
priority: z.enum(['low', 'normal', 'high', 'urgent']).optional(),
}).refine(
(data) => data.email || data.phone,
{ message: 'Email of telefoonnummer is verplicht' }
)

Beveiliging:

  • Shared secret header (X-Werkbon-Secret) i.p.v. open endpoint (lost WP security issue op)
  • Zod validatie op alle input
  • adminSupabase client (service role) — geen user auth nodig voor webhook
  • Rate limiting: 20 req/min op /incoming route

Contact matching volgorde (zelfde als huidige WP logica):

1. Email match → bestaand contact
2. Adres match (postcode + huisnummer + toevoeging) → bestaand contact
3. Geen match → nieuw contact (origin: 'website')

Website-origin contacten worden pas naar Moneybird gesynceerd wanneer er een klus met werkbon aan gekoppeld is (zelfde regel als in WP).

1. Beheerder maakt klus direct aan
└─ contact_id: selecteer bestaand contact
└─ report_id: NULL (geen melding)
└─ Adres: erft van contact OF override
2. Optioneel: later melding koppelen
└─ report_id: selecteer bestaande melding
└─ Melding status → 'accepted'
3. Verder als standaard flow (inplannen → uitvoeren → werkbon)
1. Melding: "Badkamer compleet renoveren"
├─ Klus 1: "Leidingwerk vervangen" → Werkbon A
├─ Klus 2: "Tegels verwijderen" → Werkbon B
└─ Klus 3: "Nieuwe installatie" → Werkbon C, D (2 bezoeken)
1. Klus voor Contact "Woningcorporatie BV"
2. Werkbon invullen → monteur selecteert ander contact
└─ "Jan de Vries" (bewoner/contactpersoon op locatie)
└─ work_order.contact_id = Jan's ID
└─ Factuur gaat naar Woningcorporatie (via klus.contact_id)
└─ Werkbon toont Jan als contactpersoon ter plaatse
new → accepted → (wordt klus)
→ rejected
→ merged
→ on_hold → new (heropend)
StatusLabelBeschrijvingKleur
newNieuwZojuist binnengekomen, nog niet beoordeeldBlauw
acceptedGeaccepteerdBeoordeeld, klus wordt aangemaaktGroen
rejectedAfgewezenOnterecht, duplicaat, of niet uitvoerbaarRood
mergedSamengevoegdGekoppeld aan andere meldingGrijs
on_holdOn holdWacht op informatie of goedkeuringOranje
open → assigned → scheduled → in_progress → completed
→ on_hold → in_progress
→ cancelled
StatusLabelBeschrijvingKleur
openOpenAangemaakt, nog niet toegewezenBlauw
assignedToegewezenMonteur gekoppeld, nog niet geplandLichtblauw
scheduledIngeplandDatum en tijd vastgelegdPaars
in_progressBezigMonteur is aan het werkOranje
completedAfgerondAlle werkbonnen ingevuldGroen
on_holdGepauzeerdTijdelijk gestopt (wacht op materiaal, etc.)Geel
cancelledGeannuleerdNiet meer nodigRood
draft → submitted → approved
→ revision_requested → draft (monteur past aan)
StatusLabelBeschrijvingKleur
draftConceptAuto-saved, monteur kan later terugkomenGrijs
submittedIngediendMonteur heeft afgerond, wacht op controleBlauw
approvedGoedgekeurdGecontroleerd, klaar voor facturatieGroen
revision_requestedAanpassing gevraagdBeheerder heeft opmerking, monteur moet aanpassenOranje
CREATE TABLE contacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
site_id UUID NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
-- Identiteit
type TEXT NOT NULL DEFAULT 'particulier'
CHECK (type IN ('maasdelta', 'particulier', 'zakelijk')),
company TEXT,
firstname TEXT,
lastname TEXT,
email TEXT,
phone TEXT,
-- Adres (single source of truth)
streetname TEXT,
house_number TEXT,
addition TEXT,
zipcode TEXT,
city TEXT,
-- Extern
moneybird_id TEXT,
origin TEXT DEFAULT 'app' CHECK (origin IN ('app', 'api', 'moneybird', 'website')),
-- Meta
note TEXT,
created_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
-- Minstens 1 identificatie veld verplicht
CONSTRAINT contacts_has_identity CHECK (company IS NOT NULL OR firstname IS NOT NULL)
);
CREATE TABLE reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
site_id UUID NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
-- Relatie
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE RESTRICT,
-- Status
status TEXT NOT NULL DEFAULT 'new'
CHECK (status IN ('new', 'accepted', 'rejected', 'merged', 'on_hold')),
-- Inhoud
source TEXT DEFAULT 'app' CHECK (source IN ('app', 'api', 'phone', 'email', 'website')),
description TEXT,
priority TEXT DEFAULT 'normal' CHECK (priority IN ('low', 'normal', 'high', 'urgent')),
note TEXT,
-- Klant status-check (publieke URL zonder login)
public_token TEXT UNIQUE DEFAULT encode(gen_random_bytes(16), 'hex'),
-- Samenvoegen
merged_into_id UUID REFERENCES reports(id) ON DELETE SET NULL,
-- Meta
created_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE TABLE jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
site_id UUID NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
-- Relaties
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE RESTRICT,
report_id UUID REFERENCES reports(id) ON DELETE SET NULL, -- Optioneel
-- Status
status TEXT NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'assigned', 'scheduled', 'in_progress', 'completed', 'on_hold', 'cancelled')),
-- Type (erft van contact, kan overschreven)
type TEXT NOT NULL DEFAULT 'particulier'
CHECK (type IN ('maasdelta', 'particulier', 'zakelijk')),
-- Adres (optionele override van contact adres)
address_override BOOLEAN DEFAULT false,
streetname TEXT,
house_number TEXT,
addition TEXT,
zipcode TEXT,
city TEXT,
-- Planning
scheduled_date DATE,
scheduled_time_start TIME,
scheduled_time_end TIME,
-- Tijdsregistratie (reistijd vs werktijd analyse)
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
-- Inhoud
description TEXT,
note TEXT,
-- Meta
created_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);

Vervangt de assigned_to UUID[] array — een aparte tabel maakt queries efficienter en biedt audit trail.

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

Voordelen:

  • “Mijn klussen vandaag”: WHERE user_id = auth.uid() AND job.status IN ('scheduled', 'in_progress')
  • Wie heeft toegewezen en wanneer (audit)
  • Lead monteur vs helper onderscheid

Optionele lookup tabel voor consistente materiaalnamen en rapportage.

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

Werkbon materialen verwijzen naar de catalogus maar slaan ook de naam op (snapshot op moment van invullen).

CREATE TABLE work_orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
site_id UUID NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
-- Relaties
job_id UUID NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
contact_id UUID REFERENCES contacts(id) ON DELETE SET NULL, -- NULL = erft van klus
-- Status
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'submitted', 'approved', 'revision_requested')),
-- Datum
date DATE NOT NULL DEFAULT CURRENT_DATE,
-- Werkzaamheden (gestructureerd)
work_items JSONB NOT NULL DEFAULT '[]',
-- Voorbeeld: [{"location":["Keuken"],"work":"Riool ontstopt","time":1.5,"date":"2026-04-05"}]
-- Materiaal (gestructureerd)
materials JSONB NOT NULL DEFAULT '[]',
-- Voorbeeld: [{"id":"mat_001","label":"PVC Buis 110mm","qty":2}]
-- Overig
note TEXT,
signature_url TEXT,
-- Wie
completed_by UUID REFERENCES auth.users(id),
-- Meta
created_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE TABLE activity_log (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, -- BIGINT ipv UUID (high-write, sequentieel)
site_id UUID NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
entity_type TEXT NOT NULL
CHECK (entity_type IN ('contact', 'report', 'job', 'work_order')),
entity_id UUID NOT NULL,
action TEXT NOT NULL
CHECK (action IN (
'created', 'updated', 'deleted',
'status_changed', 'assigned', 'unassigned',
'report_linked', 'report_unlinked',
'work_order_added', 'work_order_submitted', 'work_order_approved',
'webhook_sent', 'webhook_failed'
)),
old_value JSONB,
new_value JSONB,
meta JSONB,
user_id UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Foto’s, handtekeningen en documenten gekoppeld aan elke entiteit.

CREATE TABLE attachments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
site_id UUID NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
entity_type TEXT NOT NULL
CHECK (entity_type IN ('report', 'job', 'work_order', 'contact')),
entity_id UUID NOT NULL,
storage_path TEXT NOT NULL,
filename TEXT NOT NULL,
mime_type TEXT NOT NULL,
file_size INTEGER NOT NULL CHECK (file_size > 0),
category TEXT DEFAULT 'photo'
CHECK (category IN ('photo', 'signature', 'document')),
uploaded_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);

Vervangt het losse signature_url veld op werk_orders. Gebruik Supabase Storage (of R2) voor de bestanden zelf.

CREATE TABLE addresses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
site_id UUID NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
streetname TEXT NOT NULL,
house_number TEXT NOT NULL,
addition TEXT DEFAULT '',
zipcode TEXT NOT NULL,
city TEXT NOT NULL,
is_maasdelta BOOLEAN DEFAULT false,
UNIQUE (site_id, zipcode, house_number, addition)
);
-- Effectief adres van een klus
SELECT
j.id,
CASE WHEN j.address_override THEN j.streetname ELSE c.streetname END AS streetname,
CASE WHEN j.address_override THEN j.house_number ELSE c.house_number END AS house_number,
CASE WHEN j.address_override THEN j.addition ELSE c.addition END AS addition,
CASE WHEN j.address_override THEN j.zipcode ELSE c.zipcode END AS zipcode,
CASE WHEN j.address_override THEN j.city ELSE c.city END AS city
FROM jobs j
JOIN contacts c ON c.id = j.contact_id;
  • address_override = false (default): adres van contact
  • address_override = true: eigen adres op de klus (bijv. ander werkadres)
-- Effectief contact van een werkbon
SELECT
wo.id,
COALESCE(wo.contact_id, j.contact_id) AS effective_contact_id
FROM work_orders wo
JOIN jobs j ON wo.job_id = j.id;
  • work_order.contact_id = NULL (default): erft contact van klus
  • work_order.contact_id = UUID: ander contactpersoon (bijv. bewoner op locatie)
┌─ Klus Formulier ──────────────────────────────┐
│ │
│ Contact: [Jan de Vries ▼] │
│ │
│ Adres: Hoofdstraat 42, 3029AK Rotterdam │
│ (van contact) │
│ □ Ander werkadres │
│ │
└────────────────────────────────────────────────┘
┌─ Werkbon Formulier ───────────────────────────┐
│ │
│ Klus: Lekkage Hoofdstraat 42 (KL-2026-042) │
│ Contact: Jan de Vries (van klus) │
│ □ Ander contactpersoon │
│ │
│ Werkzaamheden: [+ Toevoegen] │
│ Materiaal: [+ Toevoegen] │
│ │
└────────────────────────────────────────────────┘
-- Contacts
CREATE INDEX idx_contacts_site ON contacts(site_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_contacts_email ON contacts(site_id, email) WHERE deleted_at IS NULL;
CREATE INDEX idx_contacts_moneybird ON contacts(moneybird_id) WHERE moneybird_id IS NOT NULL;
CREATE INDEX idx_contacts_type ON contacts(site_id, type) WHERE deleted_at IS NULL;
-- Dedup: voorkomt dubbele contacten bij concurrent webhooks
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;
-- Reports
CREATE INDEX idx_reports_site_status ON reports(site_id, status, created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX idx_reports_contact ON reports(contact_id);
-- Jobs
CREATE INDEX idx_jobs_site_status ON jobs(site_id, status, created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX idx_jobs_contact ON jobs(contact_id);
CREATE INDEX idx_jobs_report ON jobs(report_id) WHERE report_id IS NOT NULL;
CREATE INDEX idx_jobs_assigned ON jobs(site_id, status) WHERE status IN ('assigned', 'scheduled', 'in_progress');
CREATE INDEX idx_jobs_scheduled ON jobs(site_id, scheduled_date) WHERE scheduled_date IS NOT NULL AND status NOT IN ('completed', 'cancelled');
-- Work Orders
CREATE INDEX idx_work_orders_job ON work_orders(job_id);
CREATE INDEX idx_work_orders_draft ON work_orders(created_by, status) WHERE status = 'draft';
CREATE INDEX idx_work_orders_site_date ON work_orders(site_id, date DESC) WHERE deleted_at IS NULL;
-- Job Assignments
CREATE INDEX idx_assignments_user ON job_assignments(user_id);
CREATE INDEX idx_assignments_job ON job_assignments(job_id);
-- Activity Log
CREATE INDEX idx_activity_entity ON activity_log(entity_type, entity_id, created_at DESC);
CREATE INDEX idx_activity_site ON activity_log(site_id, created_at DESC);
-- Attachments
CREATE INDEX idx_attachments_entity ON attachments(entity_type, entity_id) WHERE deleted_at IS NULL;
-- Addresses
CREATE INDEX idx_addresses_site_zip ON addresses(site_id, zipcode);
CREATE INDEX idx_addresses_street ON addresses(site_id, streetname);
-- Helper: sites waar gebruiker toegang tot heeft
CREATE OR REPLACE FUNCTION user_site_ids()
RETURNS SETOF UUID AS $$
SELECT s.id FROM sites s
WHERE s.owner_id = get_team_owner_id(auth.uid())
AND s.deleted_at IS NULL;
$$ LANGUAGE sql SECURITY DEFINER STABLE SET search_path = public;
-- Contacts
CREATE POLICY "Team read contacts" ON contacts
FOR SELECT USING (site_id IN (SELECT user_site_ids()) AND deleted_at IS NULL);
CREATE POLICY "Team manage contacts" ON contacts
FOR ALL USING (site_id IN (SELECT user_site_ids()));
-- Reports
CREATE POLICY "Team read reports" ON reports
FOR SELECT USING (site_id IN (SELECT user_site_ids()) AND deleted_at IS NULL);
CREATE POLICY "Team manage reports" ON reports
FOR ALL USING (site_id IN (SELECT user_site_ids()));
-- Jobs
CREATE POLICY "Team read jobs" ON jobs
FOR SELECT USING (site_id IN (SELECT user_site_ids()) AND deleted_at IS NULL);
CREATE POLICY "Team manage jobs" ON jobs
FOR ALL USING (site_id IN (SELECT user_site_ids()));
CREATE POLICY "Admin delete jobs" ON jobs
FOR DELETE USING (
site_id IN (SELECT user_site_ids())
AND get_user_role(auth.uid(), get_team_owner_id(auth.uid())) IN ('owner', 'admin')
);
-- Work Orders (drafts alleen zichtbaar voor eigenaar)
CREATE POLICY "Team read work_orders" ON work_orders
FOR SELECT USING (
site_id IN (SELECT user_site_ids())
AND deleted_at IS NULL
AND (status != 'draft' OR created_by = auth.uid())
);
CREATE POLICY "Team manage work_orders" ON work_orders
FOR ALL USING (site_id IN (SELECT user_site_ids()));
-- Activity Log (read-only, writes via triggers)
CREATE POLICY "Team read activity" ON activity_log
FOR SELECT USING (site_id IN (SELECT user_site_ids()));
-- Addresses (read voor team, write voor admin)
CREATE POLICY "Team read addresses" ON addresses
FOR SELECT USING (site_id IN (SELECT user_site_ids()));
CREATE POLICY "Admin manage addresses" ON addresses
FOR ALL USING (
site_id IN (SELECT user_site_ids())
AND get_user_role(auth.uid(), get_team_owner_id(auth.uid())) IN ('owner', 'admin')
);
-- Auto-update timestamps op alle tabellen
CREATE TRIGGER update_contacts_updated_at BEFORE UPDATE ON contacts
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_reports_updated_at BEFORE UPDATE ON reports
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_jobs_updated_at BEFORE UPDATE ON jobs
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_work_orders_updated_at BEFORE UPDATE ON work_orders
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Activity log bij status wijzigingen
CREATE OR REPLACE FUNCTION log_status_change()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.status IS DISTINCT FROM NEW.status THEN
INSERT INTO activity_log (site_id, entity_type, entity_id, action, old_value, new_value, user_id)
VALUES (
NEW.site_id,
TG_ARGV[0], -- 'report', 'job', of 'work_order'
NEW.id,
'status_changed',
jsonb_build_object('status', OLD.status),
jsonb_build_object('status', NEW.status),
auth.uid()
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public;
CREATE TRIGGER report_status_change AFTER UPDATE ON reports
FOR EACH ROW EXECUTE FUNCTION log_status_change('report');
CREATE TRIGGER job_status_change AFTER UPDATE ON jobs
FOR EACH ROW EXECUTE FUNCTION log_status_change('job');
CREATE TRIGGER work_order_status_change AFTER UPDATE ON work_orders
FOR EACH ROW EXECUTE FUNCTION log_status_change('work_order');
-- Activity log bij melding koppelen aan klus
CREATE OR REPLACE FUNCTION log_report_link()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.report_id IS DISTINCT FROM NEW.report_id THEN
INSERT INTO activity_log (site_id, entity_type, entity_id, action, old_value, new_value, user_id)
VALUES (
NEW.site_id, 'job', NEW.id,
CASE WHEN NEW.report_id IS NOT NULL THEN 'report_linked' ELSE 'report_unlinked' END,
CASE WHEN OLD.report_id IS NOT NULL THEN jsonb_build_object('report_id', OLD.report_id) ELSE NULL END,
CASE WHEN NEW.report_id IS NOT NULL THEN jsonb_build_object('report_id', NEW.report_id) ELSE NULL END,
auth.uid()
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public;
CREATE TRIGGER job_report_link AFTER UPDATE ON jobs
FOR EACH ROW EXECUTE FUNCTION log_report_link();
-- Klus met effectief adres
CREATE VIEW job_details AS
SELECT
j.*,
c.company AS contact_company,
c.firstname AS contact_firstname,
c.lastname AS contact_lastname,
c.email AS contact_email,
c.phone AS contact_phone,
-- Effectief adres
CASE WHEN j.address_override THEN j.streetname ELSE c.streetname END AS effective_streetname,
CASE WHEN j.address_override THEN j.house_number ELSE c.house_number END AS effective_house_number,
CASE WHEN j.address_override THEN j.addition ELSE c.addition END AS effective_addition,
CASE WHEN j.address_override THEN j.zipcode ELSE c.zipcode END AS effective_zipcode,
CASE WHEN j.address_override THEN j.city ELSE c.city END AS effective_city,
-- Tellingen (LATERAL join, 1 pass i.p.v. 2 correlated subqueries)
COALESCE(wo_stats.total, 0) AS work_order_count,
COALESCE(wo_stats.drafts, 0) AS draft_count
FROM jobs j
LEFT JOIN contacts c ON c.id = j.contact_id
LEFT JOIN LATERAL (
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE wo.status = 'draft') AS drafts
FROM work_orders wo
WHERE wo.job_id = j.id AND wo.deleted_at IS NULL
) wo_stats ON true;
-- Werkbon met effectief contact
CREATE VIEW work_order_details AS
SELECT
wo.*,
COALESCE(wo.contact_id, j.contact_id) AS effective_contact_id,
ec.firstname AS contact_firstname,
ec.lastname AS contact_lastname,
ec.company AS contact_company,
ec.phone AS contact_phone,
ec.email AS contact_email,
j.status AS job_status,
j.description AS job_description,
j.report_id
FROM work_orders wo
JOIN jobs j ON wo.job_id = j.id
LEFT JOIN contacts ec ON ec.id = COALESCE(wo.contact_id, j.contact_id);
WordPress (oud)Supabase (nieuw)Verschil
job CPT (melding + klus in 1)reports + jobs (gesplitst)Triage stap toegevoegd
file CPT (werkbon)work_ordersGeen data duplicatie, auto-save
contact CPTcontactsSingle source of truth voor adres/contact
ACF meta veldenKolommen op tabellenDirect i.p.v. EAV pattern
wp_werkbon_addressesaddressesOngewijzigd
Geenactivity_logNieuw: audit trail
GeenreportsNieuw: triage stap
status 0/1/25 + 7 + 3 statussenPer entiteit eigen model
1:1 melding:werkbon1:N:N (melding → klussen → werkbonnen)Meerdere bezoeken mogelijk
Contact data 3x gekopieerdAlleen FK relatiesData integriteit