@flowselections/mailbox-orders 1.0.9 → 1.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist-lib/components/MailboxAuthBanner.d.ts +16 -0
  2. package/dist-lib/components/MailboxAuthBanner.d.ts.map +1 -0
  3. package/dist-lib/components/MailboxAuthBanner.js +14 -0
  4. package/dist-lib/components/MailboxProcessedPage.d.ts.map +1 -1
  5. package/dist-lib/components/MailboxProcessedPage.js +10 -2
  6. package/dist-lib/components/MailboxProposalsPage.d.ts.map +1 -1
  7. package/dist-lib/components/MailboxProposalsPage.js +425 -104
  8. package/dist-lib/components/SearchableSelect.d.ts +1 -0
  9. package/dist-lib/components/SearchableSelect.d.ts.map +1 -1
  10. package/dist-lib/components/SearchableSelect.js +2 -1
  11. package/dist-lib/components/settings/MailboxAiPromptCard.d.ts +2 -0
  12. package/dist-lib/components/settings/MailboxAiPromptCard.d.ts.map +1 -0
  13. package/dist-lib/components/settings/MailboxAiPromptCard.js +39 -0
  14. package/dist-lib/index.d.ts.map +1 -1
  15. package/dist-lib/index.js +2 -0
  16. package/dist-lib/integrations/supabase/auth-attacher.d.ts.map +1 -1
  17. package/dist-lib/integrations/supabase/auth-attacher.js +5 -1
  18. package/dist-lib/integrations/supabase/auth-middleware.d.ts +2 -11202
  19. package/dist-lib/integrations/supabase/auth-middleware.d.ts.map +1 -1
  20. package/dist-lib/integrations/supabase/auth-middleware.js +8 -7
  21. package/dist-lib/integrations/supabase/client.d.ts +16514 -7163
  22. package/dist-lib/integrations/supabase/client.d.ts.map +1 -1
  23. package/dist-lib/integrations/supabase/client.server.d.ts +16516 -7164
  24. package/dist-lib/integrations/supabase/client.server.d.ts.map +1 -1
  25. package/dist-lib/integrations/supabase/types.d.ts +16768 -7202
  26. package/dist-lib/integrations/supabase/types.d.ts.map +1 -1
  27. package/dist-lib/integrations/supabase/types.js +59 -8
  28. package/dist-lib/lib/ai-config.server.d.ts +46 -0
  29. package/dist-lib/lib/ai-config.server.d.ts.map +1 -0
  30. package/dist-lib/lib/ai-config.server.js +99 -0
  31. package/dist-lib/lib/delivery-date.d.ts +37 -0
  32. package/dist-lib/lib/delivery-date.d.ts.map +1 -0
  33. package/dist-lib/lib/delivery-date.js +96 -0
  34. package/dist-lib/lib/load-carriers.d.ts +26 -0
  35. package/dist-lib/lib/load-carriers.d.ts.map +1 -0
  36. package/dist-lib/lib/load-carriers.js +73 -0
  37. package/dist-lib/lib/mailbox-ai-prompt.d.ts +9 -0
  38. package/dist-lib/lib/mailbox-ai-prompt.d.ts.map +1 -0
  39. package/dist-lib/lib/mailbox-ai-prompt.js +30 -0
  40. package/dist-lib/lib/mailbox-ai-settings.functions.d.ts +23 -0
  41. package/dist-lib/lib/mailbox-ai-settings.functions.d.ts.map +1 -0
  42. package/dist-lib/lib/mailbox-ai-settings.functions.js +58 -0
  43. package/dist-lib/lib/mailbox-ai.server.d.ts +49 -4
  44. package/dist-lib/lib/mailbox-ai.server.d.ts.map +1 -1
  45. package/dist-lib/lib/mailbox-ai.server.js +284 -6
  46. package/dist-lib/lib/mailbox-body.d.ts +7 -0
  47. package/dist-lib/lib/mailbox-body.d.ts.map +1 -0
  48. package/dist-lib/lib/mailbox-body.js +38 -0
  49. package/dist-lib/lib/mailbox-ingest.server.d.ts +76 -0
  50. package/dist-lib/lib/mailbox-ingest.server.d.ts.map +1 -0
  51. package/dist-lib/lib/mailbox-ingest.server.js +863 -0
  52. package/dist-lib/lib/mailbox-template.functions.d.ts +7 -67213
  53. package/dist-lib/lib/mailbox-template.functions.d.ts.map +1 -1
  54. package/dist-lib/lib/mailbox.functions.d.ts +107 -190506
  55. package/dist-lib/lib/mailbox.functions.d.ts.map +1 -1
  56. package/dist-lib/lib/mailbox.functions.js +544 -270
  57. package/package.json +4 -3
