@flowselections/mailbox-orders 1.0.8 → 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.
- package/dist-lib/components/MailboxAuthBanner.d.ts +16 -0
- package/dist-lib/components/MailboxAuthBanner.d.ts.map +1 -0
- package/dist-lib/components/MailboxAuthBanner.js +14 -0
- package/dist-lib/components/MailboxProcessedPage.d.ts.map +1 -1
- package/dist-lib/components/MailboxProcessedPage.js +10 -2
- package/dist-lib/components/MailboxProposalsPage.d.ts.map +1 -1
- package/dist-lib/components/MailboxProposalsPage.js +425 -104
- package/dist-lib/components/SearchableSelect.d.ts +1 -0
- package/dist-lib/components/SearchableSelect.d.ts.map +1 -1
- package/dist-lib/components/SearchableSelect.js +2 -1
- package/dist-lib/components/settings/MailboxAiPromptCard.d.ts +2 -0
- package/dist-lib/components/settings/MailboxAiPromptCard.d.ts.map +1 -0
- package/dist-lib/components/settings/MailboxAiPromptCard.js +39 -0
- package/dist-lib/index.d.ts.map +1 -1
- package/dist-lib/index.js +2 -0
- package/dist-lib/integrations/supabase/auth-attacher.d.ts.map +1 -1
- package/dist-lib/integrations/supabase/auth-attacher.js +5 -1
- package/dist-lib/integrations/supabase/auth-middleware.d.ts +2 -11202
- package/dist-lib/integrations/supabase/auth-middleware.d.ts.map +1 -1
- package/dist-lib/integrations/supabase/auth-middleware.js +8 -7
- package/dist-lib/integrations/supabase/client.d.ts +16514 -7163
- package/dist-lib/integrations/supabase/client.d.ts.map +1 -1
- package/dist-lib/integrations/supabase/client.server.d.ts +16516 -7164
- package/dist-lib/integrations/supabase/client.server.d.ts.map +1 -1
- package/dist-lib/integrations/supabase/types.d.ts +16768 -7202
- package/dist-lib/integrations/supabase/types.d.ts.map +1 -1
- package/dist-lib/integrations/supabase/types.js +59 -8
- package/dist-lib/lib/ai-config.server.d.ts +46 -0
- package/dist-lib/lib/ai-config.server.d.ts.map +1 -0
- package/dist-lib/lib/ai-config.server.js +99 -0
- package/dist-lib/lib/delivery-date.d.ts +37 -0
- package/dist-lib/lib/delivery-date.d.ts.map +1 -0
- package/dist-lib/lib/delivery-date.js +96 -0
- package/dist-lib/lib/load-carriers.d.ts +26 -0
- package/dist-lib/lib/load-carriers.d.ts.map +1 -0
- package/dist-lib/lib/load-carriers.js +73 -0
- package/dist-lib/lib/mailbox-ai-prompt.d.ts +9 -0
- package/dist-lib/lib/mailbox-ai-prompt.d.ts.map +1 -0
- package/dist-lib/lib/mailbox-ai-prompt.js +30 -0
- package/dist-lib/lib/mailbox-ai-settings.functions.d.ts +23 -0
- package/dist-lib/lib/mailbox-ai-settings.functions.d.ts.map +1 -0
- package/dist-lib/lib/mailbox-ai-settings.functions.js +58 -0
- package/dist-lib/lib/mailbox-ai.server.d.ts +49 -4
- package/dist-lib/lib/mailbox-ai.server.d.ts.map +1 -1
- package/dist-lib/lib/mailbox-ai.server.js +284 -6
- package/dist-lib/lib/mailbox-body.d.ts +7 -0
- package/dist-lib/lib/mailbox-body.d.ts.map +1 -0
- package/dist-lib/lib/mailbox-body.js +38 -0
- package/dist-lib/lib/mailbox-ingest.server.d.ts +76 -0
- package/dist-lib/lib/mailbox-ingest.server.d.ts.map +1 -0
- package/dist-lib/lib/mailbox-ingest.server.js +863 -0
- package/dist-lib/lib/mailbox-template.functions.d.ts +7 -67213
- package/dist-lib/lib/mailbox-template.functions.d.ts.map +1 -1
- package/dist-lib/lib/mailbox.functions.d.ts +107 -190506
- package/dist-lib/lib/mailbox.functions.d.ts.map +1 -1
- package/dist-lib/lib/mailbox.functions.js +544 -270
- 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
|
+
}
|