react-shopwave-connect 0.4.1 → 0.4.2

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.
@@ -106,13 +106,25 @@ interface EntityDefinition {
106
106
  /**
107
107
  * How a delete is done upstream:
108
108
  * - `"delete"`: `DELETE {apiUrl}/<upstream>` with the `idHeader` (205, empty body)
109
- * - `"retire"`: the API has no DELETE; the record is retired by POSTing
110
- * `{ id, [retireField]: <now> }` (employees: `exitDate`, promotions: `endDate`)
109
+ * - `"retire"`: the API has no DELETE; the record is retired by setting
110
+ * `retireField` to now (employees: `exitDate`, promotions: `endDate`)
111
111
  * - `"none"`: can't be deleted
112
112
  */
113
113
  deleteMode: "delete" | "retire" | "none";
114
114
  /** Date field set to "now" when `deleteMode` is `"retire"`. */
115
115
  retireField?: string;
116
+ /**
117
+ * Retire by reading the record and POSTing all of it back with the date(s)
118
+ * changed, instead of a partial `{ id, [retireField]: <now> }`. Needed where
119
+ * the API rejects a partial save (promotions: error 899, live 14 Oct 2026).
120
+ */
121
+ retireWithRecord?: boolean;
122
+ /**
123
+ * Start-date field of a record retired with `retireWithRecord`. If the record
124
+ * hasn't started yet (start after now), the start moves to now as well, so
125
+ * start = end and the record is expired without ever running.
126
+ */
127
+ retireStartField?: string;
116
128
  }
117
129
  type EntityKind = "product" | "category" | "store" | "promotion" | "employee" | "consumer";
118
130
  /**
@@ -120,7 +132,10 @@ type EntityKind = "product" | "category" | "store" | "promotion" | "employee" |
120
132
  * (https://developer.merchantstack.com/api-reference.html) and checked against
121
133
  * the live API where noted:
122
134
  * - category/product/store: GET filtered by `<x>Ids`, POST upsert, DELETE with `<x>Id` → 205.
123
- * - promotion: no DELETE; "deleting" a promotion ends it by setting `endDate` to now.
135
+ * - promotion: no DELETE; promotions are never deleted, only expired. Expiring
136
+ * reads the promotion and saves the whole record back with `endDate` = now (and
137
+ * `startDate` = now too if it hadn't started); the live API rejects a partial
138
+ * `{ id, endDate }` save with error 899. Dates are UTC `YYYY-MM-DD HH:mm:ss`.
124
139
  * - employee: POST updates `roleId`, `joinedDate`, `exitDate` (names can't be
125
140
  * changed); no DELETE (live API: "Cannot DELETE /employee") → retired via `exitDate`.
126
141
  * - consumer: read-only (`GET` with `ids`, comma-separated); live POST → 404.
@@ -255,7 +255,10 @@ function safeEqual(a, b) {
255
255
  * (https://developer.merchantstack.com/api-reference.html) and checked against
256
256
  * the live API where noted:
257
257
  * - category/product/store: GET filtered by `<x>Ids`, POST upsert, DELETE with `<x>Id` → 205.
258
- * - promotion: no DELETE; "deleting" a promotion ends it by setting `endDate` to now.
258
+ * - promotion: no DELETE; promotions are never deleted, only expired. Expiring
259
+ * reads the promotion and saves the whole record back with `endDate` = now (and
260
+ * `startDate` = now too if it hadn't started); the live API rejects a partial
261
+ * `{ id, endDate }` save with error 899. Dates are UTC `YYYY-MM-DD HH:mm:ss`.
259
262
  * - employee: POST updates `roleId`, `joinedDate`, `exitDate` (names can't be
260
263
  * changed); no DELETE (live API: "Cannot DELETE /employee") → retired via `exitDate`.
261
264
  * - consumer: read-only (`GET` with `ids`, comma-separated); live POST → 404.
@@ -296,7 +299,9 @@ const SHOPWAVE_ENTITIES = Object.freeze({
296
299
  idHeader: "promotionId",
297
300
  writable: true,
298
301
  deleteMode: "retire",
299
- retireField: "endDate"
302
+ retireField: "endDate",
303
+ retireWithRecord: true,
304
+ retireStartField: "startDate"
300
305
  },
301
306
  employee: {
302
307
  route: "employees",
@@ -495,6 +500,18 @@ function jsonError(status, error, message) {
495
500
  function shopwaveDateTime(date = /* @__PURE__ */ new Date()) {
496
501
  return date.toISOString().slice(0, 19).replace("T", " ");
497
502
  }
