react-shopwave-connect 0.1.2 → 0.3.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.
@@ -1,86 +1,199 @@
1
+ //#region src/core/errors.ts
2
+ /**
3
+ * Thrown by every `core` function when a request fails, either at the HTTP
4
+ * level (`status` is the HTTP status) or because the Shopwave response carried
5
+ * `api.message.errors` (then `status` is the HTTP status of that response,
6
+ * usually 200/201, and `errors` holds the API errors).
7
+ *
8
+ * Branch on `status` (e.g. `isUnauthorized` → send the user to log in) rather
9
+ * than parsing `message`.
10
+ */
11
+ var ShopwaveApiError = class extends Error {
12
+ /** HTTP status of the response (0 when the request never got a response). */
13
+ status;
14
+ /** Shopwave API errors, when the response carried any. */
15
+ errors;
16
+ /** Parsed response body, when there was one. */
17
+ body;
18
+ constructor(status, message, details = {}) {
19
+ super(message, details.cause !== void 0 ? { cause: details.cause } : void 0);
20
+ this.name = "ShopwaveApiError";
21
+ this.status = status;
22
+ this.errors = details.errors ?? null;
23
+ this.body = details.body;
24
+ }
25
+ /** 401, or the Shopwave "token expired / invalid" error 908. */
26
+ get isUnauthorized() {
27
+ if (this.status === 401) return true;
28
+ return !!this.errors && Object.entries(this.errors).some(([k, v]) => k === "908" || Number(v?.id) === 908);
29
+ }
30
+ get isNotFound() {
31
+ return this.status === 404;
32
+ }
33
+ };
34
+ /** The `api.message.errors` (or `api.message.error`) map of a Shopwave response, or `null` when there are none. */
35
+ function getApiErrorMap(body) {
36
+ const message = body?.api?.message;
37
+ const errors = message?.errors ?? message?.error;
38
+ if (errors && typeof errors === "object" && Object.keys(errors).length > 0) return errors;
39
+ return null;
40
+ }
41
+ function describeErrors(errors) {
42
+ return Object.entries(errors).map(([code, e]) => {
43
+ const text = [e?.title, e?.details].filter(Boolean).join(": ");
44
+ return text ? `${code} ${text}` : code;
45
+ }).join("; ");
46
+ }
47
+ /**
48
+ * Builds a {@link ShopwaveApiError} for a response. Understands the Shopwave
49
+ * envelope (`api.message.errors`) and the `{ error, message }` bodies returned
50
+ * by the `react-shopwave-connect/next` routes.
51
+ */
52
+ function toShopwaveApiError(status, body, fallback) {
53
+ const errors = getApiErrorMap(body);
54
+ let detail = errors ? describeErrors(errors) : "";
55
+ if (!detail && body && typeof body === "object") {
56
+ const b = body;
57
+ if (typeof b.message === "string") detail = b.message;
58
+ else if (typeof b.error === "string") detail = b.error;
59
+ }
60
+ if (!detail) detail = fallback ?? (status >= 400 ? "request failed" : "API returned errors");
61
+ return new ShopwaveApiError(status, `Shopwave API error (${status}): ${detail}`, {
62
+ errors,
63
+ body
64
+ });
65
+ }
66
+ /** Throws a {@link ShopwaveApiError} when the response body carries API errors. */
67
+ function assertNoApiErrors(body, status) {
68
+ if (getApiErrorMap(body)) throw toShopwaveApiError(status, body);
69
+ }
70
+
71
+ //#endregion
1
72
  //#region src/core/request.ts
2
73
  function resolveFetch(options) {
3
74
  const f = options.fetch ?? (typeof fetch !== "undefined" ? fetch : void 0);
4
75
  if (!f) throw new Error("No fetch implementation available. Pass `options.fetch` (e.g. node-fetch / undici) when running outside the browser.");
5
76
  return f;
6
77
  }
