@flowselections/mailbox-orders 1.0.9 → 1.0.11

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 (79) 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 +16 -0
  7. package/dist-lib/components/MailboxProposalsPage.d.ts.map +1 -1
  8. package/dist-lib/components/MailboxProposalsPage.js +520 -108
  9. package/dist-lib/components/SearchableSelect.d.ts +1 -0
  10. package/dist-lib/components/SearchableSelect.d.ts.map +1 -1
  11. package/dist-lib/components/SearchableSelect.js +2 -1
  12. package/dist-lib/components/settings/MailboxAiPromptCard.d.ts +2 -0
  13. package/dist-lib/components/settings/MailboxAiPromptCard.d.ts.map +1 -0
  14. package/dist-lib/components/settings/MailboxAiPromptCard.js +39 -0
  15. package/dist-lib/index.d.ts.map +1 -1
  16. package/dist-lib/index.js +2 -0
  17. package/dist-lib/integrations/supabase/auth-attacher.d.ts.map +1 -1
  18. package/dist-lib/integrations/supabase/auth-attacher.js +5 -1
  19. package/dist-lib/integrations/supabase/auth-middleware.d.ts +2 -11202
  20. package/dist-lib/integrations/supabase/auth-middleware.d.ts.map +1 -1
  21. package/dist-lib/integrations/supabase/auth-middleware.js +8 -7
  22. package/dist-lib/integrations/supabase/client.d.ts +16689 -7180
  23. package/dist-lib/integrations/supabase/client.d.ts.map +1 -1
  24. package/dist-lib/integrations/supabase/client.server.d.ts +16691 -7181
  25. package/dist-lib/integrations/supabase/client.server.d.ts.map +1 -1
  26. package/dist-lib/integrations/supabase/types.d.ts +16944 -7220
  27. package/dist-lib/integrations/supabase/types.d.ts.map +1 -1
  28. package/dist-lib/integrations/supabase/types.js +63 -1
  29. package/dist-lib/lib/ai-config.server.d.ts +60 -0
  30. package/dist-lib/lib/ai-config.server.d.ts.map +1 -0
  31. package/dist-lib/lib/ai-config.server.js +112 -0
  32. package/dist-lib/lib/delivery-date.d.ts +37 -0
  33. package/dist-lib/lib/delivery-date.d.ts.map +1 -0
  34. package/dist-lib/lib/delivery-date.js +96 -0
  35. package/dist-lib/lib/load-carriers.d.ts +26 -0
  36. package/dist-lib/lib/load-carriers.d.ts.map +1 -0
  37. package/dist-lib/lib/load-carriers.js +73 -0
  38. package/dist-lib/lib/mailbox-ai-prompt.d.ts +9 -0
  39. package/dist-lib/lib/mailbox-ai-prompt.d.ts.map +1 -0
  40. package/dist-lib/lib/mailbox-ai-prompt.js +30 -0
  41. package/dist-lib/lib/mailbox-ai-settings.functions.d.ts +23 -0
  42. package/dist-lib/lib/mailbox-ai-settings.functions.d.ts.map +1 -0
  43. package/dist-lib/lib/mailbox-ai-settings.functions.js +58 -0
  44. package/dist-lib/lib/mailbox-ai.server.d.ts +56 -4
  45. package/dist-lib/lib/mailbox-ai.server.d.ts.map +1 -1
  46. package/dist-lib/lib/mailbox-ai.server.js +329 -6
  47. package/dist-lib/lib/mailbox-body.d.ts +7 -0
  48. package/dist-lib/lib/mailbox-body.d.ts.map +1 -0
  49. package/dist-lib/lib/mailbox-body.js +38 -0
  50. package/dist-lib/lib/mailbox-ingest.server.d.ts +89 -0
  51. package/dist-lib/lib/mailbox-ingest.server.d.ts.map +1 -0
  52. package/dist-lib/lib/mailbox-ingest.server.js +928 -0
  53. package/dist-lib/lib/mailbox-template.functions.d.ts +7 -67213
  54. package/dist-lib/lib/mailbox-template.functions.d.ts.map +1 -1
  55. package/dist-lib/lib/mailbox.functions.d.ts +130 -190511
  56. package/dist-lib/lib/mailbox.functions.d.ts.map +1 -1
  57. package/dist-lib/lib/mailbox.functions.js +641 -274
  58. package/package.json +4 -3
  59. package/dist-lib/components/CartAssignmentWizard.d.ts +0 -6
  60. package/dist-lib/components/CartAssignmentWizard.d.ts.map +0 -1
  61. package/dist-lib/components/CartAssignmentWizard.js +0 -199
  62. package/dist-lib/components/MailboxInboxPage.d.ts +0 -2
  63. package/dist-lib/components/MailboxInboxPage.d.ts.map +0 -1
  64. package/dist-lib/components/MailboxInboxPage.js +0 -120
  65. package/dist-lib/components/settings/ImapAccountsCard.d.ts +0 -2
  66. package/dist-lib/components/settings/ImapAccountsCard.d.ts.map +0 -1
  67. package/dist-lib/components/settings/ImapAccountsCard.js +0 -136
  68. package/dist-lib/components/settings/ImapProfilesCard.d.ts +0 -2
  69. package/dist-lib/components/settings/ImapProfilesCard.d.ts.map +0 -1
  70. package/dist-lib/components/settings/ImapProfilesCard.js +0 -101
  71. package/dist-lib/components/settings/MailboxSettingsCard.d.ts +0 -2
  72. package/dist-lib/components/settings/MailboxSettingsCard.d.ts.map +0 -1
  73. package/dist-lib/components/settings/MailboxSettingsCard.js +0 -85
  74. package/dist-lib/lib/imap.functions.d.ts +0 -32852
  75. package/dist-lib/lib/imap.functions.d.ts.map +0 -1
  76. package/dist-lib/lib/imap.functions.js +0 -235
  77. package/dist-lib/lib/mailbox-auto.server.d.ts +0 -9
  78. package/dist-lib/lib/mailbox-auto.server.d.ts.map +0 -1
  79. package/dist-lib/lib/mailbox-auto.server.js +0 -198