503
+ /**
504
+ * Reads a Shopwave datetime: ISO with a zone (what reads return, e.g.
505
+ * `2026-10-20T16:00:00.000Z`) or a zoneless `YYYY-MM-DD HH:mm:ss`, which is UTC.
506
+ * `null` when the value isn't a date.
507
+ */
508
+ function parseShopwaveDateTime(value) {
509
+ if (typeof value !== "string" || !value.trim()) return null;
510
+ const text = value.trim();
511
+ const zoneless = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/.test(text);
512
+ const date = new Date(zoneless ? `${text.replace(" ", "T")}Z` : text);
513
+ return Number.isNaN(date.getTime()) ? null : date;
514
+ }
498
515
  const NULL_BODY_STATUS = new Set([
499
516
  101,
500
517
  103,
@@ -694,6 +711,45 @@ function createShopwaveApiHandlers(config) {
694
711
  function badId() {
695
712
  return jsonError(400, "invalid_id", "Missing or invalid id");
696
713
  }
714
+ /**
715
+ * Retires a record the API won't take a partial save for (promotions): reads it,
716
+ * sets `field` to now (and `def.retireStartField` to now too when the record
717
+ * hasn't started yet), and POSTs the whole record back. Both dates are sent as
718
+ * UTC `YYYY-MM-DD HH:mm:ss`; reads return them as ISO with `Z`.
719
+ */
720
+ async function retireWithRecord(request, def, id, field) {
721
+ const read = await forward(request, {
722
+ path: def.upstream,
723
+ headers: { [def.idsHeader]: id },
724
+ forwardExtras: false
725
+ });
726
+ if (!read.ok) return read;
727
+ let body;
728
+ try {
729
+ body = await read.json();
730
+ } catch {
731
+ return jsonError(502, "invalid_upstream", `The Shopwave API's ${def.upstream} read wasn't JSON`);
732
+ }
733
+ const map = isPlainObject(body) && isPlainObject(body[def.collection]) ? body[def.collection] : {};
734
+ const record = Object.values(map).find((entry) => isPlainObject(entry) && String(entry.id) === id);
735
+ if (!record) return jsonError(404, "not_found", `No ${def.upstream} with id ${id}`);
736
+ const now = /* @__PURE__ */ new Date();
737
+ const retired = {
738
+ ...record,
739
+ [field]: shopwaveDateTime(now)
740
+ };
741
+ const startField = def.retireStartField;
742
+ if (startField) {
743
+ const start = parseShopwaveDateTime(record[startField]);
744
+ if (start) retired[startField] = shopwaveDateTime(start > now ? now : start);
745
+ }
746
+ return forward(request, {
747
+ method: "POST",
748
+ path: def.upstream,
749
+ postBody: { [def.collection]: { 0: retired } },
750
+ forwardExtras: false
751
+ });
752
+ }
697
753
  return {
698
754
  forward,
699
755
  entity,
@@ -729,6 +785,7 @@ function createShopwaveApiHandlers(config) {
729
785
  if (def.deleteMode === "none") return notSupported(def, "deletes");
730
786
  if (def.deleteMode === "retire") {
731
787
  const field = def.retireField ?? "exitDate";
788
+ if (def.retireWithRecord) return retireWithRecord(request, def, id, field);
732
789
  return forward(request, {
733
790
  method: "POST",
734
791
  path: def.upstream,