7
- function buildExtras(extras, options) {
78
+ /** Options with `token` filled in from a legacy per-call `params.token`, unless `options.token` is set. */
79
+ function withToken(options, token) {
80
+ return token && !options.token ? {
81
+ ...options,
82
+ token
83
+ } : options;
84
+ }
85
+ /** `Authorization` header value for a token (`OAuth <token>` unless it already has a scheme). */
86
+ function authorizationFor(token) {
87
+ const t = token.trim();
88
+ return /^(oauth|bearer)\s+\S/i.test(t) ? t : `OAuth ${t}`;
89
+ }
90
+ /** Headers shared by every request: Accept + the token, when one is given. */
91
+ function baseHeaders(options) {
92
+ const headers = { Accept: "application/json" };
93
+ if (options.token) headers.Authorization = authorizationFor(options.token);
94
+ return headers;
95
+ }
96
+ function buildExtras(extras) {
8
97
  const merged = {
9
98
  "Content-Type": "application/json",
10
99
  ...extras
11
100
  };
12
- if (options.token != null && merged.token == null) merged.token = options.token;
101
+ delete merged.token;
13
102
  return merged;
14
103
  }
104
+ /** Parses a response body as JSON; `null` for an empty body, `{ raw }` for non-JSON. */
105
+ async function readBody(response) {
106
+ const text = await response.text();
107
+ if (!text) return null;
108
+ try {
109
+ return JSON.parse(text);
110
+ } catch {
111
+ return { raw: text };
112
+ }
113
+ }
114
+ /**
115
+ * Low-level request: resolves `{ status, body }` for 2xx responses and throws
116
+ * {@link ShopwaveApiError} otherwise. `init.headers` are merged over the
117
+ * standard ones (Accept + Authorization).
118
+ */
119
+ async function apiRequest(path, init, options = {}) {
120
+ return send(path, {
121
+ ...init,
122
+ headers: {
123
+ ...baseHeaders(options),
124
+ ...init.headers
125
+ }
126
+ }, options);
127
+ }
128
+ async function send(path, init, options) {
129
+ const doFetch = resolveFetch(options);
130
+ const url = (options.baseUrl ?? "") + path;
131
+ let response;
132
+ try {
133
+ response = await doFetch(url, {
134
+ ...init,
135
+ signal: options.signal
136
+ });
137
+ } catch (error) {
138
+ if (error?.name === "AbortError") throw error;
139
+ throw new ShopwaveApiError(0, `Shopwave API request failed: ${error?.message ?? error}`, { cause: error });
140
+ }
141
+ const body = await readBody(response);
142
+ if (!response.ok) throw toShopwaveApiError(response.status, body, response.statusText || void 0);
143
+ return {
144
+ status: response.status,
145
+ body
146
+ };
147
+ }
15
148
  /**
16
149
  * Returns a JSON string of API errors if the response carries any, otherwise
17
- * `null`. Mirrors the original `!responseJson.api.message.errors` checks.
150
+ * `null`. Kept for backwards compatibility; prefer `ShopwaveApiError`.
18
151
  */
19
152
  function getApiErrors(api) {
20
- const errors = api?.message?.errors;
21
- if (errors && Object.keys(errors).length > 0) return JSON.stringify(errors);
22
- return null;
153
+ const errors = getApiErrorMap({ api });
154
+ return errors ? JSON.stringify(errors) : null;
23
155
  }
24
156
  /**
25
- * GET helper. Sends request metadata via the `extras` header exactly like the
26
- * original hooks did, parses JSON, and returns the typed payload.
157
+ * GET helper. Sends request metadata via the `extras` header, parses JSON and
158
+ * returns the payload. Throws {@link ShopwaveApiError} for non-2xx responses.
27
159
  */
28
160
  async function apiGet(path, extras, options = {}) {
29
- const doFetch = resolveFetch(options);
30
- const url = (options.baseUrl ?? "") + path;
31
- const headers = { extras: JSON.stringify(buildExtras(extras, options)) };
32
- console.log("[API GET]", {
33
- url,
34
- headers
35
- });
36
- const response = await doFetch(url, {
161
+ const { body } = await send(path, {
37
162
  method: "GET",
38
- headers,
39
- signal: options.signal
40
- });
41
- if (!response.ok) throw new Error(`Network response was not ok (${response.status})`);
42
- return await response.json();
163
+ headers: {
164
+ ...baseHeaders(options),
165
+ extras: JSON.stringify(buildExtras(extras))
166
+ }
167
+ }, options);
168
+ return body;
43
169
  }
44
170
  /**
45
- * JSON body helper for POST/PUT mutations.
171
+ * JSON body helper for POST/PUT mutations. Returns the parsed body (`null` for
172
+ * an empty one). Throws {@link ShopwaveApiError} for non-2xx responses.
46
173
  */
47
174
  async function apiSend(path, method, payload, options = {}) {
48
- const doFetch = resolveFetch(options);
49
- const url = (options.baseUrl ?? "") + path;
50
- const headers = { "Content-Type": "application/json" };
51
- if (options.token != null) headers.token = options.token;
52
- console.log(`[API ${method}]`, {
53
- url,
54
- headers,
55
- payload
56
- });
57
- const response = await doFetch(url, {
175
+ const { body } = await send(path, {
58
176
  method,
59
- headers,
60
- body: JSON.stringify(payload),
61
- signal: options.signal
62
- });
63
- if (!response.ok) throw new Error(`Failed to ${method.toLowerCase()}: ${response.statusText}`);
64
- return await response.json();
177
+ headers: {
178
+ ...baseHeaders(options),
179
+ "Content-Type": "application/json"
180
+ },
181
+ body: JSON.stringify(payload)
182
+ }, options);
183
+ return body;
65
184
  }
66
185
  /**
67
- * DELETE helper.
186
+ * DELETE helper. Shopwave answers deletes with 205 and an empty body; if a body
187
+ * does come back and carries API errors, they are thrown. Returns the parsed
188
+ * body (`null` when empty).
68
189
  */
69
190
  async function apiDelete(path, options = {}) {
70
- const doFetch = resolveFetch(options);
71
- const url = (options.baseUrl ?? "") + path;
72
- const headers = {};
73
- if (options.token != null) headers.token = options.token;
74
- console.log("[API DELETE]", {
75
- url,
76
- headers
77
- });
78
- const response = await doFetch(url, {
191
+ const { status, body } = await send(path, {
79
192
  method: "DELETE",
80
- headers,
81
- signal: options.signal
82
- });
83
- if (!response.ok) throw new Error(`Failed to delete: ${response.statusText}`);
193
+ headers: baseHeaders(options)
194
+ }, options);
195
+ if (getApiErrorMap(body)) throw toShopwaveApiError(status, body);
196
+ return body;
84
197
  }
85
198
 
86
199
  //#endregion
@@ -92,11 +205,9 @@ async function apiDelete(path, options = {}) {
92
205
  async function fetchCategories(params = {}, options = {}) {
93
206
  const extras = { deleted: params.deleted ?? false };
94
207
  if (params.categoryIds) extras.categoryIds = params.categoryIds;
95
- if (params.token) extras.token = params.token;
96
- const json = await apiGet("/api/categories", extras, options);
97
- const error = getApiErrors(json.api);
98
- if (error) throw new Error(error);
99
- return Object.values(json.categories ?? {});
208
+ const json = await apiGet("/api/categories", extras, withToken(options, params.token));
209
+ assertNoApiErrors(json, 200);
210
+ return Object.values(json?.categories ?? {});
100
211
  }
101
212
 
102
213
  //#endregion
@@ -108,9 +219,8 @@ async function fetchCategories(params = {}, options = {}) {
108
219
  async function fetchConsumers(consumerIds, options = {}) {
109
220
  if (!consumerIds || consumerIds.length === 0) return [];
110
221
  const json = await apiGet("/api/consumer", { ids: consumerIds.join(",") }, options);
111
- const error = getApiErrors(json.api);
112
- if (error) throw new Error(error);
113
- return Object.values(json.consumers ?? {});
222
+ assertNoApiErrors(json, 200);
223
+ return Object.values(json?.consumers ?? {});
114
224
  }
115
225
 
116
226
  //#endregion
@@ -128,10 +238,9 @@ const rolesById = {
128
238
  async function fetchEmployees(params = {}, options = {}) {
129
239
  const extras = { deleted: params.deleted ?? false };
130
240
  if (params.employeeIds) extras.employeeIds = params.employeeIds;
131
- const json = await apiGet("/api/employees", extras, options);
132
- const error = getApiErrors(json.api);
133
- if (error) throw new Error(error);
134
- return Object.values(json.employees ?? {});
241
+ const json = await apiGet("/api/employees", extras, withToken(options, params.token));
242
+ assertNoApiErrors(json, 200);
243
+ return Object.values(json?.employees ?? {});
135
244
  }
136
245
 
137
246
  //#endregion
@@ -143,11 +252,9 @@ async function fetchProducts(params = {}, options = {}) {
143
252
  const extras = { deleted: params.deleted ?? false };
144
253
  if (params.productIds) extras.productIds = params.productIds;
145
254
  if (params.storeId) extras.storeId = params.storeId;
146
- if (params.token) extras.token = params.token;
147
- const json = await apiGet("/api/products", extras, options);
148
- const error = getApiErrors(json.api);
149
- if (error) throw new Error(error);
150
- return Object.values(json.products ?? {});
255
+ const json = await apiGet("/api/products", extras, withToken(options, params.token));
256
+ assertNoApiErrors(json, 200);
257
+ return Object.values(json?.products ?? {});
151
258
  }
152
259
  /**
153
260
  * Fetches products by id and returns them keyed by id, transparently batching
@@ -183,11 +290,9 @@ async function fetchPromotions(params = {}, options = {}) {
183
290
  if (params.imminent !== void 0) extras.imminent = params.imminent;
184
291
  if (params.active !== void 0) extras.active = params.active;
185
292
  if (params.expired !== void 0) extras.expired = params.expired;
186
- if (params.token) extras.token = params.token;
187
- const json = await apiGet("/api/promotions", extras, options);
188
- const error = getApiErrors(json.api);
189
- if (error) throw new Error(error);
190
- return Object.values(json.promotions ?? {});
293
+ const json = await apiGet("/api/promotions", extras, withToken(options, params.token));
294
+ assertNoApiErrors(json, 200);
295
+ return Object.values(json?.promotions ?? {});
191
296
  }
192
297
 
193
298
  //#endregion
@@ -196,10 +301,11 @@ async function fetchPromotions(params = {}, options = {}) {
196
301
  * Fetches stores. Returns the flattened `Store[]`.
197
302
  */
198
303
  async function fetchStores(params = {}, options = {}) {
199
- const json = await apiGet("/api/stores", { deleted: params.deleted ?? false }, options);
200
- const error = getApiErrors(json.api);
201
- if (error) throw new Error(error);
202
- return Object.values(json.stores ?? {});
304
+ const extras = { deleted: params.deleted ?? false };
305
+ if (params.storeIds) extras.storeIds = params.storeIds;
306
+ const json = await apiGet("/api/stores", extras, withToken(options, params.token));
307
+ assertNoApiErrors(json, 200);
308
+ return Object.values(json?.stores ?? {});
203
309
  }
204
310
 
205
311
  //#endregion
@@ -211,20 +317,54 @@ async function fetchStores(params = {}, options = {}) {
211
317
  */
212
318
  async function fetchReport(query, options = {}) {
213
319
  const json = await apiGet("/api/report", { query: JSON.stringify(query) }, options);
214
- const error = getApiErrors(json.api);
215
- if (error) throw new Error(error);
216
- return json.reports ?? {};
320
+ assertNoApiErrors(json, 200);
321
+ return json?.reports ?? {};
217
322
  }
218
323
 
219
324
  //#endregion
220
325
  //#region src/core/session.ts
221
326
  /**
222
- * Ends the current server session.
327
+ * Reads the current login status from `GET /api/session`.
223
328
  *
224
- * This is the framework-agnostic half of the original `useLogout` hook. The
225
- * Next.js-specific redirect logic (router.push to the auth server logout URL,
226
- * built from server actions) is intentionally NOT included here because it is
227
- * tied to `next/navigation` and `'use server'` actions — see the README.
329
+ * Works with the new `react-shopwave-connect/next` session route
330
+ * (`{ loggedIn }`) and with older routes that returned the raw session
331
+ * (`{ token: {...} }`) — in that case only a boolean is surfaced.
332
+ */
333
+ async function fetchSession(options = {}) {
334
+ const doFetch = options.fetch ?? (typeof fetch !== "undefined" ? fetch : void 0);
335
+ if (!doFetch) throw new Error("No fetch implementation available. Pass `options.fetch`.");
336
+ const response = await doFetch(`${options.baseUrl ?? ""}/api/session`, {
337
+ method: "GET",
338
+ headers: { Accept: "application/json" },
339
+ cache: "no-store",
340
+ credentials: "same-origin",
341
+ signal: options.signal
342
+ });
343
+ if (response.status === 401) return { loggedIn: false };
344
+ if (!response.ok) throw new Error(`Session request failed (${response.status})`);
345
+ const json = await response.json();
346
+ if (json && typeof json.loggedIn === "boolean") return {
347
+ loggedIn: json.loggedIn,
348
+ expiresAt: typeof json.expiresAt === "number" ? json.expiresAt : void 0
349
+ };
350
+ return { loggedIn: Boolean(json && json.token) };
351
+ }
352
+ /**
353
+ * Path that starts a login and returns to `returnTo` afterwards.
354
+ * Navigate the browser to it (`window.location.href = loginPath(...)`).
355
+ */
356
+ function loginPath(returnTo, authPath = "/auth") {
357
+ return returnTo ? `${authPath}?returnTo=${encodeURIComponent(returnTo)}` : authPath;
358
+ }
359
+ /** Path that logs out of the app and the Shopwave auth server. */
360
+ function logoutPath(authPath = "/auth") {
361
+ return `${authPath}/logout`;
362
+ }
363
+ /**
364
+ * Ends the current server session (no redirect).
365
+ *
366
+ * Prefer navigating to {@link logoutPath} when using
367
+ * `react-shopwave-connect/next`: it also signs the user out of the auth server.
228
368
  */
229
369
  async function logout(options = {}) {
230
370
  await apiDelete("/api/session?action=logout", options);
@@ -233,10 +373,12 @@ async function logout(options = {}) {
233
373
  //#endregion
234
374
  //#region src/core/entity.ts
235
375
  /**
236
- * Generic CRUD-style mutations extracted from `useHandleDelete` /
237
- * `useHandleSubmit`. The original hooks also surfaced notistack snackbars; that
238
- * UI concern is deliberately left to the hooks/host layer so core stays
239
- * dependency-free.
376
+ * Low-level, untyped mutations: the caller supplies the route and payload.
377
+ *
378
+ * Prefer the typed functions in `./entities` (`saveProduct`, `deleteCategory`,
379
+ * …): they know each route, build the request envelope, check the response and
380
+ * return the saved entity with its id. Keep these for routes that have no typed
381
+ * function yet (e.g. `/api/merchant`).
240
382
  */
241
383
  /**
242
384
  * Deletes `/api/{resourcePath}/{entityId}`.
@@ -246,13 +388,182 @@ async function deleteEntity(resourcePath, entityId, options = {}) {
246
388
  await apiDelete(`/api/${resourcePath}/${entityId}`, options);
247
389
  }
248
390
  /**
249
- * Sends a create/update mutation and returns the parsed `result` field
250
- * (matching the original `result.result` access).
391
+ * Sends a create/update mutation. Returns the response's `result` field when it
392
+ * has one (older app routes wrapped their answer that way), otherwise the whole
393
+ * parsed response body. Throws `ShopwaveApiError` on HTTP or API errors.
251
394
  */
252
395
  async function submitEntity({ endpoint, method = "POST", payload }, options = {}) {
253
- return (await apiSend(endpoint, method, payload, options)).result;
396
+ const json = await apiSend(endpoint, method, payload, options);
397
+ assertNoApiErrors(json, 200);
398
+ if (json && typeof json === "object" && "result" in json) return json.result;
399
+ return json ?? void 0;
254
400
  }
255
401
 
402
+ //#endregion
403
+ //#region src/core/entities.ts
404
+ /**
405
+ * Addressing per entity, following the Shopwave API reference
406
+ * (https://developer.merchantstack.com/api-reference.html) and checked against
407
+ * the live API where noted:
408
+ * - category/product/store: GET filtered by `<x>Ids`, POST upsert, DELETE with `<x>Id` → 205.
409
+ * - promotion: no DELETE; "deleting" a promotion ends it by setting `endDate` to now.
410
+ * - employee: POST updates `roleId`, `joinedDate`, `exitDate` (names can't be
411
+ * changed); no DELETE (live API: "Cannot DELETE /employee") → retired via `exitDate`.
412
+ * - consumer: read-only (`GET` with `ids`, comma-separated); live POST → 404.
413
+ */
414
+ const SHOPWAVE_ENTITIES = Object.freeze({
415
+ product: {
416
+ route: "products",
417
+ collection: "products",
418
+ upstream: "product",
419
+ idsHeader: "productIds",
420
+ idHeader: "productId",
421
+ writable: true,
422
+ deleteMode: "delete"
423
+ },
424
+ category: {
425
+ route: "categories",
426
+ collection: "categories",
427
+ upstream: "category",
428
+ idsHeader: "categoryIds",
429
+ idHeader: "categoryId",
430
+ writable: true,
431
+ deleteMode: "delete"
432
+ },
433
+ store: {
434
+ route: "stores",
435
+ collection: "stores",
436
+ upstream: "store",
437
+ idsHeader: "storeIds",
438
+ idHeader: "storeId",
439
+ writable: true,
440
+ deleteMode: "delete"
441
+ },
442
+ promotion: {
443
+ route: "promotions",
444
+ collection: "promotions",
445
+ upstream: "promotion",
446
+ idsHeader: "promotionIds",
447
+ idHeader: "promotionId",
448
+ writable: true,
449
+ deleteMode: "retire",
450
+ retireField: "endDate"
451
+ },
452
+ employee: {
453
+ route: "employees",
454
+ collection: "employees",
455
+ upstream: "employee",
456
+ idsHeader: "employeeIds",
457
+ idHeader: "employeeId",
458
+ writable: true,
459
+ deleteMode: "retire",
460
+ retireField: "exitDate"
461
+ },
462
+ consumer: {
463
+ route: "consumer",
464
+ collection: "consumers",
465
+ upstream: "consumer",
466
+ idsHeader: "ids",
467
+ idHeader: "consumerId",
468
+ writable: false,
469
+ deleteMode: "none"
470
+ }
471
+ });
472
+ function definition(kind) {
473
+ const def = SHOPWAVE_ENTITIES[kind];
474
+ if (!def) throw new Error(`Unknown Shopwave entity "${kind}"`);
475
+ return def;
476
+ }
477
+ function assertId(kind, id) {
478
+ const s = id == null ? "" : String(id).trim();
479
+ if (!s) throw new Error(`${kind} id is missing`);
480
+ return encodeURIComponent(s);
481
+ }
482
+ /**
483
+ * Creates or updates several entities of one kind in a single request and
484
+ * returns them as saved (with their ids), in the order given.
485
+ *
486
+ * The request is `{ <collection>: { "0": item0, "1": item1, … } }`. Shopwave
487
+ * answers 201 and echoes every entity under the ref it was sent with, so each
488
+ * result is matched back by ref. Throws {@link ShopwaveApiError} when the
489
+ * response carries `api.message.errors` or any ref is missing from it.
490
+ *
491
+ * The API's partial-success semantics for multi-entity saves haven't been
492
+ * confirmed yet; single saves (`saveProduct` etc.) are the tested path.
493
+ */
494
+ async function saveEntities(kind, items, options = {}) {
495
+ const def = definition(kind);
496
+ if (items.length === 0) return [];
497
+ const refs = items.map((_, i) => String(i));
498
+ const payload = { [def.collection]: Object.fromEntries(refs.map((ref, i) => [ref, items[i]])) };
499
+ const { status, body: raw } = await apiRequest(`/api/${def.route}`, {
500
+ method: "POST",
501
+ headers: { "Content-Type": "application/json" },
502
+ body: JSON.stringify(payload)
503
+ }, options);
504
+ const body = raw;
505
+ assertNoApiErrors(body, status);
506
+ const echoed = body?.[def.collection] ?? {};
507
+ return refs.map((ref, i) => {
508
+ const saved = echoed[ref];
509
+ if (!saved || saved.id == null) throw new ShopwaveApiError(status, `Shopwave API error (${status}): the saved ${kind} (ref "${ref}") was not returned`, { body });
510
+ return {
511
+ ...items[i],
512
+ ...saved,
513
+ id: Number(saved.id)
514
+ };
515
+ });
516
+ }
517
+ /** Creates (no `id`) or updates (with `id`) one entity and returns it with its id. */
518
+ async function saveEntity(kind, item, options = {}) {
519
+ const [saved] = await saveEntities(kind, [item], options);
520
+ return saved;
521
+ }
522
+ /**
523
+ * Deletes one entity (a soft delete: it stays readable with `deleted: true`).
524
+ * Employees and promotions can't be deleted in Shopwave; for them the route
525
+ * sets `exitDate` / `endDate` to now instead (see `SHOPWAVE_ENTITIES`).
526
+ *
527
+ * Shopwave answers 205 with an empty body — also for ids that don't exist — so
528
+ * a resolved promise means "the API accepted the request", not "a record was
529
+ * deleted".
530
+ */
531
+ async function deleteEntityById(kind, id, options = {}) {
532
+ await apiDelete(`/api/${definition(kind).route}/${assertId(kind, id)}`, options);
533
+ }
534
+ /** Reads one entity by id, or `null` when there's no such (non-deleted) record. */
535
+ async function fetchEntityById(kind, id, params = {}, options = {}) {
536
+ const def = definition(kind);
537
+ const body = await apiGet(`/api/${def.route}/${assertId(kind, id)}`, { deleted: params.deleted ?? false }, options);
538
+ assertNoApiErrors(body, 200);
539
+ const map = body?.[def.collection] ?? {};
540
+ return map[String(id)] ?? Object.values(map).find((e) => String(e?.id) === String(id)) ?? null;
541
+ }
542
+ const saveProduct = (product, options) => saveEntity("product", product, options);
543
+ const deleteProduct = (id, options) => deleteEntityById("product", id, options);
544
+ const fetchProduct = (id, params, options) => fetchEntityById("product", id, params, options);
545
+ const saveCategory = (category, options) => saveEntity("category", category, options);
546
+ const deleteCategory = (id, options) => deleteEntityById("category", id, options);
547
+ const fetchCategory = (id, params, options) => fetchEntityById("category", id, params, options);
548
+ const saveStore = (store, options) => saveEntity("store", store, options);
549
+ const deleteStore = (id, options) => deleteEntityById("store", id, options);
550
+ const fetchStore = (id, params, options) => fetchEntityById("store", id, params, options);
551
+ const savePromotion = (promotion, options) => saveEntity("promotion", promotion, options);
552
+ /** Ends a promotion: Shopwave has no promotion DELETE, so this sets `endDate` to now. */
553
+ const deletePromotion = (id, options) => deleteEntityById("promotion", id, options);
554
+ const fetchPromotion = (id, params, options) => fetchEntityById("promotion", id, params, options);
555
+ /**
556
+ * Creates an employee, or updates one (with `id`). On update Shopwave only
557
+ * applies `roleId`, `joinedDate` and `exitDate` (and store roles); names and
558
+ * email can't be changed, and the echo only carries the updated fields — re-read
559
+ * with `fetchEmployee` if you need the stored record.
560
+ */
561
+ const saveEmployee = (employee, options) => saveEntity("employee", employee, options);
562
+ /** Retires an employee: Shopwave has no employee DELETE, so this sets `exitDate` to now. */
563
+ const deleteEmployee = (id, options) => deleteEntityById("employee", id, options);
564
+ const fetchEmployee = (id, params, options) => fetchEntityById("employee", id, params, options);
565
+ const fetchConsumer = (id, params, options) => fetchEntityById("consumer", id, params, options);
566
+
256
567
  //#endregion
257
568
  //#region src/core/basket.ts
258
569
  const DEFAULT_BASKET_FILTERS = {
@@ -398,5 +709,5 @@ function buildConsumerMap(consumers) {
398
709
  }
399
710
 
400
711
  //#endregion
401
- export { DEFAULT_BASKET_FILTERS, DEFAULT_PAGINATION, PromotionRuleSet, apiDelete, apiGet, apiSend, buildBasketReportQuery, buildConsumerMap, collectConsumerIds, collectProductIds, combineBasketRows, computeBasketSummary, deleteEntity, fetchCategories, fetchConsumers, fetchEmployees, fetchProducts, fetchProductsMap, fetchPromotions, fetchReport, fetchStores, formatDateForQuery, getApiErrors, logout, parseBasketReportData, rolesById, submitEntity };
712
+ export { DEFAULT_BASKET_FILTERS, DEFAULT_PAGINATION, PromotionRuleSet, SHOPWAVE_ENTITIES, ShopwaveApiError, apiDelete, apiGet, apiRequest, apiSend, assertNoApiErrors, authorizationFor, buildBasketReportQuery, buildConsumerMap, collectConsumerIds, collectProductIds, combineBasketRows, computeBasketSummary, deleteCategory, deleteEmployee, deleteEntity, deleteEntityById, deleteProduct, deletePromotion, deleteStore, fetchCategories, fetchCategory, fetchConsumer, fetchConsumers, fetchEmployee, fetchEmployees, fetchEntityById, fetchProduct, fetchProducts, fetchProductsMap, fetchPromotion, fetchPromotions, fetchReport, fetchSession, fetchStore, fetchStores, formatDateForQuery, getApiErrorMap, getApiErrors, loginPath, logout, logoutPath, parseBasketReportData, rolesById, saveCategory, saveEmployee, saveEntities, saveEntity, saveProduct, savePromotion, saveStore, submitEntity, toShopwaveApiError, withToken };
402
713
  //# sourceMappingURL=index.js.map