@flowselections/floriday-klanten-module 1.0.35 → 1.0.36

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 (73) hide show
  1. package/dist-lib/components/klanten/BewerkKlant.d.ts.map +1 -1
  2. package/dist-lib/components/klanten/BewerkKlant.js +21 -1
  3. package/dist-lib/components/klanten/BulkStickerUploadDialog.d.ts +14 -0
  4. package/dist-lib/components/klanten/BulkStickerUploadDialog.d.ts.map +1 -0
  5. package/dist-lib/components/klanten/BulkStickerUploadDialog.js +231 -0
  6. package/dist-lib/components/klanten/CustomerAssortmentTab.d.ts.map +1 -1
  7. package/dist-lib/components/klanten/CustomerAssortmentTab.js +37 -5
  8. package/dist-lib/components/klanten/CustomerStickerCell.d.ts +7 -0
  9. package/dist-lib/components/klanten/CustomerStickerCell.d.ts.map +1 -0
  10. package/dist-lib/components/klanten/CustomerStickerCell.js +90 -0
  11. package/dist-lib/components/klanten/CustomerStickerFormatDialog.d.ts +7 -0
  12. package/dist-lib/components/klanten/CustomerStickerFormatDialog.d.ts.map +1 -0
  13. package/dist-lib/components/klanten/CustomerStickerFormatDialog.js +36 -0
  14. package/dist-lib/index.d.ts +4 -0
  15. package/dist-lib/index.d.ts.map +1 -1
  16. package/dist-lib/index.js +4 -0
  17. package/dist-lib/integrations/supabase/auth-middleware.d.ts +565 -0
  18. package/dist-lib/integrations/supabase/auth-middleware.d.ts.map +1 -1
  19. package/dist-lib/integrations/supabase/client.d.ts +565 -0
  20. package/dist-lib/integrations/supabase/client.d.ts.map +1 -1
  21. package/dist-lib/integrations/supabase/client.server.d.ts +565 -0
  22. package/dist-lib/integrations/supabase/client.server.d.ts.map +1 -1
  23. package/dist-lib/integrations/supabase/types.d.ts +568 -0
  24. package/dist-lib/integrations/supabase/types.d.ts.map +1 -1
  25. package/dist-lib/lib/accounting/twinfield/worker.functions.d.ts +1695 -0
  26. package/dist-lib/lib/accounting/twinfield/worker.functions.d.ts.map +1 -1
  27. package/dist-lib/lib/accounting.functions.d.ts +1695 -0
  28. package/dist-lib/lib/accounting.functions.d.ts.map +1 -1
  29. package/dist-lib/lib/ai-module.server.d.ts +20 -0
  30. package/dist-lib/lib/ai-module.server.d.ts.map +1 -0
  31. package/dist-lib/lib/ai-module.server.js +86 -0
  32. package/dist-lib/lib/customer-analytics.functions.d.ts +565 -0
  33. package/dist-lib/lib/customer-analytics.functions.d.ts.map +1 -1
  34. package/dist-lib/lib/customer-assortment.functions.d.ts +565 -0
  35. package/dist-lib/lib/customer-assortment.functions.d.ts.map +1 -1
  36. package/dist-lib/lib/customer-notes.functions.d.ts +1130 -0
  37. package/dist-lib/lib/customer-notes.functions.d.ts.map +1 -1
  38. package/dist-lib/lib/customer-revenue.functions.d.ts +565 -0
  39. package/dist-lib/lib/customer-revenue.functions.d.ts.map +1 -1
  40. package/dist-lib/lib/customer-stickers.functions.d.ts +244731 -0
  41. package/dist-lib/lib/customer-stickers.functions.d.ts.map +1 -0
  42. package/dist-lib/lib/customer-stickers.functions.js +467 -0
  43. package/dist-lib/lib/customers.functions.d.ts +5085 -0
  44. package/dist-lib/lib/customers.functions.d.ts.map +1 -1
  45. package/dist-lib/lib/delivery-locations.functions.d.ts +2825 -0
  46. package/dist-lib/lib/delivery-locations.functions.d.ts.map +1 -1
  47. package/dist-lib/lib/external-customers.functions.d.ts +2260 -0
  48. package/dist-lib/lib/external-customers.functions.d.ts.map +1 -1
  49. package/dist-lib/lib/floriday-assortment.functions.d.ts +565 -0
  50. package/dist-lib/lib/floriday-assortment.functions.d.ts.map +1 -1
  51. package/dist-lib/lib/floriday-discounts.functions.d.ts +1130 -0
  52. package/dist-lib/lib/floriday-discounts.functions.d.ts.map +1 -1
  53. package/dist-lib/lib/floriday-price-import.functions.d.ts +1130 -0
  54. package/dist-lib/lib/floriday-price-import.functions.d.ts.map +1 -1
  55. package/dist-lib/lib/floriday-trade-items.functions.d.ts +565 -0
  56. package/dist-lib/lib/floriday-trade-items.functions.d.ts.map +1 -1
  57. package/dist-lib/lib/local-discounts.functions.d.ts +3956 -1
  58. package/dist-lib/lib/local-discounts.functions.d.ts.map +1 -1
  59. package/dist-lib/lib/logistics-events.functions.d.ts +565 -0
  60. package/dist-lib/lib/logistics-events.functions.d.ts.map +1 -1
  61. package/dist-lib/lib/logistics-strategy.functions.d.ts +2260 -0
  62. package/dist-lib/lib/logistics-strategy.functions.d.ts.map +1 -1
  63. package/dist-lib/lib/overdue-invoices.functions.d.ts +565 -0
  64. package/dist-lib/lib/overdue-invoices.functions.d.ts.map +1 -1
  65. package/dist-lib/lib/price-groups-local.functions.d.ts +7348 -3
  66. package/dist-lib/lib/price-groups-local.functions.d.ts.map +1 -1
  67. package/dist-lib/lib/price-groups.functions.d.ts +565 -0
  68. package/dist-lib/lib/price-groups.functions.d.ts.map +1 -1
  69. package/dist-lib/lib/twinfield-adopt.functions.d.ts +1130 -0
  70. package/dist-lib/lib/twinfield-adopt.functions.d.ts.map +1 -1
  71. package/dist-lib/lib/twinfield-ledger.functions.d.ts +565 -0
  72. package/dist-lib/lib/twinfield-ledger.functions.d.ts.map +1 -1
  73. package/package.json +1 -1