@@ -0,0 +1,863 @@
1
+ // Server-only ingestpijplijn voor de Mailbox-bestellingen module.
2
+ // Deze module wordt aangeroepen door:
3
+ // • pg_cron via het publieke endpoint /api/public/mailbox-orders/process-events
4
+ // • de client Realtime-subscriber via server-fn `syncMailboxNow`
5
+ // • de "Sync nu" knop via server-fn `syncMailboxNow`
6
+ //
7
+ // Alle triggers doorlopen exact dezelfde pipeline: één source of truth.
8
+ // GEEN imports van "@/integrations/supabase/client.server" op module-scope —
9
+ // die worden per aanroep dynamisch geladen (client-reachable modules mogen
10
+ // server-only helpers niet direct binnenhalen).
11
+ import { emailBodyText } from "./mailbox-body";
12
+ import { detectLoadCarrierInText, isLoadCarrierAllowed, loadCarrierLabel, normalizeAllowedLoadCarriers, } from "./load-carriers";
13
+ /**
14
+ * Ladingdragers komen live uit de Logistics Engine — nooit cachen, nooit een
15
+ * eigen lijst bijhouden.
16
+ */
17
+ async function fetchAllowedLoadCarriers(admin) {
18
+ const { data } = await admin.rpc("logistics_allowed_load_carriers", {
19
+ p_strategy_id: undefined,
20
+ });
21
+ return normalizeAllowedLoadCarriers(data);
22
+ }
23
+ /**
24
+ * Bepaalt de ladingdrager voor een bericht: herkenning uit de mail wint,
25
+ * anders de standaard van de Engine. Staat de herkende drager uit, dan wordt
26
+ * het bericht op handmatig controleren gezet.
27
+ */
28
+ async function resolveLoadCarrier(admin, messageId, aiValue) {
29
+ const list = await fetchAllowedLoadCarriers(admin);
30
+ const { data: msg } = await admin
31
+ .from("mailbox_messages")
32
+ .select("subject, body_text, body_html")
33
+ .eq("id", messageId)
34
+ .maybeSingle();
35
+ const text = [
36
+ msg?.subject ?? "",
37
+ emailBodyText(msg?.body_text, msg?.body_html),
38
+ ].join("\n");
39
+ const fromAi = typeof aiValue === "string" && list.carriers.some((c) => c.code === aiValue) ? aiValue : null;
40
+ const detected = fromAi ?? detectLoadCarrierInText(list, text);
41
+ if (detected && !isLoadCarrierAllowed(list, detected)) {
42
+ return { code: null, blocked: true, detectedLabel: loadCarrierLabel(list, detected) };
43
+ }
44
+ return { code: detected ?? list.default, blocked: false, detectedLabel: null };
45
+ }
46
+ function pickStr(obj, ...keys) {
47
+ for (const k of keys) {
48
+ const v = k.split(".").reduce((a, p) => (a == null ? a : a[p]), obj);
49
+ if (typeof v === "string" && v.length > 0)
50
+ return v;
51
+ }
52
+ return null;
53
+ }
54
+ /** Haalt het kale e-mailadres uit "Naam <adres@dom.nl>" of een los adres. */
55
+ function normalizeEmail(raw) {
56
+ const s = (raw ?? "").trim();
57
+ const m = s.match(/<([^>]+)>/);
58
+ return (m ? m[1] : s).trim().toLowerCase();
59
+ }
60
+ async function matchCustomerByEmail(admin, fromEmail) {
61
+ const email = normalizeEmail(fromEmail);
62
+ if (!email)
63
+ return { customerId: null, source: null, warning: null, candidates: [] };
64
+ const { data: directHits } = await admin
65
+ .from("customers")
66
+ .select("id, company_name")
67
+ .ilike("email", email)
68
+ .limit(5);
69
+ const direct = Array.isArray(directHits) ? directHits : [];
70
+ if (direct.length === 1) {
71
+ return {
72
+ customerId: direct[0].id,
73
+ source: "customers",
74
+ warning: null,
75
+ candidates: direct.map((c) => ({ id: c.id, name: c.company_name })),
76
+ };
77
+ }
78
+ if (direct.length > 1) {
79
+ return {
80
+ customerId: null,
81
+ source: "customers",
82
+ warning: `Meerdere klanten met e-mailadres ${email} — kies handmatig.`,
83
+ candidates: direct.map((c) => ({ id: c.id, name: c.company_name })),
84
+ };
85
+ }
86
+ const { data: contactHits } = await admin
87
+ .from("customer_contacts")
88
+ .select("customer_id")
89
+ .ilike("email", email)
90
+ .limit(5);
91
+ const contacts = Array.isArray(contactHits) ? contactHits : [];
92
+ const uniqueCustomerIds = Array.from(new Set(contacts.map((c) => c.customer_id).filter(Boolean)));
93
+ if (uniqueCustomerIds.length === 0) {
94
+ return { customerId: null, source: null, warning: null, candidates: [] };
95
+ }
96
+ const { data: custRows } = await admin
97
+ .from("customers")
98
+ .select("id, company_name")
99
+ .in("id", uniqueCustomerIds);
100
+ const cands = (Array.isArray(custRows) ? custRows : []).map((c) => ({
101
+ id: c.id,
102
+ name: c.company_name,
103
+ }));
104
+ if (uniqueCustomerIds.length === 1) {
105
+ return {
106
+ customerId: uniqueCustomerIds[0],
107
+ source: "customer_contacts",
108
+ warning: null,
109
+ candidates: cands,
110
+ };
111
+ }
112
+ return {
113
+ customerId: null,
114
+ source: "customer_contacts",
115
+ warning: `Meerdere klanten gevonden via contactpersoon met e-mailadres ${email} — kies handmatig.`,
116
+ candidates: cands,
117
+ };
118
+ }
119
+ // Standaard veldspecificatie voor de AI wanneer er (nog) geen rijen staan in
120
+ // mailbox_order_template_fields. Afhaallocatie/tuin en vervoerder staan hier
121
+ // bewust NIET in: dat zijn logistieke keuzes, geen maildata.
122
+ const DEFAULT_AI_FIELDS = [
123
+ {
124
+ key: "delivery_date",
125
+ label: "Gewenste leverdatum",
126
+ field_type: "date",
127
+ ai_hint: "Alleen invullen als de mail een datum of week noemt. Formaat YYYY-MM-DD.",
128
+ },
129
+ {
130
+ key: "reference",
131
+ label: "Referentie klant",
132
+ field_type: "text",
133
+ ai_hint: "Order-, referentie- of PO-nummer van de klant.",
134
+ },
135
+ {
136
+ key: "notes",
137
+ label: "Opmerking",
138
+ field_type: "text",
139
+ ai_hint: "Korte samenvatting van bijzondere wensen uit de mail.",
140
+ },
141
+ {
142
+ key: "delivery_location",
143
+ label: "Afleveradres",
144
+ field_type: "delivery_location",
145
+ ai_hint: "Kies uitsluitend een id uit de meegegeven afleverlocaties van deze klant.",
146
+ },
147
+ ];
148
+ /** Centrale AI-instellingen van deze module (aanpasbaar via Instellingen). */
149
+ export async function loadMailboxAiSettings(admin) {
150
+ const { data } = await admin
151
+ .from("mailbox_ai_settings")
152
+ .select("system_prompt, required_fields")
153
+ .limit(1)
154
+ .maybeSingle();
155
+ const req = Array.isArray(data?.required_fields) ? data.required_fields : [];
156
+ return {
157
+ systemPrompt: data?.system_prompt ?? null,
158
+ requiredFields: req.length > 0 ? req : ["customer", "delivery_date", "delivery_location", "lines"],
159
+ };
160
+ }
161
+ async function buildAiContext(admin, customerId) {
162
+ const [{ data: products }, { data: locations }, { data: tplFields }, { data: plantRows }, settings] = await Promise.all([
163
+ admin.from("products").select("id, product").order("product").range(0, 999),
164
+ customerId
165
+ ? admin
166
+ .from("customer_delivery_locations")
167
+ .select("id, name, city")
168
+ .eq("customer_id", customerId)
169
+ .limit(100)
170
+ : Promise.resolve({ data: [] }),
171
+ admin.from("mailbox_order_template_fields").select("*").order("sort_order", { ascending: true }),
172
+ admin.from("products").select("product_type").not("product_type", "is", null).range(0, 999),
173
+ loadMailboxAiSettings(admin),
174
+ ]);
175
+ const catalog = (Array.isArray(products) ? products : []).map((p) => ({
176
+ id: p.id,
177
+ name: p.product ?? "",
178
+ }));
179
+ const plantTypes = Array.from(new Set((Array.isArray(plantRows) ? plantRows : [])
180
+ .map((r) => (typeof r?.product_type === "string" ? r.product_type.trim() : ""))
181
+ .filter(Boolean))).sort();
182
+ const deliveryLocations = (Array.isArray(locations) ? locations : []).map((l) => ({
183
+ id: l.id,
184
+ name: [l.name, l.city].filter(Boolean).join(" — ") || "Locatie",
185
+ }));
186
+ const rows = (Array.isArray(tplFields) ? tplFields : []).filter((f) => f.ai_enabled !== false);
187
+ const templateFields = rows.length > 0
188
+ ? rows.map((f) => ({
189
+ key: f.key,
190
+ label: f.label ?? f.key,
191
+ field_type: f.field_type ?? "text",
192
+ ai_hint: f.ai_hint ?? null,
193
+ allowed: f.field_type === "product"
194
+ ? catalog
195
+ : f.field_type === "delivery_location"
196
+ ? deliveryLocations
197
+ : f.field_type === "plant_type"
198
+ ? plantTypes.map((n) => ({ id: n, name: n }))
199
+ : undefined,
200
+ }))
201
+ : DEFAULT_AI_FIELDS.map((f) => ({
202
+ ...f,
203
+ allowed: f.field_type === "delivery_location" ? deliveryLocations : undefined,
204
+ }));
205
+ return { catalog, deliveryLocations, templateFields, plantTypes, settings };
206
+ }
207
+ /** Voert de AI-parse uit voor één bericht. Gooit nooit — fouten komen terug in `error`. */
208
+ async function aiParseMessage(admin, messageId, customerId) {
209
+ try {
210
+ const { data: msg } = await admin
211
+ .from("mailbox_messages")
212
+ .select("subject, body_text, body_html")
213
+ .eq("id", messageId)
214
+ .maybeSingle();
215
+ const bodyText = emailBodyText(msg?.body_text, msg?.body_html);
216
+ if (!bodyText.trim())
217
+ throw new Error("Bericht bevat geen tekst om te lezen.");
218
+ const ctx = await buildAiContext(admin, customerId);
219
+ const { parseOrderEmail } = await import("./mailbox-ai.server");
220
+ const res = await parseOrderEmail(admin, {
221
+ bodyText,
222
+ subject: msg?.subject ?? "",
223
+ templateFields: ctx.templateFields,
224
+ productCatalog: ctx.catalog,
225
+ deliveryLocations: ctx.deliveryLocations,
226
+ plantTypes: ctx.plantTypes,
227
+ requiredFields: ctx.settings.requiredFields,
228
+ systemPrompt: ctx.settings.systemPrompt,
229
+ });
230
+ return { parsed: res.parsed, meta: res.meta, error: null, requiredFields: ctx.settings.requiredFields };
231
+ }
232
+ catch (e) {
233
+ return { parsed: null, meta: null, error: String(e?.message ?? e).slice(0, 500), requiredFields: [] };
234
+ }
235
+ }
236
+ const FIELD_LABELS = {
237
+ customer: "Klant",
238
+ delivery_date: "Bezorgdatum",
239
+ delivery_location: "Afleveradres",
240
+ pickup_location: "Afhaallocatie / Tuin",
241
+ plant_type: "Plantsoort",
242
+ lines: "Bestelregels",
243
+ reference: "Referentie klant",
244
+ notes: "Opmerking",
245
+ };
246
+ /**
247
+ * Combineert de zelfcontrole van de AI met een harde servercheck op de
248
+ * verplichte velden. Ontbrekende of onzekere gegevens worden expliciet
249
+ * benoemd in plaats van stilzwijgend leeg gelaten.
250
+ */
251
+ export function computeValidation(parsed, requiredFields, extra) {
252
+ const label = (k) => FIELD_LABELS[k] ?? k;
253
+ const missing = new Set(parsed.validation?.missing ?? []);
254
+ const uncertain = new Set(parsed.validation?.uncertain ?? []);
255
+ for (const key of requiredFields) {
256
+ if (key === "lines") {
257
+ if (parsed.lines.length === 0)
258
+ missing.add(label("lines"));
259
+ continue;
260
+ }
261
+ if (key === "customer") {
262
+ if (!extra.customerId && !parsed.fields?.customer?.value)
263
+ missing.add(label("customer"));
264
+ continue;
265
+ }
266
+ const f = parsed.fields?.[key];
267
+ const empty = f?.value === null || f?.value === undefined || f?.value === "";
268
+ if (empty)
269
+ missing.add(label(key));
270
+ else if (f && (f.needs_review || f.confidence < 0.8))
271
+ uncertain.add(label(key));
272
+ }
273
+ for (const l of parsed.lines) {
274
+ if (!l.product_id)
275
+ uncertain.add(`Artikel niet gekoppeld: ${l.raw_product_text || "onbekend"}`);
276
+ if (l.quantity === null || l.quantity <= 0)
277
+ missing.add(`Aantal ontbreekt: ${l.raw_product_text || "onbekend"}`);
278
+ if (!l.plant_type)
279
+ uncertain.add(`Plantsoort onbekend: ${l.raw_product_text || "onbekend"}`);
280
+ }
281
+ const m = Array.from(missing).slice(0, 50);
282
+ const u = Array.from(uncertain).slice(0, 50);
283
+ return {
284
+ missing: m,
285
+ uncertain: u,
286
+ notes: parsed.validation?.notes ?? null,
287
+ ok: m.length === 0 && u.length === 0,
288
+ };
289
+ }
290
+ function aiPayloadBlock(attempt, fingerprint) {
291
+ return attempt.parsed
292
+ ? {
293
+ ok: true,
294
+ provider: attempt.meta?.providerSlug,
295
+ model: attempt.meta?.modelName,
296
+ duration_ms: attempt.meta?.durationMs,
297
+ fingerprint: attempt.meta?.fingerprint ?? fingerprint,
298
+ parsed_at: new Date().toISOString(),
299
+ }
300
+ : {
301
+ ok: false,
302
+ error: attempt.error,
303
+ fingerprint,
304
+ parsed_at: new Date().toISOString(),
305
+ };
306
+ }
307
+ /**
308
+ * Vult het afleveradres aan met de hoofdafleverlocatie (is_primary) van de klant
309
+ * wanneer de mail/AI géén geldige locatie heeft herkend. Herkent de AI wél een
310
+ * locatie van deze klant, dan blijft die staan.
311
+ */
312
+ export async function resolveDeliveryLocation(admin, customerId, aiValue) {
313
+ if (!customerId)
314
+ return null;
315
+ const { data: rows } = await admin
316
+ .from("customer_delivery_locations")
317
+ .select("id, is_primary")
318
+ .eq("customer_id", customerId)
319
+ .order("is_primary", { ascending: false })
320
+ .limit(100);
321
+ const locations = Array.isArray(rows) ? rows : [];
322
+ if (locations.length === 0)
323
+ return null;
324
+ if (typeof aiValue === "string" && aiValue) {
325
+ const hit = locations.find((l) => String(l.id) === aiValue);
326
+ if (hit)
327
+ return { id: String(hit.id), primary: false };
328
+ }
329
+ const primary = locations.find((l) => l.is_primary === true) ?? locations[0];
330
+ return primary ? { id: String(primary.id), primary: true } : null;
331
+ }
332
+ /**
333
+ * Afhaallocatie (eigen tuin/warehouse): AI-herkenning wint, anders de hoofdlocatie
334
+ * (Floriday warehouse met isDefault = true).
335
+ */
336
+ export async function resolvePickupLocation(admin, aiValue) {
337
+ const { data: rows } = await admin
338
+ .from("floriday_warehouses_cache")
339
+ .select("floriday_id, data")
340
+ .eq("is_deleted", false);
341
+ const own = (Array.isArray(rows) ? rows : []).filter((r) => {
342
+ const d = r?.data ?? {};
343
+ return d?.hasAccess === true && d?.isExternal === false;
344
+ });
345
+ if (own.length === 0)
346
+ return null;
347
+ if (typeof aiValue === "string" && aiValue) {
348
+ const hit = own.find((r) => String(r.floriday_id) === aiValue);
349
+ if (hit)
350
+ return { id: String(hit.floriday_id), primary: false };
351
+ }
352
+ const main = own.find((r) => r?.data?.isDefault === true) ?? own[0];
353
+ return main ? { id: String(main.floriday_id), primary: true } : null;
354
+ }
355
+ /** Schrijft velden + regels van een AI-parse weg (na het opschonen van oude rijen). */
356
+ async function writeParsedDetails(admin, proposalId, parsed) {
357
+ const fieldRows = Object.entries(parsed.fields).map(([key, v]) => ({
358
+ proposal_id: proposalId,
359
+ field_key: key,
360
+ value: v.value === undefined ? null : v.value,
361
+ ai_confidence: v.confidence,
362
+ ai_filled: v.value !== null && v.value !== undefined,
363
+ needs_review: v.needs_review,
364
+ }));
365
+ if (fieldRows.length > 0) {
366
+ await admin.from("mailbox_proposal_field_values").insert(fieldRows);
367
+ }
368
+ if (parsed.lines.length > 0) {
369
+ await admin.from("mailbox_proposal_lines").insert(parsed.lines.map((l, idx) => ({
370
+ proposal_id: proposalId,
371
+ product_id: l.product_id,
372
+ plant_type: l.plant_type,
373
+ raw_product_text: l.raw_product_text,
374
+ quantity: l.quantity,
375
+ unit: l.unit,
376
+ match_confidence: l.match_confidence,
377
+ sort_order: idx,
378
+ })));
379
+ }
380
+ }
381
+ /**
382
+ * Maakt exact één voorstel per bericht. Probeert eerst de AI-parse via de
383
+ * centrale AI-Authenticatie module; lukt dat niet, dan valt hij terug op een
384
+ * leeg voorstel met de reden erbij. Ingest mag nooit stukvallen op AI.
385
+ */
386
+ export async function createStubProposal(admin, messageId, fromEmail) {
387
+ const { data: existing } = await admin
388
+ .from("mailbox_order_proposals")
389
+ .select("id")
390
+ .eq("message_id", messageId)
391
+ .maybeSingle();
392
+ if (existing?.id)
393
+ return;
394
+ // Val terug op het afzenderadres van het bericht zelf wanneer de event-payload
395
+ // geen adres meegaf — anders wordt de klant nooit automatisch herkend.
396
+ let senderEmail = normalizeEmail(fromEmail);
397
+ if (!senderEmail) {
398
+ const { data: msg } = await admin
399
+ .from("mailbox_messages")
400
+ .select("from_email")
401
+ .eq("id", messageId)
402
+ .maybeSingle();
403
+ senderEmail = normalizeEmail(msg?.from_email ?? null);
404
+ }
405
+ const matchResult = await matchCustomerByEmail(admin, senderEmail);
406
+ const basePayload = {
407
+ auto_matched_customer: matchResult.customerId !== null,
408
+ auto_match_source: matchResult.source,
409
+ auto_match_email: senderEmail || null,
410
+ auto_match_warning: matchResult.warning,
411
+ auto_match_candidates: matchResult.candidates,
412
+ };
413
+ // --- AI-parse (best effort) ---------------------------------------------
414
+ const { currentAiFingerprint } = await import("./mailbox-ai.server");
415
+ const fingerprint = await currentAiFingerprint(admin).catch(() => null);
416
+ const attempt = await aiParseMessage(admin, messageId, matchResult.customerId);
417
+ const parsed = attempt.parsed;
418
+ const fieldValue = (key) => {
419
+ const v = parsed?.fields?.[key]?.value;
420
+ return v === undefined ? null : v;
421
+ };
422
+ const deliveryDate = typeof fieldValue("delivery_date") === "string" ? fieldValue("delivery_date") : null;
423
+ const notesValue = typeof fieldValue("notes") === "string" ? fieldValue("notes") : null;
424
+ // Afleveradres: AI-herkenning wint, anders de hoofdafleverlocatie van de klant.
425
+ const loc = await resolveDeliveryLocation(admin, matchResult.customerId, fieldValue("delivery_location"));
426
+ if (loc && parsed) {
427
+ parsed.fields = {
428
+ ...parsed.fields,
429
+ delivery_location: loc.primary
430
+ ? { value: loc.id, confidence: 0.5, needs_review: true }
431
+ : (parsed.fields?.delivery_location ?? { value: loc.id, confidence: 0.9, needs_review: false }),
432
+ };
433
+ }
434
+ // Afhaallocatie: AI-herkenning wint, anders de hoofd-afhaallocatie.
435
+ const pick = await resolvePickupLocation(admin, fieldValue("pickup_location"));
436
+ if (pick && parsed) {
437
+ parsed.fields = {
438
+ ...parsed.fields,
439
+ pickup_location: pick.primary
440
+ ? { value: pick.id, confidence: 0.5, needs_review: true }
441
+ : (parsed.fields?.pickup_location ?? { value: pick.id, confidence: 0.9, needs_review: false }),
442
+ };
443
+ }
444
+ // Ladingdrager: alleen wat de Engine toestaat.
445
+ const lc = await resolveLoadCarrier(admin, messageId, fieldValue("load_carrier"));
446
+ if (lc.code && parsed) {
447
+ parsed.fields = {
448
+ ...parsed.fields,
449
+ load_carrier: { value: lc.code, confidence: 0.6, needs_review: false },
450
+ };
451
+ }
452
+ const blockedNote = lc.blocked
453
+ ? `Handmatig controleren - ladingdrager uitgeschakeld: "${lc.detectedLabel}" staat uit in de Rule Engine.`
454
+ : null;
455
+ const validation = parsed
456
+ ? computeValidation(parsed, attempt.requiredFields, { customerId: matchResult.customerId })
457
+ : null;
458
+ const { data: proposal, error } = await admin
459
+ .from("mailbox_order_proposals")
460
+ .insert({
461
+ message_id: messageId,
462
+ matched_customer_id: matchResult.customerId,
463
+ confidence: parsed ? parsed.overall_confidence : matchResult.customerId ? 1 : 0,
464
+ delivery_date: deliveryDate,
465
+ notes: blockedNote ? [blockedNote, notesValue].filter(Boolean).join("\n") : notesValue,
466
+ status: "pending",
467
+ ai_validation: validation,
468
+ parsed_payload: {
469
+ ...basePayload,
470
+ stub: parsed === null,
471
+ reason: parsed === null ? "ai_unavailable" : null,
472
+ load_carrier: lc.code,
473
+ load_carrier_blocked: lc.blocked,
474
+ load_carrier_detected: lc.detectedLabel,
475
+ ai: aiPayloadBlock(attempt, fingerprint),
476
+ fields: parsed?.fields ?? null,
477
+ validation,
478
+ },
479
+ })
480
+ .select("id")
481
+ .single();
482
+ // 23505 = unique-violation op message_id: een concurrent trigger heeft 'm
483
+ // net ingevoegd. Idempotent — negeren. Alle andere fouten laten we bubbelen.
484
+ if (error) {
485
+ if (error.code === "23505")
486
+ return;
487
+ throw new Error(error.message);
488
+ }
489
+ const proposalId = proposal.id;
490
+ if (parsed)
491
+ await writeParsedDetails(admin, proposalId, parsed);
492
+ else if (loc) {
493
+ await admin.from("mailbox_proposal_field_values").insert({
494
+ proposal_id: proposalId,
495
+ field_key: "delivery_location",
496
+ value: loc.id,
497
+ ai_confidence: 0.5,
498
+ ai_filled: false,
499
+ needs_review: true,
500
+ });
501
+ }
502
+ if (!parsed && lc.code) {
503
+ await admin.from("mailbox_proposal_field_values").upsert({
504
+ proposal_id: proposalId,
505
+ field_key: "load_carrier",
506
+ value: lc.code,
507
+ ai_confidence: 0.6,
508
+ ai_filled: false,
509
+ needs_review: false,
510
+ }, { onConflict: "proposal_id,field_key" });
511
+ }
512
+ if (lc.blocked) {
513
+ await admin
514
+ .from("mailbox_messages")
515
+ .update({ status: "manual_review_load_carrier_disabled" })
516
+ .eq("id", messageId);
517
+ }
518
+ }
519
+ /**
520
+ * Analyseert een bestaand (nog niet goedgekeurd) voorstel opnieuw met de
521
+ * huidige AI-agent. De vorige AI-analyse wordt vervangen.
522
+ */
523
+ export async function reparseProposal(admin, proposalId) {
524
+ const { data: prop } = await admin
525
+ .from("mailbox_order_proposals")
526
+ .select("id, message_id, status, matched_customer_id, parsed_payload")
527
+ .eq("id", proposalId)
528
+ .maybeSingle();
529
+ if (!prop?.id)
530
+ return { ok: false, error: "Voorstel niet gevonden." };
531
+ if (prop.status !== "pending") {
532
+ return { ok: false, error: "Voorstel is al verwerkt — niet opnieuw geanalyseerd." };
533
+ }
534
+ // Klantherkenning opnieuw proberen op basis van het afzenderadres.
535
+ let matchedCustomerId = prop.matched_customer_id ?? null;
536
+ const { data: msgRow } = await admin
537
+ .from("mailbox_messages")
538
+ .select("from_email")
539
+ .eq("id", prop.message_id)
540
+ .maybeSingle();
541
+ const senderEmail = normalizeEmail(msgRow?.from_email ?? null);
542
+ const rematch = matchedCustomerId ? null : await matchCustomerByEmail(admin, senderEmail);
543
+ if (rematch?.customerId)
544
+ matchedCustomerId = rematch.customerId;
545
+ const { currentAiFingerprint } = await import("./mailbox-ai.server");
546
+ const fingerprint = await currentAiFingerprint(admin).catch(() => null);
547
+ const attempt = await aiParseMessage(admin, prop.message_id, matchedCustomerId);
548
+ const prevBase = (prop.parsed_payload && typeof prop.parsed_payload === "object" ? prop.parsed_payload : {});
549
+ const base = rematch
550
+ ? {
551
+ ...prevBase,
552
+ auto_matched_customer: rematch.customerId !== null,
553
+ auto_match_source: rematch.source,
554
+ auto_match_email: senderEmail || null,
555
+ auto_match_warning: rematch.warning,
556
+ auto_match_candidates: rematch.candidates,
557
+ }
558
+ : prevBase;
559
+ if (!attempt.parsed) {
560
+ // Mislukte hernieuwde analyse: bestaande gegevens laten staan, alleen de
561
+ // AI-status bijwerken zodat we niet in een lus blijven proberen.
562
+ await admin
563
+ .from("mailbox_order_proposals")
564
+ .update({ parsed_payload: { ...base, ai: aiPayloadBlock(attempt, fingerprint) } })
565
+ .eq("id", proposalId);
566
+ return { ok: false, error: attempt.error };
567
+ }
568
+ const parsed = attempt.parsed;
569
+ const fv = (key) => {
570
+ const v = parsed.fields?.[key]?.value;
571
+ return v === undefined ? null : v;
572
+ };
573
+ // Afleveradres: AI-herkenning wint, anders de hoofdafleverlocatie van de klant.
574
+ const rloc = await resolveDeliveryLocation(admin, matchedCustomerId, fv("delivery_location"));
575
+ if (rloc) {
576
+ parsed.fields = {
577
+ ...parsed.fields,
578
+ delivery_location: rloc.primary
579
+ ? { value: rloc.id, confidence: 0.5, needs_review: true }
580
+ : (parsed.fields?.delivery_location ?? { value: rloc.id, confidence: 0.9, needs_review: false }),
581
+ };
582
+ }
583
+ // Afhaallocatie: AI-herkenning wint, anders de hoofd-afhaallocatie.
584
+ const rpick = await resolvePickupLocation(admin, fv("pickup_location"));
585
+ if (rpick) {
586
+ parsed.fields = {
587
+ ...parsed.fields,
588
+ pickup_location: rpick.primary
589
+ ? { value: rpick.id, confidence: 0.5, needs_review: true }
590
+ : (parsed.fields?.pickup_location ?? { value: rpick.id, confidence: 0.9, needs_review: false }),
591
+ };
592
+ }
593
+ // Ladingdrager: alleen wat de Engine toestaat.
594
+ const rlc = await resolveLoadCarrier(admin, prop.message_id, fv("load_carrier"));
595
+ if (rlc.code) {
596
+ parsed.fields = {
597
+ ...parsed.fields,
598
+ load_carrier: { value: rlc.code, confidence: 0.6, needs_review: false },
599
+ };
600
+ }
601
+ else if (parsed.fields) {
602
+ delete parsed.fields.load_carrier;
603
+ }
604
+ const rBlockedNote = rlc.blocked
605
+ ? `Handmatig controleren - ladingdrager uitgeschakeld: "${rlc.detectedLabel}" staat uit in de Rule Engine.`
606
+ : null;
607
+ await admin.from("mailbox_proposal_lines").delete().eq("proposal_id", proposalId);
608
+ await admin.from("mailbox_proposal_field_values").delete().eq("proposal_id", proposalId);
609
+ const rvalidation = computeValidation(parsed, attempt.requiredFields, {
610
+ customerId: matchedCustomerId,
611
+ });
612
+ const notesFromAi = typeof fv("notes") === "string" ? fv("notes") : null;
613
+ const { error: updErr } = await admin
614
+ .from("mailbox_order_proposals")
615
+ .update({
616
+ matched_customer_id: matchedCustomerId,
617
+ confidence: parsed.overall_confidence,
618
+ delivery_date: typeof fv("delivery_date") === "string" ? fv("delivery_date") : null,
619
+ notes: rBlockedNote ? [rBlockedNote, notesFromAi].filter(Boolean).join("\n") : notesFromAi,
620
+ ai_validation: rvalidation,
621
+ parsed_payload: {
622
+ ...base,
623
+ stub: false,
624
+ reason: null,
625
+ reparsed: true,
626
+ load_carrier: rlc.code,
627
+ load_carrier_blocked: rlc.blocked,
628
+ load_carrier_detected: rlc.detectedLabel,
629
+ ai: aiPayloadBlock(attempt, fingerprint),
630
+ fields: parsed.fields,
631
+ validation: rvalidation,
632
+ },
633
+ })
634
+ .eq("id", proposalId);
635
+ if (updErr)
636
+ return { ok: false, error: updErr.message };
637
+ if (rlc.blocked) {
638
+ await admin
639
+ .from("mailbox_messages")
640
+ .update({ status: "manual_review_load_carrier_disabled" })
641
+ .eq("id", prop.message_id);
642
+ }
643
+ await writeParsedDetails(admin, proposalId, parsed);
644
+ return { ok: true, error: null };
645
+ }
646
+ /**
647
+ * Detecteert een gewijzigde AI-agent: alle openstaande voorstellen waarvan de
648
+ * opgeslagen vingerafdruk afwijkt van de huidige worden opnieuw geanalyseerd.
649
+ */
650
+ export async function reparseChangedAgentProposals(admin, limit = 25) {
651
+ const { currentAiFingerprint } = await import("./mailbox-ai.server");
652
+ const fingerprint = await currentAiFingerprint(admin).catch(() => null);
653
+ if (!fingerprint)
654
+ return { checked: 0, reparsed: 0, failed: 0, fingerprint: null };
655
+ const { data: rows } = await admin
656
+ .from("mailbox_order_proposals")
657
+ .select("id, parsed_payload")
658
+ .eq("status", "pending")
659
+ .order("created_at", { ascending: false })
660
+ .limit(200);
661
+ const list = Array.isArray(rows) ? rows : [];
662
+ let reparsed = 0;
663
+ let failed = 0;
664
+ let checked = 0;
665
+ for (const r of list) {
666
+ const stored = r.parsed_payload?.ai?.fingerprint ?? null;
667
+ if (stored === fingerprint)
668
+ continue;
669
+ checked++;
670
+ if (reparsed + failed >= limit)
671
+ break;
672
+ const res = await reparseProposal(admin, r.id);
673
+ if (res.ok)
674
+ reparsed++;
675
+ else
676
+ failed++;
677
+ }
678
+ return { checked, reparsed, failed, fingerprint };
679
+ }
680
+ async function ingestSingleEvent(admin, connectionId, ev) {
681
+ const p = ev.payload ?? {};
682
+ const externalId = ev.external_id ?? pickStr(p, "message_id", "messageId", "id") ?? ev.id;
683
+ // Idempotent: bestaat er al een message voor deze external_id op deze connectie?
684
+ if (externalId) {
685
+ const { data: existing } = await admin
686
+ .from("mailbox_messages")
687
+ .select("id")
688
+ .eq("connection_id", connectionId)
689
+ .eq("external_id", externalId)
690
+ .maybeSingle();
691
+ if (existing?.id) {
692
+ // Zorg dat er in elk geval een voorstel is (herstel oude berichten zonder stub).
693
+ await createStubProposal(admin, existing.id, pickStr(p, "from_email", "fromEmail", "from.address"));
694
+ return { inserted: false, messageId: existing.id };
695
+ }
696
+ }
697
+ const fromEmail = pickStr(p, "from_email", "fromEmail", "from.address", "from.email", "sender.address") ?? "";
698
+ const fromName = pickStr(p, "from_name", "fromName", "from.name", "sender.name");
699
+ const subject = pickStr(p, "subject", "title") ?? "";
700
+ const bodyText = pickStr(p, "body_text", "bodyText", "text", "body.text", "snippet");
701
+ const bodyHtml = pickStr(p, "body_html", "bodyHtml", "html", "body.html");
702
+ const receivedAt = pickStr(p, "received_at", "receivedAt", "date", "internalDate") ??
703
+ ev.received_at ??
704
+ new Date().toISOString();
705
+ const { data: inserted, error: insErr } = await admin
706
+ .from("mailbox_messages")
707
+ .insert({
708
+ external_id: externalId,
709
+ message_id: pickStr(p, "message_id", "messageId") ?? externalId,
710
+ connection_id: connectionId,
711
+ from_email: fromEmail,
712
+ from_name: fromName,
713
+ subject,
714
+ body_text: bodyText,
715
+ body_html: bodyHtml,
716
+ received_at: receivedAt,
717
+ status: "new",
718
+ })
719
+ .select("id")
720
+ .single();
721
+ if (insErr) {
722
+ // Race met cron/realtime — de andere trigger heeft 'm net ingevoegd.
723
+ if (insErr.code === "23505") {
724
+ const { data: dup } = await admin
725
+ .from("mailbox_messages")
726
+ .select("id")
727
+ .eq("connection_id", connectionId)
728
+ .eq("external_id", externalId)
729
+ .maybeSingle();
730
+ if (dup?.id)
731
+ await createStubProposal(admin, dup.id, fromEmail);
732
+ return { inserted: false, messageId: dup?.id ?? null };
733
+ }
734
+ throw new Error(insErr.message);
735
+ }
736
+ await createStubProposal(admin, inserted.id, fromEmail);
737
+ return { inserted: true, messageId: inserted.id };
738
+ }
739
+ // De volledige pipeline. Alle triggers roepen dit aan.
740
+ export async function runIngest(trigger) {
741
+ const { supabaseAdmin } = await import("../integrations/supabase/client.server");
742
+ // 1. Actieve orders/receive connectie ophalen (service_role → geen bearer nodig).
743
+ const { data: active, error: activeErr } = await supabaseAdmin.rpc("email_auth_for_purpose", {
744
+ _purpose: "orders",
745
+ _direction: "receive",
746
+ });
747
+ if (activeErr)
748
+ throw new Error(`email_auth_for_purpose: ${activeErr.message}`);
749
+ const row = Array.isArray(active) ? active[0] : null;
750
+ const connectionId = row?.id ?? null;
751
+ if (!connectionId) {
752
+ return {
753
+ ok: true,
754
+ runId: null,
755
+ processed: 0,
756
+ inserted: 0,
757
+ errors: 0,
758
+ errorSample: null,
759
+ connectionId: null,
760
+ skipped: "no_active_connection",
761
+ };
762
+ }
763
+ // 2. Open run-record voor observability.
764
+ const { data: runRow } = await supabaseAdmin
765
+ .from("mailbox_ingest_runs")
766
+ .insert({ trigger, connection_id: connectionId })
767
+ .select("id")
768
+ .single();
769
+ const runId = runRow?.id ?? null;
770
+ let processed = 0;
771
+ let inserted = 0;
772
+ let errors = 0;
773
+ let errorSample = null;
774
+ const processedEventIds = [];
775
+ try {
776
+ // 3. Onverwerkte events voor deze connectie ophalen (max 200 per run).
777
+ const { data: events, error: evErr } = await supabaseAdmin
778
+ .from("email_auth_events")
779
+ .select("id, connection_id, external_id, payload, received_at")
780
+ .eq("connection_id", connectionId)
781
+ .is("consumed_at", null)
782
+ .order("received_at", { ascending: true })
783
+ .limit(200);
784
+ if (evErr)
785
+ throw new Error(`select email_auth_events: ${evErr.message}`);
786
+ for (const ev of (events ?? [])) {
787
+ processed++;
788
+ try {
789
+ const res = await ingestSingleEvent(supabaseAdmin, connectionId, {
790
+ id: ev.id,
791
+ external_id: ev.external_id ?? null,
792
+ payload: ev.payload ?? {},
793
+ received_at: ev.received_at ?? null,
794
+ });
795
+ if (res.inserted)
796
+ inserted++;
797
+ processedEventIds.push(ev.id);
798
+ }
799
+ catch (e) {
800
+ errors++;
801
+ if (!errorSample)
802
+ errorSample = String(e?.message ?? e).slice(0, 500);
803
+ // We laten dit event onverwerkt zodat de volgende run 'm opnieuw probeert.
804
+ }
805
+ }
806
+ // 4. Markeer succesvolle events als consumed via directe update (service_role).
807
+ if (processedEventIds.length > 0) {
808
+ const { error: markErr } = await supabaseAdmin
809
+ .from("email_auth_events")
810
+ .update({ consumed_at: new Date().toISOString() })
811
+ .in("id", processedEventIds);
812
+ if (markErr) {
813
+ errors++;
814
+ if (!errorSample)
815
+ errorSample = `mark consumed: ${markErr.message}`.slice(0, 500);
816
+ }
817
+ }
818
+ // 5. Backfill: bestaande messages zonder proposal krijgen alsnog een stub.
819
+ const { data: orphans } = await supabaseAdmin
820
+ .from("mailbox_messages")
821
+ .select("id, from_email, mailbox_order_proposals(id)")
822
+ .eq("connection_id", connectionId)
823
+ .order("received_at", { ascending: false })
824
+ .limit(200);
825
+ for (const m of (orphans ?? [])) {
826
+ const props = Array.isArray(m.mailbox_order_proposals) ? m.mailbox_order_proposals : [];
827
+ if (props.length > 0)
828
+ continue;
829
+ try {
830
+ await createStubProposal(supabaseAdmin, m.id, m.from_email ?? null);
831
+ }
832
+ catch (e) {
833
+ errors++;
834
+ if (!errorSample)
835
+ errorSample = `orphan-stub: ${String(e?.message ?? e)}`.slice(0, 500);
836
+ }
837
+ }
838
+ // 6. Gewijzigde AI-agent? Openstaande voorstellen opnieuw analyseren.
839
+ try {
840
+ await reparseChangedAgentProposals(supabaseAdmin);
841
+ }
842
+ catch (e) {
843
+ errors++;
844
+ if (!errorSample)
845
+ errorSample = `reparse: ${String(e?.message ?? e)}`.slice(0, 500);
846
+ }
847
+ }
848
+ finally {
849
+ if (runId) {
850
+ await supabaseAdmin
851
+ .from("mailbox_ingest_runs")
852
+ .update({
853
+ finished_at: new Date().toISOString(),
854
+ processed,
855
+ inserted,
856
+ errors,
857
+ error_sample: errorSample,
858
+ })
859
+ .eq("id", runId);
860
+ }
861
+ }
862
+ return { ok: true, runId, processed, inserted, errors, errorSample, connectionId };
863
+ }