@flowselections/floriday-klanten-module 1.0.0

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 (71) hide show
  1. package/dist-lib/_core-safelist.d.ts +2 -0
  2. package/dist-lib/_core-safelist.d.ts.map +1 -0
  3. package/dist-lib/_core-safelist.js +15 -0
  4. package/dist-lib/components/CustomerAvatar.d.ts +7 -0
  5. package/dist-lib/components/CustomerAvatar.d.ts.map +1 -0
  6. package/dist-lib/components/CustomerAvatar.js +18 -0
  7. package/dist-lib/components/TemplatePage.d.ts +2 -0
  8. package/dist-lib/components/TemplatePage.d.ts.map +1 -0
  9. package/dist-lib/components/TemplatePage.js +4 -0
  10. package/dist-lib/components/settings/CustomerFieldsCard.d.ts +2 -0
  11. package/dist-lib/components/settings/CustomerFieldsCard.d.ts.map +1 -0
  12. package/dist-lib/components/settings/CustomerFieldsCard.js +59 -0
  13. package/dist-lib/index.d.ts +5 -0
  14. package/dist-lib/index.d.ts.map +1 -0
  15. package/dist-lib/index.js +32 -0
  16. package/dist-lib/integrations/supabase/auth-attacher.d.ts +2 -0
  17. package/dist-lib/integrations/supabase/auth-attacher.d.ts.map +1 -0
  18. package/dist-lib/integrations/supabase/auth-attacher.js +12 -0
  19. package/dist-lib/integrations/supabase/auth-middleware.d.ts +1560 -0
  20. package/dist-lib/integrations/supabase/auth-middleware.d.ts.map +1 -0
  21. package/dist-lib/integrations/supabase/auth-middleware.js +52 -0
  22. package/dist-lib/integrations/supabase/client.d.ts +1556 -0
  23. package/dist-lib/integrations/supabase/client.d.ts.map +1 -0
  24. package/dist-lib/integrations/supabase/client.js +28 -0
  25. package/dist-lib/integrations/supabase/client.server.d.ts +1556 -0
  26. package/dist-lib/integrations/supabase/client.server.d.ts.map +1 -0
  27. package/dist-lib/integrations/supabase/client.server.js +50 -0
  28. package/dist-lib/integrations/supabase/types.d.ts +1656 -0
  29. package/dist-lib/integrations/supabase/types.d.ts.map +1 -0
  30. package/dist-lib/integrations/supabase/types.js +8 -0
  31. package/dist-lib/lib/accounting/exact/client.server.d.ts +37 -0
  32. package/dist-lib/lib/accounting/exact/client.server.d.ts.map +1 -0
  33. package/dist-lib/lib/accounting/exact/client.server.js +160 -0
  34. package/dist-lib/lib/accounting/exact/mapping.d.ts +39 -0
  35. package/dist-lib/lib/accounting/exact/mapping.d.ts.map +1 -0
  36. package/dist-lib/lib/accounting/exact/mapping.js +45 -0
  37. package/dist-lib/lib/accounting/exact/sync.server.d.ts +4 -0
  38. package/dist-lib/lib/accounting/exact/sync.server.d.ts.map +1 -0
  39. package/dist-lib/lib/accounting/exact/sync.server.js +181 -0
  40. package/dist-lib/lib/accounting/floriday/sync.server.d.ts +3 -0
  41. package/dist-lib/lib/accounting/floriday/sync.server.d.ts.map +1 -0
  42. package/dist-lib/lib/accounting/floriday/sync.server.js +17 -0
  43. package/dist-lib/lib/accounting/registry.server.d.ts +4 -0
  44. package/dist-lib/lib/accounting/registry.server.d.ts.map +1 -0
  45. package/dist-lib/lib/accounting/registry.server.js +9 -0
  46. package/dist-lib/lib/accounting/types.d.ts +24 -0
  47. package/dist-lib/lib/accounting/types.d.ts.map +1 -0
  48. package/dist-lib/lib/accounting/types.js +4 -0
  49. package/dist-lib/lib/accounting.functions.d.ts +4682 -0
  50. package/dist-lib/lib/accounting.functions.d.ts.map +1 -0
  51. package/dist-lib/lib/accounting.functions.js +50 -0
  52. package/dist-lib/lib/crypto.server.d.ts +10 -0
  53. package/dist-lib/lib/crypto.server.d.ts.map +1 -0
  54. package/dist-lib/lib/crypto.server.js +61 -0
  55. package/dist-lib/lib/customer-schemas.d.ts +53 -0
  56. package/dist-lib/lib/customer-schemas.d.ts.map +1 -0
  57. package/dist-lib/lib/customer-schemas.js +31 -0
  58. package/dist-lib/lib/customers.functions.d.ts +15904 -0
  59. package/dist-lib/lib/customers.functions.d.ts.map +1 -0
  60. package/dist-lib/lib/customers.functions.js +251 -0
  61. package/dist-lib/lib/floriday.server.d.ts +55 -0
  62. package/dist-lib/lib/floriday.server.d.ts.map +1 -0
  63. package/dist-lib/lib/floriday.server.js +765 -0
  64. package/dist-lib/lib/utils.d.ts +3 -0
  65. package/dist-lib/lib/utils.d.ts.map +1 -0
  66. package/dist-lib/lib/utils.js +5 -0
  67. package/dist-lib/lib/validationSchemas.d.ts +15 -0
  68. package/dist-lib/lib/validationSchemas.d.ts.map +1 -0
  69. package/dist-lib/lib/validationSchemas.js +25 -0
  70. package/dist-lib/styles.css +1 -0
  71. package/package.json +74 -0