@@ -0,0 +1,928 @@
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, transient: false, requiredFields: ctx.settings.requiredFields };
231
+ }
232
+ catch (e) {
233
+ const msg = String(e?.message ?? e);
234
+ const { isTransientAiError } = await import("./mailbox-ai.server");
235
+ const transient = e?.name === "AiUnavailableError" ? Boolean(e.transient) : isTransientAiError(msg);
236
+ return { parsed: null, meta: null, error: msg.slice(0, 500), transient, requiredFields: [] };
237
+ }
238
+ }
239
+ const FIELD_LABELS = {
240
+ customer: "Klant",
241
+ delivery_date: "Bezorgdatum",
242
+ delivery_location: "Afleveradres",
243
+ pickup_location: "Afhaallocatie / Tuin",
244
+ plant_type: "Plantsoort",
245
+ lines: "Bestelregels",
246
+ reference: "Referentie klant",
247
+ notes: "Opmerking",
248
+ };
249
+ /**
250
+ * Combineert de zelfcontrole van de AI met een harde servercheck op de
251
+ * verplichte velden. Ontbrekende of onzekere gegevens worden expliciet
252
+ * benoemd in plaats van stilzwijgend leeg gelaten.
253
+ */
254
+ export function computeValidation(parsed, requiredFields, extra) {
255
+ const label = (k) => FIELD_LABELS[k] ?? k;
256
+ const missing = new Set(parsed.validation?.missing ?? []);
257
+ const uncertain = new Set(parsed.validation?.uncertain ?? []);
258
+ for (const key of requiredFields) {
259
+ if (key === "lines") {
260
+ if (parsed.lines.length === 0)
261
+ missing.add(label("lines"));
262
+ continue;
263
+ }
264
+ if (key === "customer") {
265
+ if (!extra.customerId && !parsed.fields?.customer?.value)
266
+ missing.add(label("customer"));
267
+ continue;
268
+ }
269
+ const f = parsed.fields?.[key];
270
+ const empty = f?.value === null || f?.value === undefined || f?.value === "";
271
+ if (empty)
272
+ missing.add(label(key));
273
+ else if (f && (f.needs_review || f.confidence < 0.8))
274
+ uncertain.add(label(key));
275
+ }
276
+ for (const l of parsed.lines) {
277
+ if (!l.product_id)
278
+ uncertain.add(`Artikel niet gekoppeld: ${l.raw_product_text || "onbekend"}`);
279
+ if (l.quantity === null || l.quantity <= 0)
280
+ missing.add(`Aantal ontbreekt: ${l.raw_product_text || "onbekend"}`);
281
+ if (!l.plant_type)
282
+ uncertain.add(`Plantsoort onbekend: ${l.raw_product_text || "onbekend"}`);
283
+ }
284
+ const m = Array.from(missing).slice(0, 50);
285
+ const u = Array.from(uncertain).slice(0, 50);
286
+ return {
287
+ missing: m,
288
+ uncertain: u,
289
+ notes: parsed.validation?.notes ?? null,
290
+ ok: m.length === 0 && u.length === 0,
291
+ };
292
+ }
293
+ /** Maximaal aantal achtergrondpogingen voordat we het voorstel handmatig laten. */
294
+ export const AI_MAX_BACKGROUND_RETRIES = 6;
295
+ function aiPayloadBlock(attempt, fingerprint, prevAi) {
296
+ if (attempt.parsed) {
297
+ return {
298
+ ok: true,
299
+ provider: attempt.meta?.providerSlug,
300
+ model: attempt.meta?.modelName,
301
+ duration_ms: attempt.meta?.durationMs,
302
+ fingerprint: attempt.meta?.fingerprint ?? fingerprint,
303
+ parsed_at: new Date().toISOString(),
304
+ retry_count: 0,
305
+ retry_pending: false,
306
+ };
307
+ }
308
+ const prevCount = Number(prevAi?.retry_count ?? 0);
309
+ const retryCount = Number.isFinite(prevCount) && prevCount > 0 ? prevCount + 1 : 1;
310
+ const transient = attempt.transient === true;
311
+ return {
312
+ ok: false,
313
+ error: attempt.error,
314
+ transient,
315
+ retry_count: retryCount,
316
+ retry_pending: transient && retryCount < AI_MAX_BACKGROUND_RETRIES,
317
+ last_attempt_at: new Date().toISOString(),
318
+ fingerprint,
319
+ parsed_at: new Date().toISOString(),
320
+ };
321
+ }
322
+ /**
323
+ * Vult het afleveradres aan met de hoofdafleverlocatie (is_primary) van de klant
324
+ * wanneer de mail/AI géén geldige locatie heeft herkend. Herkent de AI wél een
325
+ * locatie van deze klant, dan blijft die staan.
326
+ */
327
+ export async function resolveDeliveryLocation(admin, customerId, aiValue) {
328
+ if (!customerId)
329
+ return null;
330
+ const { data: rows } = await admin
331
+ .from("customer_delivery_locations")
332
+ .select("id, is_primary")
333
+ .eq("customer_id", customerId)
334
+ .order("is_primary", { ascending: false })
335
+ .limit(100);
336
+ const locations = Array.isArray(rows) ? rows : [];
337
+ if (locations.length === 0)
338
+ return null;
339
+ if (typeof aiValue === "string" && aiValue) {
340
+ const hit = locations.find((l) => String(l.id) === aiValue);
341
+ if (hit)
342
+ return { id: String(hit.id), primary: false };
343
+ }
344
+ const primary = locations.find((l) => l.is_primary === true) ?? locations[0];
345
+ return primary ? { id: String(primary.id), primary: true } : null;
346
+ }
347
+ /**
348
+ * Afhaallocatie (eigen tuin/warehouse): AI-herkenning wint, anders de hoofdlocatie
349
+ * (Floriday warehouse met isDefault = true).
350
+ */
351
+ export async function resolvePickupLocation(admin, aiValue) {
352
+ const { data: rows } = await admin
353
+ .from("floriday_warehouses_cache")
354
+ .select("floriday_id, data")
355
+ .eq("is_deleted", false);
356
+ const own = (Array.isArray(rows) ? rows : []).filter((r) => {
357
+ const d = r?.data ?? {};
358
+ return d?.hasAccess === true && d?.isExternal === false;
359
+ });
360
+ if (own.length === 0)
361
+ return null;
362
+ if (typeof aiValue === "string" && aiValue) {
363
+ const hit = own.find((r) => String(r.floriday_id) === aiValue);
364
+ if (hit)
365
+ return { id: String(hit.floriday_id), primary: false };
366
+ }
367
+ const main = own.find((r) => r?.data?.isDefault === true) ?? own[0];
368
+ return main ? { id: String(main.floriday_id), primary: true } : null;
369
+ }
370
+ /** Schrijft velden + regels van een AI-parse weg (na het opschonen van oude rijen). */
371
+ async function writeParsedDetails(admin, proposalId, parsed) {
372
+ const fieldRows = Object.entries(parsed.fields).map(([key, v]) => ({
373
+ proposal_id: proposalId,
374
+ field_key: key,
375
+ value: v.value === undefined ? null : v.value,
376
+ ai_confidence: v.confidence,
377
+ ai_filled: v.value !== null && v.value !== undefined,
378
+ needs_review: v.needs_review,
379
+ }));
380
+ if (fieldRows.length > 0) {
381
+ await admin.from("mailbox_proposal_field_values").insert(fieldRows);
382
+ }
383
+ if (parsed.lines.length > 0) {
384
+ await admin.from("mailbox_proposal_lines").insert(parsed.lines.map((l, idx) => ({
385
+ proposal_id: proposalId,
386
+ product_id: l.product_id,
387
+ plant_type: l.plant_type,
388
+ raw_product_text: l.raw_product_text,
389
+ quantity: l.quantity,
390
+ unit: l.unit,
391
+ match_confidence: l.match_confidence,
392
+ sort_order: idx,
393
+ })));
394
+ }
395
+ }
396
+ /**
397
+ * Maakt exact één voorstel per bericht. Probeert eerst de AI-parse via de
398
+ * centrale AI-Authenticatie module; lukt dat niet, dan valt hij terug op een
399
+ * leeg voorstel met de reden erbij. Ingest mag nooit stukvallen op AI.
400
+ */
401
+ export async function createStubProposal(admin, messageId, fromEmail) {
402
+ const { data: existing } = await admin
403
+ .from("mailbox_order_proposals")
404
+ .select("id")
405
+ .eq("message_id", messageId)
406
+ .maybeSingle();
407
+ if (existing?.id)
408
+ return;
409
+ // Val terug op het afzenderadres van het bericht zelf wanneer de event-payload
410
+ // geen adres meegaf — anders wordt de klant nooit automatisch herkend.
411
+ let senderEmail = normalizeEmail(fromEmail);
412
+ if (!senderEmail) {
413
+ const { data: msg } = await admin
414
+ .from("mailbox_messages")
415
+ .select("from_email")
416
+ .eq("id", messageId)
417
+ .maybeSingle();
418
+ senderEmail = normalizeEmail(msg?.from_email ?? null);
419
+ }
420
+ const matchResult = await matchCustomerByEmail(admin, senderEmail);
421
+ const basePayload = {
422
+ auto_matched_customer: matchResult.customerId !== null,
423
+ auto_match_source: matchResult.source,
424
+ auto_match_email: senderEmail || null,
425
+ auto_match_warning: matchResult.warning,
426
+ auto_match_candidates: matchResult.candidates,
427
+ };
428
+ // --- AI-parse (best effort) ---------------------------------------------
429
+ const { currentAiFingerprint } = await import("./mailbox-ai.server");
430
+ const fingerprint = await currentAiFingerprint(admin).catch(() => null);
431
+ const attempt = await aiParseMessage(admin, messageId, matchResult.customerId);
432
+ const parsed = attempt.parsed;
433
+ const fieldValue = (key) => {
434
+ const v = parsed?.fields?.[key]?.value;
435
+ return v === undefined ? null : v;
436
+ };
437
+ const deliveryDate = typeof fieldValue("delivery_date") === "string" ? fieldValue("delivery_date") : null;
438
+ const notesValue = typeof fieldValue("notes") === "string" ? fieldValue("notes") : null;
439
+ // Afleveradres: AI-herkenning wint, anders de hoofdafleverlocatie van de klant.
440
+ const loc = await resolveDeliveryLocation(admin, matchResult.customerId, fieldValue("delivery_location"));
441
+ if (loc && parsed) {
442
+ parsed.fields = {
443
+ ...parsed.fields,
444
+ delivery_location: loc.primary
445
+ ? { value: loc.id, confidence: 0.5, needs_review: true }
446
+ : (parsed.fields?.delivery_location ?? { value: loc.id, confidence: 0.9, needs_review: false }),
447
+ };
448
+ }
449
+ // Afhaallocatie: AI-herkenning wint, anders de hoofd-afhaallocatie.
450
+ const pick = await resolvePickupLocation(admin, fieldValue("pickup_location"));
451
+ if (pick && parsed) {
452
+ parsed.fields = {
453
+ ...parsed.fields,
454
+ pickup_location: pick.primary
455
+ ? { value: pick.id, confidence: 0.5, needs_review: true }
456
+ : (parsed.fields?.pickup_location ?? { value: pick.id, confidence: 0.9, needs_review: false }),
457
+ };
458
+ }
459
+ // Ladingdrager: alleen wat de Engine toestaat.
460
+ const lc = await resolveLoadCarrier(admin, messageId, fieldValue("load_carrier"));
461
+ if (lc.code && parsed) {
462
+ parsed.fields = {
463
+ ...parsed.fields,
464
+ load_carrier: { value: lc.code, confidence: 0.6, needs_review: false },
465
+ };
466
+ }
467
+ const blockedNote = lc.blocked
468
+ ? `Handmatig controleren - ladingdrager uitgeschakeld: "${lc.detectedLabel}" staat uit in de Rule Engine.`
469
+ : null;
470
+ const validation = parsed
471
+ ? computeValidation(parsed, attempt.requiredFields, { customerId: matchResult.customerId })
472
+ : null;
473
+ const { data: proposal, error } = await admin
474
+ .from("mailbox_order_proposals")
475
+ .insert({
476
+ message_id: messageId,
477
+ matched_customer_id: matchResult.customerId,
478
+ confidence: parsed ? parsed.overall_confidence : matchResult.customerId ? 1 : 0,
479
+ delivery_date: deliveryDate,
480
+ notes: blockedNote ? [blockedNote, notesValue].filter(Boolean).join("\n") : notesValue,
481
+ status: "pending",
482
+ ai_validation: validation,
483
+ parsed_payload: {
484
+ ...basePayload,
485
+ stub: parsed === null,
486
+ reason: parsed === null ? "ai_unavailable" : null,
487
+ load_carrier: lc.code,
488
+ load_carrier_blocked: lc.blocked,
489
+ load_carrier_detected: lc.detectedLabel,
490
+ ai: aiPayloadBlock(attempt, fingerprint),
491
+ fields: parsed?.fields ?? null,
492
+ validation,
493
+ },
494
+ })
495
+ .select("id")
496
+ .single();
497
+ // 23505 = unique-violation op message_id: een concurrent trigger heeft 'm
498
+ // net ingevoegd. Idempotent — negeren. Alle andere fouten laten we bubbelen.
499
+ if (error) {
500
+ if (error.code === "23505")
501
+ return;
502
+ throw new Error(error.message);
503
+ }
504
+ const proposalId = proposal.id;
505
+ if (parsed)
506
+ await writeParsedDetails(admin, proposalId, parsed);
507
+ else if (loc) {
508
+ await admin.from("mailbox_proposal_field_values").insert({
509
+ proposal_id: proposalId,
510
+ field_key: "delivery_location",
511
+ value: loc.id,
512
+ ai_confidence: 0.5,
513
+ ai_filled: false,
514
+ needs_review: true,
515
+ });
516
+ }
517
+ if (!parsed && lc.code) {
518
+ await admin.from("mailbox_proposal_field_values").upsert({
519
+ proposal_id: proposalId,
520
+ field_key: "load_carrier",
521
+ value: lc.code,
522
+ ai_confidence: 0.6,
523
+ ai_filled: false,
524
+ needs_review: false,
525
+ }, { onConflict: "proposal_id,field_key" });
526
+ }
527
+ if (lc.blocked) {
528
+ await admin
529
+ .from("mailbox_messages")
530
+ .update({ status: "manual_review_load_carrier_disabled" })
531
+ .eq("id", messageId);
532
+ }
533
+ }
534
+ /**
535
+ * Analyseert een bestaand (nog niet goedgekeurd) voorstel opnieuw met de
536
+ * huidige AI-agent. De vorige AI-analyse wordt vervangen.
537
+ */
538
+ export async function reparseProposal(admin, proposalId) {
539
+ const { data: prop } = await admin
540
+ .from("mailbox_order_proposals")
541
+ .select("id, message_id, status, matched_customer_id, parsed_payload")
542
+ .eq("id", proposalId)
543
+ .maybeSingle();
544
+ if (!prop?.id)
545
+ return { ok: false, error: "Voorstel niet gevonden." };
546
+ if (prop.status !== "pending") {
547
+ return { ok: false, error: "Voorstel is al verwerkt — niet opnieuw geanalyseerd." };
548
+ }
549
+ // Klantherkenning opnieuw proberen op basis van het afzenderadres.
550
+ let matchedCustomerId = prop.matched_customer_id ?? null;
551
+ const { data: msgRow } = await admin
552
+ .from("mailbox_messages")
553
+ .select("from_email")
554
+ .eq("id", prop.message_id)
555
+ .maybeSingle();
556
+ const senderEmail = normalizeEmail(msgRow?.from_email ?? null);
557
+ const rematch = matchedCustomerId ? null : await matchCustomerByEmail(admin, senderEmail);
558
+ if (rematch?.customerId)
559
+ matchedCustomerId = rematch.customerId;
560
+ const { currentAiFingerprint } = await import("./mailbox-ai.server");
561
+ const fingerprint = await currentAiFingerprint(admin).catch(() => null);
562
+ const attempt = await aiParseMessage(admin, prop.message_id, matchedCustomerId);
563
+ const prevBase = (prop.parsed_payload && typeof prop.parsed_payload === "object" ? prop.parsed_payload : {});
564
+ const base = rematch
565
+ ? {
566
+ ...prevBase,
567
+ auto_matched_customer: rematch.customerId !== null,
568
+ auto_match_source: rematch.source,
569
+ auto_match_email: senderEmail || null,
570
+ auto_match_warning: rematch.warning,
571
+ auto_match_candidates: rematch.candidates,
572
+ }
573
+ : prevBase;
574
+ if (!attempt.parsed) {
575
+ // Mislukte hernieuwde analyse: bestaande gegevens laten staan, alleen de
576
+ // AI-status bijwerken zodat we niet in een lus blijven proberen.
577
+ await admin
578
+ .from("mailbox_order_proposals")
579
+ .update({ parsed_payload: { ...base, ai: aiPayloadBlock(attempt, fingerprint, prevBase?.ai) } })
580
+ .eq("id", proposalId);
581
+ return { ok: false, error: attempt.error };
582
+ }
583
+ const parsed = attempt.parsed;
584
+ const fv = (key) => {
585
+ const v = parsed.fields?.[key]?.value;
586
+ return v === undefined ? null : v;
587
+ };
588
+ // Afleveradres: AI-herkenning wint, anders de hoofdafleverlocatie van de klant.
589
+ const rloc = await resolveDeliveryLocation(admin, matchedCustomerId, fv("delivery_location"));
590
+ if (rloc) {
591
+ parsed.fields = {
592
+ ...parsed.fields,
593
+ delivery_location: rloc.primary
594
+ ? { value: rloc.id, confidence: 0.5, needs_review: true }
595
+ : (parsed.fields?.delivery_location ?? { value: rloc.id, confidence: 0.9, needs_review: false }),
596
+ };
597
+ }
598
+ // Afhaallocatie: AI-herkenning wint, anders de hoofd-afhaallocatie.
599
+ const rpick = await resolvePickupLocation(admin, fv("pickup_location"));
600
+ if (rpick) {
601
+ parsed.fields = {
602
+ ...parsed.fields,
603
+ pickup_location: rpick.primary
604
+ ? { value: rpick.id, confidence: 0.5, needs_review: true }
605
+ : (parsed.fields?.pickup_location ?? { value: rpick.id, confidence: 0.9, needs_review: false }),
606
+ };
607
+ }
608
+ // Ladingdrager: alleen wat de Engine toestaat.
609
+ const rlc = await resolveLoadCarrier(admin, prop.message_id, fv("load_carrier"));
610
+ if (rlc.code) {
611
+ parsed.fields = {
612
+ ...parsed.fields,
613
+ load_carrier: { value: rlc.code, confidence: 0.6, needs_review: false },
614
+ };
615
+ }
616
+ else if (parsed.fields) {
617
+ delete parsed.fields.load_carrier;
618
+ }
619
+ const rBlockedNote = rlc.blocked
620
+ ? `Handmatig controleren - ladingdrager uitgeschakeld: "${rlc.detectedLabel}" staat uit in de Rule Engine.`
621
+ : null;
622
+ await admin.from("mailbox_proposal_lines").delete().eq("proposal_id", proposalId);
623
+ await admin.from("mailbox_proposal_field_values").delete().eq("proposal_id", proposalId);
624
+ const rvalidation = computeValidation(parsed, attempt.requiredFields, {
625
+ customerId: matchedCustomerId,
626
+ });
627
+ const notesFromAi = typeof fv("notes") === "string" ? fv("notes") : null;
628
+ const { error: updErr } = await admin
629
+ .from("mailbox_order_proposals")
630
+ .update({
631
+ matched_customer_id: matchedCustomerId,
632
+ confidence: parsed.overall_confidence,
633
+ delivery_date: typeof fv("delivery_date") === "string" ? fv("delivery_date") : null,
634
+ notes: rBlockedNote ? [rBlockedNote, notesFromAi].filter(Boolean).join("\n") : notesFromAi,
635
+ ai_validation: rvalidation,
636
+ parsed_payload: {
637
+ ...base,
638
+ stub: false,
639
+ reason: null,
640
+ reparsed: true,
641
+ load_carrier: rlc.code,
642
+ load_carrier_blocked: rlc.blocked,
643
+ load_carrier_detected: rlc.detectedLabel,
644
+ ai: aiPayloadBlock(attempt, fingerprint),
645
+ fields: parsed.fields,
646
+ validation: rvalidation,
647
+ },
648
+ })
649
+ .eq("id", proposalId);
650
+ if (updErr)
651
+ return { ok: false, error: updErr.message };
652
+ if (rlc.blocked) {
653
+ await admin
654
+ .from("mailbox_messages")
655
+ .update({ status: "manual_review_load_carrier_disabled" })
656
+ .eq("id", prop.message_id);
657
+ }
658
+ await writeParsedDetails(admin, proposalId, parsed);
659
+ return { ok: true, error: null };
660
+ }
661
+ /**
662
+ * Detecteert een gewijzigde AI-agent: alle openstaande voorstellen waarvan de
663
+ * opgeslagen vingerafdruk afwijkt van de huidige worden opnieuw geanalyseerd.
664
+ */
665
+ export async function reparseChangedAgentProposals(admin, limit = 25) {
666
+ const { currentAiFingerprint } = await import("./mailbox-ai.server");
667
+ const fingerprint = await currentAiFingerprint(admin).catch(() => null);
668
+ if (!fingerprint)
669
+ return { checked: 0, reparsed: 0, failed: 0, fingerprint: null };
670
+ const { data: rows } = await admin
671
+ .from("mailbox_order_proposals")
672
+ .select("id, parsed_payload")
673
+ .eq("status", "pending")
674
+ .order("created_at", { ascending: false })
675
+ .limit(200);
676
+ const list = Array.isArray(rows) ? rows : [];
677
+ let reparsed = 0;
678
+ let failed = 0;
679
+ let checked = 0;
680
+ for (const r of list) {
681
+ const stored = r.parsed_payload?.ai?.fingerprint ?? null;
682
+ if (stored === fingerprint)
683
+ continue;
684
+ checked++;
685
+ if (reparsed + failed >= limit)
686
+ break;
687
+ const res = await reparseProposal(admin, r.id);
688
+ if (res.ok)
689
+ reparsed++;
690
+ else
691
+ failed++;
692
+ }
693
+ return { checked, reparsed, failed, fingerprint };
694
+ }
695
+ /**
696
+ * Achtergrondherstel: voorstellen waarvan de AI-analyse door een tijdelijke
697
+ * storing (bijv. model overbelast) mislukte, worden bij een volgende run
698
+ * automatisch opnieuw geanalyseerd — tot AI_MAX_BACKGROUND_RETRIES pogingen.
699
+ * Blijft strikt idempotent: alleen 'pending' voorstellen worden aangeraakt.
700
+ */
701
+ export async function retryFailedAiProposals(admin, limit = 10) {
702
+ const { data: rows } = await admin
703
+ .from("mailbox_order_proposals")
704
+ .select("id, parsed_payload")
705
+ .eq("status", "pending")
706
+ .order("created_at", { ascending: false })
707
+ .limit(200);
708
+ const list = Array.isArray(rows) ? rows : [];
709
+ let retried = 0;
710
+ let recovered = 0;
711
+ let stillFailing = 0;
712
+ for (const r of list) {
713
+ const ai = r.parsed_payload?.ai ?? null;
714
+ if (!ai || ai.ok !== false)
715
+ continue;
716
+ if (ai.retry_pending !== true)
717
+ continue;
718
+ if (Number(ai.retry_count ?? 0) >= AI_MAX_BACKGROUND_RETRIES)
719
+ continue;
720
+ if (retried >= limit)
721
+ break;
722
+ retried++;
723
+ try {
724
+ const res = await reparseProposal(admin, r.id);
725
+ if (res.ok)
726
+ recovered++;
727
+ else
728
+ stillFailing++;
729
+ }
730
+ catch {
731
+ stillFailing++;
732
+ }
733
+ }
734
+ return { retried, recovered, stillFailing };
735
+ }
736
+ async function ingestSingleEvent(admin, connectionId, ev) {
737
+ const p = ev.payload ?? {};
738
+ const externalId = ev.external_id ?? pickStr(p, "message_id", "messageId", "id") ?? ev.id;
739
+ // Idempotent: bestaat er al een message voor deze external_id op deze connectie?
740
+ if (externalId) {
741
+ const { data: existing } = await admin
742
+ .from("mailbox_messages")
743
+ .select("id")
744
+ .eq("connection_id", connectionId)
745
+ .eq("external_id", externalId)
746
+ .maybeSingle();
747
+ if (existing?.id) {
748
+ // Zorg dat er in elk geval een voorstel is (herstel oude berichten zonder stub).
749
+ await createStubProposal(admin, existing.id, pickStr(p, "from_email", "fromEmail", "from.address"));
750
+ return { inserted: false, messageId: existing.id };
751
+ }
752
+ }
753
+ const fromEmail = pickStr(p, "from_email", "fromEmail", "from.address", "from.email", "sender.address") ?? "";
754
+ const fromName = pickStr(p, "from_name", "fromName", "from.name", "sender.name");
755
+ const subject = pickStr(p, "subject", "title") ?? "";
756
+ const bodyText = pickStr(p, "body_text", "bodyText", "text", "body.text", "snippet");
757
+ const bodyHtml = pickStr(p, "body_html", "bodyHtml", "html", "body.html");
758
+ const receivedAt = pickStr(p, "received_at", "receivedAt", "date", "internalDate") ??
759
+ ev.received_at ??
760
+ new Date().toISOString();
761
+ const { data: inserted, error: insErr } = await admin
762
+ .from("mailbox_messages")
763
+ .insert({
764
+ external_id: externalId,
765
+ message_id: pickStr(p, "message_id", "messageId") ?? externalId,
766
+ connection_id: connectionId,
767
+ from_email: fromEmail,
768
+ from_name: fromName,
769
+ subject,
770
+ body_text: bodyText,
771
+ body_html: bodyHtml,
772
+ received_at: receivedAt,
773
+ status: "new",
774
+ })
775
+ .select("id")
776
+ .single();
777
+ if (insErr) {
778
+ // Race met cron/realtime — de andere trigger heeft 'm net ingevoegd.
779
+ if (insErr.code === "23505") {
780
+ const { data: dup } = await admin
781
+ .from("mailbox_messages")
782
+ .select("id")
783
+ .eq("connection_id", connectionId)
784
+ .eq("external_id", externalId)
785
+ .maybeSingle();
786
+ if (dup?.id)
787
+ await createStubProposal(admin, dup.id, fromEmail);
788
+ return { inserted: false, messageId: dup?.id ?? null };
789
+ }
790
+ throw new Error(insErr.message);
791
+ }
792
+ await createStubProposal(admin, inserted.id, fromEmail);
793
+ return { inserted: true, messageId: inserted.id };
794
+ }
795
+ // De volledige pipeline. Alle triggers roepen dit aan.
796
+ export async function runIngest(trigger) {
797
+ const { supabaseAdmin } = await import("../integrations/supabase/client.server");
798
+ // 1. Actieve orders/receive connectie ophalen (service_role → geen bearer nodig).
799
+ const { data: active, error: activeErr } = await supabaseAdmin.rpc("email_auth_for_purpose", {
800
+ _purpose: "orders",
801
+ _direction: "receive",
802
+ });
803
+ if (activeErr)
804
+ throw new Error(`email_auth_for_purpose: ${activeErr.message}`);
805
+ const row = Array.isArray(active) ? active[0] : null;
806
+ const connectionId = row?.id ?? null;
807
+ if (!connectionId) {
808
+ return {
809
+ ok: true,
810
+ runId: null,
811
+ processed: 0,
812
+ inserted: 0,
813
+ errors: 0,
814
+ errorSample: null,
815
+ connectionId: null,
816
+ skipped: "no_active_connection",
817
+ };
818
+ }
819
+ // 2. Open run-record voor observability.
820
+ const { data: runRow } = await supabaseAdmin
821
+ .from("mailbox_ingest_runs")
822
+ .insert({ trigger, connection_id: connectionId })
823
+ .select("id")
824
+ .single();
825
+ const runId = runRow?.id ?? null;
826
+ let processed = 0;
827
+ let inserted = 0;
828
+ let errors = 0;
829
+ let errorSample = null;
830
+ const processedEventIds = [];
831
+ try {
832
+ // 3. Onverwerkte events voor deze connectie ophalen (max 200 per run).
833
+ const { data: events, error: evErr } = await supabaseAdmin
834
+ .from("email_auth_events")
835
+ .select("id, connection_id, external_id, payload, received_at")
836
+ .eq("connection_id", connectionId)
837
+ .is("consumed_at", null)
838
+ .order("received_at", { ascending: true })
839
+ .limit(200);
840
+ if (evErr)
841
+ throw new Error(`select email_auth_events: ${evErr.message}`);
842
+ for (const ev of (events ?? [])) {
843
+ processed++;
844
+ try {
845
+ const res = await ingestSingleEvent(supabaseAdmin, connectionId, {
846
+ id: ev.id,
847
+ external_id: ev.external_id ?? null,
848
+ payload: ev.payload ?? {},
849
+ received_at: ev.received_at ?? null,
850
+ });
851
+ if (res.inserted)
852
+ inserted++;
853
+ processedEventIds.push(ev.id);
854
+ }
855
+ catch (e) {
856
+ errors++;
857
+ if (!errorSample)
858
+ errorSample = String(e?.message ?? e).slice(0, 500);
859
+ // We laten dit event onverwerkt zodat de volgende run 'm opnieuw probeert.
860
+ }
861
+ }
862
+ // 4. Markeer succesvolle events als consumed via directe update (service_role).
863
+ if (processedEventIds.length > 0) {
864
+ const { error: markErr } = await supabaseAdmin
865
+ .from("email_auth_events")
866
+ .update({ consumed_at: new Date().toISOString() })
867
+ .in("id", processedEventIds);
868
+ if (markErr) {
869
+ errors++;
870
+ if (!errorSample)
871
+ errorSample = `mark consumed: ${markErr.message}`.slice(0, 500);
872
+ }
873
+ }
874
+ // 5. Backfill: bestaande messages zonder proposal krijgen alsnog een stub.
875
+ const { data: orphans } = await supabaseAdmin
876
+ .from("mailbox_messages")
877
+ .select("id, from_email, mailbox_order_proposals(id)")
878
+ .eq("connection_id", connectionId)
879
+ .order("received_at", { ascending: false })
880
+ .limit(200);
881
+ for (const m of (orphans ?? [])) {
882
+ const props = Array.isArray(m.mailbox_order_proposals) ? m.mailbox_order_proposals : [];
883
+ if (props.length > 0)
884
+ continue;
885
+ try {
886
+ await createStubProposal(supabaseAdmin, m.id, m.from_email ?? null);
887
+ }
888
+ catch (e) {
889
+ errors++;
890
+ if (!errorSample)
891
+ errorSample = `orphan-stub: ${String(e?.message ?? e)}`.slice(0, 500);
892
+ }
893
+ }
894
+ // 6. Gewijzigde AI-agent? Openstaande voorstellen opnieuw analyseren.
895
+ try {
896
+ await reparseChangedAgentProposals(supabaseAdmin);
897
+ }
898
+ catch (e) {
899
+ errors++;
900
+ if (!errorSample)
901
+ errorSample = `reparse: ${String(e?.message ?? e)}`.slice(0, 500);
902
+ }
903
+ // 7. Herstelronde: mislukte AI-analyses door tijdelijke storingen opnieuw proberen.
904
+ try {
905
+ await retryFailedAiProposals(supabaseAdmin);
906
+ }
907
+ catch (e) {
908
+ errors++;
909
+ if (!errorSample)
910
+ errorSample = `reparse: ${String(e?.message ?? e)}`.slice(0, 500);
911
+ }
912
+ }
913
+ finally {
914
+ if (runId) {
915
+ await supabaseAdmin
916
+ .from("mailbox_ingest_runs")
917
+ .update({
918
+ finished_at: new Date().toISOString(),
919
+ processed,
920
+ inserted,
921
+ errors,
922
+ error_sample: errorSample,
923
+ })
924
+ .eq("id", runId);
925
+ }
926
+ }
927
+ return { ok: true, runId, processed, inserted, errors, errorSample, connectionId };
928
+ }