Datamodel (Nieuw)
Overzicht
Section titled “Overzicht”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.
Entiteiten
Section titled “Entiteiten”| Entiteit | Tabel | Wat is het | Voorbeeld |
|---|---|---|---|
| Contact | contacts | Klant of opdrachtgever | ”Loodgieter BV”, “Jan de Vries” |
| Melding | reports | Binnenkomend signaal/verzoek | ”Lekkage gemeld op Hoofdstraat 42” |
| Klus | jobs | Geaccepteerd werk, inplanbaar | ”Lekkage Hoofdstraat 42 — ma 9:00, Piet” |
| Werkbon | work_orders | Documentatie van uitgevoerd werk | ”Lekkage verholpen, 1.5 uur, 2x PVC buis” |
Relaties
Section titled “Relaties” ┌──────────┐ │ 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:
| Van | Naar | Kardinaliteit | Verplicht | Toelichting |
|---|---|---|---|---|
| Melding | Contact | N:1 | Ja | Elk signaal hoort bij een klant |
| Klus | Contact | N:1 | Ja | Elke klus heeft een klant |
| Klus | Melding | N:1 | Nee | Klus kan los bestaan of later gekoppeld |
| Werkbon | Klus | N:1 | Ja | Werkbon hoort altijd bij een klus |
| Werkbon | Contact | N:1 | Nee | Als 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 factuurFlow 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 aanmakenAPI Route:
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
adminSupabaseclient (service role) — geen user auth nodig voor webhook- Rate limiting: 20 req/min op
/incomingroute
Contact matching volgorde (zelfde als huidige WP logica):
1. Email match → bestaand contact2. Adres match (postcode + huisnummer + toevoeging) → bestaand contact3. 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).
Flow 3: Directe klus (zonder melding)
Section titled “Flow 3: Directe klus (zonder melding)”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)Flow 4: Meerdere klussen per melding
Section titled “Flow 4: Meerdere klussen per melding”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)Flow 5: Ander contact op werkbon
Section titled “Flow 5: Ander contact op werkbon”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 plaatseStatus Modellen
Section titled “Status Modellen”Melding Statussen
Section titled “Melding Statussen”new → accepted → (wordt klus) → rejected → merged → on_hold → new (heropend)| Status | Label | Beschrijving | Kleur |
|---|---|---|---|
new | Nieuw | Zojuist binnengekomen, nog niet beoordeeld | Blauw |
accepted | Geaccepteerd | Beoordeeld, klus wordt aangemaakt | Groen |
rejected | Afgewezen | Onterecht, duplicaat, of niet uitvoerbaar | Rood |
merged | Samengevoegd | Gekoppeld aan andere melding | Grijs |
on_hold | On hold | Wacht op informatie of goedkeuring | Oranje |
Klus Statussen
Section titled “Klus Statussen”open → assigned → scheduled → in_progress → completed → on_hold → in_progress → cancelled| Status | Label | Beschrijving | Kleur |
|---|---|---|---|
open | Open | Aangemaakt, nog niet toegewezen | Blauw |
assigned | Toegewezen | Monteur gekoppeld, nog niet gepland | Lichtblauw |
scheduled | Ingepland | Datum en tijd vastgelegd | Paars |
in_progress | Bezig | Monteur is aan het werk | Oranje |
completed | Afgerond | Alle werkbonnen ingevuld | Groen |
on_hold | Gepauzeerd | Tijdelijk gestopt (wacht op materiaal, etc.) | Geel |
cancelled | Geannuleerd | Niet meer nodig | Rood |
Werkbon Statussen
Section titled “Werkbon Statussen”draft → submitted → approved → revision_requested → draft (monteur past aan)| Status | Label | Beschrijving | Kleur |
|---|---|---|---|
draft | Concept | Auto-saved, monteur kan later terugkomen | Grijs |
submitted | Ingediend | Monteur heeft afgerond, wacht op controle | Blauw |
approved | Goedgekeurd | Gecontroleerd, klaar voor facturatie | Groen |
revision_requested | Aanpassing gevraagd | Beheerder heeft opmerking, monteur moet aanpassen | Oranje |
Supabase Schema
Section titled “Supabase Schema”contacts
Section titled “contacts”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));reports (meldingen)
Section titled “reports (meldingen)”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);jobs (klussen)
Section titled “jobs (klussen)”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);job_assignments (toewijzingen)
Section titled “job_assignments (toewijzingen)”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
material_catalog (materiaal catalogus)
Section titled “material_catalog (materiaal catalogus)”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).
work_orders (werkbonnen)
Section titled “work_orders (werkbonnen)”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);activity_log
Section titled “activity_log”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());attachments (bijlagen)
Section titled “attachments (bijlagen)”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.
addresses (lookup database)
Section titled “addresses (lookup database)”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));Overerving
Section titled “Overerving”Contact → Klus (adres)
Section titled “Contact → Klus (adres)”-- Effectief adres van een klusSELECT 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 cityFROM jobs jJOIN contacts c ON c.id = j.contact_id;address_override = false(default): adres van contactaddress_override = true: eigen adres op de klus (bijv. ander werkadres)
Klus → Werkbon (contact)
Section titled “Klus → Werkbon (contact)”-- Effectief contact van een werkbonSELECT wo.id, COALESCE(wo.contact_id, j.contact_id) AS effective_contact_idFROM work_orders woJOIN jobs j ON wo.job_id = j.id;work_order.contact_id = NULL(default): erft contact van kluswork_order.contact_id = UUID: ander contactpersoon (bijv. bewoner op locatie)
UX Pattern
Section titled “UX Pattern”┌─ 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] ││ │└────────────────────────────────────────────────┘Indexes
Section titled “Indexes”-- ContactsCREATE 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 webhooksCREATE UNIQUE INDEX idx_contacts_unique_email ON contacts(site_id, lower(email)) WHERE email IS NOT NULL AND email != '' AND deleted_at IS NULL;
-- ReportsCREATE 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);
-- JobsCREATE 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 OrdersCREATE 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 AssignmentsCREATE INDEX idx_assignments_user ON job_assignments(user_id);CREATE INDEX idx_assignments_job ON job_assignments(job_id);
-- Activity LogCREATE 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);
-- AttachmentsCREATE INDEX idx_attachments_entity ON attachments(entity_type, entity_id) WHERE deleted_at IS NULL;
-- AddressesCREATE INDEX idx_addresses_site_zip ON addresses(site_id, zipcode);CREATE INDEX idx_addresses_street ON addresses(site_id, streetname);RLS Policies
Section titled “RLS Policies”-- Helper: sites waar gebruiker toegang tot heeftCREATE 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;
-- ContactsCREATE 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()));
-- ReportsCREATE 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()));
-- JobsCREATE 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') );Triggers
Section titled “Triggers”-- Auto-update timestamps op alle tabellenCREATE 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 wijzigingenCREATE 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 klusCREATE 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();Views (voor queries)
Section titled “Views (voor queries)”-- Klus met effectief adresCREATE VIEW job_details ASSELECT 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_countFROM jobs jLEFT JOIN contacts c ON c.id = j.contact_idLEFT 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 contactCREATE VIEW work_order_details ASSELECT 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_idFROM work_orders woJOIN jobs j ON wo.job_id = j.idLEFT JOIN contacts ec ON ec.id = COALESCE(wo.contact_id, j.contact_id);Mapping: WP → Supabase
Section titled “Mapping: WP → Supabase”| WordPress (oud) | Supabase (nieuw) | Verschil |
|---|---|---|
| job CPT (melding + klus in 1) | reports + jobs (gesplitst) | Triage stap toegevoegd |
| file CPT (werkbon) | work_orders | Geen data duplicatie, auto-save |
| contact CPT | contacts | Single source of truth voor adres/contact |
| ACF meta velden | Kolommen op tabellen | Direct i.p.v. EAV pattern |
| wp_werkbon_addresses | addresses | Ongewijzigd |
| Geen | activity_log | Nieuw: audit trail |
| Geen | reports | Nieuw: triage stap |
| status 0/1/2 | 5 + 7 + 3 statussen | Per entiteit eigen model |
| 1:1 melding:werkbon | 1:N:N (melding → klussen → werkbonnen) | Meerdere bezoeken mogelijk |
| Contact data 3x gekopieerd | Alleen FK relaties | Data integriteit |