+ {errorMsg ?? 'De chat-link is ongeldig of verlopen. Gebruik de meest recente link uit je e-mail, of mail ons op '}
+ {!errorMsg && hallo@ribba.app}
+
+
+
+ );
+ }
+
+ if (phase === 'otp') {
+ return (
+
+
+
+
Verifieer je e-mailadres
+
+ Om de chat met {info.counterpart_name} te openen, verifieer je het
+ e-mailadres waarop je deze uitnodiging ontving ({info.expected_email_masked}).
+
+ {mismatchEmail && (
+
+ Je bent ingelogd als {mismatchEmail}, maar deze chat hoort bij een ander adres.{' '}
+
+
+ Je aanvraag is verstuurd naar {info.counterpart_name}, maar de rijschool
+ heeft de chat nog niet geopend. Zodra er een reactie is, krijg je een e-mail.
+
+ `
+ : '';
+
+ const html = wrap({
+ pillLabel: 'Nieuwe aanvraag',
+ pillBg: '#CCFBF1',
+ pillColor: '#134E4A',
+ title: `${voornaam} zoekt een rijschool en koos jou`,
+ bodyHtml: `
+
Beste ${escapeHtml(input.rijschoolName)},
+
Via ribba.app heeft een leerling een informatie-aanvraag naar jouw rijschool gestuurd. Reageer via de beveiligde chat — geen account of app nodig, e-mailverificatie is genoeg.
+
+ ${rows}
+
+ ${berichtBlok}
+
Contactgegevens van de leerling worden zichtbaar zodra je de aanvraag accepteert. Tot die tijd verloopt alle communicatie anoniem via Ribba.
+ `,
+ ctaLabel: 'Beantwoord de aanvraag',
+ ctaHref: chatUrl,
+ ctaColor: '#0D9488',
+ footerHtml: `Je ontvangt dit bericht omdat een leerling jouw rijschool selecteerde op ribba.app. `,
+ });
+
+ return sendMail(
+ input.to,
+ `Nieuwe rijles-aanvraag van ${voornaam} via Ribba`,
+ html,
+ 'rijschool_outreach',
+ );
+}
+
+export interface ReplyNotificationInput {
+ to: string;
+ senderName: string; // geanonimiseerd: voornaam leerling of rijschoolnaam
+ messageCount: number;
+ preview: string; // korte preview van het nieuwste bericht
+ chatToken: string; // token van de ontvangende kant → /chat/{token}
+}
+
+// Reply-notificatie (issue ribba.app#44): de ontvanger heeft geen actieve
+// push, dus e-mail met link naar de web-chat gate + app-download prompt.
+// De chat-token van de ontvangende kant fungeert ook als opt-out-token.
+export async function sendReplyNotificationMail(input: ReplyNotificationInput): Promise {
+ const chatUrl = `${BASE_URL}/chat/${input.chatToken}`;
+ const optOutUrl = `${BASE_URL}/api/notifications/opt-out?token=${input.chatToken}`;
+ const preview = input.preview.length > 120 ? `${input.preview.slice(0, 117)}…` : input.preview;
+ const single = input.messageCount === 1;
+
+ const html = wrap({
+ pillLabel: single ? 'Nieuw bericht' : `${input.messageCount} nieuwe berichten`,
+ pillBg: '#DBEAFE',
+ pillColor: '#1E40AF',
+ title: single
+ ? `Je hebt een reactie van ${input.senderName}`
+ : `Je hebt ${input.messageCount} nieuwe berichten van ${input.senderName}`,
+ bodyHtml: `
+
“${escapeHtml(preview)}”
+
Open de beveiligde chat om te antwoorden — geen account of app nodig, e-mailverificatie is genoeg.
+
Tip: download de Ribba app om je berichten te volgen met push-meldingen, zonder browser-tab:
+ `,
+ ctaLabel: 'Open de chat',
+ ctaHref: chatUrl,
+ ctaColor: '#2563EB',
+ footerHtml: `Geen e-mails meer over deze gesprekken `,
+ });
+
+ return sendMail(
+ input.to,
+ single
+ ? `Je hebt een reactie van ${input.senderName} — Ribba`
+ : `${input.messageCount} nieuwe berichten van ${input.senderName} — Ribba`,
+ html,
+ 'reply_notification',
+ );
+}
diff --git a/lib/marketplace-types.ts b/lib/marketplace-types.ts
new file mode 100644
index 0000000..e95151c
--- /dev/null
+++ b/lib/marketplace-types.ts
@@ -0,0 +1,87 @@
+// Handgeschreven row-types voor de marketplace-tabellen
+// (supabase/migrations/20260711000000_marketplace_mvp.sql).
+// Het gedeelde Supabase-project is niet CLI-gelinkt vanuit deze repo, dus
+// geen `supabase gen types` — houd dit bestand in sync met de migratie.
+
+export type RijbewijsCategorie = 'B' | 'AM' | 'A' | 'BE' | 'C' | 'CE' | 'D' | 'DE' | 'T';
+
+export type Schakeling = 'handgeschakeld' | 'automaat' | 'beide';
+
+export type InquiryRecipientStatus =
+ | 'pending'
+ | 'app_notified'
+ | 'opened'
+ | 'accepted'
+ | 'declined'
+ | 'expired';
+
+export type ChatRole = 'leerling' | 'rijschool';
+
+export type UserProfileRole = 'leerling' | 'rijschool' | 'admin';
+
+export interface InquiryRow {
+ id: string;
+ leerling_user_id: string | null;
+ leerling_email: string;
+ leerling_phone: string | null;
+ leerling_name: string;
+ rijbewijs_categorie: RijbewijsCategorie;
+ schakeling: Schakeling | null;
+ gewenste_startdatum: string | null; // ISO date
+ opleidingsvoorkeur: string | null;
+ bericht: string | null;
+ source_page: string | null;
+ toestemming_at: string;
+ created_at: string;
+}
+
+export interface InquiryRecipientRow {
+ id: string;
+ inquiry_id: string;
+ rijschool_id: number;
+ rijschool_user_id: string | null;
+ status: InquiryRecipientStatus;
+ accepted_at: string | null;
+ declined_at: string | null;
+ notification_email_sent_at: string | null;
+ notification_sms_sent_at: string | null;
+ notified_email: string | null;
+ rijschool_chat_token: string;
+ leerling_chat_token: string;
+ leerling_email_optout_at: string | null;
+ rijschool_email_optout_at: string | null;
+ created_at: string;
+}
+
+export interface ConversationRow {
+ id: string;
+ inquiry_recipient_id: string;
+ leerling_user_id: string | null; // null tot de leerling zijn kant claimt
+ rijschool_user_id: string;
+ rijschool_id: number;
+ last_message_at: string | null;
+ leerling_last_notified_at: string | null;
+ rijschool_last_notified_at: string | null;
+ created_at: string;
+}
+
+export interface MessageRow {
+ id: string;
+ conversation_id: string;
+ sender_user_id: string;
+ sender_role: ChatRole;
+ body: string;
+ read_at: string | null;
+ created_at: string;
+}
+
+export interface UserProfileRow {
+ user_id: string;
+ role: UserProfileRole;
+ full_name: string | null;
+ phone: string | null;
+ rijschool_id: number | null;
+ expo_push_token: string | null;
+ email_notifications: boolean;
+ created_at: string;
+}
diff --git a/middleware.ts b/middleware.ts
index 60ea310..ab82cf1 100644
--- a/middleware.ts
+++ b/middleware.ts
@@ -14,6 +14,7 @@ const PLANNER_ROUTES = [
'/payment',
'/reset',
'/join',
+ '/chat',
];
function isPlannerRoute(pathname: string): boolean {
diff --git a/public/chat-manifest.webmanifest b/public/chat-manifest.webmanifest
new file mode 100644
index 0000000..64ae855
--- /dev/null
+++ b/public/chat-manifest.webmanifest
@@ -0,0 +1,18 @@
+{
+ "name": "Ribba Chat",
+ "short_name": "Ribba",
+ "display": "browser",
+ "start_url": "/chat",
+ "related_applications": [
+ {
+ "platform": "play",
+ "id": "app.ribba.pro",
+ "url": "https://play.google.com/store/apps/details?id=app.ribba.pro"
+ },
+ {
+ "platform": "itunes",
+ "url": "https://apps.apple.com/nl/app/ribba-rijles-planner/id6757161459"
+ }
+ ],
+ "prefer_related_applications": true
+}
diff --git a/supabase/migrations/20260711000000_marketplace_mvp.sql b/supabase/migrations/20260711000000_marketplace_mvp.sql
new file mode 100644
index 0000000..6255eff
--- /dev/null
+++ b/supabase/migrations/20260711000000_marketplace_mvp.sql
@@ -0,0 +1,296 @@
+-- ============================================================================
+-- Marketplace MVP schema (PolderLabs/ribba.app#36, onderdeel van Epic #35)
+--
+-- Tabellen voor de two-sided inquiry-flow: leerling stuurt aanvraag vanaf de
+-- vergelijkingssite, Ribba routeert naar rijscholen, beide partijen chatten
+-- geanonimiseerd via de web-chat gateway (link.ribba.app/chat/{token}) én via
+-- de native apps. Eén bron van waarheid, meerdere clients.
+--
+-- BEWUSTE AFWIJKINGEN t.o.v. de draft-SQL in issue #36:
+--
+-- D1. `rijschool_subscriptions` is WEGGELATEN. Billing-state leeft al in
+-- `instructor_licenses` (Mollie, web-owned — zie docs/ARCHITECTUUR.md).
+-- Een tweede subscription-tabel zou twee bronnen van billing-waarheid
+-- creëren. Het freemium-model voor Chats beslist ribbaPro#141.
+--
+-- D2. Conversation vóór accept. Issue #36 maakte conversations pas bij
+-- accept aan, maar #42 eist dat de rijschool direct na e-mailverificatie
+-- in een (geanonimiseerde) chat zit. Daarom:
+-- - conversations worden aangemaakt bij de eerste claim van de
+-- rijschool (OTP-verificatie op /chat/{token}), niet bij accept;
+-- - `conversations.leerling_user_id` is NULLABLE en wordt gevuld
+-- zodra de leerling zijn kant claimt;
+-- - accept (ribbaPro#140) is enkel een status-flip op
+-- inquiry_recipients die contact-reveal ontgrendelt.
+--
+-- D3. Chat-tokens als kolommen op `inquiry_recipients`:
+-- `rijschool_chat_token` en `leerling_chat_token` (opaque uuid's).
+-- De outreach-/notificatiemails linken naar /chat/{token}; één route
+-- dekt beide rollen. Resolutie gebeurt server-side met de service role,
+-- tokens zijn nooit via anon RLS bereikbaar. `notified_email` is een
+-- snapshot van het adres dat we daadwerkelijk gemaild hebben (cbr-data
+-- kan wijzigen; de claim-check vergelijkt tegen wat we stuurden).
+--
+-- D4. Notificatie-kolommen voor reply-mails (#44) zitten er direct in:
+-- `conversations.{leerling,rijschool}_last_notified_at` en
+-- `inquiry_recipients.{leerling,rijschool}_email_optout_at`.
+--
+-- D5. GEEN anonieme INSERT-policy op `inquiries`. Alle writes lopen via
+-- /api/inquiry-submit met de service role.
+--
+-- D6. `inquiry_recipients.conversation_id` is WEGGELATEN (circulaire FK in
+-- de draft). De canonieke link is `conversations.inquiry_recipient_id`
+-- (UNIQUE); join daarop.
+--
+-- Idempotent: veilig om meermaals te draaien.
+-- ============================================================================
+
+-- ----------------------------------------------------------------------------
+-- inquiries — één aanvraag per leerling per sessie (kan N recipients hebben)
+-- ----------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS public.inquiries (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ leerling_user_id uuid REFERENCES auth.users(id), -- null tot leerling claimt via web-chat of app
+ leerling_email text NOT NULL,
+ leerling_phone text,
+ leerling_name text NOT NULL,
+ rijbewijs_categorie text NOT NULL CHECK (rijbewijs_categorie IN ('B', 'AM', 'A', 'BE', 'C', 'CE', 'D', 'DE', 'T')),
+ schakeling text CHECK (schakeling IN ('handgeschakeld', 'automaat', 'beide')),
+ gewenste_startdatum date,
+ opleidingsvoorkeur text,
+ bericht text,
+ source_page text,
+ toestemming_at timestamptz NOT NULL DEFAULT now(),
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_inquiries_leerling ON public.inquiries(leerling_user_id);
+CREATE INDEX IF NOT EXISTS idx_inquiries_leerling_email ON public.inquiries(lower(leerling_email));
+
+-- ----------------------------------------------------------------------------
+-- inquiry_recipients — één rij per (inquiry × rijschool)
+-- ----------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS public.inquiry_recipients (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ inquiry_id uuid NOT NULL REFERENCES public.inquiries(id) ON DELETE CASCADE,
+ rijschool_id integer NOT NULL REFERENCES public.cbr_rijscholen(id),
+ rijschool_user_id uuid REFERENCES auth.users(id), -- null tot rijschool claimt (web-chat of app)
+ status text NOT NULL DEFAULT 'pending' CHECK (status IN (
+ 'pending', -- net binnen, nog geen outreach gelukt
+ 'app_notified', -- rijschool kreeg mail/sms
+ 'opened', -- rijschool heeft de aanvraag geopend (web-chat claim of app)
+ 'accepted', -- rijschool accepteerde → contact-reveal
+ 'declined', -- rijschool wees af
+ 'expired' -- geen reactie binnen de vervaltermijn
+ )),
+ accepted_at timestamptz,
+ declined_at timestamptz,
+ notification_email_sent_at timestamptz,
+ notification_sms_sent_at timestamptz,
+ notified_email text, -- snapshot van gemaild rijschool-adres (D3)
+ rijschool_chat_token uuid NOT NULL DEFAULT gen_random_uuid() UNIQUE,
+ leerling_chat_token uuid NOT NULL DEFAULT gen_random_uuid() UNIQUE,
+ leerling_email_optout_at timestamptz, -- opt-out reply-mails vóór claim (D4)
+ rijschool_email_optout_at timestamptz,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (inquiry_id, rijschool_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_inquiry_recipients_rijschool_status ON public.inquiry_recipients(rijschool_id, status);
+CREATE INDEX IF NOT EXISTS idx_inquiry_recipients_inquiry_status ON public.inquiry_recipients(inquiry_id, status);
+
+-- ----------------------------------------------------------------------------
+-- conversations — één conversatie per inquiry_recipient (aangemaakt bij claim, D2)
+-- ----------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS public.conversations (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ inquiry_recipient_id uuid NOT NULL UNIQUE REFERENCES public.inquiry_recipients(id) ON DELETE CASCADE,
+ leerling_user_id uuid REFERENCES auth.users(id), -- nullable tot leerling-claim (D2)
+ rijschool_user_id uuid NOT NULL REFERENCES auth.users(id),
+ rijschool_id integer NOT NULL REFERENCES public.cbr_rijscholen(id),
+ last_message_at timestamptz,
+ leerling_last_notified_at timestamptz, -- reply-mail throttling (D4)
+ rijschool_last_notified_at timestamptz,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_conversations_leerling ON public.conversations(leerling_user_id, last_message_at DESC);
+CREATE INDEX IF NOT EXISTS idx_conversations_rijschool ON public.conversations(rijschool_user_id, last_message_at DESC);
+
+-- ----------------------------------------------------------------------------
+-- messages
+-- ----------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS public.messages (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ conversation_id uuid NOT NULL REFERENCES public.conversations(id) ON DELETE CASCADE,
+ sender_user_id uuid NOT NULL REFERENCES auth.users(id),
+ sender_role text NOT NULL CHECK (sender_role IN ('leerling', 'rijschool')),
+ body text NOT NULL,
+ read_at timestamptz,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_messages_conversation ON public.messages(conversation_id, created_at DESC);
+
+-- ----------------------------------------------------------------------------
+-- user_profiles — rol-onderscheid; auth.users bevat alleen credentials
+-- ----------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS public.user_profiles (
+ user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
+ role text NOT NULL CHECK (role IN ('leerling', 'rijschool', 'admin')),
+ full_name text,
+ phone text,
+ rijschool_id integer REFERENCES public.cbr_rijscholen(id), -- alleen voor role='rijschool'
+ expo_push_token text,
+ email_notifications boolean NOT NULL DEFAULT true,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_user_profiles_rijschool ON public.user_profiles(rijschool_id) WHERE role = 'rijschool';
+
+-- ----------------------------------------------------------------------------
+-- Trigger: last_message_at bijhouden (SECURITY DEFINER, dus geen client
+-- UPDATE-policy op conversations nodig)
+-- ----------------------------------------------------------------------------
+CREATE OR REPLACE FUNCTION public.messages_touch_conversation()
+RETURNS trigger
+SECURITY DEFINER
+SET search_path = public
+LANGUAGE plpgsql
+AS $$
+BEGIN
+ UPDATE public.conversations
+ SET last_message_at = NEW.created_at
+ WHERE id = NEW.conversation_id;
+ RETURN NEW;
+END;
+$$;
+
+DROP TRIGGER IF EXISTS trg_messages_touch_conversation ON public.messages;
+CREATE TRIGGER trg_messages_touch_conversation
+ AFTER INSERT ON public.messages
+ FOR EACH ROW EXECUTE FUNCTION public.messages_touch_conversation();
+
+-- ----------------------------------------------------------------------------
+-- RLS
+-- ----------------------------------------------------------------------------
+ALTER TABLE public.inquiries ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.inquiry_recipients ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.conversations ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.messages ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.user_profiles ENABLE ROW LEVEL SECURITY;
+
+-- anon heeft niets te zoeken in deze tabellen (alle anonieme flows lopen via
+-- service-role API routes); authenticated alleen wat de policies toestaan.
+REVOKE ALL ON public.inquiries, public.inquiry_recipients, public.conversations, public.messages, public.user_profiles FROM anon;
+
+-- inquiries: alleen de leerling zelf. Rijscholen krijgen een geanonimiseerde
+-- preview via /api/chat/resolve (service role) — contactvelden staan hier.
+DROP POLICY IF EXISTS inquiries_select_own ON public.inquiries;
+CREATE POLICY inquiries_select_own ON public.inquiries
+ FOR SELECT TO authenticated
+ USING (leerling_user_id = auth.uid());
+
+-- inquiry_recipients: eigen rijschool-kant óf eigen leerling-inquiry.
+DROP POLICY IF EXISTS inquiry_recipients_select_participant ON public.inquiry_recipients;
+CREATE POLICY inquiry_recipients_select_participant ON public.inquiry_recipients
+ FOR SELECT TO authenticated
+ USING (
+ rijschool_user_id = auth.uid()
+ OR inquiry_id IN (SELECT id FROM public.inquiries WHERE leerling_user_id = auth.uid())
+ );
+
+-- conversations: alleen participants. Geen client-INSERT/UPDATE (claim-API en
+-- trigger regelen mutaties).
+DROP POLICY IF EXISTS conversations_select_participant ON public.conversations;
+CREATE POLICY conversations_select_participant ON public.conversations
+ FOR SELECT TO authenticated
+ USING (auth.uid() = leerling_user_id OR auth.uid() = rijschool_user_id);
+
+-- messages: lezen als participant; schrijven alleen als jezelf, op de kant
+-- die je in de conversatie inneemt; updaten (read receipts) alleen op
+-- berichten van de ander — column-grant beperkt dat tot read_at.
+DROP POLICY IF EXISTS messages_select_participant ON public.messages;
+CREATE POLICY messages_select_participant ON public.messages
+ FOR SELECT TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM public.conversations c
+ WHERE c.id = conversation_id
+ AND (auth.uid() = c.leerling_user_id OR auth.uid() = c.rijschool_user_id)
+ )
+ );
+
+DROP POLICY IF EXISTS messages_insert_own ON public.messages;
+CREATE POLICY messages_insert_own ON public.messages
+ FOR INSERT TO authenticated
+ WITH CHECK (
+ sender_user_id = auth.uid()
+ AND EXISTS (
+ SELECT 1 FROM public.conversations c
+ WHERE c.id = conversation_id
+ AND (
+ (sender_role = 'leerling' AND c.leerling_user_id = auth.uid())
+ OR (sender_role = 'rijschool' AND c.rijschool_user_id = auth.uid())
+ )
+ )
+ );
+
+DROP POLICY IF EXISTS messages_update_read_receipt ON public.messages;
+CREATE POLICY messages_update_read_receipt ON public.messages
+ FOR UPDATE TO authenticated
+ USING (
+ sender_user_id <> auth.uid()
+ AND EXISTS (
+ SELECT 1 FROM public.conversations c
+ WHERE c.id = conversation_id
+ AND (auth.uid() = c.leerling_user_id OR auth.uid() = c.rijschool_user_id)
+ )
+ )
+ WITH CHECK (
+ sender_user_id <> auth.uid()
+ AND EXISTS (
+ SELECT 1 FROM public.conversations c
+ WHERE c.id = conversation_id
+ AND (auth.uid() = c.leerling_user_id OR auth.uid() = c.rijschool_user_id)
+ )
+ );
+
+-- Column-grant: authenticated mag via UPDATE alléén read_at aanraken.
+REVOKE UPDATE ON public.messages FROM authenticated;
+GRANT UPDATE (read_at) ON public.messages TO authenticated;
+
+-- user_profiles: alleen eigen rij (INSERT via service role in de claim-stap).
+DROP POLICY IF EXISTS user_profiles_select_own ON public.user_profiles;
+CREATE POLICY user_profiles_select_own ON public.user_profiles
+ FOR SELECT TO authenticated
+ USING (user_id = auth.uid());
+
+DROP POLICY IF EXISTS user_profiles_update_own ON public.user_profiles;
+CREATE POLICY user_profiles_update_own ON public.user_profiles
+ FOR UPDATE TO authenticated
+ USING (user_id = auth.uid())
+ WITH CHECK (user_id = auth.uid());
+
+-- ----------------------------------------------------------------------------
+-- Realtime: messages + conversations in de supabase_realtime publication
+-- (idempotent; RLS geldt ook voor realtime — client moet realtime.setAuth()
+-- aanroepen met een geldig access token)
+-- ----------------------------------------------------------------------------
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_publication_tables
+ WHERE pubname = 'supabase_realtime' AND schemaname = 'public' AND tablename = 'messages'
+ ) THEN
+ ALTER PUBLICATION supabase_realtime ADD TABLE public.messages;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_publication_tables
+ WHERE pubname = 'supabase_realtime' AND schemaname = 'public' AND tablename = 'conversations'
+ ) THEN
+ ALTER PUBLICATION supabase_realtime ADD TABLE public.conversations;
+ END IF;
+END;
+$$;
diff --git a/supabase/migrations/README.md b/supabase/migrations/README.md
new file mode 100644
index 0000000..f5434f9
--- /dev/null
+++ b/supabase/migrations/README.md
@@ -0,0 +1,34 @@
+# Supabase-migraties
+
+Deze repo heeft (nog) geen CLI-gelinkte migratie-historie. Migraties hier zijn
+SQL-bestanden die **handmatig** op het gedeelde Supabase-project
+(`vsuhctqdtsxyimzsbjds.supabase.co`) toegepast worden — hetzelfde project dat
+ook door de native apps (ribbaPro) gebruikt wordt.
+
+## Toepassen
+
+Optie 1 — dashboard (aanbevolen voor nu):
+
+1. Open het Supabase-dashboard → SQL Editor.
+2. Plak de inhoud van het migratie-bestand en voer uit.
+3. Migraties zijn idempotent — nogmaals draaien is veilig.
+
+Optie 2 — CLI:
+
+```bash
+supabase link --project-ref vsuhctqdtsxyimzsbjds
+supabase db push
+```
+
+## Aandachtspunten
+
+- `20260711000000_marketplace_mvp.sql` gaat ervan uit dat `cbr_rijscholen`
+ bestaat met een **integer** primary key. Is de PK `bigint`, pas dan de
+ `rijschool_id`-kolommen in de migratie aan vóór het draaien.
+- De migratie bevat bewuste afwijkingen t.o.v. de draft in issue #36 — zie de
+ header van het bestand. Communiceer die naar het ribbaPro-team (issues
+ #139/#140/#141 bouwen tegen dit schema).
+- Voor de web-chat gate (#42) moet in het dashboard de **e-mail OTP-flow**
+ aanstaan: Auth → Email Templates → "Magic Link" template moet `{{ .Token }}`
+ bevatten (anders krijgen gebruikers alleen een link, geen 6-cijferige code).
+ ⚠️ Dit template is gedeeld met de native apps — eerst afstemmen.
diff --git a/vercel.json b/vercel.json
index 015fa34..94134cd 100644
--- a/vercel.json
+++ b/vercel.json
@@ -7,6 +7,10 @@
{
"path": "/api/cron/reconcile-subscriptions",
"schedule": "0 3 * * *"
+ },
+ {
+ "path": "/api/cron/chat-notifications",
+ "schedule": "*/5 * * * *"
}
]
}
From 1a9d4b63085bac32823676fcde532ed30e55ad01 Mon Sep 17 00:00:00 2001
From: Melvin
Date: Fri, 10 Jul 2026 11:12:14 -0700
Subject: [PATCH 2/9] fix: verwerk schema-contract-review + PR-feedback
(web/app-sync)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Sync met ribba.app#36-contract (issuecomment-4933108256) en de reviews op
ribba.app#45 / ribbaPro#218:
- marketplace_profiles i.p.v. user_profiles: minimaal rol-register, geen
expo_push_token (push_tokens-tabel van de app is SSoT)
- inquiry_recipients: + opened_at, decline_reason, expires_at, school_id;
conversations: + school_id, last_message_preview (trigger onderhoudt preview)
- Gedeelde SECURITY DEFINER RPC's i.p.v. Next-only claim-routes:
get_chat_context, claim_inquiry, claim_inquiry_recipient (e-mail-match
tegen actueel cbr_rijscholen.email), get_inquiry_for_recipient,
mark_messages_read — web-chat en ribbaPro-app delen exact dezelfde
semantiek; /api/chat/resolve en /api/chat/claim vervallen
- messages: géén client-UPDATE meer; read_at alleen via mark_messages_read
(NULL → now(), alleen counterpart-berichten)
- Realtime publication: + inquiry_recipients (app-inboxen #140/#142)
- Cron: push/e-mail-dedupe via push_tokens-tabel
- inquiry-submit: 24u (e-mail × rijschool) dedupe, marketing_optin,
bevestigingsmail naar de leerling, startdatum-synthese gedocumenteerd
- AASA: /chat/* toegevoegd — één URL-schema voor mail-links; met de app
geïnstalleerd opent de universal link de app (assetlinks dekt Android al
via handle_all_urls)
---
app/api/chat/claim/route.ts | 202 --------
app/api/chat/resolve/route.ts | 96 ----
app/api/cron/chat-notifications/route.ts | 47 +-
app/api/inquiry-submit/route.ts | 55 +-
app/api/notifications/opt-out/route.ts | 2 +-
components/chat/ChatGateway.tsx | 116 +++--
components/chat/ChatThread.tsx | 18 +-
docs/ARCHITECTUUR.md | 13 +-
lib/marketplace-emails.ts | 40 ++
lib/marketplace-types.ts | 42 +-
public/.well-known/apple-app-site-association | 3 +-
.../20260711000000_marketplace_mvp.sql | 470 ++++++++++++++----
supabase/migrations/README.md | 20 +-
13 files changed, 625 insertions(+), 499 deletions(-)
delete mode 100644 app/api/chat/claim/route.ts
delete mode 100644 app/api/chat/resolve/route.ts
diff --git a/app/api/chat/claim/route.ts b/app/api/chat/claim/route.ts
deleted file mode 100644
index 245393f..0000000
--- a/app/api/chat/claim/route.ts
+++ /dev/null
@@ -1,202 +0,0 @@
-// Claim-stap van de web-chat gateway (issue ribba.app#42): koppelt een zojuist
-// via OTP geverifieerde Supabase-user aan zijn kant van de inquiry en maakt
-// (voor de rijschool) de conversatie aan. Het geverifieerde e-mailadres MOET
-// matchen met het adres waar we de link naartoe stuurden — dit is tegelijk de
-// identiteitskoppeling voor account-continuïteit web → app (ribbaPro#139).
-
-import { NextRequest, NextResponse } from 'next/server';
-import { rateLimit } from '@/lib/rate-limit';
-import { getServiceClient, lookupRecipientByToken } from '@/lib/marketplace-db';
-import type { ChatRole } from '@/lib/marketplace-types';
-
-async function ensureUserProfile(
- userId: string,
- role: ChatRole,
- fullName: string | null,
- rijschoolId: number | null,
-): Promise {
- const supabase = getServiceClient();
- const { data: existing } = await supabase
- .from('user_profiles')
- .select('user_id, role')
- .eq('user_id', userId)
- .maybeSingle();
-
- // Bestaand profiel NOOIT overschrijven: de auth-pool is gedeeld met de
- // native apps en een bestaande rol (bijv. via de app aangemaakt) is leidend.
- if (existing) return;
-
- const { error } = await supabase.from('user_profiles').insert({
- user_id: userId,
- role,
- full_name: fullName,
- rijschool_id: role === 'rijschool' ? rijschoolId : null,
- });
- if (error && error.code !== '23505') {
- throw new Error(`user_profiles insert failed: ${error.message}`);
- }
-}
-
-export async function POST(request: NextRequest) {
- const ip = request.headers.get('x-forwarded-for')?.split(',')[0] ?? 'unknown';
- if (!rateLimit(`chat-claim:${ip}`, { maxRequests: 20, windowMs: 60_000 })) {
- return NextResponse.json({ error: 'Te veel verzoeken.' }, { status: 429 });
- }
-
- const authHeader = request.headers.get('authorization');
- const jwt = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
- if (!jwt) {
- return NextResponse.json({ error: 'Niet ingelogd.' }, { status: 401 });
- }
-
- let body: { token?: unknown };
- try {
- body = await request.json();
- } catch {
- return NextResponse.json({ error: 'Ongeldige request body.' }, { status: 400 });
- }
- const token = typeof body.token === 'string' ? body.token.trim() : '';
-
- try {
- const supabase = getServiceClient();
-
- const { data: userData, error: userError } = await supabase.auth.getUser(jwt);
- const user = userData?.user;
- if (userError || !user?.email) {
- return NextResponse.json({ error: 'Sessie ongeldig. Log opnieuw in.' }, { status: 401 });
- }
-
- const lookup = await lookupRecipientByToken(token);
- if (!lookup) {
- return NextResponse.json({ error: 'Ongeldige of verlopen link.' }, { status: 404 });
- }
- const { recipient, role } = lookup;
-
- const { data: inquiry } = await supabase
- .from('inquiries')
- .select('id, leerling_user_id, leerling_email, leerling_name')
- .eq('id', recipient.inquiry_id)
- .single();
- if (!inquiry) {
- return NextResponse.json({ error: 'Aanvraag niet gevonden.' }, { status: 404 });
- }
-
- const expectedEmail = role === 'rijschool' ? recipient.notified_email : inquiry.leerling_email;
- if (!expectedEmail || user.email.toLowerCase() !== expectedEmail.toLowerCase()) {
- return NextResponse.json(
- { error: 'Dit e-mailadres hoort niet bij deze chat.' },
- { status: 403 },
- );
- }
-
- if (role === 'rijschool') {
- await ensureUserProfile(user.id, 'rijschool', null, recipient.rijschool_id);
-
- const recipientUpdate: Record = { rijschool_user_id: user.id };
- if (recipient.status === 'pending' || recipient.status === 'app_notified') {
- recipientUpdate.status = 'opened';
- }
- const { error: updateError } = await supabase
- .from('inquiry_recipients')
- .update(recipientUpdate)
- .eq('id', recipient.id);
- if (updateError) {
- throw new Error(`inquiry_recipients update failed: ${updateError.message}`);
- }
-
- // Conversatie lazy aanmaken bij eerste claim (schema-afwijking D2).
- let { data: conversation } = await supabase
- .from('conversations')
- .select('id')
- .eq('inquiry_recipient_id', recipient.id)
- .maybeSingle();
-
- if (!conversation) {
- const { data: created, error: convError } = await supabase
- .from('conversations')
- .insert({
- inquiry_recipient_id: recipient.id,
- rijschool_user_id: user.id,
- rijschool_id: recipient.rijschool_id,
- leerling_user_id: inquiry.leerling_user_id,
- })
- .select('id')
- .single();
- if (convError) {
- // 23505 = race met een parallelle claim; dan bestaat hij inmiddels.
- if (convError.code !== '23505') {
- throw new Error(`conversations insert failed: ${convError.message}`);
- }
- const { data: raced } = await supabase
- .from('conversations')
- .select('id')
- .eq('inquiry_recipient_id', recipient.id)
- .maybeSingle();
- conversation = raced;
- } else {
- conversation = created;
- }
- }
-
- return NextResponse.json({
- conversation_id: conversation?.id ?? null,
- role,
- status: recipientUpdate.status ?? recipient.status,
- });
- }
-
- // role === 'leerling'
- await ensureUserProfile(user.id, 'leerling', inquiry.leerling_name, null);
-
- if (!inquiry.leerling_user_id) {
- const { error: linkError } = await supabase
- .from('inquiries')
- .update({ leerling_user_id: user.id })
- .eq('id', inquiry.id)
- .is('leerling_user_id', null);
- if (linkError) {
- throw new Error(`inquiries link failed: ${linkError.message}`);
- }
- } else if (inquiry.leerling_user_id !== user.id) {
- // Zelfde e-mailadres kan niet bij twee verschillende auth-users horen,
- // maar wees defensief.
- return NextResponse.json({ error: 'Deze aanvraag hoort bij een ander account.' }, { status: 403 });
- }
-
- // Backfill: álle conversaties van deze inquiry (ook die van andere
- // rijscholen) aan dit leerling-account hangen, zodat web en app dezelfde
- // gesprekken tonen (account-continuïteit, Epic #35).
- const { data: recipientIds } = await supabase
- .from('inquiry_recipients')
- .select('id')
- .eq('inquiry_id', inquiry.id);
- if (recipientIds && recipientIds.length > 0) {
- const { error: backfillError } = await supabase
- .from('conversations')
- .update({ leerling_user_id: user.id })
- .in('inquiry_recipient_id', recipientIds.map((r) => r.id))
- .is('leerling_user_id', null);
- if (backfillError) {
- throw new Error(`conversations backfill failed: ${backfillError.message}`);
- }
- }
-
- const { data: conversation } = await supabase
- .from('conversations')
- .select('id')
- .eq('inquiry_recipient_id', recipient.id)
- .maybeSingle();
-
- // Geen conversatie = rijschool heeft nog nooit geclaimd; de UI toont dan
- // een wachtstand (kan alleen via een verouderde link gebeuren — reply-
- // mails bestaan pas nádat de rijschool een bericht stuurde).
- return NextResponse.json({
- conversation_id: conversation?.id ?? null,
- role,
- status: recipient.status,
- });
- } catch (error) {
- console.error('chat/claim error:', error);
- return NextResponse.json({ error: 'Er ging iets mis.' }, { status: 500 });
- }
-}
diff --git a/app/api/chat/resolve/route.ts b/app/api/chat/resolve/route.ts
deleted file mode 100644
index 63f9afa..0000000
--- a/app/api/chat/resolve/route.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-// Token-resolutie voor de web-chat gateway (/chat/{token}, issue ribba.app#42).
-// Geeft een geanonimiseerde preview terug: geen e-mail/telefoon van de
-// leerling vóór accept, alleen een masked hint van het te verifiëren adres.
-
-import { NextRequest, NextResponse } from 'next/server';
-import { rateLimit } from '@/lib/rate-limit';
-import { getServiceClient, getCbrRijscholen, lookupRecipientByToken } from '@/lib/marketplace-db';
-import { anonymizedFirstName } from '@/lib/marketplace-emails';
-
-function maskEmail(email: string): string {
- const [local, domain] = email.split('@');
- if (!domain) return '***';
- return `${local.slice(0, 1)}${'*'.repeat(Math.max(local.length - 1, 2))}@${domain}`;
-}
-
-export async function POST(request: NextRequest) {
- const ip = request.headers.get('x-forwarded-for')?.split(',')[0] ?? 'unknown';
- if (!rateLimit(`chat-resolve:${ip}`, { maxRequests: 30, windowMs: 60_000 })) {
- return NextResponse.json({ error: 'Te veel verzoeken.' }, { status: 429 });
- }
-
- let body: { token?: unknown };
- try {
- body = await request.json();
- } catch {
- return NextResponse.json({ error: 'Ongeldige request body.' }, { status: 400 });
- }
- const token = typeof body.token === 'string' ? body.token.trim() : '';
-
- try {
- const lookup = await lookupRecipientByToken(token);
- if (!lookup) {
- return NextResponse.json({ error: 'Ongeldige of verlopen link.' }, { status: 404 });
- }
- const { recipient, role } = lookup;
- const supabase = getServiceClient();
-
- const { data: inquiry } = await supabase
- .from('inquiries')
- .select('id, leerling_user_id, leerling_email, leerling_phone, leerling_name, rijbewijs_categorie, schakeling, gewenste_startdatum, bericht, created_at')
- .eq('id', recipient.inquiry_id)
- .single();
- if (!inquiry) {
- return NextResponse.json({ error: 'Aanvraag niet gevonden.' }, { status: 404 });
- }
-
- const [school] = await getCbrRijscholen([recipient.rijschool_id]);
-
- const { data: conversation } = await supabase
- .from('conversations')
- .select('id')
- .eq('inquiry_recipient_id', recipient.id)
- .maybeSingle();
-
- const expectedEmail = role === 'rijschool' ? recipient.notified_email : inquiry.leerling_email;
- if (!expectedEmail) {
- // Rijschool-token terwijl er nooit een outreach-mail is verstuurd:
- // hoort niet voor te komen (de link staat alleen in die mail).
- return NextResponse.json({ error: 'Deze link is nog niet actief.' }, { status: 409 });
- }
-
- const claimed = role === 'rijschool'
- ? recipient.rijschool_user_id !== null
- : inquiry.leerling_user_id !== null;
-
- return NextResponse.json({
- role,
- status: recipient.status,
- claimed,
- conversation_id: conversation?.id ?? null,
- expected_email_masked: maskEmail(expectedEmail),
- counterpart_name: role === 'rijschool'
- ? anonymizedFirstName(inquiry.leerling_name)
- : (school?.name ?? 'Rijschool'),
- inquiry_preview: {
- voornaam: anonymizedFirstName(inquiry.leerling_name),
- rijbewijs_categorie: inquiry.rijbewijs_categorie,
- schakeling: inquiry.schakeling,
- gewenste_startdatum: inquiry.gewenste_startdatum,
- bericht: inquiry.bericht,
- created_at: inquiry.created_at,
- },
- // Contact-reveal pas na accept (ribbaPro#140) en alleen voor de rijschool.
- contact: role === 'rijschool' && recipient.status === 'accepted'
- ? {
- name: inquiry.leerling_name,
- email: inquiry.leerling_email,
- phone: inquiry.leerling_phone,
- }
- : null,
- });
- } catch (error) {
- console.error('chat/resolve error:', error);
- return NextResponse.json({ error: 'Er ging iets mis.' }, { status: 500 });
- }
-}
diff --git a/app/api/cron/chat-notifications/route.ts b/app/api/cron/chat-notifications/route.ts
index 687afcf..aac2ab8 100644
--- a/app/api/cron/chat-notifications/route.ts
+++ b/app/api/cron/chat-notifications/route.ts
@@ -11,7 +11,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServiceClient, getCbrRijscholen } from '@/lib/marketplace-db';
import { sendReplyNotificationMail, anonymizedFirstName } from '@/lib/marketplace-emails';
-import type { ChatRole, MessageRow, UserProfileRow } from '@/lib/marketplace-types';
+import type { ChatRole, MessageRow } from '@/lib/marketplace-types';
export const dynamic = 'force-dynamic';
export const maxDuration = 60;
@@ -76,20 +76,36 @@ export async function GET(request: NextRequest) {
const conversations = (candidates ?? []) as unknown as ConversationJoin[];
- // Profielen in bulk ophalen (push-token + e-mailvoorkeur).
+ // E-mailvoorkeuren + push-status in bulk. Push-status komt uit de bestaande
+ // multi-device `push_tokens`-tabel die de app onderhoudt (SSoT) — wie daar
+ // een device heeft, krijgt push via ribbaPro#144 en dus géén e-mail.
const userIds = [
...new Set(
conversations.flatMap((c) => [c.leerling_user_id, c.rijschool_user_id]).filter((id): id is string => !!id),
),
];
- const profiles = new Map>();
+ const emailPrefs = new Map();
+ const usersWithPush = new Set();
if (userIds.length > 0) {
const { data: profileRows } = await supabase
- .from('user_profiles')
- .select('user_id, expo_push_token, email_notifications')
+ .from('marketplace_profiles')
+ .select('user_id, email_notifications')
.in('user_id', userIds);
for (const p of profileRows ?? []) {
- profiles.set(p.user_id, p);
+ emailPrefs.set(p.user_id, p.email_notifications);
+ }
+
+ const { data: pushRows, error: pushError } = await supabase
+ .from('push_tokens')
+ .select('user_id')
+ .in('user_id', userIds);
+ if (pushError) {
+ // Tabel(naam) niet beschikbaar → conservatief: niemand als push-gedekt
+ // beschouwen (liever een dubbele notificatie dan geen enkele).
+ console.warn('chat-notifications: push_tokens lookup failed', pushError.message);
+ }
+ for (const p of pushRows ?? []) {
+ usersWithPush.add(p.user_id);
}
}
@@ -153,17 +169,14 @@ export async function GET(request: NextRequest) {
continue;
}
if (sideUserId) {
- const profile = profiles.get(sideUserId);
- if (profile) {
- // Actieve push in de app → geen dubbele e-mail (ribbaPro#144 dekt push).
- if (profile.expo_push_token) {
- skipped++;
- continue;
- }
- if (!profile.email_notifications) {
- skipped++;
- continue;
- }
+ // Actieve push in de app → geen dubbele e-mail (ribbaPro#144 dekt push).
+ if (usersWithPush.has(sideUserId)) {
+ skipped++;
+ continue;
+ }
+ if (emailPrefs.get(sideUserId) === false) {
+ skipped++;
+ continue;
}
}
// Ongeclaimde leerling (geen user_id): altijd mailen — dit is de stap
diff --git a/app/api/inquiry-submit/route.ts b/app/api/inquiry-submit/route.ts
index 4efacf3..3f18cda 100644
--- a/app/api/inquiry-submit/route.ts
+++ b/app/api/inquiry-submit/route.ts
@@ -8,7 +8,7 @@ import { rateLimit } from '@/lib/rate-limit';
import { isValidEmail, isValidInternationalPhone } from '@/utils/validation';
import { corsHeaders, corsPreflight } from '@/lib/cors';
import { getServiceClient, getCbrRijscholen } from '@/lib/marketplace-db';
-import { sendRijschoolOutreachMail } from '@/lib/marketplace-emails';
+import { sendRijschoolOutreachMail, sendLeerlingBevestigingMail } from '@/lib/marketplace-emails';
export const maxDuration = 60;
@@ -57,9 +57,12 @@ export async function POST(request: NextRequest) {
: null;
const categorie = body.rijbewijs_categorie;
const schakeling = body.schakeling ?? null;
+ // Mag client-side gesynthetiseerd zijn: de ContactForm vertaalt zsm/+1m/+3m
+ // naar een concrete datum en "later" naar null.
const startdatum = typeof body.gewenste_startdatum === 'string' && body.gewenste_startdatum !== ''
? body.gewenste_startdatum
: null;
+ const marketingOptin = body.marketing_optin === true;
const opleidingsvoorkeur = typeof body.opleidingsvoorkeur === 'string' && body.opleidingsvoorkeur.trim() !== ''
? body.opleidingsvoorkeur.trim().slice(0, 500)
: null;
@@ -115,6 +118,31 @@ export async function POST(request: NextRequest) {
const supabase = getServiceClient();
+ // Dedupe: rijscholen die dit e-mailadres < 24u geleden al aanschreef
+ // overslaan (rate limiting per IP dekt geen roterende IP's).
+ const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
+ const { data: recentRows, error: dedupeError } = await supabase
+ .from('inquiry_recipients')
+ .select('rijschool_id, inquiries!inner(leerling_email)')
+ .in('rijschool_id', rijschoolIds)
+ .gte('created_at', dayAgo)
+ .eq('inquiries.leerling_email', leerlingEmail);
+ if (dedupeError) {
+ console.error('inquiry-submit: dedupe query failed', dedupeError);
+ return NextResponse.json(
+ { error: 'Er ging iets mis. Probeer het opnieuw.' },
+ { status: 500, headers },
+ );
+ }
+ const recentIds = new Set((recentRows ?? []).map((r) => r.rijschool_id as number));
+ const freshIds = rijschoolIds.filter((id) => !recentIds.has(id));
+ if (freshIds.length === 0) {
+ return NextResponse.json(
+ { error: 'Je hebt deze rijscholen de afgelopen 24 uur al een aanvraag gestuurd.' },
+ { status: 409, headers },
+ );
+ }
+
const { data: inquiry, error: inquiryError } = await supabase
.from('inquiries')
.insert({
@@ -127,6 +155,7 @@ export async function POST(request: NextRequest) {
opleidingsvoorkeur,
bericht,
source_page: sourcePage,
+ marketing_optin: marketingOptin,
})
.select('id')
.single();
@@ -141,7 +170,7 @@ export async function POST(request: NextRequest) {
const { data: recipients, error: recipientsError } = await supabase
.from('inquiry_recipients')
- .insert(rijschoolIds.map((rijschoolId) => ({
+ .insert(freshIds.map((rijschoolId) => ({
inquiry_id: inquiry.id,
rijschool_id: rijschoolId,
})))
@@ -157,11 +186,27 @@ export async function POST(request: NextRequest) {
);
}
- // Outreach ná de response: de leerling hoeft niet op 10 Resend-calls te
- // wachten. Eén mislukte mail laat de recipient op 'pending' staan
- // (zichtbaar in de data; retry is een follow-up van de notificatie-cron).
+ // Outreach + leerling-bevestiging ná de response: de leerling hoeft niet
+ // op 10+ Resend-calls te wachten. Eén mislukte mail laat de recipient op
+ // 'pending' staan (zichtbaar in de data; retry is een follow-up van de
+ // notificatie-cron).
after(async () => {
const schoolById = new Map(schools.map((s) => [s.id, s]));
+
+ // Bevestigingsmail naar de leerling: verwachtingen zetten + het eerste
+ // contactmoment (warmt de mailbox op vóór de reply-notificaties).
+ try {
+ await sendLeerlingBevestigingMail({
+ to: leerlingEmail,
+ leerlingFullName: leerlingName,
+ schoolNames: recipients
+ .map((r) => schoolById.get(r.rijschool_id)?.name)
+ .filter((n): n is string => !!n),
+ });
+ } catch (err) {
+ console.error('inquiry-submit: leerling-bevestiging failed', err);
+ }
+
for (const recipient of recipients) {
const school = schoolById.get(recipient.rijschool_id);
if (!school?.email) {
diff --git a/app/api/notifications/opt-out/route.ts b/app/api/notifications/opt-out/route.ts
index 352da77..09bcd3c 100644
--- a/app/api/notifications/opt-out/route.ts
+++ b/app/api/notifications/opt-out/route.ts
@@ -65,7 +65,7 @@ export async function GET(request: NextRequest) {
}
if (sideUserId) {
await supabase
- .from('user_profiles')
+ .from('marketplace_profiles')
.update({ email_notifications: false })
.eq('user_id', sideUserId);
}
diff --git a/components/chat/ChatGateway.tsx b/components/chat/ChatGateway.tsx
index 2767933..30e5edd 100644
--- a/components/chat/ChatGateway.tsx
+++ b/components/chat/ChatGateway.tsx
@@ -3,32 +3,19 @@
// Web-chat gateway (issue ribba.app#42): vangt de link uit de outreach-/
// reply-mail op, gate't op e-mailverificatie (Supabase OTP) en plaatst de
// geverifieerde gebruiker in de geanonimiseerde 1-op-1 chat.
+//
+// Alle chat-semantiek loopt via de gedeelde SECURITY DEFINER RPC's uit de
+// marketplace-migratie (get_chat_context / claim_inquiry /
+// claim_inquiry_recipient) — exact hetzelfde contract als de ribbaPro-app,
+// zodat web en app nooit uit elkaar kunnen lopen.
import { useCallback, useEffect, useRef, useState } from 'react';
import { createBrowserClient } from '@supabase/ssr';
-import type { Session, SupabaseClient } from '@supabase/supabase-js';
+import type { SupabaseClient } from '@supabase/supabase-js';
import RibbaLogo from '@/app/components/RibbaLogo';
import OtpGate from './OtpGate';
import ChatThread from './ChatThread';
-import type { ChatRole, InquiryRecipientStatus } from '@/lib/marketplace-types';
-
-export interface ResolveInfo {
- role: ChatRole;
- status: InquiryRecipientStatus;
- claimed: boolean;
- conversation_id: string | null;
- expected_email_masked: string;
- counterpart_name: string;
- inquiry_preview: {
- voornaam: string;
- rijbewijs_categorie: string;
- schakeling: string | null;
- gewenste_startdatum: string | null;
- bericht: string | null;
- created_at: string;
- };
- contact: { name: string; email: string; phone: string | null } | null;
-}
+import type { ChatContext } from '@/lib/marketplace-types';
type Phase =
| 'resolving'
@@ -51,44 +38,70 @@ function getSupabase(): SupabaseClient {
return browserClient;
}
+// SQLSTATE 28000 = e-mail-mismatch / niet ingelogd in de claim-RPC's.
+function isEmailMismatch(error: { code?: string; message?: string } | null): boolean {
+ return error?.code === '28000' || (error?.message ?? '').includes('komt niet overeen');
+}
+
export default function ChatGateway({ token }: { token: string }) {
const [phase, setPhase] = useState('resolving');
- const [info, setInfo] = useState(null);
+ const [info, setInfo] = useState(null);
const [conversationId, setConversationId] = useState(null);
const [errorMsg, setErrorMsg] = useState(null);
const [mismatchEmail, setMismatchEmail] = useState(null);
const claimInFlight = useRef(false);
- const claim = useCallback(async (session: Session): Promise => {
+ const claim = useCallback(async (context: ChatContext): Promise => {
if (claimInFlight.current) return;
claimInFlight.current = true;
setPhase('claiming');
+ const supabase = getSupabase();
try {
- const res = await fetch('/api/chat/claim', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Authorization: `Bearer ${session.access_token}`,
- },
- body: JSON.stringify({ token }),
- });
- const data = await res.json().catch(() => ({}));
-
- if (res.status === 403) {
- // Ingelogd met een ander adres dan waar de mail heen ging.
- setMismatchEmail(session.user.email ?? null);
+ const { data: { session } } = await supabase.auth.getSession();
+ if (!session) {
setPhase('otp');
return;
}
- if (!res.ok) {
- setErrorMsg(data.error ?? 'Er ging iets mis.');
- setPhase('error');
+
+ if (context.role === 'rijschool') {
+ const { data, error } = await supabase.rpc('claim_inquiry_recipient', {
+ p_recipient_id: context.recipient_id,
+ });
+ if (error) {
+ if (isEmailMismatch(error)) {
+ setMismatchEmail(session.user.email ?? null);
+ setPhase('otp');
+ } else {
+ setErrorMsg('Er ging iets mis bij het openen van de chat.');
+ setPhase('error');
+ }
+ return;
+ }
+ setConversationId(data?.conversation_id ?? null);
+ setPhase(data?.conversation_id ? 'chat' : 'error');
return;
}
- if (data.conversation_id) {
- setConversationId(data.conversation_id);
+
+ // role === 'leerling'
+ const { error } = await supabase.rpc('claim_inquiry', {
+ p_inquiry_id: context.inquiry_id,
+ });
+ if (error) {
+ if (isEmailMismatch(error)) {
+ setMismatchEmail(session.user.email ?? null);
+ setPhase('otp');
+ } else {
+ setErrorMsg('Er ging iets mis bij het openen van de chat.');
+ setPhase('error');
+ }
+ return;
+ }
+ if (context.conversation_id) {
+ setConversationId(context.conversation_id);
setPhase('chat');
} else {
+ // Rijschool heeft de chat nog nooit geopend — kan alleen via een
+ // verouderde link (reply-mails bestaan pas ná een rijschool-bericht).
setPhase('waiting');
}
} catch {
@@ -97,27 +110,23 @@ export default function ChatGateway({ token }: { token: string }) {
} finally {
claimInFlight.current = false;
}
- }, [token]);
+ }, []);
useEffect(() => {
(async () => {
- const res = await fetch('/api/chat/resolve', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ token }),
- }).catch(() => null);
+ const supabase = getSupabase();
+ const { data, error } = await supabase.rpc('get_chat_context', { p_token: token });
- if (!res || !res.ok) {
+ if (error || !data?.found) {
setPhase('invalid');
return;
}
- const data: ResolveInfo = await res.json();
- setInfo(data);
+ const context = data as ChatContext;
+ setInfo(context);
- const supabase = getSupabase();
const { data: { session } } = await supabase.auth.getSession();
if (session) {
- await claim(session);
+ await claim(context);
} else {
setPhase('otp');
}
@@ -163,7 +172,8 @@ export default function ChatGateway({ token }: { token: string }) {
Verifieer je e-mailadres
Om de chat met {info.counterpart_name} te openen, verifieer je het
- e-mailadres waarop je deze uitnodiging ontving ({info.expected_email_masked}).
+ e-mailadres waarop je deze uitnodiging ontving
+ {info.expected_email_masked ? ` (${info.expected_email_masked})` : ''}.
We hebben je aanvraag doorgestuurd naar ${count === 1 ? 'deze rijschool' : `deze ${count} rijscholen`}:
+
${lijst}
+
Rijscholen reageren meestal binnen 24 uur. Zodra een rijschool antwoordt, krijg je van ons een e-mail met een link naar de beveiligde chat — geen account of app nodig.
+
Tip: houd ook je spam-map in de gaten, zodat je geen reactie mist.
+ `,
+ footerHtml: `Je ontvangt dit bericht omdat je via ribba.app een informatie-aanvraag verstuurde. `,
+ });
+
+ return sendMail(
+ input.to,
+ count === 1
+ ? 'Je aanvraag is verstuurd — Ribba'
+ : `Je aanvraag is verstuurd naar ${count} rijscholen — Ribba`,
+ html,
+ 'leerling_bevestiging',
+ );
+}
+
export interface ReplyNotificationInput {
to: string;
senderName: string; // geanonimiseerd: voornaam leerling of rijschoolnaam
diff --git a/lib/marketplace-types.ts b/lib/marketplace-types.ts
index e95151c..8dfe853 100644
--- a/lib/marketplace-types.ts
+++ b/lib/marketplace-types.ts
@@ -17,8 +17,6 @@ export type InquiryRecipientStatus =
export type ChatRole = 'leerling' | 'rijschool';
-export type UserProfileRole = 'leerling' | 'rijschool' | 'admin';
-
export interface InquiryRow {
id: string;
leerling_user_id: string | null;
@@ -27,10 +25,11 @@ export interface InquiryRow {
leerling_name: string;
rijbewijs_categorie: RijbewijsCategorie;
schakeling: Schakeling | null;
- gewenste_startdatum: string | null; // ISO date
+ gewenste_startdatum: string | null; // ISO date; mag client-side gesynthetiseerd zijn (zsm/+1m/+3m → datum, "later" → null)
opleidingsvoorkeur: string | null;
bericht: string | null;
source_page: string | null;
+ marketing_optin: boolean;
toestemming_at: string;
created_at: string;
}
@@ -40,9 +39,13 @@ export interface InquiryRecipientRow {
inquiry_id: string;
rijschool_id: number;
rijschool_user_id: string | null;
+ school_id: string | null; // uuid → drivingschools, gevuld ná KvK-claim/approval
status: InquiryRecipientStatus;
+ opened_at: string | null;
accepted_at: string | null;
declined_at: string | null;
+ decline_reason: string | null;
+ expires_at: string;
notification_email_sent_at: string | null;
notification_sms_sent_at: string | null;
notified_email: string | null;
@@ -59,7 +62,9 @@ export interface ConversationRow {
leerling_user_id: string | null; // null tot de leerling zijn kant claimt
rijschool_user_id: string;
rijschool_id: number;
+ school_id: string | null;
last_message_at: string | null;
+ last_message_preview: string | null;
leerling_last_notified_at: string | null;
rijschool_last_notified_at: string | null;
created_at: string;
@@ -75,13 +80,32 @@ export interface MessageRow {
created_at: string;
}
-export interface UserProfileRow {
+export interface MarketplaceProfileRow {
user_id: string;
- role: UserProfileRole;
- full_name: string | null;
- phone: string | null;
- rijschool_id: number | null;
- expo_push_token: string | null;
+ marketplace_role: ChatRole;
email_notifications: boolean;
created_at: string;
}
+
+// Retour-shape van de gedeelde RPC get_chat_context(p_token) — identiek
+// contract voor de web-chat gateway en de ribbaPro-app.
+export interface ChatContext {
+ found: boolean;
+ role: ChatRole;
+ inquiry_id: string;
+ recipient_id: string;
+ conversation_id: string | null;
+ status: InquiryRecipientStatus;
+ claimed: boolean;
+ expected_email_masked: string | null;
+ counterpart_name: string;
+ inquiry_preview: {
+ voornaam: string;
+ rijbewijs_categorie: RijbewijsCategorie;
+ schakeling: Schakeling | null;
+ gewenste_startdatum: string | null;
+ bericht: string | null;
+ created_at: string;
+ };
+ contact: { name: string; email: string; phone: string | null } | null;
+}
diff --git a/public/.well-known/apple-app-site-association b/public/.well-known/apple-app-site-association
index 1399e91..ccfc415 100644
--- a/public/.well-known/apple-app-site-association
+++ b/public/.well-known/apple-app-site-association
@@ -13,7 +13,8 @@
{ "/": "/payment/success" },
{ "/": "/join/*" },
{ "/": "/reset" },
- { "/": "/reset/*" }
+ { "/": "/reset/*" },
+ { "/": "/chat/*", "comment": "marketplace web-chat: met app geïnstalleerd opent de mail-link de app (ribbaPro#139)" }
]
}
]
diff --git a/supabase/migrations/20260711000000_marketplace_mvp.sql b/supabase/migrations/20260711000000_marketplace_mvp.sql
index 6255eff..c03cdbd 100644
--- a/supabase/migrations/20260711000000_marketplace_mvp.sql
+++ b/supabase/migrations/20260711000000_marketplace_mvp.sql
@@ -1,46 +1,54 @@
-- ============================================================================
-- Marketplace MVP schema (PolderLabs/ribba.app#36, onderdeel van Epic #35)
--
--- Tabellen voor de two-sided inquiry-flow: leerling stuurt aanvraag vanaf de
--- vergelijkingssite, Ribba routeert naar rijscholen, beide partijen chatten
--- geanonimiseerd via de web-chat gateway (link.ribba.app/chat/{token}) én via
--- de native apps. Eén bron van waarheid, meerdere clients.
+-- Tabellen + gedeelde RPC's voor de two-sided inquiry-flow: leerling stuurt
+-- aanvraag vanaf de vergelijkingssite, Ribba routeert naar rijscholen, beide
+-- partijen chatten geanonimiseerd via de web-chat gateway
+-- (link.ribba.app/chat/{token}) én via de native apps. Eén bron van waarheid.
--
--- BEWUSTE AFWIJKINGEN t.o.v. de draft-SQL in issue #36:
+-- Verwerkt het ribbaPro schema-contract-review
+-- (ribba.app#36#issuecomment-4933108256) en de PR-review op ribba-web#25:
--
--- D1. `rijschool_subscriptions` is WEGGELATEN. Billing-state leeft al in
--- `instructor_licenses` (Mollie, web-owned — zie docs/ARCHITECTUUR.md).
--- Een tweede subscription-tabel zou twee bronnen van billing-waarheid
--- creëren. Het freemium-model voor Chats beslist ribbaPro#141.
+-- C1. `marketplace_profiles` i.p.v. `user_profiles`: minimaal additief
+-- register (rol-resolutie in de app: instructor > student > marketplace-
+-- rol). Geen expo_push_token (bestaande `push_tokens`-tabel is de SSoT),
+-- geen full_name/phone (staan al op inquiries/drivingschools). Alleen
+-- de claim-RPC's schrijven hier.
+-- C2. `rijschool_subscriptions` bestaat niet: plan/trial-state leeft op
+-- `instructor_licenses`; freemium-teller `marketplace_contacts` volgt in
+-- een ribbaPro-migratie.
+-- C3. inquiry_recipients: + opened_at, decline_reason, expires_at, school_id
+-- (uuid → drivingschools, gevuld ná KvK-claim/approval).
+-- conversations: + school_id, last_message_preview (trigger onderhoudt
+-- last_message_at + preview — gesprekkenlijst zonder N+1).
+-- C4. messages: GÉÉN client-UPDATE. read_at wordt uitsluitend gezet via de
+-- RPC `mark_messages_read` (NULL → now(), alleen counterpart-berichten) —
+-- een afzender kan zijn eigen berichten dus niet "gelezen" maken en
+-- read_at kan nooit teruggezet of geantidateerd worden.
+-- C5. Claim-semantiek als SECURITY DEFINER RPC's, identiek voor web én app:
+-- `claim_inquiry` / `claim_inquiry_recipient` (e-mail-match verplicht,
+-- idempotent) + `get_chat_context` (token → geanonimiseerde context,
+-- masking server-side) + `get_inquiry_for_recipient`.
+-- C6. Realtime publication: messages + conversations + inquiry_recipients
+-- (app-inboxen draaien op status-updates en tab-badges).
--
--- D2. Conversation vóór accept. Issue #36 maakte conversations pas bij
--- accept aan, maar #42 eist dat de rijschool direct na e-mailverificatie
--- in een (geanonimiseerde) chat zit. Daarom:
--- - conversations worden aangemaakt bij de eerste claim van de
--- rijschool (OTP-verificatie op /chat/{token}), niet bij accept;
--- - `conversations.leerling_user_id` is NULLABLE en wordt gevuld
--- zodra de leerling zijn kant claimt;
--- - accept (ribbaPro#140) is enkel een status-flip op
--- inquiry_recipients die contact-reveal ontgrendelt.
---
--- D3. Chat-tokens als kolommen op `inquiry_recipients`:
--- `rijschool_chat_token` en `leerling_chat_token` (opaque uuid's).
--- De outreach-/notificatiemails linken naar /chat/{token}; één route
--- dekt beide rollen. Resolutie gebeurt server-side met de service role,
--- tokens zijn nooit via anon RLS bereikbaar. `notified_email` is een
--- snapshot van het adres dat we daadwerkelijk gemaild hebben (cbr-data
--- kan wijzigen; de claim-check vergelijkt tegen wat we stuurden).
---
--- D4. Notificatie-kolommen voor reply-mails (#44) zitten er direct in:
--- `conversations.{leerling,rijschool}_last_notified_at` en
--- `inquiry_recipients.{leerling,rijschool}_email_optout_at`.
---
--- D5. GEEN anonieme INSERT-policy op `inquiries`. Alle writes lopen via
--- /api/inquiry-submit met de service role.
---
--- D6. `inquiry_recipients.conversation_id` is WEGGELATEN (circulaire FK in
--- de draft). De canonieke link is `conversations.inquiry_recipient_id`
--- (UNIQUE); join daarop.
+-- Eerdere bewuste afwijkingen van de issue-draft blijven staan:
+-- D2. Conversation ontstaat bij de eerste claim van de rijschool (web-chat
+-- vóór accept, #42); conversations.leerling_user_id nullable tot de
+-- leerling claimt. Accept (ribbaPro#140) is een status-flip die
+-- contact-reveal ontgrendelt (accept/decline-RPC's levert ribbaPro als
+-- vervolg-migratie; claim zet zelf opened_at bij eerste opening).
+-- D3. Chat-tokens als kolommen op inquiry_recipients (rijschool_chat_token /
+-- leerling_chat_token); mails linken naar /chat/{token} — één URL-schema
+-- voor browser én app (universal link). notified_email is een audit-
+-- snapshot van het gemailde adres; de claim matcht tegen het actuele
+-- cbr_rijscholen.email (besluit plan-review 2026-07-10).
+-- D4. Notificatie-kolommen voor reply-mails (#44) op conversations
+-- (…_last_notified_at) en inquiry_recipients (…_email_optout_at).
+-- D5. Geen anonieme INSERT-policy op inquiries: alle writes via
+-- /api/inquiry-submit (service role).
+-- D6. Geen inquiry_recipients.conversation_id (circulair); canonieke link is
+-- conversations.inquiry_recipient_id (UNIQUE).
--
-- Idempotent: veilig om meermaals te draaien.
-- ============================================================================
@@ -56,16 +64,18 @@ CREATE TABLE IF NOT EXISTS public.inquiries (
leerling_name text NOT NULL,
rijbewijs_categorie text NOT NULL CHECK (rijbewijs_categorie IN ('B', 'AM', 'A', 'BE', 'C', 'CE', 'D', 'DE', 'T')),
schakeling text CHECK (schakeling IN ('handgeschakeld', 'automaat', 'beide')),
+ -- Mag client-side gesynthetiseerd zijn (zsm/+1m/+3m → datum; "later" → null)
gewenste_startdatum date,
opleidingsvoorkeur text,
bericht text,
source_page text,
+ marketing_optin boolean NOT NULL DEFAULT false,
toestemming_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_inquiries_leerling ON public.inquiries(leerling_user_id);
-CREATE INDEX IF NOT EXISTS idx_inquiries_leerling_email ON public.inquiries(lower(leerling_email));
+CREATE INDEX IF NOT EXISTS idx_inquiries_leerling_email ON public.inquiries(lower(leerling_email), created_at);
-- ----------------------------------------------------------------------------
-- inquiry_recipients — één rij per (inquiry × rijschool)
@@ -75,19 +85,23 @@ CREATE TABLE IF NOT EXISTS public.inquiry_recipients (
inquiry_id uuid NOT NULL REFERENCES public.inquiries(id) ON DELETE CASCADE,
rijschool_id integer NOT NULL REFERENCES public.cbr_rijscholen(id),
rijschool_user_id uuid REFERENCES auth.users(id), -- null tot rijschool claimt (web-chat of app)
+ school_id uuid REFERENCES public.drivingschools(id), -- gevuld ná KvK-claim/approval (C3)
status text NOT NULL DEFAULT 'pending' CHECK (status IN (
'pending', -- net binnen, nog geen outreach gelukt
- 'app_notified', -- rijschool kreeg mail/sms
- 'opened', -- rijschool heeft de aanvraag geopend (web-chat claim of app)
+ 'app_notified', -- rijschool kreeg mail/sms (app rendert als "Nieuw")
+ 'opened', -- rijschool heeft de aanvraag geopend (claim of app)
'accepted', -- rijschool accepteerde → contact-reveal
'declined', -- rijschool wees af
'expired' -- geen reactie binnen de vervaltermijn
)),
+ opened_at timestamptz, -- "gezien op" (ribbaPro#140/#142)
accepted_at timestamptz,
declined_at timestamptz,
+ decline_reason text, -- reden bij afwijzen (ribbaPro#140)
+ expires_at timestamptz NOT NULL DEFAULT (now() + interval '14 days'),
notification_email_sent_at timestamptz,
notification_sms_sent_at timestamptz,
- notified_email text, -- snapshot van gemaild rijschool-adres (D3)
+ notified_email text, -- audit-snapshot van gemaild rijschool-adres (D3)
rijschool_chat_token uuid NOT NULL DEFAULT gen_random_uuid() UNIQUE,
leerling_chat_token uuid NOT NULL DEFAULT gen_random_uuid() UNIQUE,
leerling_email_optout_at timestamptz, -- opt-out reply-mails vóór claim (D4)
@@ -98,6 +112,7 @@ CREATE TABLE IF NOT EXISTS public.inquiry_recipients (
CREATE INDEX IF NOT EXISTS idx_inquiry_recipients_rijschool_status ON public.inquiry_recipients(rijschool_id, status);
CREATE INDEX IF NOT EXISTS idx_inquiry_recipients_inquiry_status ON public.inquiry_recipients(inquiry_id, status);
+CREATE INDEX IF NOT EXISTS idx_inquiry_recipients_school ON public.inquiry_recipients(school_id) WHERE school_id IS NOT NULL;
-- ----------------------------------------------------------------------------
-- conversations — één conversatie per inquiry_recipient (aangemaakt bij claim, D2)
@@ -108,7 +123,9 @@ CREATE TABLE IF NOT EXISTS public.conversations (
leerling_user_id uuid REFERENCES auth.users(id), -- nullable tot leerling-claim (D2)
rijschool_user_id uuid NOT NULL REFERENCES auth.users(id),
rijschool_id integer NOT NULL REFERENCES public.cbr_rijscholen(id),
+ school_id uuid REFERENCES public.drivingschools(id), -- gevuld ná KvK-claim/approval (C3)
last_message_at timestamptz,
+ last_message_preview text, -- gesprekkenlijst zonder N+1 (C3)
leerling_last_notified_at timestamptz, -- reply-mail throttling (D4)
rijschool_last_notified_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
@@ -125,7 +142,7 @@ CREATE TABLE IF NOT EXISTS public.messages (
conversation_id uuid NOT NULL REFERENCES public.conversations(id) ON DELETE CASCADE,
sender_user_id uuid NOT NULL REFERENCES auth.users(id),
sender_role text NOT NULL CHECK (sender_role IN ('leerling', 'rijschool')),
- body text NOT NULL,
+ body text NOT NULL CHECK (char_length(body) BETWEEN 1 AND 4000),
read_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
@@ -133,24 +150,17 @@ CREATE TABLE IF NOT EXISTS public.messages (
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON public.messages(conversation_id, created_at DESC);
-- ----------------------------------------------------------------------------
--- user_profiles — rol-onderscheid; auth.users bevat alleen credentials
+-- marketplace_profiles — minimaal additief rol-register (C1)
-- ----------------------------------------------------------------------------
-CREATE TABLE IF NOT EXISTS public.user_profiles (
+CREATE TABLE IF NOT EXISTS public.marketplace_profiles (
user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
- role text NOT NULL CHECK (role IN ('leerling', 'rijschool', 'admin')),
- full_name text,
- phone text,
- rijschool_id integer REFERENCES public.cbr_rijscholen(id), -- alleen voor role='rijschool'
- expo_push_token text,
+ marketplace_role text NOT NULL CHECK (marketplace_role IN ('leerling', 'rijschool')),
email_notifications boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
-CREATE INDEX IF NOT EXISTS idx_user_profiles_rijschool ON public.user_profiles(rijschool_id) WHERE role = 'rijschool';
-
-- ----------------------------------------------------------------------------
--- Trigger: last_message_at bijhouden (SECURITY DEFINER, dus geen client
--- UPDATE-policy op conversations nodig)
+-- Trigger: last_message_at + preview bijhouden (C3)
-- ----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.messages_touch_conversation()
RETURNS trigger
@@ -160,7 +170,8 @@ LANGUAGE plpgsql
AS $$
BEGIN
UPDATE public.conversations
- SET last_message_at = NEW.created_at
+ SET last_message_at = NEW.created_at,
+ last_message_preview = left(NEW.body, 140)
WHERE id = NEW.conversation_id;
RETURN NEW;
END;
@@ -178,20 +189,23 @@ ALTER TABLE public.inquiries ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.inquiry_recipients ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversations ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.messages ENABLE ROW LEVEL SECURITY;
-ALTER TABLE public.user_profiles ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.marketplace_profiles ENABLE ROW LEVEL SECURITY;
--- anon heeft niets te zoeken in deze tabellen (alle anonieme flows lopen via
--- service-role API routes); authenticated alleen wat de policies toestaan.
-REVOKE ALL ON public.inquiries, public.inquiry_recipients, public.conversations, public.messages, public.user_profiles FROM anon;
+-- anon heeft niets te zoeken in deze tabellen; authenticated alleen wat de
+-- policies toestaan. Alle overige mutaties: service role of RPC's.
+REVOKE ALL ON public.inquiries, public.inquiry_recipients, public.conversations, public.messages, public.marketplace_profiles FROM anon;
--- inquiries: alleen de leerling zelf. Rijscholen krijgen een geanonimiseerde
--- preview via /api/chat/resolve (service role) — contactvelden staan hier.
+-- inquiries: alleen de leerling zelf. Rijschool-clients SELECTeren inquiries
+-- nooit direct (contactvelden!) — zij krijgen get_inquiry_for_recipient /
+-- get_chat_context met server-side masking.
DROP POLICY IF EXISTS inquiries_select_own ON public.inquiries;
CREATE POLICY inquiries_select_own ON public.inquiries
FOR SELECT TO authenticated
USING (leerling_user_id = auth.uid());
-- inquiry_recipients: eigen rijschool-kant óf eigen leerling-inquiry.
+-- Status-transities alleen via RPC's (claim hieronder; accept/decline levert
+-- ribbaPro) — dus géén client-UPDATE policy.
DROP POLICY IF EXISTS inquiry_recipients_select_participant ON public.inquiry_recipients;
CREATE POLICY inquiry_recipients_select_participant ON public.inquiry_recipients
FOR SELECT TO authenticated
@@ -200,16 +214,14 @@ CREATE POLICY inquiry_recipients_select_participant ON public.inquiry_recipients
OR inquiry_id IN (SELECT id FROM public.inquiries WHERE leerling_user_id = auth.uid())
);
--- conversations: alleen participants. Geen client-INSERT/UPDATE (claim-API en
--- trigger regelen mutaties).
+-- conversations: alleen participants; mutaties via claim-RPC's en trigger.
DROP POLICY IF EXISTS conversations_select_participant ON public.conversations;
CREATE POLICY conversations_select_participant ON public.conversations
FOR SELECT TO authenticated
USING (auth.uid() = leerling_user_id OR auth.uid() = rijschool_user_id);
--- messages: lezen als participant; schrijven alleen als jezelf, op de kant
--- die je in de conversatie inneemt; updaten (read receipts) alleen op
--- berichten van de ander — column-grant beperkt dat tot read_at.
+-- messages: lezen als participant; schrijven alleen als jezelf op de kant die
+-- je inneemt. GÉÉN client-UPDATE (C4) — read_at alleen via mark_messages_read.
DROP POLICY IF EXISTS messages_select_participant ON public.messages;
CREATE POLICY messages_select_participant ON public.messages
FOR SELECT TO authenticated
@@ -236,46 +248,309 @@ CREATE POLICY messages_insert_own ON public.messages
)
);
+-- Opruimen van de eerdere read-receipt-policy + grant (C4).
DROP POLICY IF EXISTS messages_update_read_receipt ON public.messages;
-CREATE POLICY messages_update_read_receipt ON public.messages
- FOR UPDATE TO authenticated
- USING (
- sender_user_id <> auth.uid()
- AND EXISTS (
- SELECT 1 FROM public.conversations c
- WHERE c.id = conversation_id
- AND (auth.uid() = c.leerling_user_id OR auth.uid() = c.rijschool_user_id)
- )
- )
- WITH CHECK (
- sender_user_id <> auth.uid()
- AND EXISTS (
- SELECT 1 FROM public.conversations c
- WHERE c.id = conversation_id
- AND (auth.uid() = c.leerling_user_id OR auth.uid() = c.rijschool_user_id)
- )
- );
-
--- Column-grant: authenticated mag via UPDATE alléén read_at aanraken.
REVOKE UPDATE ON public.messages FROM authenticated;
-GRANT UPDATE (read_at) ON public.messages TO authenticated;
--- user_profiles: alleen eigen rij (INSERT via service role in de claim-stap).
-DROP POLICY IF EXISTS user_profiles_select_own ON public.user_profiles;
-CREATE POLICY user_profiles_select_own ON public.user_profiles
+-- marketplace_profiles: alleen eigen rij lezen; alleen email_notifications
+-- zelf aanpassen (marketplace_role wordt uitsluitend door claim-RPC's geschreven).
+DROP POLICY IF EXISTS marketplace_profiles_select_own ON public.marketplace_profiles;
+CREATE POLICY marketplace_profiles_select_own ON public.marketplace_profiles
FOR SELECT TO authenticated
USING (user_id = auth.uid());
-DROP POLICY IF EXISTS user_profiles_update_own ON public.user_profiles;
-CREATE POLICY user_profiles_update_own ON public.user_profiles
+DROP POLICY IF EXISTS marketplace_profiles_update_own ON public.marketplace_profiles;
+CREATE POLICY marketplace_profiles_update_own ON public.marketplace_profiles
FOR UPDATE TO authenticated
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());
+REVOKE UPDATE ON public.marketplace_profiles FROM authenticated;
+GRANT UPDATE (email_notifications) ON public.marketplace_profiles TO authenticated;
+
-- ----------------------------------------------------------------------------
--- Realtime: messages + conversations in de supabase_realtime publication
--- (idempotent; RLS geldt ook voor realtime — client moet realtime.setAuth()
--- aanroepen met een geldig access token)
+-- RPC: get_chat_context — token → geanonimiseerde context (web + app, C5)
+-- ----------------------------------------------------------------------------
+-- Masking is server-side en identiek voor beide clients. Geeft nooit het
+-- volledige verwachte e-mailadres terug; contactgegevens alleen na accept en
+-- alleen aan de rijschool-kant.
+CREATE OR REPLACE FUNCTION public.get_chat_context(p_token uuid)
+RETURNS jsonb
+LANGUAGE plpgsql
+STABLE
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_role text;
+ v_rec public.inquiry_recipients%ROWTYPE;
+ v_inq public.inquiries%ROWTYPE;
+ v_school RECORD;
+ v_conv RECORD;
+ v_expected_email text;
+ v_voornaam text;
+BEGIN
+ SELECT * INTO v_rec FROM public.inquiry_recipients WHERE rijschool_chat_token = p_token;
+ IF FOUND THEN
+ v_role := 'rijschool';
+ ELSE
+ SELECT * INTO v_rec FROM public.inquiry_recipients WHERE leerling_chat_token = p_token;
+ IF FOUND THEN
+ v_role := 'leerling';
+ ELSE
+ RETURN jsonb_build_object('found', false);
+ END IF;
+ END IF;
+
+ SELECT * INTO v_inq FROM public.inquiries WHERE id = v_rec.inquiry_id;
+ SELECT id, name, email, city INTO v_school FROM public.cbr_rijscholen WHERE id = v_rec.rijschool_id;
+ SELECT id, leerling_user_id INTO v_conv FROM public.conversations WHERE inquiry_recipient_id = v_rec.id;
+
+ v_expected_email := CASE v_role
+ WHEN 'rijschool' THEN coalesce(v_school.email, v_rec.notified_email)
+ ELSE v_inq.leerling_email
+ END;
+ v_voornaam := initcap(split_part(trim(v_inq.leerling_name), ' ', 1));
+
+ RETURN jsonb_build_object(
+ 'found', true,
+ 'role', v_role,
+ 'inquiry_id', v_inq.id,
+ 'recipient_id', v_rec.id,
+ 'conversation_id', v_conv.id,
+ 'status', v_rec.status,
+ 'claimed', CASE v_role WHEN 'rijschool' THEN v_rec.rijschool_user_id IS NOT NULL
+ ELSE v_inq.leerling_user_id IS NOT NULL END,
+ 'expected_email_masked', CASE WHEN v_expected_email IS NULL THEN NULL
+ ELSE left(split_part(v_expected_email, '@', 1), 1) || '•••@' || split_part(v_expected_email, '@', 2) END,
+ 'counterpart_name', CASE v_role WHEN 'rijschool' THEN v_voornaam ELSE coalesce(v_school.name, 'Rijschool') END,
+ 'inquiry_preview', jsonb_build_object(
+ 'voornaam', v_voornaam,
+ 'rijbewijs_categorie', v_inq.rijbewijs_categorie,
+ 'schakeling', v_inq.schakeling,
+ 'gewenste_startdatum', v_inq.gewenste_startdatum,
+ 'bericht', v_inq.bericht,
+ 'created_at', v_inq.created_at
+ ),
+ 'contact', CASE WHEN v_role = 'rijschool' AND v_rec.status = 'accepted'
+ THEN jsonb_build_object('name', v_inq.leerling_name, 'email', v_inq.leerling_email, 'phone', v_inq.leerling_phone)
+ ELSE NULL END
+ );
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.get_chat_context(uuid) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.get_chat_context(uuid) TO anon, authenticated;
+
+-- ----------------------------------------------------------------------------
+-- RPC: claim_inquiry — leerling koppelt zijn auth-account (C5)
+-- ----------------------------------------------------------------------------
+-- Idempotent. E-mail-match verplicht (anti-hijack): een geforwarde link is
+-- waardeloos zonder toegang tot de mailbox. Backfillt álle conversaties van
+-- deze inquiry (account-continuïteit web ↔ app).
+CREATE OR REPLACE FUNCTION public.claim_inquiry(p_inquiry_id uuid)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_uid uuid := auth.uid();
+ v_email text := lower(coalesce(auth.jwt()->>'email', ''));
+ v_inq public.inquiries%ROWTYPE;
+BEGIN
+ IF v_uid IS NULL OR v_email = '' THEN
+ RAISE EXCEPTION 'niet ingelogd' USING ERRCODE = '28000';
+ END IF;
+
+ SELECT * INTO v_inq FROM public.inquiries WHERE id = p_inquiry_id FOR UPDATE;
+ IF NOT FOUND THEN
+ RAISE EXCEPTION 'aanvraag niet gevonden' USING ERRCODE = 'P0002';
+ END IF;
+ IF lower(v_inq.leerling_email) <> v_email THEN
+ RAISE EXCEPTION 'e-mailadres komt niet overeen' USING ERRCODE = '28000';
+ END IF;
+
+ IF v_inq.leerling_user_id IS NULL THEN
+ UPDATE public.inquiries SET leerling_user_id = v_uid WHERE id = p_inquiry_id;
+ ELSIF v_inq.leerling_user_id <> v_uid THEN
+ RAISE EXCEPTION 'aanvraag hoort bij een ander account' USING ERRCODE = '28000';
+ END IF;
+
+ INSERT INTO public.marketplace_profiles (user_id, marketplace_role)
+ VALUES (v_uid, 'leerling')
+ ON CONFLICT (user_id) DO NOTHING;
+
+ UPDATE public.conversations c
+ SET leerling_user_id = v_uid
+ FROM public.inquiry_recipients ir
+ WHERE c.inquiry_recipient_id = ir.id
+ AND ir.inquiry_id = p_inquiry_id
+ AND c.leerling_user_id IS NULL;
+
+ RETURN jsonb_build_object('inquiry_id', p_inquiry_id);
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.claim_inquiry(uuid) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.claim_inquiry(uuid) TO authenticated;
+
+-- ----------------------------------------------------------------------------
+-- RPC: claim_inquiry_recipient — rijschool koppelt zijn auth-account (C5)
+-- ----------------------------------------------------------------------------
+-- Idempotent. E-mail-match tegen het actuele cbr_rijscholen.email (besluit
+-- plan-review 2026-07-10). Maakt de conversatie aan bij de eerste claim (D2)
+-- en zet opened_at/status 'opened' (eerste opening ís de claim).
+CREATE OR REPLACE FUNCTION public.claim_inquiry_recipient(p_recipient_id uuid)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_uid uuid := auth.uid();
+ v_email text := lower(coalesce(auth.jwt()->>'email', ''));
+ v_rec public.inquiry_recipients%ROWTYPE;
+ v_school_email text;
+ v_leerling_user_id uuid;
+ v_conv_id uuid;
+BEGIN
+ IF v_uid IS NULL OR v_email = '' THEN
+ RAISE EXCEPTION 'niet ingelogd' USING ERRCODE = '28000';
+ END IF;
+
+ SELECT * INTO v_rec FROM public.inquiry_recipients WHERE id = p_recipient_id FOR UPDATE;
+ IF NOT FOUND THEN
+ RAISE EXCEPTION 'aanvraag niet gevonden' USING ERRCODE = 'P0002';
+ END IF;
+
+ SELECT nullif(lower(trim(email)), '') INTO v_school_email
+ FROM public.cbr_rijscholen WHERE id = v_rec.rijschool_id;
+ IF v_school_email IS NULL OR v_school_email <> v_email THEN
+ RAISE EXCEPTION 'e-mailadres komt niet overeen' USING ERRCODE = '28000';
+ END IF;
+
+ IF v_rec.rijschool_user_id IS NULL THEN
+ UPDATE public.inquiry_recipients
+ SET rijschool_user_id = v_uid,
+ opened_at = coalesce(opened_at, now()),
+ status = CASE WHEN status IN ('pending', 'app_notified') THEN 'opened' ELSE status END
+ WHERE id = p_recipient_id;
+ ELSIF v_rec.rijschool_user_id <> v_uid THEN
+ RAISE EXCEPTION 'aanvraag hoort bij een ander account' USING ERRCODE = '28000';
+ END IF;
+
+ INSERT INTO public.marketplace_profiles (user_id, marketplace_role)
+ VALUES (v_uid, 'rijschool')
+ ON CONFLICT (user_id) DO NOTHING;
+
+ SELECT leerling_user_id INTO v_leerling_user_id FROM public.inquiries WHERE id = v_rec.inquiry_id;
+
+ INSERT INTO public.conversations (inquiry_recipient_id, rijschool_user_id, rijschool_id, leerling_user_id)
+ VALUES (p_recipient_id, v_uid, v_rec.rijschool_id, v_leerling_user_id)
+ ON CONFLICT (inquiry_recipient_id) DO NOTHING;
+
+ SELECT id INTO v_conv_id FROM public.conversations WHERE inquiry_recipient_id = p_recipient_id;
+
+ RETURN jsonb_build_object(
+ 'conversation_id', v_conv_id,
+ 'status', (SELECT status FROM public.inquiry_recipients WHERE id = p_recipient_id),
+ 'contact_revealed', v_rec.status = 'accepted'
+ );
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.claim_inquiry_recipient(uuid) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.claim_inquiry_recipient(uuid) TO authenticated;
+
+-- ----------------------------------------------------------------------------
+-- RPC: get_inquiry_for_recipient — inquiry-details met server-side masking (C5)
+-- ----------------------------------------------------------------------------
+-- Voor de geclaimde rijschool-kant (app-inbox ribbaPro#140). Contactvelden
+-- zijn NULL tenzij status = 'accepted'.
+CREATE OR REPLACE FUNCTION public.get_inquiry_for_recipient(p_recipient_id uuid)
+RETURNS jsonb
+LANGUAGE plpgsql
+STABLE
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_rec public.inquiry_recipients%ROWTYPE;
+ v_inq public.inquiries%ROWTYPE;
+ v_accepted boolean;
+BEGIN
+ SELECT * INTO v_rec FROM public.inquiry_recipients WHERE id = p_recipient_id;
+ IF NOT FOUND OR v_rec.rijschool_user_id IS DISTINCT FROM auth.uid() THEN
+ RAISE EXCEPTION 'geen toegang' USING ERRCODE = '42501';
+ END IF;
+
+ SELECT * INTO v_inq FROM public.inquiries WHERE id = v_rec.inquiry_id;
+ v_accepted := v_rec.status = 'accepted';
+
+ RETURN jsonb_build_object(
+ 'recipient_id', v_rec.id,
+ 'inquiry_id', v_inq.id,
+ 'status', v_rec.status,
+ 'opened_at', v_rec.opened_at,
+ 'expires_at', v_rec.expires_at,
+ 'voornaam', initcap(split_part(trim(v_inq.leerling_name), ' ', 1)),
+ 'leerling_name', CASE WHEN v_accepted THEN v_inq.leerling_name ELSE NULL END,
+ 'leerling_email', CASE WHEN v_accepted THEN v_inq.leerling_email ELSE NULL END,
+ 'leerling_phone', CASE WHEN v_accepted THEN v_inq.leerling_phone ELSE NULL END,
+ 'rijbewijs_categorie', v_inq.rijbewijs_categorie,
+ 'schakeling', v_inq.schakeling,
+ 'gewenste_startdatum', v_inq.gewenste_startdatum,
+ 'opleidingsvoorkeur', v_inq.opleidingsvoorkeur,
+ 'bericht', v_inq.bericht,
+ 'created_at', v_inq.created_at
+ );
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.get_inquiry_for_recipient(uuid) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.get_inquiry_for_recipient(uuid) TO authenticated;
+
+-- ----------------------------------------------------------------------------
+-- RPC: mark_messages_read — enige schrijfpad voor read_at (C4)
+-- ----------------------------------------------------------------------------
+-- Markeert alle ongelezen counterpart-berichten in één keer (NULL → now()).
+CREATE OR REPLACE FUNCTION public.mark_messages_read(p_conversation_id uuid)
+RETURNS integer
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_uid uuid := auth.uid();
+ v_count integer;
+BEGIN
+ IF v_uid IS NULL THEN
+ RAISE EXCEPTION 'niet ingelogd' USING ERRCODE = '28000';
+ END IF;
+ IF NOT EXISTS (
+ SELECT 1 FROM public.conversations c
+ WHERE c.id = p_conversation_id
+ AND (v_uid = c.leerling_user_id OR v_uid = c.rijschool_user_id)
+ ) THEN
+ RAISE EXCEPTION 'geen toegang' USING ERRCODE = '42501';
+ END IF;
+
+ UPDATE public.messages
+ SET read_at = now()
+ WHERE conversation_id = p_conversation_id
+ AND sender_user_id <> v_uid
+ AND read_at IS NULL;
+ GET DIAGNOSTICS v_count = ROW_COUNT;
+ RETURN v_count;
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.mark_messages_read(uuid) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.mark_messages_read(uuid) TO authenticated;
+
+-- ----------------------------------------------------------------------------
+-- Realtime: messages + conversations + inquiry_recipients (C6)
-- ----------------------------------------------------------------------------
DO $$
BEGIN
@@ -292,5 +567,12 @@ BEGIN
) THEN
ALTER PUBLICATION supabase_realtime ADD TABLE public.conversations;
END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_publication_tables
+ WHERE pubname = 'supabase_realtime' AND schemaname = 'public' AND tablename = 'inquiry_recipients'
+ ) THEN
+ ALTER PUBLICATION supabase_realtime ADD TABLE public.inquiry_recipients;
+ END IF;
END;
$$;
diff --git a/supabase/migrations/README.md b/supabase/migrations/README.md
index f5434f9..b6de554 100644
--- a/supabase/migrations/README.md
+++ b/supabase/migrations/README.md
@@ -22,12 +22,20 @@ supabase db push
## Aandachtspunten
-- `20260711000000_marketplace_mvp.sql` gaat ervan uit dat `cbr_rijscholen`
- bestaat met een **integer** primary key. Is de PK `bigint`, pas dan de
- `rijschool_id`-kolommen in de migratie aan vóór het draaien.
-- De migratie bevat bewuste afwijkingen t.o.v. de draft in issue #36 — zie de
- header van het bestand. Communiceer die naar het ribbaPro-team (issues
- #139/#140/#141 bouwen tegen dit schema).
+- `20260711000000_marketplace_mvp.sql` implementeert het schema-contract uit
+ het ribbaPro-review (ribba.app#36#issuecomment-4933108256): o.a.
+ `marketplace_profiles` i.p.v. `user_profiles`, gedeelde claim-RPC's
+ (`claim_inquiry` / `claim_inquiry_recipient`), server-side masking
+ (`get_chat_context` / `get_inquiry_for_recipient`) en `mark_messages_read`
+ als enige schrijfpad voor read-receipts. Zie de header van het bestand voor
+ alle contract- (C1–C6) en design-beslissingen (D2–D6).
+- `cbr_rijscholen` bestaat in het gedeelde project (integer PK, kolommen
+ `email` + `kvk` aanwezig) maar had bij de contract-review **0 rijen** —
+ vullen is een voorwaarde voor de funnel én voor de e-mail-match bij de
+ rijschool-claim.
+- De cron `chat-notifications` leest de bestaande `push_tokens`-tabel van de
+ app (kolom `user_id` aangenomen) voor push/e-mail-dedupe — check de
+ kolomnaam bij het eerste draaien.
- Voor de web-chat gate (#42) moet in het dashboard de **e-mail OTP-flow**
aanstaan: Auth → Email Templates → "Magic Link" template moet `{{ .Token }}`
bevatten (anders krijgen gebruikers alleen een link, geen 6-cijferige code).
From 65128a650fcb204400717589bf7fedea34b68ebe Mon Sep 17 00:00:00 2001
From: Melvin
Date: Fri, 10 Jul 2026 11:22:06 -0700
Subject: [PATCH 3/9] fix: URL-schema /i + /r hersteld, token-expiry, app-links
in mails
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Sync met de definitieve ribbaPro#218-stand (commits 1a55c037 + 0070eb99):
- AASA: /chat/* vervangen door /i/* + /r/* — de app registreert alleen die
paden (op link.ribba.app); /chat-parsing is daar ge-revert
- Browser-fallbacks /i/[id] en /r/[id] terug (app-download CTA, noindex)
- Mails bevatten nu twee links: web-chat /chat/{token} (primaire CTA) én de
universal link /r/{recipient_id} resp. /i/{inquiry_id} ('open in de app');
kale ids zijn geen bearer-tokens — claimen vereist e-mail-match (RPC's)
- Token-expiry (review ribba.app): chat_tokens_expire_at (30 dagen, rolling —
cron verlengt bij elke notificatiemail); get_chat_context weigert verlopen
tokens voor niet-geclaimde bezoekers, geclaimde deelnemers behouden toegang
- Gateway toont een duidelijke melding bij een verlopen link
---
app/api/cron/chat-notifications/route.ts | 11 +++++-
app/api/inquiry-submit/route.ts | 1 +
app/i/[id]/page.tsx | 11 ++++++
app/r/[id]/page.tsx | 11 ++++++
components/MarketplaceAppFallback.tsx | 34 +++++++++++++++++++
components/chat/ChatGateway.tsx | 3 ++
docs/ARCHITECTUUR.md | 7 ++--
lib/marketplace-emails.ts | 15 ++++++++
lib/marketplace-types.ts | 2 ++
public/.well-known/apple-app-site-association | 3 +-
.../20260711000000_marketplace_mvp.sql | 14 ++++++++
11 files changed, 108 insertions(+), 4 deletions(-)
create mode 100644 app/i/[id]/page.tsx
create mode 100644 app/r/[id]/page.tsx
create mode 100644 components/MarketplaceAppFallback.tsx
diff --git a/app/api/cron/chat-notifications/route.ts b/app/api/cron/chat-notifications/route.ts
index aac2ab8..7f46978 100644
--- a/app/api/cron/chat-notifications/route.ts
+++ b/app/api/cron/chat-notifications/route.ts
@@ -29,6 +29,7 @@ interface ConversationJoin {
rijschool_last_notified_at: string | null;
inquiry_recipients: {
id: string;
+ inquiry_id: string;
notified_email: string | null;
rijschool_chat_token: string;
leerling_chat_token: string;
@@ -60,7 +61,7 @@ export async function GET(request: NextRequest) {
id, leerling_user_id, rijschool_user_id, rijschool_id,
last_message_at, leerling_last_notified_at, rijschool_last_notified_at,
inquiry_recipients (
- id, notified_email, rijschool_chat_token, leerling_chat_token,
+ id, inquiry_id, notified_email, rijschool_chat_token, leerling_chat_token,
leerling_email_optout_at, rijschool_email_optout_at,
inquiries ( leerling_email, leerling_name )
)
@@ -200,6 +201,9 @@ export async function GET(request: NextRequest) {
messageCount: unreadMessages.length,
preview: unreadMessages[0].body,
chatToken: side === 'leerling' ? recipientRow.leerling_chat_token : recipientRow.rijschool_chat_token,
+ appPath: side === 'leerling'
+ ? `/i/${recipientRow.inquiry_id}`
+ : `/r/${recipientRow.id}`,
});
if (ok) {
@@ -211,6 +215,11 @@ export async function GET(request: NextRequest) {
: { rijschool_last_notified_at: new Date().toISOString() },
)
.eq('id', conv.id);
+ // Rolling token-expiry: de zojuist gemailde link moet 30 dagen werken.
+ await supabase
+ .from('inquiry_recipients')
+ .update({ chat_tokens_expire_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString() })
+ .eq('id', recipientRow.id);
sent++;
} else {
failed++;
diff --git a/app/api/inquiry-submit/route.ts b/app/api/inquiry-submit/route.ts
index 3f18cda..7ee3dbb 100644
--- a/app/api/inquiry-submit/route.ts
+++ b/app/api/inquiry-submit/route.ts
@@ -223,6 +223,7 @@ export async function POST(request: NextRequest) {
gewensteStartdatum: startdatum,
bericht,
chatToken: recipient.rijschool_chat_token,
+ recipientId: recipient.id,
});
if (sent) {
await supabase
diff --git a/app/i/[id]/page.tsx b/app/i/[id]/page.tsx
new file mode 100644
index 0000000..1df3052
--- /dev/null
+++ b/app/i/[id]/page.tsx
@@ -0,0 +1,11 @@
+import type { Metadata } from 'next';
+import MarketplaceAppFallback from '@/components/MarketplaceAppFallback';
+
+export const metadata: Metadata = {
+ title: 'Open in de Ribba app',
+ robots: { index: false, follow: false },
+};
+
+export default function InquiryDeepLinkFallback() {
+ return ;
+}
diff --git a/app/r/[id]/page.tsx b/app/r/[id]/page.tsx
new file mode 100644
index 0000000..b0b6235
--- /dev/null
+++ b/app/r/[id]/page.tsx
@@ -0,0 +1,11 @@
+import type { Metadata } from 'next';
+import MarketplaceAppFallback from '@/components/MarketplaceAppFallback';
+
+export const metadata: Metadata = {
+ title: 'Open in de Ribba app',
+ robots: { index: false, follow: false },
+};
+
+export default function RecipientDeepLinkFallback() {
+ return ;
+}
diff --git a/components/MarketplaceAppFallback.tsx b/components/MarketplaceAppFallback.tsx
new file mode 100644
index 0000000..3f56350
--- /dev/null
+++ b/components/MarketplaceAppFallback.tsx
@@ -0,0 +1,34 @@
+// Browser-fallback voor de marketplace universal links van de ribbaPro-app
+// (link.ribba.app/i/{inquiry_id} en /r/{recipient_id}, ribbaPro#139). Met de
+// app geïnstalleerd opent iOS/Android de app en komt de gebruiker hier nooit;
+// zonder app tonen we de download-CTA. De web-chat zelf leeft op
+// link.ribba.app/chat/{token} — die token-link staat als aparte knop in
+// dezelfde e-mail, niet in deze URL (een kale /i- of /r-id geeft bewust geen
+// chat-toegang; claimen vereist e-mail-match via de claim-RPC's).
+
+import RibbaLogo from '@/app/components/RibbaLogo';
+import { AppStoreBadge, GooglePlayBadge } from '@/app/components/StoreBadges';
+
+export default function MarketplaceAppFallback() {
+ return (
+
+
+
+
Open deze link met de Ribba app
+
+ Deze link hoort bij een rijles-aanvraag en opent in de Ribba app. Download de app en
+ open de link daarna opnieuw — je logt in met het e-mailadres waarop je de aanvraag-mail
+ ontving.
+
+
+
+
+
+
+ Liever geen app? In de e-mail over je aanvraag staat ook een chat-knop die gewoon in je
+ browser werkt.
+
+
+
+ );
+}
diff --git a/components/chat/ChatGateway.tsx b/components/chat/ChatGateway.tsx
index 30e5edd..fc0b28f 100644
--- a/components/chat/ChatGateway.tsx
+++ b/components/chat/ChatGateway.tsx
@@ -118,6 +118,9 @@ export default function ChatGateway({ token }: { token: string }) {
const { data, error } = await supabase.rpc('get_chat_context', { p_token: token });
if (error || !data?.found) {
+ if (data?.expired) {
+ setErrorMsg('Deze chat-link is verlopen. In je meest recente e-mail over dit gesprek staat een werkende link.');
+ }
setPhase('invalid');
return;
}
diff --git a/docs/ARCHITECTUUR.md b/docs/ARCHITECTUUR.md
index 2023eb1..c015ba3 100644
--- a/docs/ARCHITECTUUR.md
+++ b/docs/ARCHITECTUUR.md
@@ -44,8 +44,11 @@ Deze repo = **de website voor Ribba Rijschool Planner**, gehost op `link.ribba.a
**plus de gedeelde SECURITY DEFINER RPC's** (`get_chat_context`, `claim_inquiry`,
`claim_inquiry_recipient`, `get_inquiry_for_recipient`, `mark_messages_read`) — web-chat en
ribbaPro-app gebruiken exact dezelfde claim/masking-semantiek
- - `.well-known/apple-app-site-association` bevat `/chat/*` — met de app geïnstalleerd opent
- de mail-link de app i.p.v. de browser (universal link, ribbaPro#139)
+ - **Deep-links voor de app** (ribbaPro#139): mails bevatten twee links — de web-chat
+ (`/chat/{token}`, primaire CTA) en een universal link (`/i/{inquiry_id}` leerling-kant,
+ `/r/{recipient_id}` rijschool-kant). AASA bevat `/i/*` + `/r/*`; zonder app tonen die
+ routes een download-fallback. Kale ids geven geen chat-toegang (claim vereist
+ e-mail-match via de RPC's). Gebruik in mails/QR uitsluitend link.ribba.app-URLs.
- **Wachtwoord reset** (`/reset`)
- **iCal proxy** (`/api/ical`)
- **Legal pagina's voor de Rijschool Planner**:
diff --git a/lib/marketplace-emails.ts b/lib/marketplace-emails.ts
index 3bb7dfe..0bd5341 100644
--- a/lib/marketplace-emails.ts
+++ b/lib/marketplace-emails.ts
@@ -137,6 +137,18 @@ export interface OutreachMailInput {
gewensteStartdatum: string | null; // ISO date
bericht: string | null;
chatToken: string;
+ recipientId: string; // voor de app-deep-link /r/{recipient_id} (ribbaPro#139)
+}
+
+// Universal link die de Ribba app opent als die geïnstalleerd is; zonder app
+// toont link.ribba.app/i|/r een download-fallback. Claimen via deze kale id
+// kan alleen met e-mail-match (claim-RPC's), dus de link is geen bearer-token.
+function appLinkBlock(path: string): string {
+ return `
+
+ `;
}
// Outreach naar de rijschool: nieuwe aanvraag, geanonimiseerd (alleen
@@ -172,6 +184,7 @@ export async function sendRijschoolOutreachMail(input: OutreachMailInput): Promi
${berichtBlok}
Contactgegevens van de leerling worden zichtbaar zodra je de aanvraag accepteert. Tot die tijd verloopt alle communicatie anoniem via Ribba.
+ ${appLinkBlock(`/r/${input.recipientId}`)}
`,
ctaLabel: 'Beantwoord de aanvraag',
ctaHref: chatUrl,
@@ -233,6 +246,7 @@ export interface ReplyNotificationInput {
messageCount: number;
preview: string; // korte preview van het nieuwste bericht
chatToken: string; // token van de ontvangende kant → /chat/{token}
+ appPath: string; // universal link: /i/{inquiry_id} (leerling) of /r/{recipient_id} (rijschool)
}
// Reply-notificatie (issue ribba.app#44): de ontvanger heeft geen actieve
@@ -260,6 +274,7 @@ export async function sendReplyNotificationMail(input: ReplyNotificationInput):
·
Google Play
+ ${appLinkBlock(input.appPath)}
`,
ctaLabel: 'Open de chat',
ctaHref: chatUrl,
diff --git a/lib/marketplace-types.ts b/lib/marketplace-types.ts
index 8dfe853..e56097b 100644
--- a/lib/marketplace-types.ts
+++ b/lib/marketplace-types.ts
@@ -51,6 +51,7 @@ export interface InquiryRecipientRow {
notified_email: string | null;
rijschool_chat_token: string;
leerling_chat_token: string;
+ chat_tokens_expire_at: string; // rolling: cron verlengt bij elke notificatiemail
leerling_email_optout_at: string | null;
rijschool_email_optout_at: string | null;
created_at: string;
@@ -91,6 +92,7 @@ export interface MarketplaceProfileRow {
// contract voor de web-chat gateway en de ribbaPro-app.
export interface ChatContext {
found: boolean;
+ expired?: boolean; // token verlopen (alleen relevant bij found: false)
role: ChatRole;
inquiry_id: string;
recipient_id: string;
diff --git a/public/.well-known/apple-app-site-association b/public/.well-known/apple-app-site-association
index ccfc415..3c53ba2 100644
--- a/public/.well-known/apple-app-site-association
+++ b/public/.well-known/apple-app-site-association
@@ -14,7 +14,8 @@
{ "/": "/join/*" },
{ "/": "/reset" },
{ "/": "/reset/*" },
- { "/": "/chat/*", "comment": "marketplace web-chat: met app geïnstalleerd opent de mail-link de app (ribbaPro#139)" }
+ { "/": "/i/*", "comment": "marketplace inquiry deep-link, leerling-kant (ribbaPro#139)" },
+ { "/": "/r/*", "comment": "marketplace recipient deep-link, rijschool-kant (ribbaPro#139)" }
]
}
]
diff --git a/supabase/migrations/20260711000000_marketplace_mvp.sql b/supabase/migrations/20260711000000_marketplace_mvp.sql
index c03cdbd..ef39ac8 100644
--- a/supabase/migrations/20260711000000_marketplace_mvp.sql
+++ b/supabase/migrations/20260711000000_marketplace_mvp.sql
@@ -104,6 +104,11 @@ CREATE TABLE IF NOT EXISTS public.inquiry_recipients (
notified_email text, -- audit-snapshot van gemaild rijschool-adres (D3)
rijschool_chat_token uuid NOT NULL DEFAULT gen_random_uuid() UNIQUE,
leerling_chat_token uuid NOT NULL DEFAULT gen_random_uuid() UNIQUE,
+ -- Rolling expiry voor beide chat-tokens: de notificatie-cron verlengt bij
+ -- elke verstuurde mail, zodat links in recente mails altijd werken maar een
+ -- oud gelekt token vanzelf dooft. Geclaimde deelnemers behouden toegang
+ -- (expiry gate zit in get_chat_context, niet in RLS).
+ chat_tokens_expire_at timestamptz NOT NULL DEFAULT (now() + interval '30 days'),
leerling_email_optout_at timestamptz, -- opt-out reply-mails vóór claim (D4)
rijschool_email_optout_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
@@ -289,6 +294,7 @@ DECLARE
v_conv RECORD;
v_expected_email text;
v_voornaam text;
+ v_claimed_by_caller boolean;
BEGIN
SELECT * INTO v_rec FROM public.inquiry_recipients WHERE rijschool_chat_token = p_token;
IF FOUND THEN
@@ -303,6 +309,14 @@ BEGIN
END IF;
SELECT * INTO v_inq FROM public.inquiries WHERE id = v_rec.inquiry_id;
+
+ -- Token-expiry: verlopen links werken niet meer voor nieuwe bezoekers,
+ -- maar wie zijn kant al claimde behoudt toegang (RLS dekt de data toch al).
+ v_claimed_by_caller := (v_role = 'rijschool' AND v_rec.rijschool_user_id IS NOT NULL AND v_rec.rijschool_user_id = auth.uid())
+ OR (v_role = 'leerling' AND v_inq.leerling_user_id IS NOT NULL AND v_inq.leerling_user_id = auth.uid());
+ IF now() > v_rec.chat_tokens_expire_at AND NOT v_claimed_by_caller THEN
+ RETURN jsonb_build_object('found', false, 'expired', true);
+ END IF;
SELECT id, name, email, city INTO v_school FROM public.cbr_rijscholen WHERE id = v_rec.rijschool_id;
SELECT id, leerling_user_id INTO v_conv FROM public.conversations WHERE inquiry_recipient_id = v_rec.id;
From 50462be06f3adb6ae08cd546c77beef68894a68a Mon Sep 17 00:00:00 2001
From: Melvin
Date: Sat, 11 Jul 2026 17:24:08 -0700
Subject: [PATCH 4/9] =?UTF-8?q?fix:=20review-findings=20=E2=80=94=20foutaf?=
=?UTF-8?q?handeling,=20subscribe-before-fetch,=20OTP-resend,=20mail-timeo?=
=?UTF-8?q?ut?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- lookupRecipientByToken en opt-out: DB-fouten niet meer stil als 'niet
gevonden'/succes behandelen (throw → bestaande 500/foutpagina-afhandeling)
- ChatGateway: try/catch om de resolve-flow (geen eeuwige spinner) +
support-mailto altijd zichtbaar in de foutstaat
- ChatThread: ontbrekende sessie → foutstaat i.p.v. spinner; realtime-kanaal
subscribet vóór de initial fetch (geen verloren berichten in de race),
historie laadt ook bij een falend kanaal
- OtpGate: 'code opnieuw versturen'-actie
- Cron: goedkope filters vóór de messages-query; kandidaten-query begrensd
(oudste eerst, limit 200 — rest pakt de volgende run op)
- sendMail: 10s AbortSignal-timeout + catch → false, volgende ontvanger gaat
door; ctaHref/ctaColor ge-escaped in het mail-template
---
app/api/cron/chat-notifications/route.ts | 47 +++++++++++++-----------
app/api/notifications/opt-out/route.ts | 7 +++-
components/chat/ChatGateway.tsx | 42 ++++++++++++---------
components/chat/ChatThread.tsx | 40 ++++++++++++++++----
components/chat/OtpGate.tsx | 37 ++++++++++++++++---
lib/marketplace-db.ts | 10 ++++-
lib/marketplace-emails.ts | 44 +++++++++++++---------
7 files changed, 154 insertions(+), 73 deletions(-)
diff --git a/app/api/cron/chat-notifications/route.ts b/app/api/cron/chat-notifications/route.ts
index 7f46978..1ba3b5e 100644
--- a/app/api/cron/chat-notifications/route.ts
+++ b/app/api/cron/chat-notifications/route.ts
@@ -68,7 +68,11 @@ export async function GET(request: NextRequest) {
`)
.not('last_message_at', 'is', null)
.lte('last_message_at', settleCutoff)
- .gte('last_message_at', new Date(now - 7 * 24 * 60 * 60 * 1000).toISOString());
+ .gte('last_message_at', new Date(now - 7 * 24 * 60 * 60 * 1000).toISOString())
+ // Bewuste cap: oudste activiteit eerst, rest pakt de volgende run (5 min)
+ // op — voorkomt een onbegrensde respons én een maxDuration-overschrijding.
+ .order('last_message_at', { ascending: true })
+ .limit(200);
if (error) {
console.error('chat-notifications: conversations query failed', error);
@@ -141,26 +145,8 @@ export async function GET(request: NextRequest) {
continue;
}
- const counterpartRole: ChatRole = side === 'leerling' ? 'rijschool' : 'leerling';
- let unreadQuery = supabase
- .from('messages')
- .select('body, created_at')
- .eq('conversation_id', conv.id)
- .eq('sender_role', counterpartRole)
- .is('read_at', null)
- .lte('created_at', settleCutoff)
- .order('created_at', { ascending: false });
- if (lastNotified) {
- unreadQuery = unreadQuery.gt('created_at', lastNotified);
- }
- const { data: unread } = await unreadQuery;
- const unreadMessages = (unread ?? []) as Pick[];
- if (unreadMessages.length === 0) {
- skipped++;
- continue;
- }
-
- // Notify-regel per kant.
+ // Goedkope filters éérst — de messages-query alleen voor kanten die
+ // überhaupt gemaild mogen worden.
const sideUserId = side === 'leerling' ? conv.leerling_user_id : conv.rijschool_user_id;
const optedOut = side === 'leerling'
? recipientRow.leerling_email_optout_at !== null
@@ -191,6 +177,25 @@ export async function GET(request: NextRequest) {
continue;
}
+ const counterpartRole: ChatRole = side === 'leerling' ? 'rijschool' : 'leerling';
+ let unreadQuery = supabase
+ .from('messages')
+ .select('body, created_at')
+ .eq('conversation_id', conv.id)
+ .eq('sender_role', counterpartRole)
+ .is('read_at', null)
+ .lte('created_at', settleCutoff)
+ .order('created_at', { ascending: false });
+ if (lastNotified) {
+ unreadQuery = unreadQuery.gt('created_at', lastNotified);
+ }
+ const { data: unread } = await unreadQuery;
+ const unreadMessages = (unread ?? []) as Pick[];
+ if (unreadMessages.length === 0) {
+ skipped++;
+ continue;
+ }
+
const senderName = side === 'leerling'
? (schoolById.get(conv.rijschool_id)?.name ?? 'de rijschool')
: anonymizedFirstName(recipientRow.inquiries.leerling_name);
diff --git a/app/api/notifications/opt-out/route.ts b/app/api/notifications/opt-out/route.ts
index 09bcd3c..1b492f0 100644
--- a/app/api/notifications/opt-out/route.ts
+++ b/app/api/notifications/opt-out/route.ts
@@ -64,10 +64,15 @@ export async function GET(request: NextRequest) {
sideUserId = inquiry?.leerling_user_id ?? null;
}
if (sideUserId) {
- await supabase
+ const { error: prefError } = await supabase
.from('marketplace_profiles')
.update({ email_notifications: false })
.eq('user_id', sideUserId);
+ if (prefError) {
+ // Stamp op inquiry_recipients staat al (idempotent); laat de gebruiker
+ // opnieuw proberen zodat ook de account-brede voorkeur uit gaat.
+ throw new Error(`profile opt-out failed: ${prefError.message}`);
+ }
}
return page(
diff --git a/components/chat/ChatGateway.tsx b/components/chat/ChatGateway.tsx
index fc0b28f..d6a8e60 100644
--- a/components/chat/ChatGateway.tsx
+++ b/components/chat/ChatGateway.tsx
@@ -114,24 +114,30 @@ export default function ChatGateway({ token }: { token: string }) {
useEffect(() => {
(async () => {
- const supabase = getSupabase();
- const { data, error } = await supabase.rpc('get_chat_context', { p_token: token });
+ try {
+ const supabase = getSupabase();
+ const { data, error } = await supabase.rpc('get_chat_context', { p_token: token });
- if (error || !data?.found) {
- if (data?.expired) {
- setErrorMsg('Deze chat-link is verlopen. In je meest recente e-mail over dit gesprek staat een werkende link.');
+ if (error || !data?.found) {
+ if (data?.expired) {
+ setErrorMsg('Deze chat-link is verlopen. In je meest recente e-mail over dit gesprek staat een werkende link.');
+ }
+ setPhase('invalid');
+ return;
}
- setPhase('invalid');
- return;
- }
- const context = data as ChatContext;
- setInfo(context);
+ const context = data as ChatContext;
+ setInfo(context);
- const { data: { session } } = await supabase.auth.getSession();
- if (session) {
- await claim(context);
- } else {
- setPhase('otp');
+ const { data: { session } } = await supabase.auth.getSession();
+ if (session) {
+ await claim(context);
+ } else {
+ setPhase('otp');
+ }
+ } catch {
+ // Onverwachte fout (netwerk, client-init) mag de spinner niet
+ // eeuwig laten draaien.
+ setPhase('invalid');
}
})();
}, [token, claim]);
@@ -159,8 +165,10 @@ export default function ChatGateway({ token }: { token: string }) {
Deze link werkt niet
- {errorMsg ?? 'De chat-link is ongeldig of verlopen. Gebruik de meest recente link uit je e-mail, of mail ons op '}
- {!errorMsg && hallo@ribba.app}
+ {errorMsg ?? 'De chat-link is ongeldig of verlopen. Gebruik de meest recente link uit je e-mail.'}
+
From 7ef35424b7f7833c434ed5130343659944c62a1f Mon Sep 17 00:00:00 2001
From: Melvin
Date: Sat, 11 Jul 2026 17:53:34 -0700
Subject: [PATCH 5/9] =?UTF-8?q?fix:=20review=20ronde=202=20=E2=80=94=20opt?=
=?UTF-8?q?-out=20via=20POST-bevestiging,=20expiry=20NULL-bug,=20OtpGate-r?=
=?UTF-8?q?emount?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Opt-out: GET valideert alleen en toont een bevestigingsknop; de mutatie zit
in POST — mail-scanners/prefetchers GET'en links en mogen niemand afmelden
- Migratie: v_claimed_by_caller ge-coalesced naar false — bij een anonieme
caller was de expressie NULL en werd de token-expiry-check stil overgeslagen
- ChatGateway: OtpGate remount (key) bij account-wissel zodat step/email/code
resetten; ChatContext als discriminated union op found (succes-velden pas
na de found-check toegankelijk)
- CSS: word-break: break-word (deprecated) → overflow-wrap: anywhere
---
app/api/notifications/opt-out/route.ts | 48 +++++++++++++++----
app/globals.css | 2 +-
components/chat/ChatGateway.tsx | 16 ++++---
components/chat/ChatThread.tsx | 6 +--
lib/marketplace-types.ts | 11 +++--
supabase/.temp/cli-latest | 1 +
supabase/.temp/linked-project.json | 1 +
.../20260711000000_marketplace_mvp.sql | 9 +++-
8 files changed, 70 insertions(+), 24 deletions(-)
create mode 100644 supabase/.temp/cli-latest
create mode 100644 supabase/.temp/linked-project.json
diff --git a/app/api/notifications/opt-out/route.ts b/app/api/notifications/opt-out/route.ts
index 1b492f0..84bccfd 100644
--- a/app/api/notifications/opt-out/route.ts
+++ b/app/api/notifications/opt-out/route.ts
@@ -1,7 +1,9 @@
// Opt-out voor reply-notificatie e-mails (issue ribba.app#44). De chat-token
-// uit de mail identificeert de kant (leerling/rijschool). Zet de optout-stempel
-// op de inquiry_recipient en — als die kant al een account heeft —
-// email_notifications=false op het profiel.
+// uit de mail identificeert de kant (leerling/rijschool).
+//
+// GET valideert alleen en toont een bevestigingsknop; de daadwerkelijke
+// mutatie zit in POST. Mail-scanners en link-prefetchers GET'en elke link in
+// een mail — die mogen niemand ongevraagd afmelden.
import { NextRequest, NextResponse } from 'next/server';
import { rateLimit } from '@/lib/rate-limit';
@@ -9,22 +11,52 @@ import { getServiceClient, lookupRecipientByToken } from '@/lib/marketplace-db';
export const dynamic = 'force-dynamic';
-function page(title: string, body: string): NextResponse {
+function page(title: string, body: string, extraHtml = ''): NextResponse {
return new NextResponse(
`
${title} — Ribba
-