@@ -0,0 +1,765 @@
1
+ // Server-only Floriday API helper. NEVER import from client-side code.
2
+ import { supabaseAdmin } from "@/integrations/supabase/client.server";
3
+ const FLORIDAY_BASE_URLS = {
4
+ staging: "https://api.staging.floriday.io/suppliers-api-2026v1",
5
+ production: "https://api.floriday.io/suppliers-api-2026v1",
6
+ acceptance: "https://api.acceptance.floriday.io/suppliers-api-2026v1",
7
+ };
8
+ const FLORIDAY_TOKEN_URLS = {
9
+ staging: "https://idm.staging.floriday.io/oauth2/ausmw6b47z1BnlHkw0h7/v1/token",
10
+ production: "https://idm.floriday.io/oauth2/aus3testdcf2vyfs70i7/v1/token",
11
+ acceptance: "https://idm.staging.floriday.io/oauth2/ausmw6b47z1BnlHkw0h7/v1/token",
12
+ };
13
+ const FLORIDAY_SCOPE = "role:app organization:read network:read catalog:read supply:read sales-order:read delivery-conditions:read fulfillment:read";
14
+ function baseUrlFor(environment) {
15
+ return FLORIDAY_BASE_URLS[environment] ?? FLORIDAY_BASE_URLS.staging;
16
+ }
17
+ async function refreshAccessToken(conn) {
18
+ const params = new URLSearchParams({
19
+ grant_type: "client_credentials",
20
+ client_id: conn.client_id,
21
+ client_secret: conn.client_secret,
22
+ scope: FLORIDAY_SCOPE,
23
+ });
24
+ const res = await fetch(FLORIDAY_TOKEN_URLS[conn.environment] ?? FLORIDAY_TOKEN_URLS.staging, {
25
+ method: "POST",
26
+ headers: {
27
+ Accept: "application/json",
28
+ "Content-Type": "application/x-www-form-urlencoded",
29
+ },
30
+ body: params.toString(),
31
+ });
32
+ if (!res.ok) {
33
+ throw new Error(`Token refresh failed (${res.status}): ${await res.text()}`);
34
+ }
35
+ const data = (await res.json());
36
+ const expiresAt = new Date(Date.now() + (data.expires_in - 60) * 1000);
37
+ await supabaseAdmin
38
+ .from("floriday_connections")
39
+ .update({
40
+ access_token: data.access_token,
41
+ token_expires_at: expiresAt.toISOString(),
42
+ })
43
+ .eq("id", conn.id);
44
+ return data.access_token;
45
+ }
46
+ async function ensureValidToken(conn) {
47
+ const expiresAt = conn.token_expires_at ? new Date(conn.token_expires_at) : null;
48
+ const validForOneMin = expiresAt && expiresAt.getTime() > Date.now() + 60000;
49
+ if (conn.access_token && validForOneMin) {
50
+ return conn.access_token;
51
+ }
52
+ return refreshAccessToken(conn);
53
+ }
54
+ async function fetchWithRetry(url, init, attempts = 3) {
55
+ let lastErr = null;
56
+ for (let i = 0; i < attempts; i++) {
57
+ try {
58
+ const res = await fetch(url, init);
59
+ if (res.ok || (res.status >= 400 && res.status < 500 && res.status !== 429)) {
60
+ return res;
61
+ }
62
+ lastErr = new Error(`HTTP ${res.status}`);
63
+ }
64
+ catch (err) {
65
+ lastErr = err;
66
+ }
67
+ await new Promise((r) => setTimeout(r, [1000, 3000, 9000][i] ?? 9000));
68
+ }
69
+ throw lastErr instanceof Error ? lastErr : new Error("Request failed");
70
+ }
71
+ function mapFloridayPartner(raw, connectionId) {
72
+ const orgId = raw?.organizationId ?? raw?.id ?? raw?.tradePartnerId;
73
+ if (!orgId)
74
+ return null;
75
+ const addr = raw?.physicalAddress ??
76
+ raw?.mailingAddress ??
77
+ raw?.address ??
78
+ raw?.addresses?.[0] ??
79
+ {};
80
+ const contact = raw?.contactPerson ?? raw?.contactPersons?.[0] ?? {};
81
+ const contactInfo = raw?.contactInformation ??
82
+ raw?.contactInfo ??
83
+ raw?.contact ??
84
+ {};
85
+ const primaryContact = raw?.primaryContact ??
86
+ raw?.generalContact ??
87
+ raw?.mainContact ??
88
+ {};
89
+ const name = raw?.organizationName ??
90
+ raw?.name ??
91
+ raw?.commercialName ??
92
+ (raw?.companyGln ? `Floriday-organisatie ${raw.companyGln}` : null) ??
93
+ `Floriday-organisatie ${String(orgId).slice(0, 8)}`;
94
+ const toArr = (v) => Array.isArray(v) ? v.map((x) => String(x)).filter(Boolean) : [];
95
+ const pick = (...vals) => {
96
+ for (const v of vals) {
97
+ if (typeof v === "string" && v.trim().length > 0)
98
+ return v.trim();
99
+ if (typeof v === "number")
100
+ return String(v);
101
+ }
102
+ return null;
103
+ };
104
+ const customFields = {};
105
+ const rfhRelationId = pick(raw?.rfhRelationId, raw?.rfhRelationNumber, raw?.rfhCompanyAccountId);
106
+ if (rfhRelationId)
107
+ customFields.rfh_relation_id = rfhRelationId;
108
+ if (raw?.plantionRegistrationNumber)
109
+ customFields.plantion_registration_number = raw.plantionRegistrationNumber;
110
+ return {
111
+ organization_id: String(orgId),
112
+ connection_id: connectionId,
113
+ company_name: name,
114
+ vat_number: pick(raw?.vatNumber, raw?.taxNumber, raw?.chamberOfCommerceNumber, raw?.kvkNumber),
115
+ email: pick(raw?.email, raw?.emailAddress, contactInfo?.email, contactInfo?.emailAddress, primaryContact?.email, primaryContact?.emailAddress, contact?.email, contact?.emailAddress),
116
+ phone: pick(raw?.phoneNumber, raw?.phone, raw?.telephoneNumber, contactInfo?.phoneNumber, contactInfo?.phone, primaryContact?.phoneNumber, primaryContact?.phone, contact?.phoneNumber, contact?.phone),
117
+ contact_person: contact?.fullName ??
118
+ ([contact?.firstName, contact?.lastName].filter(Boolean).join(" ") ||
119
+ null),
120
+ street: addr?.streetName ?? addr?.street ?? addr?.addressLine ?? null,
121
+ house_number: addr?.houseNumber ?? null,
122
+ postal_code: addr?.postalCode ?? addr?.zipCode ?? null,
123
+ city: addr?.city ?? null,
124
+ country: addr?.countryCode ?? addr?.country ?? null,
125
+ gln: raw?.companyGln ?? raw?.gln ?? null,
126
+ commercial_name: raw?.commercialName ?? null,
127
+ bio: raw?.bio ?? raw?.description ?? null,
128
+ segments: toArr(raw?.segments ?? raw?.buyerSegments),
129
+ trade_forms: toArr(raw?.tradeForms ?? raw?.tradeForm),
130
+ markets: toArr(raw?.markets ?? raw?.salesMarkets),
131
+ product_groups: toArr(raw?.productGroups),
132
+ payment_methods: toArr(raw?.paymentProviders ?? raw?.paymentMethods),
133
+ organization_type: raw?.organizationType ?? null,
134
+ website: pick(raw?.website, raw?.websiteUrl, raw?.url, contactInfo?.website, contactInfo?.websiteUrl),
135
+ logo_url: raw?.logoUrl ?? null,
136
+ custom_fields: customFields,
137
+ raw,
138
+ };
139
+ }
140
+ /**
141
+ * Realtime sync — alleen handelsrelaties (trade-partners) van elke actieve
142
+ * connectie. Dit is wat de "Synchroniseer Floriday"-knop aanroept en wat de
143
+ * uurlijkse token-cron triggert. Het volledige Floriday-netwerk wordt apart
144
+ * 's nachts gesynchroniseerd via syncFloridayNetwork().
145
+ */
146
+ export async function syncAllFloridayCustomers() {
147
+ const { data: settings } = await supabaseAdmin
148
+ .from("floriday_settings")
149
+ .select("active_environment")
150
+ .eq("id", 1)
151
+ .maybeSingle();
152
+ const activeEnv = settings?.active_environment ?? "staging";
153
+ const { data: connections, error } = await supabaseAdmin
154
+ .from("floriday_connections")
155
+ .select("*")
156
+ .eq("is_active", true)
157
+ .eq("environment", activeEnv);
158
+ if (error) {
159
+ const hint = error.message?.toLowerCase().includes("invalid api key")
160
+ ? ' (Het secret SB_SERVICE_ROLE_KEY lijkt ongeldig — controleer of de service_role key uit Supabase → Project Settings → API is ingeplakt.)'
161
+ : "";
162
+ throw new Error(`Supabase-fout bij ophalen Floriday-verbindingen: ${error.message}${hint}`);
163
+ }
164
+ const errors = [];
165
+ let totalSynced = 0;
166
+ for (const conn of connections ?? []) {
167
+ try {
168
+ const token = await ensureValidToken(conn);
169
+ const base = baseUrlFor(conn.environment);
170
+ // Reset connection_status voor deze connectie. De upsert hieronder
171
+ // zet alleen de daadwerkelijke trade-partners weer op 'connected',
172
+ // zodat klanten die uit Floriday zijn verwijderd automatisch uit
173
+ // de klantenlijst verdwijnen (history blijft behouden).
174
+ await supabaseAdmin
175
+ .from("customers")
176
+ .update({ connection_status: "none" })
177
+ .eq("floriday_connection_id", conn.id)
178
+ .eq("source", "floriday");
179
+ // In de Suppliers API (2026v1) bestaan géén /customers of
180
+ // /trade-partners endpoints. Handelsrelaties worden uitgedrukt als
181
+ // Connections: GET /connections/sync/{seq} geeft alle actieve
182
+ // connecties van deze supplier-organisatie, waarbij elke connectie
183
+ // verwijst naar een customerOrganizationId. Voor de volledige
184
+ // klantgegevens halen we vervolgens GET /organizations/{id} op.
185
+ const headers = {
186
+ Authorization: `Bearer ${token}`,
187
+ "X-Api-Key": conn.api_key,
188
+ Accept: "application/json",
189
+ };
190
+ // 1) Haal alle connecties op (sequence-paged, 1000 per pagina).
191
+ const allConnections = [];
192
+ let seq = 0;
193
+ // veiligheidslimiet — voorkomt oneindige loop bij API-bugs.
194
+ for (let page = 0; page < 50; page++) {
195
+ const url = `${base}/connections/sync/${seq}?limitResult=1000`;
196
+ const res = await fetchWithRetry(url, { headers });
197
+ if (!res.ok) {
198
+ const body = await res.text().catch(() => "");
199
+ throw new Error(`GET /connections/sync/${seq} → HTTP ${res.status}${body ? `: ${body.slice(0, 300)}` : ""}`);
200
+ }
201
+ const json = await res.json();
202
+ const items = Array.isArray(json)
203
+ ? json
204
+ : Array.isArray(json?.results)
205
+ ? json.results
206
+ : Array.isArray(json?.items)
207
+ ? json.items
208
+ : [];
209
+ if (items.length === 0)
210
+ break;
211
+ allConnections.push(...items);
212
+ const lastSeq = items
213
+ .map((c) => Number(c?.sequenceNumber ?? c?.sequence ?? 0))
214
+ .filter((n) => Number.isFinite(n) && n > 0)
215
+ .reduce((a, b) => Math.max(a, b), 0);
216
+ if (!lastSeq || lastSeq <= seq)
217
+ break;
218
+ seq = lastSeq;
219
+ }
220
+ // 2) Filter op actieve connecties + unieke customer organization IDs.
221
+ const customerOrgIds = Array.from(new Set(allConnections
222
+ .filter((c) => {
223
+ // Het Suppliers-API 2026v1 /connections/sync endpoint geeft
224
+ // alleen { customerOrganizationId, lastModifiedDateTime,
225
+ // isDeleted, sequenceNumber } terug — geen status-veld.
226
+ // Een connectie is actief zolang isDeleted=false.
227
+ if (c?.isDeleted === true)
228
+ return false;
229
+ // endDate is optioneel; als die in het verleden ligt is de
230
+ // connectie beëindigd.
231
+ const endDate = c?.endDate ? new Date(c.endDate) : null;
232
+ if (endDate && Number.isFinite(endDate.getTime()) && endDate.getTime() < Date.now()) {
233
+ return false;
234
+ }
235
+ return true;
236
+ })
237
+ .map((c) => c?.customerOrganizationId ??
238
+ c?.organizationId ??
239
+ c?.partnerOrganizationId ??
240
+ null)
241
+ .filter((id) => typeof id === "string" && id.length > 0)));
242
+ // 3) Haal per organisatie de details op (parallel, throttled).
243
+ const partners = [];
244
+ const concurrency = 5;
245
+ let cursor = 0;
246
+ async function worker() {
247
+ while (cursor < customerOrgIds.length) {
248
+ const idx = cursor++;
249
+ const orgId = customerOrgIds[idx];
250
+ try {
251
+ const r = await fetch(`${base}/organizations/${encodeURIComponent(orgId)}`, { headers });
252
+ if (!r.ok) {
253
+ // Fallback: alléén ID — beter dan helemaal niets in de lijst.
254
+ partners.push({ organizationId: orgId });
255
+ continue;
256
+ }
257
+ const detail = await r.json();
258
+ partners.push({ organizationId: orgId, ...detail });
259
+ }
260
+ catch {
261
+ partners.push({ organizationId: orgId });
262
+ }
263
+ await new Promise((res2) => setTimeout(res2, 50));
264
+ }
265
+ }
266
+ await Promise.all(Array.from({ length: Math.min(concurrency, Math.max(customerOrgIds.length, 1)) }, () => worker()));
267
+ const rows = partners
268
+ .map((p) => mapFloridayPartner(p, conn.id))
269
+ .filter((p) => {
270
+ if (p === null)
271
+ return false;
272
+ // Skip organisaties zonder echte naam én zonder GLN — dat zijn
273
+ // geanonimiseerde / beëindigde organisaties die Floriday niet meer
274
+ // als handelsrelatie toont.
275
+ const hasRealName = p.company_name &&
276
+ !p.company_name.startsWith("Floriday-organisatie ");
277
+ if (!hasRealName && !p.gln)
278
+ return false;
279
+ // Skip beëindigde organisaties (endDate in het verleden).
280
+ const rawAny = p.raw;
281
+ const endDate = rawAny?.endDate ? new Date(rawAny.endDate) : null;
282
+ if (endDate && Number.isFinite(endDate.getTime()) && endDate.getTime() < Date.now()) {
283
+ return false;
284
+ }
285
+ return true;
286
+ });
287
+ if (rows.length > 0) {
288
+ const payload = rows.map((r) => ({
289
+ organization_id: r.organization_id,
290
+ connection_id: r.connection_id,
291
+ data: JSON.parse(JSON.stringify(r)),
292
+ fetched_at: new Date().toISOString(),
293
+ }));
294
+ const { error: upsertError } = await supabaseAdmin
295
+ .from("floriday_customers_cache")
296
+ .upsert(payload);
297
+ if (upsertError)
298
+ throw new Error(upsertError.message);
299
+ // Upsert ook naar de zichtbare customers-tabel zodat Floriday-klanten
300
+ // verschijnen in /klanten.
301
+ const nowIso = new Date().toISOString();
302
+ const customerRows = rows.map((r) => ({
303
+ company_name: r.company_name,
304
+ contact_person: r.contact_person,
305
+ email: r.email,
306
+ phone: r.phone,
307
+ vat_number: r.vat_number,
308
+ street: r.street,
309
+ house_number: r.house_number,
310
+ postal_code: r.postal_code,
311
+ city: r.city,
312
+ country: r.country,
313
+ floriday_organization_id: r.organization_id,
314
+ floriday_connection_id: r.connection_id,
315
+ source: "floriday",
316
+ connection_status: "connected",
317
+ sync_status: "synced",
318
+ sync_error: null,
319
+ last_synced_at: nowIso,
320
+ is_active: true,
321
+ gln: r.gln,
322
+ commercial_name: r.commercial_name,
323
+ bio: r.bio,
324
+ segments: r.segments,
325
+ trade_forms: r.trade_forms,
326
+ markets: r.markets,
327
+ product_groups: r.product_groups,
328
+ payment_methods: r.payment_methods,
329
+ organization_type: r.organization_type,
330
+ website: r.website,
331
+ logo_url: r.logo_url,
332
+ custom_fields: JSON.parse(JSON.stringify(r.custom_fields ?? {})),
333
+ floriday_raw: JSON.parse(JSON.stringify(r.raw ?? {})),
334
+ }));
335
+ // Batchen om payload-limieten te ontwijken.
336
+ const batchSize = 250;
337
+ for (let i = 0; i < customerRows.length; i += batchSize) {
338
+ const batch = customerRows.slice(i, i + batchSize);
339
+ const { error: custErr } = await supabaseAdmin
340
+ .from("customers")
341
+ .upsert(batch, {
342
+ onConflict: "floriday_connection_id,floriday_organization_id",
343
+ });
344
+ if (custErr)
345
+ throw new Error(custErr.message);
346
+ }
347
+ // Haal customer-ids op om child-tabellen te kunnen vullen.
348
+ const orgIds = rows.map((r) => r.organization_id);
349
+ const { data: custIdRows } = await supabaseAdmin
350
+ .from("customers")
351
+ .select("id, floriday_organization_id")
352
+ .eq("floriday_connection_id", conn.id)
353
+ .in("floriday_organization_id", orgIds);
354
+ const orgToCustomerId = new Map((custIdRows ?? []).map((r) => [
355
+ r.floriday_organization_id,
356
+ r.id,
357
+ ]));
358
+ const { matchWarnings } = await syncChildEntities({
359
+ token,
360
+ base,
361
+ apiKey: conn.api_key,
362
+ orgToCustomerId,
363
+ });
364
+ for (const w of matchWarnings) {
365
+ errors.push({
366
+ connectionId: conn.id,
367
+ message: `Waarschuwing child-sync (connection ${conn.id}): ${w}`,
368
+ });
369
+ }
370
+ }
371
+ totalSynced += rows.length;
372
+ }
373
+ catch (err) {
374
+ errors.push({
375
+ connectionId: conn.id,
376
+ message: `Floriday API-fout (connection ${conn.id}): ${err instanceof Error ? err.message : String(err)}`,
377
+ });
378
+ }
379
+ }
380
+ return { synced: totalSynced, errors };
381
+ }
382
+ /**
383
+ * Best-effort sync van child-entiteiten (contactpersonen, afleverlocaties,
384
+ * handelsinstellingen, geselecteerd assortiment). Floriday's Suppliers API
385
+ * heeft niet altijd 1-op-1 dezelfde endpoints als de FHC-koper-UI; mislukte
386
+ * endpoints worden stil overgeslagen zodat de hoofdsync niet faalt.
387
+ */
388
+ async function syncChildEntities(params) {
389
+ const { token, base, apiKey, orgToCustomerId } = params;
390
+ const headers = {
391
+ Authorization: `Bearer ${token}`,
392
+ "X-Api-Key": apiKey,
393
+ Accept: "application/json",
394
+ };
395
+ const matchWarnings = [];
396
+ const trackMatch = (label, total, matched) => {
397
+ if (total === 0)
398
+ return;
399
+ if (matched / total < 0.5) {
400
+ matchWarnings.push(`${label}: ${matched}/${total} gekoppeld aan klant — controleer id-veld`);
401
+ }
402
+ };
403
+ async function safeFetchList(url) {
404
+ try {
405
+ const res = await fetch(url, { headers });
406
+ if (!res.ok)
407
+ return [];
408
+ const json = await res.json();
409
+ if (Array.isArray(json))
410
+ return json;
411
+ if (Array.isArray(json?.results))
412
+ return json.results;
413
+ if (Array.isArray(json?.items))
414
+ return json.items;
415
+ return [];
416
+ }
417
+ catch {
418
+ return [];
419
+ }
420
+ }
421
+ // CONTACTPERSONEN — probeer endpoint-varianten
422
+ // Let op: de Suppliers API 2026v1 exposeert géén contact-personen van
423
+ // klant-organisaties (alleen /organizations en /connections bestaan).
424
+ // We proberen toch de bulk sync-endpoints — voor het geval Floriday er
425
+ // ooit één publiceert — maar slaan de per-organisatie probe over,
426
+ // omdat die endpoints niet bestaan en alleen onnodige HTTP-calls
427
+ // genereren bij elke sync. Contactgegevens (e-mail, telefoon) moeten
428
+ // handmatig op de klant worden ingevuld.
429
+ const contactEndpoints = [
430
+ `${base}/contact-persons/sync/0?limitResult=1000`,
431
+ `${base}/contacts/sync/0?limitResult=1000`,
432
+ ];
433
+ let contacts = [];
434
+ for (const url of contactEndpoints) {
435
+ contacts = await safeFetchList(url);
436
+ if (contacts.length > 0)
437
+ break;
438
+ }
439
+ if (contacts.length > 0) {
440
+ let matched = 0;
441
+ const rows = contacts
442
+ .map((c) => {
443
+ const orgId = c?.organizationId ?? c?.companyId ?? c?.tradePartnerId;
444
+ const customerId = orgToCustomerId.get(String(orgId));
445
+ if (!customerId)
446
+ return null;
447
+ matched += 1;
448
+ const fullName = c?.fullName ??
449
+ [c?.firstName, c?.lastName].filter(Boolean).join(" ") ??
450
+ c?.name ??
451
+ null;
452
+ if (!fullName)
453
+ return null;
454
+ return {
455
+ customer_id: customerId,
456
+ floriday_contact_id: c?.contactPersonId ?? c?.id ?? c?.contactId ?? null,
457
+ full_name: fullName,
458
+ job_title: c?.jobTitle ?? c?.role ?? c?.function ?? null,
459
+ email: c?.email ?? c?.emailAddress ?? null,
460
+ phone: c?.phoneNumber ?? c?.phone ?? null,
461
+ photo_url: c?.photoUrl ?? c?.avatarUrl ?? null,
462
+ is_active: c?.isActive ?? true,
463
+ raw: c,
464
+ };
465
+ })
466
+ .filter((r) => r !== null);
467
+ trackMatch("contactpersonen", contacts.length, matched);
468
+ for (let i = 0; i < rows.length; i += 250) {
469
+ await supabaseAdmin
470
+ .from("customer_contacts")
471
+ .upsert(rows.slice(i, i + 250), {
472
+ onConflict: "customer_id,floriday_contact_id",
473
+ });
474
+ }
475
+ }
476
+ // AFLEVERLOCATIES
477
+ const deliveryEndpoints = [
478
+ `${base}/delivery-locations/sync/0?limitResult=1000`,
479
+ `${base}/customer-delivery-locations/sync/0?limitResult=1000`,
480
+ ];
481
+ let locations = [];
482
+ for (const url of deliveryEndpoints) {
483
+ locations = await safeFetchList(url);
484
+ if (locations.length > 0)
485
+ break;
486
+ }
487
+ if (locations.length > 0) {
488
+ let matched = 0;
489
+ const rows = locations
490
+ .map((l) => {
491
+ const orgId = l?.organizationId ?? l?.companyId ?? l?.customerId;
492
+ const customerId = orgToCustomerId.get(String(orgId));
493
+ if (!customerId)
494
+ return null;
495
+ matched += 1;
496
+ const addr = l?.address ?? l?.physicalAddress ?? {};
497
+ return {
498
+ customer_id: customerId,
499
+ floriday_location_id: l?.deliveryLocationId ?? l?.locationId ?? l?.id ?? null,
500
+ name: l?.name ?? l?.locationName ?? null,
501
+ address_line: addr?.addressLine ??
502
+ ([addr?.streetName, addr?.houseNumber].filter(Boolean).join(" ") ||
503
+ null),
504
+ postal_code: addr?.postalCode ?? null,
505
+ city: addr?.city ?? null,
506
+ country: addr?.countryCode ?? addr?.country ?? null,
507
+ gln: l?.gln ?? l?.locationGln ?? null,
508
+ is_primary: l?.isPrimary ?? l?.isFixed ?? false,
509
+ raw: l,
510
+ };
511
+ })
512
+ .filter((r) => r !== null);
513
+ trackMatch("afleverlocaties", locations.length, matched);
514
+ for (let i = 0; i < rows.length; i += 250) {
515
+ await supabaseAdmin
516
+ .from("customer_delivery_locations")
517
+ .upsert(rows.slice(i, i + 250), {
518
+ onConflict: "customer_id,floriday_location_id",
519
+ });
520
+ }
521
+ }
522
+ // GESELECTEERD ASSORTIMENT
523
+ const assortmentEndpoints = [
524
+ `${base}/customer-specific-offerings/sync/0?limitResult=1000`,
525
+ `${base}/selected-assortment/sync/0?limitResult=1000`,
526
+ `${base}/customer-assortments/sync/0?limitResult=1000`,
527
+ ];
528
+ let assortment = [];
529
+ for (const url of assortmentEndpoints) {
530
+ assortment = await safeFetchList(url);
531
+ if (assortment.length > 0)
532
+ break;
533
+ }
534
+ if (assortment.length > 0) {
535
+ let matched = 0;
536
+ const rows = assortment
537
+ .map((a) => {
538
+ const orgId = a?.organizationId ?? a?.customerId ?? a?.tradePartnerId;
539
+ const customerId = orgToCustomerId.get(String(orgId));
540
+ if (!customerId)
541
+ return null;
542
+ matched += 1;
543
+ const name = a?.productName ?? a?.tradeItemName ?? a?.name ?? null;
544
+ if (!name)
545
+ return null;
546
+ return {
547
+ customer_id: customerId,
548
+ floriday_item_id: a?.tradeItemId ?? a?.offeringId ?? a?.itemId ?? a?.id ?? null,
549
+ article_number: a?.articleNumber ?? a?.gtin ?? null,
550
+ product_name: name,
551
+ product_group: a?.productGroup ?? a?.category ?? null,
552
+ price: typeof a?.price === "number"
553
+ ? a.price
554
+ : typeof a?.unitPrice === "number"
555
+ ? a.unitPrice
556
+ : null,
557
+ unit: a?.unit ?? a?.priceUnit ?? null,
558
+ raw: a,
559
+ };
560
+ })
561
+ .filter((r) => r !== null);
562
+ trackMatch("assortiment", assortment.length, matched);
563
+ for (let i = 0; i < rows.length; i += 250) {
564
+ await supabaseAdmin
565
+ .from("customer_selected_assortment")
566
+ .upsert(rows.slice(i, i + 250), {
567
+ onConflict: "customer_id,floriday_item_id",
568
+ });
569
+ }
570
+ }
571
+ return { matchWarnings };
572
+ }
573
+ /**
574
+ * Nachtelijke sync van het volledige Floriday-netwerk (alle organisaties
575
+ * waar de connectie zicht op heeft). Wordt incrementeel opgehaald via een
576
+ * per-connectie sequence number en in `floriday_network_cache` opgeslagen.
577
+ * Raakt de `customers`-tabel níet aan — die wordt door
578
+ * syncAllFloridayCustomers() (= trade-partners) gevuld.
579
+ */
580
+ export async function syncFloridayNetwork() {
581
+ const { data: settings } = await supabaseAdmin
582
+ .from("floriday_settings")
583
+ .select("active_environment")
584
+ .eq("id", 1)
585
+ .maybeSingle();
586
+ const activeEnv = settings?.active_environment ?? "staging";
587
+ const { data: connections, error } = await supabaseAdmin
588
+ .from("floriday_connections")
589
+ .select("*")
590
+ .eq("is_active", true)
591
+ .eq("environment", activeEnv);
592
+ if (error)
593
+ throw new Error(error.message);
594
+ const errors = [];
595
+ let totalUpserted = 0;
596
+ for (const conn of connections ?? []) {
597
+ try {
598
+ const token = await ensureValidToken(conn);
599
+ const base = baseUrlFor(conn.environment);
600
+ const { data: state } = await supabaseAdmin
601
+ .from("floriday_network_sync_state")
602
+ .select("last_sequence_number")
603
+ .eq("connection_id", conn.id)
604
+ .maybeSingle();
605
+ let seq = state?.last_sequence_number ?? 0;
606
+ let pages = 0;
607
+ const maxPages = 50; // bovengrens, beschermt tegen runaway loops
608
+ while (pages < maxPages) {
609
+ const url = `${base}/organizations/sync/${seq}?limitResult=1000`;
610
+ const res = await fetchWithRetry(url, {
611
+ headers: {
612
+ Authorization: `Bearer ${token}`,
613
+ "X-Api-Key": conn.api_key,
614
+ Accept: "application/json",
615
+ },
616
+ });
617
+ if (!res.ok) {
618
+ const body = await res.text().catch(() => "");
619
+ throw new Error(`${url} → HTTP ${res.status}${body ? `: ${body.slice(0, 200)}` : ""}`);
620
+ }
621
+ const json = (await res.json());
622
+ const items = Array.isArray(json)
623
+ ? json
624
+ : Array.isArray(json?.results)
625
+ ? json.results
626
+ : Array.isArray(json?.items)
627
+ ? json.items
628
+ : [];
629
+ if (items.length === 0)
630
+ break;
631
+ const rows = items
632
+ .map((o) => {
633
+ const orgId = o?.organizationId ?? o?.id;
634
+ if (!orgId)
635
+ return null;
636
+ const addr = o?.physicalAddress ?? o?.mailingAddress ?? o?.address ?? {};
637
+ return {
638
+ connection_id: conn.id,
639
+ organization_id: String(orgId),
640
+ organization_name: o?.organizationName ?? o?.name ?? o?.commercialName ?? null,
641
+ gln: o?.companyGln ?? o?.gln ?? null,
642
+ country: addr?.countryCode ?? addr?.country ?? null,
643
+ city: addr?.city ?? null,
644
+ data: o,
645
+ fetched_at: new Date().toISOString(),
646
+ };
647
+ })
648
+ .filter((r) => r !== null);
649
+ if (rows.length > 0) {
650
+ const { error: upErr } = await supabaseAdmin
651
+ .from("floriday_network_cache")
652
+ .upsert(rows, { onConflict: "connection_id,organization_id" });
653
+ if (upErr)
654
+ throw new Error(upErr.message);
655
+ totalUpserted += rows.length;
656
+ }
657
+ // Volgende sequence: hoogste sequenceNumber in de batch + 1.
658
+ const maxSeq = items.reduce((m, it) => {
659
+ const s = Number(it?.sequenceNumber ?? it?.seq ?? 0);
660
+ return Number.isFinite(s) && s > m ? s : m;
661
+ }, seq);
662
+ if (maxSeq <= seq)
663
+ break;
664
+ seq = maxSeq;
665
+ pages += 1;
666
+ if (items.length < 1000)
667
+ break; // laatste pagina
668
+ }
669
+ await supabaseAdmin
670
+ .from("floriday_network_sync_state")
671
+ .upsert({
672
+ connection_id: conn.id,
673
+ last_sequence_number: seq,
674
+ last_synced_at: new Date().toISOString(),
675
+ last_error: null,
676
+ updated_at: new Date().toISOString(),
677
+ });
678
+ // Verrijk organisaties met lege naam via /organizations/{id} — Floriday
679
+ // anonimiseert namen in /organizations/sync; het detail-endpoint geeft
680
+ // ze wel terug voor organisaties waar deze connectie rechten op heeft.
681
+ await enrichNetworkOrganizations({
682
+ connectionId: conn.id,
683
+ token,
684
+ base,
685
+ apiKey: conn.api_key,
686
+ });
687
+ }
688
+ catch (err) {
689
+ const message = err instanceof Error ? err.message : String(err);
690
+ errors.push({ connectionId: conn.id, message });
691
+ await supabaseAdmin
692
+ .from("floriday_network_sync_state")
693
+ .upsert({
694
+ connection_id: conn.id,
695
+ last_sequence_number: 0,
696
+ last_error: message,
697
+ updated_at: new Date().toISOString(),
698
+ });
699
+ }
700
+ }
701
+ return { upserted: totalUpserted, errors };
702
+ }
703
+ async function enrichNetworkOrganizations(params) {
704
+ const { connectionId, token, base, apiKey } = params;
705
+ const headers = {
706
+ Authorization: `Bearer ${token}`,
707
+ "X-Api-Key": apiKey,
708
+ Accept: "application/json",
709
+ };
710
+ // Pak max 200 organisaties per run zonder naam, om Floriday-rate-limits te
711
+ // respecteren. Volgende runs werken de rest af.
712
+ const { data: emptyRows } = await supabaseAdmin
713
+ .from("floriday_network_cache")
714
+ .select("organization_id")
715
+ .eq("connection_id", connectionId)
716
+ .or("organization_name.is.null,organization_name.eq.")
717
+ .limit(200);
718
+ if (!emptyRows || emptyRows.length === 0)
719
+ return;
720
+ const rows = emptyRows;
721
+ const concurrency = 5;
722
+ let cursor = 0;
723
+ async function worker() {
724
+ while (cursor < rows.length) {
725
+ const idx = cursor++;
726
+ const row = rows[idx];
727
+ const url = `${base}/organizations/${encodeURIComponent(row.organization_id)}`;
728
+ try {
729
+ const res = await fetch(url, { headers });
730
+ if (!res.ok)
731
+ continue;
732
+ const detail = await res.json();
733
+ const addr = detail?.physicalAddress ??
734
+ detail?.mailingAddress ??
735
+ detail?.address ??
736
+ {};
737
+ const name = detail?.organizationName ??
738
+ detail?.name ??
739
+ detail?.commercialName ??
740
+ null;
741
+ const gln = detail?.companyGln ?? detail?.gln ?? null;
742
+ if (!name && !gln)
743
+ continue; // niets bruikbaars terug
744
+ await supabaseAdmin
745
+ .from("floriday_network_cache")
746
+ .update({
747
+ organization_name: name || null,
748
+ gln: gln || null,
749
+ city: addr?.city ?? null,
750
+ country: addr?.countryCode ?? addr?.country ?? null,
751
+ data: detail,
752
+ fetched_at: new Date().toISOString(),
753
+ })
754
+ .eq("connection_id", connectionId)
755
+ .eq("organization_id", row.organization_id);
756
+ }
757
+ catch {
758
+ // ignore — best effort
759
+ }
760
+ // throttle een beetje
761
+ await new Promise((r) => setTimeout(r, 100));
762
+ }
763
+ }
764
+ await Promise.all(Array.from({ length: Math.min(concurrency, rows.length) }, () => worker()));
765
+ }