@@ -0,0 +1,467 @@
1
+ import { createServerFn } from "@tanstack/react-start";
2
+ import { z } from "zod";
3
+ import { requireSupabaseAuth } from "../integrations/supabase/auth-middleware";
4
+ const BUCKET = "customer-stickers";
5
+ const SIGNED_TTL = 60 * 60; // 1 uur
6
+ export const ALLOWED_STICKER_MIME = [
7
+ "application/pdf",
8
+ "image/png",
9
+ "image/jpeg",
10
+ "image/svg+xml",
11
+ ];
12
+ async function signed(sb, path) {
13
+ const { data } = await sb.storage.from(BUCKET).createSignedUrl(path, SIGNED_TTL);
14
+ return data?.signedUrl ?? null;
15
+ }
16
+ export const listCustomerStickers = createServerFn({ method: "POST" })
17
+ .middleware([requireSupabaseAuth])
18
+ .inputValidator((input) => z.object({ customerKey: z.string().min(1) }).parse(input))
19
+ .handler(async ({ data, context }) => {
20
+ const sb = context.supabase;
21
+ const { data: rows, error } = await sb
22
+ .from("customer_product_stickers")
23
+ .select("trade_item_key, file_path, file_name, mime_type, file_size, updated_at")
24
+ .eq("customer_key", data.customerKey);
25
+ if (error)
26
+ throw new Error(error.message);
27
+ const stickers = [];
28
+ for (const r of rows ?? []) {
29
+ stickers.push({
30
+ trade_item_key: String(r.trade_item_key),
31
+ file_path: r.file_path,
32
+ file_name: r.file_name,
33
+ mime_type: r.mime_type ?? null,
34
+ file_size: r.file_size ?? null,
35
+ updated_at: r.updated_at,
36
+ url: await signed(sb, r.file_path),
37
+ });
38
+ }
39
+ const { data: fmt } = await sb
40
+ .from("customer_sticker_settings")
41
+ .select("width_mm, height_mm")
42
+ .eq("customer_key", data.customerKey)
43
+ .maybeSingle();
44
+ return {
45
+ stickers,
46
+ format: fmt ? { width_mm: Number(fmt.width_mm), height_mm: Number(fmt.height_mm) } : null,
47
+ };
48
+ });
49
+ export const confirmStickerUpload = createServerFn({ method: "POST" })
50
+ .middleware([requireSupabaseAuth])
51
+ .inputValidator((input) => z
52
+ .object({
53
+ customerKey: z.string().min(1),
54
+ tradeItemKey: z.string().min(1),
55
+ filePath: z.string().min(1),
56
+ fileName: z.string().min(1),
57
+ mimeType: z.string().min(1),
58
+ fileSize: z.number().nullable().optional(),
59
+ })
60
+ .parse(input))
61
+ .handler(async ({ data, context }) => {
62
+ const sb = context.supabase;
63
+ if (!ALLOWED_STICKER_MIME.includes(data.mimeType)) {
64
+ throw new Error("Dit bestandstype wordt niet ondersteund. Gebruik PDF, PNG, JPG of SVG.");
65
+ }
66
+ const safe = (v) => v.replace(/[^a-zA-Z0-9._-]/g, "_");
67
+ if (!data.filePath.startsWith(`${data.customerKey}/`) &&
68
+ !data.filePath.startsWith(`${safe(data.customerKey)}/`)) {
69
+ throw new Error("Ongeldig bestandspad.");
70
+ }
71
+ const { data: existing } = await sb
72
+ .from("customer_product_stickers")
73
+ .select("file_path")
74
+ .eq("customer_key", data.customerKey)
75
+ .eq("trade_item_key", data.tradeItemKey)
76
+ .maybeSingle();
77
+ const { error } = await sb.from("customer_product_stickers").upsert({
78
+ customer_key: data.customerKey,
79
+ trade_item_key: data.tradeItemKey,
80
+ file_path: data.filePath,
81
+ file_name: data.fileName,
82
+ mime_type: data.mimeType,
83
+ file_size: data.fileSize ?? null,
84
+ uploaded_by: context.userId ?? null,
85
+ updated_at: new Date().toISOString(),
86
+ }, { onConflict: "customer_key,trade_item_key" });
87
+ if (error)
88
+ throw new Error(error.message);
89
+ if (existing?.file_path && existing.file_path !== data.filePath) {
90
+ await sb.storage.from(BUCKET).remove([existing.file_path]);
91
+ }
92
+ return { ok: true };
93
+ });
94
+ export const deleteCustomerSticker = createServerFn({ method: "POST" })
95
+ .middleware([requireSupabaseAuth])
96
+ .inputValidator((input) => z
97
+ .object({ customerKey: z.string().min(1), tradeItemKey: z.string().min(1) })
98
+ .parse(input))
99
+ .handler(async ({ data, context }) => {
100
+ const sb = context.supabase;
101
+ const { data: row } = await sb
102
+ .from("customer_product_stickers")
103
+ .select("file_path")
104
+ .eq("customer_key", data.customerKey)
105
+ .eq("trade_item_key", data.tradeItemKey)
106
+ .maybeSingle();
107
+ const { error } = await sb
108
+ .from("customer_product_stickers")
109
+ .delete()
110
+ .eq("customer_key", data.customerKey)
111
+ .eq("trade_item_key", data.tradeItemKey);
112
+ if (error)
113
+ throw new Error(error.message);
114
+ if (row?.file_path)
115
+ await sb.storage.from(BUCKET).remove([row.file_path]);
116
+ return { ok: true };
117
+ });
118
+ export const getCustomerStickerSettings = createServerFn({ method: "POST" })
119
+ .middleware([requireSupabaseAuth])
120
+ .inputValidator((input) => z.object({ customerKey: z.string().min(1) }).parse(input))
121
+ .handler(async ({ data, context }) => {
122
+ const sb = context.supabase;
123
+ const { data: fmt, error } = await sb
124
+ .from("customer_sticker_settings")
125
+ .select("width_mm, height_mm")
126
+ .eq("customer_key", data.customerKey)
127
+ .maybeSingle();
128
+ if (error)
129
+ throw new Error(error.message);
130
+ return fmt ? { width_mm: Number(fmt.width_mm), height_mm: Number(fmt.height_mm) } : null;
131
+ });
132
+ export const setCustomerStickerSettings = createServerFn({ method: "POST" })
133
+ .middleware([requireSupabaseAuth])
134
+ .inputValidator((input) => z
135
+ .object({
136
+ customerKey: z.string().min(1),
137
+ widthMm: z.number().positive(),
138
+ heightMm: z.number().positive(),
139
+ })
140
+ .parse(input))
141
+ .handler(async ({ data, context }) => {
142
+ const sb = context.supabase;
143
+ const { error } = await sb.from("customer_sticker_settings").upsert({
144
+ customer_key: data.customerKey,
145
+ width_mm: data.widthMm,
146
+ height_mm: data.heightMm,
147
+ updated_at: new Date().toISOString(),
148
+ }, { onConflict: "customer_key" });
149
+ if (error)
150
+ throw new Error(error.message);
151
+ return { ok: true };
152
+ });
153
+ /**
154
+ * Aanknopingspunt voor de pick-/printmodule: geeft het stickerbestand en het
155
+ * ingestelde formaat terug, of null als er geen klantsticker gekoppeld is.
156
+ */
157
+ export const getStickerForPrint = createServerFn({ method: "POST" })
158
+ .middleware([requireSupabaseAuth])
159
+ .inputValidator((input) => z
160
+ .object({ customerKey: z.string().min(1), tradeItemKey: z.string().min(1) })
161
+ .parse(input))
162
+ .handler(async ({ data, context }) => {
163
+ const sb = context.supabase;
164
+ const { data: row } = await sb
165
+ .from("customer_product_stickers")
166
+ .select("file_path, file_name, mime_type")
167
+ .eq("customer_key", data.customerKey)
168
+ .eq("trade_item_key", data.tradeItemKey)
169
+ .maybeSingle();
170
+ if (!row?.file_path)
171
+ return null;
172
+ const { data: fmt } = await sb
173
+ .from("customer_sticker_settings")
174
+ .select("width_mm, height_mm")
175
+ .eq("customer_key", data.customerKey)
176
+ .maybeSingle();
177
+ return {
178
+ url: await signed(sb, row.file_path),
179
+ file_name: row.file_name,
180
+ mime_type: (row.mime_type ?? null),
181
+ width_mm: fmt ? Number(fmt.width_mm) : null,
182
+ height_mm: fmt ? Number(fmt.height_mm) : null,
183
+ };
184
+ });
185
+ // ---------------------------------------------------------------------------
186
+ // Bulk-upload met AI-herkenning
187
+ // ---------------------------------------------------------------------------
188
+ const MAX_STICKER_SIZE = 10 * 1024 * 1024;
189
+ const AI_MODULE_KEY = "klanten-stickers";
190
+ export const safeStorageKey = (v) => v.replace(/[^a-zA-Z0-9._-]/g, "_");
191
+ function normalizeToken(v) {
192
+ return v.toLowerCase().replace(/[^a-z0-9]/g, "");
193
+ }
194
+ /** Goedkope voorbewerking: exacte code in de bestandsnaam. */
195
+ function matchByFileName(fileName, products) {
196
+ const hay = normalizeToken(fileName);
197
+ if (!hay)
198
+ return null;
199
+ for (const p of products) {
200
+ for (const [label, code] of [
201
+ ["artikelcode", p.code],
202
+ ["VBN-code", p.vbn],
203
+ ]) {
204
+ const t = code ? normalizeToken(code) : "";
205
+ if (t.length >= 4 && hay.includes(t)) {
206
+ return { tradeItemKey: p.key, reason: `Bestandsnaam bevat ${label} ${code}` };
207
+ }
208
+ }
209
+ }
210
+ return null;
211
+ }
212
+ export const suggestStickerMatches = createServerFn({ method: "POST" })
213
+ .middleware([requireSupabaseAuth])
214
+ .inputValidator((input) => z
215
+ .object({
216
+ files: z
217
+ .array(z.object({
218
+ fileId: z.string().min(1),
219
+ fileName: z.string().min(1),
220
+ thumbnail: z.string().nullable().optional(),
221
+ }))
222
+ .min(1)
223
+ .max(200),
224
+ products: z
225
+ .array(z.object({
226
+ key: z.string().min(1),
227
+ name: z.string(),
228
+ code: z.string().nullable().optional(),
229
+ vbn: z.string().nullable().optional(),
230
+ botanical: z.string().nullable().optional(),
231
+ }))
232
+ .min(1)
233
+ .max(2000),
234
+ })
235
+ .parse(input))
236
+ .handler(async ({ data, context }) => {
237
+ const products = data.products.map((p) => ({
238
+ key: p.key,
239
+ name: p.name,
240
+ code: p.code ?? null,
241
+ vbn: p.vbn ?? null,
242
+ botanical: p.botanical ?? null,
243
+ }));
244
+ const suggestions = [];
245
+ const remaining = [];
246
+ for (const f of data.files) {
247
+ const hit = matchByFileName(f.fileName, products);
248
+ if (hit) {
249
+ suggestions.push({
250
+ fileId: f.fileId,
251
+ tradeItemKey: hit.tradeItemKey,
252
+ confidence: 0.95,
253
+ reason: hit.reason,
254
+ });
255
+ }
256
+ else {
257
+ remaining.push(f);
258
+ }
259
+ }
260
+ if (remaining.length === 0)
261
+ return { suggestions };
262
+ const { resolveModuleModel, logAiCall, AiModuleError } = await import("./ai-module.server");
263
+ let model;
264
+ try {
265
+ model = await resolveModuleModel(AI_MODULE_KEY);
266
+ }
267
+ catch (e) {
268
+ if (e instanceof AiModuleError)
269
+ throw new Error(e.message);
270
+ throw e;
271
+ }
272
+ const productList = products
273
+ .map((p) => `- key: ${p.key} | naam: ${p.name}` +
274
+ (p.code ? ` | artikelcode: ${p.code}` : "") +
275
+ (p.vbn ? ` | VBN: ${p.vbn}` : "") +
276
+ (p.botanical ? ` | botanisch: ${p.botanical}` : ""))
277
+ .join("\n");
278
+ for (let i = 0; i < remaining.length; i += 8) {
279
+ const batch = remaining.slice(i, i + 8);
280
+ const started = Date.now();
281
+ try {
282
+ const parts = [
283
+ {
284
+ text: "Je koppelt stickerbestanden aan producten uit het assortiment van een bloemenhandel.\n" +
285
+ "Producten:\n" +
286
+ productList +
287
+ "\n\nHieronder volgen stickers. Voor elke sticker krijg je eerst de bestandsnaam en daarna (indien beschikbaar) een afbeelding van de sticker.\n" +
288
+ "Geef per sticker het best passende product terug. Kies alleen een product als je het redelijk zeker weet; anders tradeItemKey = null.\n" +
289
+ 'Antwoord uitsluitend als JSON-array: [{"fileId":"...","tradeItemKey":"..."|null,"confidence":0.0-1.0,"reason":"korte uitleg in het Nederlands"}]',
290
+ },
291
+ ];
292
+ for (const f of batch) {
293
+ parts.push({ text: `Sticker fileId=${f.fileId} bestandsnaam="${f.fileName}"` });
294
+ const t = f.thumbnail ?? "";
295
+ const m = /^data:([^;,]+);base64,(.+)$/.exec(t);
296
+ if (m)
297
+ parts.push({ inline_data: { mime_type: m[1], data: m[2] } });
298
+ }
299
+ const url = model.providerSlug === "gemini"
300
+ ? `${model.baseUrl}/v1beta/models/${model.modelSlug}:generateContent`
301
+ : null;
302
+ if (!url) {
303
+ throw new Error(`AI-aanbieder "${model.providerSlug}" wordt hier nog niet ondersteund. Kies een Gemini-model bij AI-authenticatie.`);
304
+ }
305
+ const res = await fetch(url, {
306
+ method: "POST",
307
+ headers: { "Content-Type": "application/json", "x-goog-api-key": model.apiKey },
308
+ body: JSON.stringify({
309
+ contents: [{ role: "user", parts }],
310
+ generationConfig: { responseMimeType: "application/json", temperature: 0 },
311
+ }),
312
+ });
313
+ if (!res.ok) {
314
+ const body = await res.text();
315
+ await logAiCall({
316
+ moduleKey: AI_MODULE_KEY,
317
+ providerSlug: model.providerSlug,
318
+ modelSlug: model.modelSlug,
319
+ status: "error",
320
+ durationMs: Date.now() - started,
321
+ errorCode: String(res.status),
322
+ errorMessage: body.slice(0, 500),
323
+ createdBy: context.userId ?? null,
324
+ });
325
+ if (res.status === 401 || res.status === 403) {
326
+ throw new Error("De AI-sleutel wordt geweigerd. Controleer de sleutel bij AI-authenticatie.");
327
+ }
328
+ if (res.status === 429) {
329
+ throw new Error("De AI-dienst is tijdelijk overbelast. Probeer het zo nog eens.");
330
+ }
331
+ throw new Error(`De AI-dienst gaf een fout (${res.status}).`);
332
+ }
333
+ const json = await res.json();
334
+ const text = json?.candidates?.[0]?.content?.parts?.map((p) => p?.text ?? "").join("") ?? "";
335
+ const cleaned = text.trim().replace(/^```(?:json)?/i, "").replace(/```$/, "");
336
+ let parsed = [];
337
+ try {
338
+ parsed = JSON.parse(cleaned);
339
+ }
340
+ catch {
341
+ parsed = [];
342
+ }
343
+ const validKeys = new Set(products.map((p) => p.key));
344
+ for (const row of Array.isArray(parsed) ? parsed : []) {
345
+ const fileId = String(row?.fileId ?? "");
346
+ if (!batch.some((b) => b.fileId === fileId))
347
+ continue;
348
+ const key = row?.tradeItemKey ? String(row.tradeItemKey) : null;
349
+ suggestions.push({
350
+ fileId,
351
+ tradeItemKey: key && validKeys.has(key) ? key : null,
352
+ confidence: Number.isFinite(Number(row?.confidence)) ? Number(row.confidence) : 0,
353
+ reason: row?.reason ? String(row.reason).slice(0, 200) : null,
354
+ });
355
+ }
356
+ for (const b of batch) {
357
+ if (!suggestions.some((s) => s.fileId === b.fileId)) {
358
+ suggestions.push({ fileId: b.fileId, tradeItemKey: null, confidence: 0, reason: null });
359
+ }
360
+ }
361
+ await logAiCall({
362
+ moduleKey: AI_MODULE_KEY,
363
+ providerSlug: model.providerSlug,
364
+ modelSlug: model.modelSlug,
365
+ status: "success",
366
+ durationMs: Date.now() - started,
367
+ createdBy: context.userId ?? null,
368
+ });
369
+ }
370
+ catch (e) {
371
+ throw e instanceof Error ? e : new Error("AI-herkenning mislukt.");
372
+ }
373
+ }
374
+ return { suggestions };
375
+ });
376
+ export const commitStickerBatch = createServerFn({ method: "POST" })
377
+ .middleware([requireSupabaseAuth])
378
+ .inputValidator((input) => z
379
+ .object({
380
+ customerKey: z.string().min(1),
381
+ batchId: z.string().min(1),
382
+ items: z
383
+ .array(z.object({
384
+ inboxPath: z.string().min(1),
385
+ fileName: z.string().min(1),
386
+ mimeType: z.string().min(1),
387
+ fileSize: z.number().nullable().optional(),
388
+ tradeItemKey: z.string().min(1),
389
+ replaceExisting: z.boolean().default(false),
390
+ }))
391
+ .min(1),
392
+ })
393
+ .parse(input))
394
+ .handler(async ({ data, context }) => {
395
+ const sb = context.supabase;
396
+ const prefix = `${safeStorageKey(data.customerKey)}/_inbox/${safeStorageKey(data.batchId)}/`;
397
+ let linked = 0;
398
+ let replaced = 0;
399
+ let skipped = 0;
400
+ const failed = [];
401
+ for (const it of data.items) {
402
+ try {
403
+ if (!it.inboxPath.startsWith(prefix))
404
+ throw new Error("Ongeldig bestandspad.");
405
+ if (!ALLOWED_STICKER_MIME.includes(it.mimeType)) {
406
+ throw new Error("Bestandstype wordt niet ondersteund.");
407
+ }
408
+ if ((it.fileSize ?? 0) > MAX_STICKER_SIZE)
409
+ throw new Error("Bestand is groter dan 10 MB.");
410
+ const { data: existing } = await sb
411
+ .from("customer_product_stickers")
412
+ .select("file_path")
413
+ .eq("customer_key", data.customerKey)
414
+ .eq("trade_item_key", it.tradeItemKey)
415
+ .maybeSingle();
416
+ if (existing?.file_path && !it.replaceExisting) {
417
+ skipped++;
418
+ continue;
419
+ }
420
+ const ext = (/\.([a-z0-9]+)$/i.exec(it.fileName)?.[1] ?? "bin").toLowerCase();
421
+ const target = `${safeStorageKey(data.customerKey)}/${safeStorageKey(it.tradeItemKey)}/${crypto.randomUUID()}.${ext}`;
422
+ const { error: moveErr } = await sb.storage.from(BUCKET).move(it.inboxPath, target);
423
+ if (moveErr)
424
+ throw new Error(moveErr.message);
425
+ const { error: upErr } = await sb.from("customer_product_stickers").upsert({
426
+ customer_key: data.customerKey,
427
+ trade_item_key: it.tradeItemKey,
428
+ file_path: target,
429
+ file_name: it.fileName,
430
+ mime_type: it.mimeType,
431
+ file_size: it.fileSize ?? null,
432
+ uploaded_by: context.userId ?? null,
433
+ updated_at: new Date().toISOString(),
434
+ }, { onConflict: "customer_key,trade_item_key" });
435
+ if (upErr)
436
+ throw new Error(upErr.message);
437
+ if (existing?.file_path) {
438
+ await sb.storage.from(BUCKET).remove([existing.file_path]);
439
+ replaced++;
440
+ }
441
+ else {
442
+ linked++;
443
+ }
444
+ }
445
+ catch (e) {
446
+ failed.push({ fileName: it.fileName, reason: e instanceof Error ? e.message : "Onbekend" });
447
+ }
448
+ }
449
+ // Restanten in de inbox opruimen
450
+ const { data: leftovers } = await sb.storage.from(BUCKET).list(prefix.replace(/\/$/, ""));
451
+ const paths = (leftovers ?? []).map((o) => `${prefix}${o.name}`);
452
+ if (paths.length)
453
+ await sb.storage.from(BUCKET).remove(paths);
454
+ return { linked, replaced, skipped, failed };
455
+ });
456
+ export const discardStickerBatch = createServerFn({ method: "POST" })
457
+ .middleware([requireSupabaseAuth])
458
+ .inputValidator((input) => z.object({ customerKey: z.string().min(1), batchId: z.string().min(1) }).parse(input))
459
+ .handler(async ({ data, context }) => {
460
+ const sb = context.supabase;
461
+ const dir = `${safeStorageKey(data.customerKey)}/_inbox/${safeStorageKey(data.batchId)}`;
462
+ const { data: objects } = await sb.storage.from(BUCKET).list(dir);
463
+ const paths = (objects ?? []).map((o) => `${dir}/${o.name}`);
464
+ if (paths.length)
465
+ await sb.storage.from(BUCKET).remove(paths);
466
+ return { removed: paths.length };
467
+ });