@flowselections/floriday-voorraad 1.0.82 → 1.0.84

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.
@@ -1,5 +1,10 @@
1
1
  import type { PackingConfigurationInput } from "./floriday-payload.js";
2
2
  import type { FloridayInfo } from "./floriday-info.js";
3
+ /** Fout bij foto-upload: `message` is voor de gebruiker, `adminDetail` voor "Details voor beheer". */
4
+ export declare class PhotoUploadError extends Error {
5
+ adminDetail: string;
6
+ constructor(message: string, adminDetail: string);
7
+ }
3
8
  export interface PrimaryPackingSummary {
4
9
  packagingType: string;
5
10
  unitsPerPackage: number;
@@ -1 +1 @@
1
- {"version":3,"file":"floriday-client.d.ts","sourceRoot":"","sources":["../../src/lib/floriday-client.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,oBAAoB,CAAC;AACpE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAEpD,MAAM,WAAW,qBAAqB;IACpC,aAAa,EAAE,MAAM,CAAC;IACtB,eAAe,EAAE,MAAM,CAAC;IACxB,gBAAgB,EAAE,MAAM,CAAC;IACzB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,WAAW,EAAE,MAAM,CAAC;CACrB;AA2CD,MAAM,WAAW,wBAAwB;IACvC,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,sBAAsB,EAAE,MAAM,GAAG,IAAI,CAAC;IACtC,wBAAwB,EAAE,MAAM,GAAG,IAAI,CAAC;IACxC,kBAAkB,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,MAAM,kBAAkB,GAC1B,UAAU,GACV,QAAQ,GACR,kBAAkB,GAClB,kBAAkB,GAClB,gBAAgB,GAChB,iBAAiB,GACjB,WAAW,CAAC;AAEhB,MAAM,WAAW,SAAS;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,aAAa,EAAE,MAAM,CAAC;IACtB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,gBAAgB,EAAE,MAAM,CAAC;IACzB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,gBAAgB,EAAE,MAAM,CAAC;IACzB,gBAAgB,EAAE,MAAM,CAAC;IACzB,GAAG,EAAE,OAAO,CAAC;IACb,wGAAwG;IACxG,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,yFAAyF;IACzF,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,oDAAoD;IACpD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uEAAuE;IACvE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,uEAAuE;IACvE,UAAU,CAAC,EAAE,kBAAkB,CAAC;IAChC,+FAA+F;IAC/F,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB,oDAAoD;IACpD,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,iDAAiD;IACjD,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,2CAA2C;IAC3C,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,yFAAyF;IACzF,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,GAAG,OAAO,GAAG,KAAK,GAAG,SAAS,CAAC;IAC7D,uFAAuF;IACvF,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,iDAAiD;IACjD,QAAQ,CAAC,EAAE,SAAS,GAAG,cAAc,GAAG,eAAe,CAAC;IACxD,qEAAqE;IACrE,qBAAqB,CAAC,EAAE,yBAAyB,EAAE,CAAC;IACpD,+DAA+D;IAC/D,cAAc,CAAC,EAAE,qBAAqB,CAAC;IACvC;8DAC0D;IAC1D,QAAQ,CAAC,EAAE,YAAY,GAAG,IAAI,CAAC;CAChC;AAED,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,yBAAyB,EAAE,GAAG,SAAS,GAC/C,qBAAqB,GAAG,SAAS,CAWnC;AAiCD,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,GAAG,GAAG,SAAS,CAsBtD;AAED;;;;;GAKG;AACH,eAAO,MAAM,QAAQ;oCAKmB,MAAM,WAAW,GAAG;;;;;;oCAwC1C,MAAM,eACP,MAAM,WACV,GAAG;;;;;;IAuCd;;;;;;;OAOG;iCAEc,MAAM,QACf,IAAI,iBACK,MAAM,aACV,MAAM;;iBAmE4C,MAAM,GAAG,IAAI;;aAEjD,MAAM;;CASlC,CAAC"}
1
+ {"version":3,"file":"floriday-client.d.ts","sourceRoot":"","sources":["../../src/lib/floriday-client.ts"],"names":[],"mappings":"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"}
@@ -1,5 +1,13 @@
1
1
  import { supabase } from "../integrations/supabase/client.js";
2
2
  import { publishTradeItem, updateTradeItemFn } from "./floriday-writes.functions.js";
3
+ /** Fout bij foto-upload: `message` is voor de gebruiker, `adminDetail` voor "Details voor beheer". */
4
+ export class PhotoUploadError extends Error {
5
+ constructor(message, adminDetail) {
6
+ super(message);
7
+ this.adminDetail = adminDetail;
8
+ this.name = "PhotoUploadError";
9
+ }
10
+ }
3
11
  function summarizePayload(payload) {
4
12
  if (!payload || typeof payload !== "object")
5
13
  return {};
@@ -203,9 +211,8 @@ export const floriday = {
203
211
  // Geef het bestand een naam mee — anders krijgt server "blob".
204
212
  const fname = filename ?? file.name ?? `photo-${Date.now()}.jpg`;
205
213
  form.append("file", file, fname);
206
- // Stille retry: 2× extra pogingen met exponentiële backoff bij 5xx /
207
- // netwerkfouten. Bij client-side fouten (te klein, verkeerd type) tonen
208
- // we de server-message meteen aan de gebruiker — geen retry.
214
+ // Alleen bij serverfouten (5xx) of netwerkfouten stil opnieuw proberen.
215
+ // 404, 401 of een afgekeurde foto meteen met de echte reden tonen.
209
216
  const GENERIC = "Foto kon niet worden geüpload. Probeer het later opnieuw.";
210
217
  const backoffs = [0, 300, 900];
211
218
  let lastErr = null;
@@ -213,63 +220,65 @@ export const floriday = {
213
220
  if (backoffs[attempt] > 0) {
214
221
  await new Promise((r) => setTimeout(r, backoffs[attempt]));
215
222
  }
223
+ let res;
216
224
  try {
217
- const res = await fetch("/api/trade-item-photo", {
225
+ res = await fetch("/api/trade-item-photo", {
218
226
  method: "POST",
219
227
  headers: { Authorization: `Bearer ${token}` },
220
228
  body: form,
221
229
  });
222
- const ct = res.headers.get("content-type") ?? "";
223
- const text = await res.text();
224
- let parsed = null;
225
- if (ct.toLowerCase().includes("application/json") && text) {
226
- try {
227
- parsed = JSON.parse(text);
228
- }
229
- catch {
230
- /* ignore */
231
- }
232
- }
233
- if (!res.ok) {
234
- const base = parsed?.message || parsed?.error || GENERIC;
235
- const withDetail = parsed?.detail ? `${base} (${parsed.detail})` : base;
236
- const withStatus = parsed?.gatewayStatus
237
- ? `${withDetail} [status ${parsed.gatewayStatus}]`
238
- : withDetail;
239
- lastErr = new Error(withStatus);
240
- if (res.status >= 500 && attempt < backoffs.length - 1)
241
- continue;
242
- throw lastErr;
230
+ }
231
+ catch (e) {
232
+ lastErr = new PhotoUploadError("Floriday is even niet bereikbaar. Probeer het zo opnieuw.", `netwerkfout: ${e?.message ?? String(e)}`);
233
+ continue;
234
+ }
235
+ const ct = res.headers.get("content-type") ?? "";
236
+ const text = await res.text().catch(() => "");
237
+ let parsed = null;
238
+ if (ct.toLowerCase().includes("application/json") && text) {
239
+ try {
240
+ parsed = JSON.parse(text);
243
241
  }
244
- if (parsed?.ok === false) {
245
- const base = parsed.message || GENERIC;
246
- const withDetail = parsed.detail ? `${base} (${parsed.detail})` : base;
247
- const withStatus = parsed.gatewayStatus
248
- ? `${withDetail} [status ${parsed.gatewayStatus}]`
249
- : withDetail;
250
- lastErr = new Error(withStatus);
251
- if (parsed.retryable && attempt < backoffs.length - 1)
252
- continue;
253
- throw lastErr;
242
+ catch {
243
+ /* ignore */
254
244
  }
255
- if (!parsed?.url) {
256
- lastErr = new Error(GENERIC);
257
- if (attempt < backoffs.length - 1)
258
- continue;
245
+ }
246
+ const adminDetail = [
247
+ `POST /api/trade-item-photo → HTTP ${res.status}`,
248
+ parsed?.reason ? `reden: ${parsed.reason}` : null,
249
+ parsed?.gatewayStatus ? `gateway-status: ${parsed.gatewayStatus}` : null,
250
+ parsed?.detail ? `detail: ${parsed.detail}` : null,
251
+ !parsed && text ? `antwoord: ${text.slice(0, 160)}` : null,
252
+ ]
253
+ .filter(Boolean)
254
+ .join(" · ");
255
+ if (res.status === 404 || (!parsed && res.ok)) {
256
+ throw new PhotoUploadError("Deze functie ontbreekt in de installatie. Vraag de beheerder de Shell bij te werken.", adminDetail);
257
+ }
258
+ if (res.status === 401 || res.status === 403) {
259
+ throw new PhotoUploadError(parsed?.message ?? "Je bent uitgelogd. Log opnieuw in en probeer het nog eens.", adminDetail);
260
+ }
261
+ if (res.status >= 500) {
262
+ lastErr = new PhotoUploadError(parsed?.message ?? GENERIC, adminDetail);
263
+ if (parsed?.reason === "config_missing")
259
264
  throw lastErr;
260
- }
261
- return {
262
- ok: true,
263
- imageId: (parsed.imageId ?? parsed.mediaId ?? null),
264
- mediaId: parsed.mediaId ?? parsed.imageId ?? null,
265
- url: parsed.url,
266
- };
265
+ continue;
267
266
  }
268
- catch (e) {
269
- lastErr = e instanceof Error ? e : new Error(String(e));
270
- if (attempt >= backoffs.length - 1)
271
- break;
267
+ if (!res.ok || parsed?.ok === false) {
268
+ lastErr = new PhotoUploadError(parsed?.message ?? GENERIC, adminDetail);
269
+ if (parsed?.retryable)
270
+ continue;
271
+ throw lastErr;
272
272
  }
273
+ if (!parsed?.url) {
274
+ throw new PhotoUploadError(GENERIC, adminDetail);
275
+ }
276
+ return {
277
+ ok: true,
278
+ imageId: (parsed.imageId ?? parsed.mediaId ?? null),
279
+ mediaId: parsed.mediaId ?? parsed.imageId ?? null,
280
+ url: parsed.url,
281
+ };
273
282
  }
274
283
  throw lastErr instanceof Error ? lastErr : new Error(GENERIC);
275
284
  },
@@ -60,8 +60,8 @@ export function buildReadinessReport(input) {
60
60
  label: "Minimaal één foto toegevoegd",
61
61
  ok: Array.isArray(d.images) && d.images.length > 0,
62
62
  stepKey: "fotos",
63
- blocking: false,
64
- hint: "Zonder foto is het product minder goed vindbaar.",
63
+ blocking: true,
64
+ hint: "Floriday vraagt minstens één foto. Opslaan als concept kan zonder foto.",
65
65
  });
66
66
  // Verplichte eigenschappen uit de productregels + eerder afgekeurde velden.
67
67
  const required = new Set([
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=trade-item-photo.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"trade-item-photo.test.d.ts","sourceRoot":"","sources":["../../../src/server-hooks/__tests__/trade-item-photo.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { checkImage } from "../trade-item-photo.js";
3
+ function png(w, h) {
4
+ const b = new Uint8Array(32);
5
+ b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0);
6
+ const dv = new DataView(b.buffer);
7
+ dv.setUint32(16, w);
8
+ dv.setUint32(20, h);
9
+ return b;
10
+ }
11
+ function jpg(w, h) {
12
+ return new Uint8Array([0xff, 0xd8, 0xff, 0xc0, 0x00, 0x11, 0x08, h >> 8, h & 255, w >> 8, w & 255, 3, 0, 0, 0, 0]);
13
+ }
14
+ describe("foto-controle op de server", () => {
15
+ it("accepteert jpg van 800x1200", () => expect(checkImage(jpg(800, 1200), 400000)).toBeNull());
16
+ it("weigert kleiner dan 640x640", () => expect(checkImage(png(639, 800), 1000)?.reason).toBe("image_too_small"));
17
+ it("weigert groter dan 5 MB", () => expect(checkImage(png(800, 800), 5 * 1024 * 1024 + 1)?.reason).toBe("image_too_large"));
18
+ it("weigert ander bestandstype", () => expect(checkImage(new TextEncoder().encode("GIF89a-----"), 100)?.reason).toBe("unsupported_type"));
19
+ });
@@ -0,0 +1,13 @@
1
+ type UploadReason = "image_too_small" | "image_too_large" | "unsupported_type" | "invalid_payload" | "network" | "not_logged_in" | "not_connected" | "config_missing" | "unknown";
2
+ /** Breedte/hoogte uit PNG-IHDR of JPEG-SOF-marker. */
3
+ export declare function imageSize(b: Uint8Array): {
4
+ w: number;
5
+ h: number;
6
+ } | null;
7
+ export declare function checkImage(head: Uint8Array, size: number): {
8
+ reason: UploadReason;
9
+ detail: string;
10
+ } | null;
11
+ export declare function handleTradeItemPhoto(request: Request): Promise<Response>;
12
+ export {};
13
+ //# sourceMappingURL=trade-item-photo.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"trade-item-photo.d.ts","sourceRoot":"","sources":["../../src/server-hooks/trade-item-photo.ts"],"names":[],"mappings":"AAqBA,KAAK,YAAY,GACb,iBAAiB,GACjB,iBAAiB,GACjB,kBAAkB,GAClB,iBAAiB,GACjB,SAAS,GACT,eAAe,GACf,eAAe,GACf,gBAAgB,GAChB,SAAS,CAAC;AAgFd,sDAAsD;AACtD,wBAAgB,SAAS,CAAC,CAAC,EAAE,UAAU,GAAG;IAAE,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAmBxE;AAKD,wBAAgB,UAAU,CACxB,IAAI,EAAE,UAAU,EAChB,IAAI,EAAE,MAAM,GACX;IAAE,MAAM,EAAE,YAAY,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CASjD;AAMD,wBAAsB,oBAAoB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CA+P9E"}
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Upload-handler voor artikelfoto's (losse app én Shell via flowselections.serverRoutes).
3
+ * Alleen ingelogde gebruikers van deze database (sessie-bearer); de gateway-
4
+ * sleutel blijft server-side. Server-only.
5
+ *
6
+ * Flow (§14.11):
7
+ * 1. Browser POSTet multipart/form-data met `file` en optioneel `tradeItemId`.
8
+ * 2. Wij verifiëren de Supabase-bearer en bepalen de actieve koppeling zelf.
9
+ * 3. We sturen de RAW bytes door naar /api/public/gateway/media-upload —
10
+ * géén base64-inflatie, géén formData-rebuild. HMAC zit in
11
+ * gatewayMediaUpload (server-only secret).
12
+ */
13
+ import { createClient } from "@supabase/supabase-js";
14
+ function json(body, status = 200) {
15
+ return new Response(JSON.stringify(body), {
16
+ status,
17
+ headers: { "Content-Type": "application/json" },
18
+ });
19
+ }
20
+ function messageForReason(reason) {
21
+ switch (reason) {
22
+ case "image_too_small":
23
+ return "De foto is te klein. Gebruik een foto van minstens 640 × 640 pixels.";
24
+ case "image_too_large":
25
+ return "De foto is te groot. Gebruik een foto van maximaal 5 MB.";
26
+ case "unsupported_type":
27
+ return "Dit bestandstype kan niet. Gebruik een JPG- of PNG-foto.";
28
+ case "invalid_payload":
29
+ return "Floriday accepteert deze foto niet. Probeer een andere foto.";
30
+ case "network":
31
+ return "Floriday is even niet bereikbaar. Probeer het zo opnieuw.";
32
+ case "not_logged_in":
33
+ return "Je bent uitgelogd. Log opnieuw in en probeer het nog eens.";
34
+ case "not_connected":
35
+ case "config_missing":
36
+ return "De koppeling met Floriday werkt niet. Neem contact op met Flow Selections.";
37
+ default:
38
+ return "Foto kon niet worden geüpload. Probeer het opnieuw.";
39
+ }
40
+ }
41
+ function classifyGatewayError(status, message) {
42
+ const m = message.toLowerCase();
43
+ if (m.includes("resolution") || m.includes("640") || m.includes("too small") || m.includes("dimensions")) {
44
+ return "image_too_small";
45
+ }
46
+ if (m.includes("5mb") || m.includes("too large") || m.includes("size") && m.includes("exceed")) {
47
+ return "image_too_large";
48
+ }
49
+ if (m.includes("unsupported") || m.includes("content-type") || m.includes("media type")) {
50
+ return "unsupported_type";
51
+ }
52
+ if (status && status >= 500)
53
+ return "network";
54
+ if (status && status >= 400 && status < 500)
55
+ return "invalid_payload";
56
+ return "unknown";
57
+ }
58
+ function sanitizeDetail(msg) {
59
+ // Verwijder tokens/headers/HMAC uit de error-string voordat we het naar
60
+ // de browser sturen. Alleen de eerste 240 tekens zijn nuttig voor UX.
61
+ return msg
62
+ .replace(/Bearer\s+[A-Za-z0-9._-]+/gi, "Bearer ***")
63
+ .replace(/(x-hmac[^:]*:\s*)[A-Fa-f0-9]+/gi, "$1***")
64
+ .replace(/(authorization[^:]*:\s*)[^\s,]+/gi, "$1***")
65
+ .slice(0, 240);
66
+ }
67
+ function uploadFailed(reason, extra = {}) {
68
+ // Bewust géén HTTP 502/5xx naar de browser: Lovable preview toont zulke
69
+ // route-responses als runtime error/blank screen. De client krijgt een
70
+ // typed failure terug en beslist zelf over retry + vriendelijke melding.
71
+ const retryable = reason === "network" || reason === "unknown";
72
+ return json({
73
+ ok: false,
74
+ error: "upload-mislukt",
75
+ reason,
76
+ message: messageForReason(reason),
77
+ retryable,
78
+ ...extra,
79
+ }, 200);
80
+ }
81
+ /** Herkent jpg/png aan de eerste bytes (niet aan bestandsnaam of browsertype). */
82
+ function sniffType(b) {
83
+ if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff)
84
+ return "image/jpeg";
85
+ if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47)
86
+ return "image/png";
87
+ return null;
88
+ }
89
+ /** Breedte/hoogte uit PNG-IHDR of JPEG-SOF-marker. */
90
+ export function imageSize(b) {
91
+ const t = sniffType(b);
92
+ if (t === "image/png" && b.length >= 24) {
93
+ const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
94
+ return { w: dv.getUint32(16), h: dv.getUint32(20) };
95
+ }
96
+ if (t === "image/jpeg") {
97
+ let i = 2;
98
+ while (i + 9 < b.length) {
99
+ if (b[i] !== 0xff) {
100
+ i++;
101
+ continue;
102
+ }
103
+ const m = b[i + 1];
104
+ const len = (b[i + 2] << 8) | b[i + 3];
105
+ if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) {
106
+ return { h: (b[i + 5] << 8) | b[i + 6], w: (b[i + 7] << 8) | b[i + 8] };
107
+ }
108
+ i += 2 + len;
109
+ }
110
+ }
111
+ return null;
112
+ }
113
+ const MAX_BYTES = 5 * 1024 * 1024;
114
+ const MIN_SIDE = 640;
115
+ export function checkImage(head, size) {
116
+ if (!sniffType(head))
117
+ return { reason: "unsupported_type", detail: "geen jpg/png-handtekening" };
118
+ if (size > MAX_BYTES)
119
+ return { reason: "image_too_large", detail: `${size} bytes > ${MAX_BYTES}` };
120
+ const dim = imageSize(head);
121
+ if (!dim)
122
+ return { reason: "invalid_payload", detail: "afmeting niet leesbaar" };
123
+ if (dim.w < MIN_SIDE || dim.h < MIN_SIDE) {
124
+ return { reason: "image_too_small", detail: `${dim.w}x${dim.h} < ${MIN_SIDE}x${MIN_SIDE}` };
125
+ }
126
+ return null;
127
+ }
128
+ function plainFail(reason, status, detail) {
129
+ return json({ ok: false, reason, message: messageForReason(reason), detail }, status);
130
+ }
131
+ export async function handleTradeItemPhoto(request) {
132
+ if (request.method === "OPTIONS")
133
+ return new Response(null, { status: 204 });
134
+ if (request.method !== "POST")
135
+ return json({ error: "Method not allowed" }, 405);
136
+ try {
137
+ const SUPABASE_URL = process.env.SUPABASE_URL;
138
+ const SUPABASE_PUBLISHABLE_KEY = process.env.SUPABASE_PUBLISHABLE_KEY;
139
+ if (!SUPABASE_URL || !SUPABASE_PUBLISHABLE_KEY) {
140
+ return plainFail("config_missing", 500, "SUPABASE_URL/SUPABASE_PUBLISHABLE_KEY ontbreekt op de server");
141
+ }
142
+ // --- 1. Bearer-validatie -------------------------------------------------
143
+ const authHeader = request.headers.get("authorization");
144
+ if (!authHeader)
145
+ return plainFail("not_logged_in", 401, "Unauthorized: geen bearer");
146
+ if (!authHeader.startsWith("Bearer "))
147
+ return plainFail("not_logged_in", 401, "Unauthorized: alleen Bearer-tokens");
148
+ const token = authHeader.slice("Bearer ".length).trim();
149
+ if (!token)
150
+ return plainFail("not_logged_in", 401, "Unauthorized: leeg token");
151
+ const supabase = createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, {
152
+ global: { headers: { Authorization: `Bearer ${token}` } },
153
+ auth: { storage: undefined, persistSession: false, autoRefreshToken: false },
154
+ });
155
+ const { data: claimsData, error: claimsErr } = await supabase.auth.getClaims(token);
156
+ if (claimsErr || !claimsData?.claims?.sub) {
157
+ return plainFail("not_logged_in", 401, "Unauthorized: token ongeldig");
158
+ }
159
+ // --- 2. Multipart body ---------------------------------------------------
160
+ let form;
161
+ try {
162
+ form = await request.formData();
163
+ }
164
+ catch {
165
+ return json({ error: "Bad request: multipart/form-data verwacht" }, 400);
166
+ }
167
+ // Oude clients kunnen nog een connectionId meesturen. Deze waarde is
168
+ // nooit leidend: de actieve koppeling wordt hieronder server-side
169
+ // bepaald. Zo kan een browser nooit een koppeling/account kiezen.
170
+ const clientConnectionId = String(form.get("connectionId") ?? "").trim();
171
+ const tradeItemId = form.get("tradeItemId")
172
+ ? String(form.get("tradeItemId"))
173
+ : null;
174
+ const file = form.get("file");
175
+ if (!(file instanceof Blob))
176
+ return json({ error: "file ontbreekt of geen Blob" }, 400);
177
+ if (file.size === 0)
178
+ return json({ error: "Leeg bestand" }, 400);
179
+ const filename = file.name ||
180
+ `photo-${Date.now()}.${(file.type || "image/jpeg").split("/")[1] ?? "jpg"}`;
181
+ const headBytes = new Uint8Array(await file.slice(0, 256 * 1024).arrayBuffer());
182
+ const check = checkImage(headBytes, file.size);
183
+ if (check)
184
+ return uploadFailed(check.reason, { detail: check.detail });
185
+ const contentType = sniffType(headBytes) ?? "image/jpeg";
186
+ // --- 3. Actieve koppeling + omgeving via de gedeelde bron ---------------
187
+ let environment;
188
+ try {
189
+ const { checkFloridayReadiness } = await import("../lib/floriday-readiness.server.js");
190
+ const readiness = await checkFloridayReadiness(supabase);
191
+ if (!readiness.ready || !readiness.connectionId || !readiness.environment) {
192
+ return plainFail("not_connected", 409, `FLORIDAY_NOT_READY: ${readiness.message}`);
193
+ }
194
+ if (clientConnectionId && readiness.connectionId !== clientConnectionId) {
195
+ return json({
196
+ error: "De verbinding is gewijzigd. Vernieuw de pagina en probeer opnieuw.",
197
+ }, 409);
198
+ }
199
+ environment = readiness.environment;
200
+ }
201
+ catch (e) {
202
+ const { FloridayEnvError } = await import("../lib/floriday-env.js");
203
+ if (e instanceof FloridayEnvError) {
204
+ return plainFail("not_connected", 400, `${e.code}: ${e.message}`);
205
+ }
206
+ throw e;
207
+ }
208
+ // --- 4. Raw bytes doorsturen naar de centrale gateway -------------------
209
+ const buf = new Uint8Array(await file.arrayBuffer());
210
+ const hashBuf = await crypto.subtle.digest("SHA-256", buf);
211
+ const hashHex = Array.from(new Uint8Array(hashBuf))
212
+ .map((b) => b.toString(16).padStart(2, "0"))
213
+ .join("");
214
+ const idempotencyKey = `inventory:image:${tradeItemId ?? "anon"}:${hashHex.slice(0, 16)}`;
215
+ // Floriday Suppliers API 2026v1: POST /media (operatie AddImage),
216
+ // multipart/form-data met verplicht form-veld "image".
217
+ // LET OP: de gateway plakt het pad achter https://api.[staging.]floriday.io
218
+ // ZONDER versie-prefix — die moet dus in het pad zelf zitten.
219
+ const path = "/suppliers-api-2026v1/media";
220
+ const requestTimestamp = Date.now();
221
+ const clientUserId = claimsData.claims.sub;
222
+ const reportFailure = async (info) => {
223
+ // (a) push naar Auth-module diagnostics-endpoint
224
+ try {
225
+ const { reportGatewayFailure } = await import("../lib/floriday-gateway.server.js");
226
+ await reportGatewayFailure({
227
+ module: "inventory",
228
+ action: "media-upload",
229
+ environment,
230
+ path,
231
+ referenceId: tradeItemId ?? null,
232
+ idempotencyKey,
233
+ attemptNumber: info.attemptNumber,
234
+ requestTimestamp,
235
+ requestBodyHash: hashHex,
236
+ fileMeta: {
237
+ filename,
238
+ contentType,
239
+ sizeBytes: buf.byteLength,
240
+ },
241
+ responseStatus: info.responseStatus ?? null,
242
+ responseContentType: info.responseContentType ?? null,
243
+ responseSnippet: info.responseSnippet?.slice(0, 500) ?? null,
244
+ errorMessage: info.errorMessage,
245
+ clientUserId,
246
+ });
247
+ }
248
+ catch (e) {
249
+ console.warn("[trade-item-photo] reportGatewayFailure faalde", e);
250
+ }
251
+ // (b) eigen audit-tabel (zonder snippet)
252
+ try {
253
+ const { getAdminClient } = await import("../lib/supabase-admin.server.js");
254
+ const supabaseAdmin = getAdminClient();
255
+ const { error: insErr } = await supabaseAdmin
256
+ .from("voorraad_media_upload_failures")
257
+ .insert({
258
+ environment,
259
+ path,
260
+ reference_id: tradeItemId ?? null,
261
+ idempotency_key: idempotencyKey,
262
+ attempt_number: info.attemptNumber,
263
+ request_timestamp: requestTimestamp,
264
+ request_body_hash: hashHex,
265
+ filename,
266
+ content_type: contentType,
267
+ size_bytes: buf.byteLength,
268
+ response_status: info.responseStatus ?? null,
269
+ response_content_type: info.responseContentType ?? null,
270
+ error_message: info.errorMessage,
271
+ client_user_id: clientUserId,
272
+ });
273
+ if (insErr) {
274
+ console.error("[trade-item-photo] audit-insert error", insErr.message, insErr.details);
275
+ }
276
+ }
277
+ catch (e) {
278
+ console.warn("[trade-item-photo] audit-insert faalde", e);
279
+ }
280
+ };
281
+ try {
282
+ const { gatewayMediaUpload } = await import("../lib/floriday-gateway.server.js");
283
+ const res = await gatewayMediaUpload({
284
+ caller: "voorraad",
285
+ environment: environment,
286
+ path,
287
+ method: "POST",
288
+ filename,
289
+ contentType,
290
+ bytes: buf,
291
+ formField: "image", // Floriday POST /media vereist form-veld "image"
292
+ referenceId: tradeItemId ?? undefined,
293
+ idempotencyKey,
294
+ });
295
+ // Floriday 2026v1 levert { mediaId: "<uuid>" } terug op POST /media.
296
+ const mediaId = (res.data &&
297
+ (res.data.mediaId ||
298
+ res.data.imageId ||
299
+ res.data.id)) ||
300
+ null;
301
+ const host = environment === "live"
302
+ ? "https://image.floriday.io"
303
+ : "https://image.staging.floriday.io";
304
+ // Docs: https://image.[staging.]floriday.io/{mediaId}.{ext}
305
+ const ext = contentType === "image/png" ? "png" : "jpg";
306
+ const url = mediaId
307
+ ? `${host}/${mediaId}.${ext}`
308
+ : (res.data && (res.data.url || res.data.imageUrl)) || null;
309
+ if (!mediaId || !url) {
310
+ const snippet = JSON.stringify(res.data ?? {}).slice(0, 500);
311
+ console.error("[trade-item-photo] geen mediaId", { status: res.status, snippet, filename, contentType, size: buf.byteLength });
312
+ await reportFailure({
313
+ attemptNumber: 1,
314
+ responseStatus: res.status ?? null,
315
+ responseContentType: "application/json",
316
+ responseSnippet: snippet,
317
+ errorMessage: "Gateway leverde geen mediaId terug",
318
+ });
319
+ return uploadFailed("invalid_payload", {
320
+ attemptId: res.attemptId ?? null,
321
+ gatewayStatus: res.status ?? null,
322
+ detail: sanitizeDetail(snippet),
323
+ });
324
+ }
325
+ return json({ ok: true, url, imageId: mediaId, mediaId, attemptId: res.attemptId });
326
+ }
327
+ catch (e) {
328
+ const msg = e instanceof Error ? e.message : String(e);
329
+ // Probeer status/content-type uit de error-tekst te halen (gateway
330
+ // gooit messages als "gateway media-upload → niet-JSON antwoord
331
+ // (status=502)").
332
+ const statusMatch = /status=(\d{3})/.exec(msg);
333
+ const responseStatus = statusMatch ? Number(statusMatch[1]) : null;
334
+ const reason = classifyGatewayError(responseStatus, msg);
335
+ console.error("[trade-item-photo] gateway-fout", { reason, responseStatus, msg, filename, contentType, size: buf.byteLength, tradeItemId });
336
+ await reportFailure({
337
+ attemptNumber: 1,
338
+ responseStatus,
339
+ responseContentType: null,
340
+ responseSnippet: msg.slice(0, 500),
341
+ errorMessage: msg,
342
+ });
343
+ return uploadFailed(reason, {
344
+ gatewayStatus: responseStatus,
345
+ detail: sanitizeDetail(msg),
346
+ });
347
+ }
348
+ }
349
+ catch (outer) {
350
+ const msg = outer instanceof Error ? outer.message : String(outer);
351
+ console.error("[trade-item-photo] unhandled", msg, outer?.stack);
352
+ return plainFail("unknown", 500, sanitizeDetail(`Interne fout: ${msg}`));
353
+ }
354
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowselections/floriday-voorraad",
3
- "version": "1.0.82",
3
+ "version": "1.0.84",
4
4
  "sideEffects": false,
5
5
  "type": "module",
6
6
  "main": "./dist-lib/index.js",
@@ -158,6 +158,15 @@
158
158
  "POST",
159
159
  "OPTIONS"
160
160
  ]
161
+ },
162
+ {
163
+ "routePath": "/api/trade-item-photo",
164
+ "import": "@flowselections/floriday-voorraad/hooks",
165
+ "handler": "handleTradeItemPhoto",
166
+ "methods": [
167
+ "POST",
168
+ "OPTIONS"
169
+ ]
161
170
  }
162
171
  ]
163
172
  }