@blamejs/blamejs-shop 0.0.65 → 0.0.70

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 (54) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/lib/assembly-instructions.js +777 -0
  3. package/lib/auto-replenish.js +933 -0
  4. package/lib/business-hours.js +980 -0
  5. package/lib/click-and-collect.js +711 -0
  6. package/lib/clickstream.js +713 -0
  7. package/lib/cost-layers.js +774 -0
  8. package/lib/credit-limits.js +752 -0
  9. package/lib/currency-rounding.js +525 -0
  10. package/lib/customer-activity.js +862 -0
  11. package/lib/customer-notes.js +712 -0
  12. package/lib/customer-risk-profile.js +593 -0
  13. package/lib/customer-surveys.js +1012 -0
  14. package/lib/damage-photos.js +473 -0
  15. package/lib/discount-allocation.js +557 -0
  16. package/lib/dropship-forwarding.js +645 -0
  17. package/lib/email-templates.js +817 -0
  18. package/lib/index.js +45 -0
  19. package/lib/inventory-allocations.js +559 -0
  20. package/lib/inventory-writeoffs.js +636 -0
  21. package/lib/knowledge-base.js +1104 -0
  22. package/lib/locale-router.js +1077 -0
  23. package/lib/operator-roles.js +768 -0
  24. package/lib/order-escalation.js +951 -0
  25. package/lib/order-ratings.js +495 -0
  26. package/lib/order-tags.js +944 -0
  27. package/lib/packing-slips.js +810 -0
  28. package/lib/payment-retries.js +816 -0
  29. package/lib/pick-lists.js +639 -0
  30. package/lib/pixel-events.js +995 -0
  31. package/lib/preorder.js +595 -0
  32. package/lib/print-queue.js +681 -0
  33. package/lib/product-qa.js +749 -0
  34. package/lib/promo-bundles.js +835 -0
  35. package/lib/push-notifications.js +937 -0
  36. package/lib/refund-automation.js +853 -0
  37. package/lib/reorder-reminders.js +798 -0
  38. package/lib/robots-config.js +753 -0
  39. package/lib/seller-signup.js +1052 -0
  40. package/lib/site-redirects.js +690 -0
  41. package/lib/sitemap-generator.js +717 -0
  42. package/lib/subscription-gifts.js +710 -0
  43. package/lib/tax-cert-renewals.js +632 -0
  44. package/lib/theme-assets.js +711 -0
  45. package/lib/tier-benefits.js +776 -0
  46. package/lib/vendor/MANIFEST.json +2 -2
  47. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  48. package/lib/vendor/blamejs/api-snapshot.json +2 -2
  49. package/lib/vendor/blamejs/lib/metrics.js +68 -4
  50. package/lib/vendor/blamejs/package.json +1 -1
  51. package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
  52. package/lib/wishlist-alerts.js +842 -0
  53. package/lib/wishlist-sharing.js +718 -0
  54. package/package.json +1 -1
@@ -0,0 +1,980 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.businessHours
4
+ * @title Business hours — operator-defined open/close windows per
5
+ * location or tenant
6
+ *
7
+ * @intro
8
+ * The storefront, support-routing layer, and "we're closed" banner
9
+ * all ask the same three questions of an operator-authored schedule:
10
+ *
11
+ * - "Are we open right now?" (`isOpenAt`)
12
+ * - "If not, when do we re-open?" (`nextOpenAt`)
13
+ * - "If yes, when do we close?" (`nextCloseAt`)
14
+ *
15
+ * A schedule has three layered parts:
16
+ *
17
+ * 1. `weekly_hours` — base array of `{ day, open, close }` entries.
18
+ * `day` is 0..6 with Sun=0; `open` / `close` are `HH:MM` 24-hour
19
+ * strings in the schedule's IANA `timezone`. Days absent from
20
+ * the array are closed by default. Multiple entries for the
21
+ * same day are allowed (split shift — open mornings + evenings,
22
+ * closed at lunch).
23
+ *
24
+ * 2. Holidays — full-day closures attached to a specific calendar
25
+ * date (YYYY-MM-DD in the schedule's timezone). Suppress the
26
+ * regular weekly_hours for that weekday. Authored once a year.
27
+ *
28
+ * 3. Exceptions — per-date overrides that adjust open/close for a
29
+ * specific day without removing it (early-close for a private
30
+ * event, late-open after maintenance, fully-closed one-off).
31
+ * When `closed = true` the day is treated as closed regardless;
32
+ * otherwise non-null `open` / `close` columns REPLACE the
33
+ * weekly_hours window for that date (null inherits the base).
34
+ *
35
+ * All date math is calendar-day-deterministic against the schedule's
36
+ * IANA timezone via `Intl.DateTimeFormat` — no manual offset
37
+ * bookkeeping, no DST drift. The `when` input is epoch-ms; the
38
+ * `nextOpenAt` / `nextCloseAt` return epoch-ms so the caller can
39
+ * format for any locale without re-doing the timezone math.
40
+ *
41
+ * Monotonic clock pattern: every primitive that reads "now" accepts
42
+ * an injectable `when` (epoch-ms) so tests can pin a moment without
43
+ * stubbing the global clock; live callers omit `when` and the
44
+ * primitive falls back to `Date.now()`.
45
+ *
46
+ * Composes:
47
+ * - Pure JS + `Intl.DateTimeFormat` for tz-correct calendar math.
48
+ * No blamejs primitives are required for this module — the
49
+ * storage layer is the same query-injection shape every shop
50
+ * primitive uses (`opts.query` -> framework `externalDb.query`).
51
+ *
52
+ * Surface:
53
+ * defineSchedule({ slug, timezone, weekly_hours, holidays?, exceptions? })
54
+ * isOpenAt({ slug, when? })
55
+ * nextOpenAt({ slug, when? })
56
+ * nextCloseAt({ slug, when? })
57
+ * weekSummary({ slug })
58
+ * addException({ slug, date, open?, close?, closed? })
59
+ * removeException({ slug, date })
60
+ * addHoliday({ slug, date, name })
61
+ * listSchedules({ include_archived? })
62
+ * archiveSchedule(slug)
63
+ *
64
+ * Storage:
65
+ * - `business_hour_schedules` + `business_hour_exceptions`
66
+ * (migration `0127_business_hours.sql`).
67
+ *
68
+ * @primitive businessHours
69
+ * @related shop.deliveryEstimate
70
+ */
71
+
72
+ var MAX_SLUG_LEN = 64;
73
+ var SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
74
+
75
+ var TZ_RE = /^[A-Za-z][A-Za-z0-9_+-]*(?:\/[A-Za-z0-9_+-]+){1,2}$/;
76
+
77
+ var HHMM_RE = /^([01][0-9]|2[0-3]):([0-5][0-9])$/;
78
+
79
+ var YMD_RE = /^\d{4}-\d{2}-\d{2}$/;
80
+
81
+ var MAX_WEEKLY_HOURS = 64; // 7 days x 8 split-shifts each is more than any real schedule needs
82
+ var MAX_NAME_LEN = 200;
83
+
84
+ var DAY_MS = 24 * 60 * 60 * 1000;
85
+ var SEARCH_DAYS = 366 * 2; // worst-case "find next open" walk — 2 calendar years of closures
86
+
87
+ // ---- validators ---------------------------------------------------------
88
+
89
+ function _hasControlByte(s) {
90
+ for (var i = 0; i < s.length; i += 1) {
91
+ var cc = s.charCodeAt(i);
92
+ if (cc <= 0x1f || cc === 0x7f) return true;
93
+ }
94
+ return false;
95
+ }
96
+
97
+ function _slug(s) {
98
+ if (typeof s !== "string" || !s.length) {
99
+ throw new TypeError("businessHours: slug must be a non-empty string");
100
+ }
101
+ if (s.length > MAX_SLUG_LEN) {
102
+ throw new TypeError("businessHours: slug must be <= " + MAX_SLUG_LEN + " characters");
103
+ }
104
+ if (!SLUG_RE.test(s)) {
105
+ throw new TypeError(
106
+ "businessHours: slug must match /^[a-z0-9][a-z0-9_-]{0,63}$/ — " +
107
+ "lowercase alphanumerics with `_`/`-`, must not start with separator"
108
+ );
109
+ }
110
+ return s;
111
+ }
112
+
113
+ function _tz(s, label) {
114
+ if (typeof s !== "string" || !TZ_RE.test(s)) {
115
+ throw new TypeError(
116
+ "businessHours: " + label + " must be an IANA timezone name (Area/City)"
117
+ );
118
+ }
119
+ // Semantic gate — Intl throws on an unknown zone (typo at config
120
+ // time surfaces loud).
121
+ try {
122
+ new Intl.DateTimeFormat("en-US", { timeZone: s });
123
+ } catch (_e) {
124
+ throw new TypeError(
125
+ "businessHours: " + label + " is not a known IANA timezone (got " + JSON.stringify(s) + ")"
126
+ );
127
+ }
128
+ return s;
129
+ }
130
+
131
+ function _hhmm(s, label) {
132
+ if (typeof s !== "string" || !HHMM_RE.test(s)) {
133
+ throw new TypeError(
134
+ "businessHours: " + label + " must be a HH:MM 24-hour string (got " + JSON.stringify(s) + ")"
135
+ );
136
+ }
137
+ return s;
138
+ }
139
+
140
+ function _day(n, label) {
141
+ if (!Number.isInteger(n) || n < 0 || n > 6) {
142
+ throw new TypeError("businessHours: " + label + " must be an integer 0..6 (Sun=0)");
143
+ }
144
+ return n;
145
+ }
146
+
147
+ function _ymd(s, label) {
148
+ if (typeof s !== "string" || !YMD_RE.test(s)) {
149
+ throw new TypeError("businessHours: " + label + " must be a YYYY-MM-DD string");
150
+ }
151
+ var y = Number(s.slice(0, 4));
152
+ var mo = Number(s.slice(5, 7));
153
+ var d = Number(s.slice(8, 10));
154
+ // Round-trip via Date.UTC — refuses Feb 30, month 13, etc.
155
+ var ts = Date.UTC(y, mo - 1, d);
156
+ var dt = new Date(ts);
157
+ if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== (mo - 1) || dt.getUTCDate() !== d) {
158
+ throw new TypeError("businessHours: " + label + " — not a real calendar date");
159
+ }
160
+ return s;
161
+ }
162
+
163
+ function _name(s, label) {
164
+ if (typeof s !== "string" || !s.length) {
165
+ throw new TypeError("businessHours: " + label + " must be a non-empty string");
166
+ }
167
+ if (s.length > MAX_NAME_LEN) {
168
+ throw new TypeError("businessHours: " + label + " must be <= " + MAX_NAME_LEN + " characters");
169
+ }
170
+ if (_hasControlByte(s)) {
171
+ throw new TypeError("businessHours: " + label + " must not contain control characters");
172
+ }
173
+ return s;
174
+ }
175
+
176
+ function _epochMs(n, label) {
177
+ if (!Number.isInteger(n)) {
178
+ throw new TypeError("businessHours: " + label + " must be an integer epoch-ms");
179
+ }
180
+ return n;
181
+ }
182
+
183
+ // Convert HH:MM into minutes-since-midnight (0..1439). 24:00 is NOT
184
+ // accepted as an alias for midnight — operators use 23:59 if they
185
+ // want "open until the very end of the day" and the lookup paths
186
+ // treat close-after-open as a normal window. Closed-day = absent
187
+ // row, not zero-length window.
188
+ function _hhmmToMinutes(s) {
189
+ var hh = Number(s.slice(0, 2));
190
+ var mm = Number(s.slice(3, 5));
191
+ return hh * 60 + mm;
192
+ }
193
+
194
+ // Validate one weekly-hours row. Refuses close <= open (zero-length
195
+ // or inverted windows). Operators with overnight-open hours (bar
196
+ // 18:00 -> 02:00) author the row as two entries: day N 18:00-23:59
197
+ // and day N+1 00:00-02:00. Cross-midnight windows are intentionally
198
+ // not a single-row concept — the storefront banner has to know which
199
+ // calendar day owns "right now" and the two-row form makes that
200
+ // unambiguous.
201
+ function _weeklyHourEntry(row, label) {
202
+ if (!row || typeof row !== "object" || Array.isArray(row)) {
203
+ throw new TypeError("businessHours: " + label + " must be an object {day, open, close}");
204
+ }
205
+ var day = _day(row.day, label + ".day");
206
+ var open = _hhmm(row.open, label + ".open");
207
+ var close = _hhmm(row.close, label + ".close");
208
+ var openM = _hhmmToMinutes(open);
209
+ var closeM = _hhmmToMinutes(close);
210
+ if (closeM <= openM) {
211
+ throw new TypeError(
212
+ "businessHours: " + label + " — close must be strictly after open " +
213
+ "(got " + JSON.stringify(open) + " -> " + JSON.stringify(close) + "). " +
214
+ "Author overnight-open windows as two adjacent-day entries"
215
+ );
216
+ }
217
+ return { day: day, open: open, close: close };
218
+ }
219
+
220
+ function _weeklyHours(arr) {
221
+ if (!Array.isArray(arr)) {
222
+ throw new TypeError("businessHours: weekly_hours must be an array");
223
+ }
224
+ if (arr.length > MAX_WEEKLY_HOURS) {
225
+ throw new TypeError("businessHours: weekly_hours must be <= " + MAX_WEEKLY_HOURS + " entries");
226
+ }
227
+ var out = [];
228
+ for (var i = 0; i < arr.length; i += 1) {
229
+ out.push(_weeklyHourEntry(arr[i], "weekly_hours[" + i + "]"));
230
+ }
231
+ // Refuse overlapping windows on the same day — operator either
232
+ // authored a typo or expected merge semantics; neither is safe to
233
+ // assume silently.
234
+ var byDay = {};
235
+ for (var j = 0; j < out.length; j += 1) {
236
+ var e = out[j];
237
+ if (!byDay[e.day]) byDay[e.day] = [];
238
+ byDay[e.day].push(e);
239
+ }
240
+ for (var d = 0; d < 7; d += 1) {
241
+ var rows = byDay[d];
242
+ if (!rows || rows.length < 2) continue;
243
+ rows.sort(function (a, b) { return _hhmmToMinutes(a.open) - _hhmmToMinutes(b.open); });
244
+ for (var k = 1; k < rows.length; k += 1) {
245
+ var prevClose = _hhmmToMinutes(rows[k - 1].close);
246
+ var thisOpen = _hhmmToMinutes(rows[k].open);
247
+ if (thisOpen < prevClose) {
248
+ throw new TypeError(
249
+ "businessHours: weekly_hours — overlapping windows on day " + d +
250
+ " (" + rows[k - 1].open + "-" + rows[k - 1].close + " and " +
251
+ rows[k].open + "-" + rows[k].close + ")"
252
+ );
253
+ }
254
+ }
255
+ }
256
+ return out;
257
+ }
258
+
259
+ function _now() { return Date.now(); }
260
+
261
+ // ---- timezone-aware calendar math ---------------------------------------
262
+
263
+ // Decompose an epoch-ms into {y, m, d, hh, mm, weekday(0..6 Sun=0)}
264
+ // at the requested IANA timezone. Intl handles DST automatically —
265
+ // March / November transitions produce the wall-clock parts an
266
+ // operator authored against, no manual offset math.
267
+ function _wallClockIn(tz, epochMs) {
268
+ var fmt = new Intl.DateTimeFormat("en-US", {
269
+ timeZone: tz,
270
+ year: "numeric",
271
+ month: "2-digit",
272
+ day: "2-digit",
273
+ hour: "2-digit",
274
+ minute: "2-digit",
275
+ hour12: false,
276
+ weekday: "short",
277
+ });
278
+ var parts = fmt.formatToParts(new Date(epochMs));
279
+ var out = { y: 0, m: 0, d: 0, hh: 0, mm: 0, weekday: 0 };
280
+ for (var i = 0; i < parts.length; i += 1) {
281
+ var p = parts[i];
282
+ if (p.type === "year") out.y = Number(p.value);
283
+ if (p.type === "month") out.m = Number(p.value);
284
+ if (p.type === "day") out.d = Number(p.value);
285
+ if (p.type === "hour") {
286
+ // Intl's "h23" hourCycle emits 0..23 but some locales emit "24"
287
+ // at midnight; normalize defensively.
288
+ var hh = Number(p.value);
289
+ out.hh = hh === 24 ? 0 : hh;
290
+ }
291
+ if (p.type === "minute") out.mm = Number(p.value);
292
+ if (p.type === "weekday") {
293
+ var map = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
294
+ out.weekday = map[p.value] != null ? map[p.value] : 0;
295
+ }
296
+ }
297
+ return out;
298
+ }
299
+
300
+ // Pad helpers
301
+ function _pad2(n) { return n < 10 ? "0" + n : String(n); }
302
+
303
+ // Format {y, m, d} as YYYY-MM-DD.
304
+ function _ymdString(parts) {
305
+ return String(parts.y) + "-" + _pad2(parts.m) + "-" + _pad2(parts.d);
306
+ }
307
+
308
+ // Add `n` calendar days to {y, m, d}. UTC arithmetic — month / year
309
+ // roll over naturally without DST entanglement (this walks calendar
310
+ // days, not wall-clock spans).
311
+ function _addCalendarDays(parts, n) {
312
+ var ts = Date.UTC(parts.y, parts.m - 1, parts.d) + n * DAY_MS;
313
+ var dt = new Date(ts);
314
+ return {
315
+ y: dt.getUTCFullYear(),
316
+ m: dt.getUTCMonth() + 1,
317
+ d: dt.getUTCDate(),
318
+ };
319
+ }
320
+
321
+ // Weekday (Sun=0..Sat=6) of a {y, m, d} calendar date — uses Date.UTC
322
+ // so the result is timezone-independent (a calendar date has a fixed
323
+ // weekday regardless of the viewer's zone).
324
+ function _weekdayOfYMD(parts) {
325
+ var ts = Date.UTC(parts.y, parts.m - 1, parts.d);
326
+ return new Date(ts).getUTCDay();
327
+ }
328
+
329
+ // Resolve a (YYYY-MM-DD, HH:MM, IANA tz) tuple back to an epoch-ms.
330
+ // The challenge: `Date.UTC` doesn't know about DST. We need an epoch
331
+ // that, when re-formatted in `tz`, produces the requested wall clock.
332
+ //
333
+ // Algorithm: assume the local time is UTC, then ask Intl what wall
334
+ // clock that epoch maps to in `tz`, then correct by the delta. Two
335
+ // passes converge in every real case (the second iteration handles
336
+ // the rare DST-transition fold where the first guess landed on the
337
+ // wrong side of the boundary).
338
+ function _wallClockToEpochMs(tz, ymd, hhmm) {
339
+ var y = Number(ymd.slice(0, 4));
340
+ var mo = Number(ymd.slice(5, 7));
341
+ var d = Number(ymd.slice(8, 10));
342
+ var hh = Number(hhmm.slice(0, 2));
343
+ var mm = Number(hhmm.slice(3, 5));
344
+ // First guess: treat the input as UTC.
345
+ var guess = Date.UTC(y, mo - 1, d, hh, mm);
346
+ // Two refinement passes — DST transitions need at most one re-walk;
347
+ // a second pass is a no-op cheap safety.
348
+ for (var pass = 0; pass < 2; pass += 1) {
349
+ var wall = _wallClockIn(tz, guess);
350
+ var wallMs = Date.UTC(wall.y, wall.m - 1, wall.d, wall.hh, wall.mm);
351
+ var requestedMs = Date.UTC(y, mo - 1, d, hh, mm);
352
+ var delta = requestedMs - wallMs;
353
+ if (delta === 0) return guess;
354
+ guess += delta;
355
+ }
356
+ return guess;
357
+ }
358
+
359
+ // ---- factory ------------------------------------------------------------
360
+
361
+ function create(opts) {
362
+ opts = opts || {};
363
+ var query = opts.query;
364
+ if (!query) {
365
+ query = function (sql, params) { return require("./index").framework.externalDb.query(sql, params); };
366
+ }
367
+
368
+ function _hydrateSchedule(row) {
369
+ if (!row) return null;
370
+ var weekly = [];
371
+ try { weekly = JSON.parse(row.weekly_hours_json || "[]"); }
372
+ catch (_e) { weekly = []; /* drop-silent — by design; stored shape is primitive-owned */ }
373
+ return {
374
+ slug: row.slug,
375
+ timezone: row.timezone,
376
+ weekly_hours: weekly,
377
+ archived_at: row.archived_at == null ? null : Number(row.archived_at),
378
+ created_at: Number(row.created_at),
379
+ updated_at: Number(row.updated_at),
380
+ };
381
+ }
382
+
383
+ function _hydrateException(row) {
384
+ return {
385
+ date: row.date,
386
+ open: row.open == null ? null : row.open,
387
+ close: row.close == null ? null : row.close,
388
+ closed: Number(row.closed) === 1,
389
+ kind: row.kind,
390
+ name: row.name == null ? null : row.name,
391
+ created_at: Number(row.created_at),
392
+ updated_at: Number(row.updated_at),
393
+ };
394
+ }
395
+
396
+ async function _getScheduleRaw(slug) {
397
+ var r = await query(
398
+ "SELECT * FROM business_hour_schedules WHERE slug = ?1",
399
+ [slug],
400
+ );
401
+ return r.rows[0] || null;
402
+ }
403
+
404
+ async function _getExceptionsByDate(slug) {
405
+ var r = await query(
406
+ "SELECT * FROM business_hour_exceptions WHERE schedule_slug = ?1",
407
+ [slug],
408
+ );
409
+ var byDate = {};
410
+ for (var i = 0; i < r.rows.length; i += 1) {
411
+ var row = r.rows[i];
412
+ byDate[row.date] = _hydrateException(row);
413
+ }
414
+ return byDate;
415
+ }
416
+
417
+ // Build the list of `{open, close}` windows that apply on a given
418
+ // calendar date for the schedule. Returns `[]` for closed days. The
419
+ // override layering is: exception (with closed=true OR explicit
420
+ // open/close) > holiday > weekly_hours. Exceptions with one-side
421
+ // null inherit the other side from weekly_hours.
422
+ function _windowsForDate(schedule, ymd, overrides) {
423
+ var override = overrides[ymd];
424
+ if (override && override.kind === "holiday") return [];
425
+ if (override && override.kind === "exception" && override.closed) return [];
426
+
427
+ var weekday = _weekdayOfYMD({
428
+ y: Number(ymd.slice(0, 4)),
429
+ m: Number(ymd.slice(5, 7)),
430
+ d: Number(ymd.slice(8, 10)),
431
+ });
432
+ var base = [];
433
+ for (var i = 0; i < schedule.weekly_hours.length; i += 1) {
434
+ if (schedule.weekly_hours[i].day === weekday) {
435
+ base.push({
436
+ open: schedule.weekly_hours[i].open,
437
+ close: schedule.weekly_hours[i].close,
438
+ });
439
+ }
440
+ }
441
+
442
+ if (!override || override.kind !== "exception") return base;
443
+
444
+ // Exception with explicit open/close — REPLACES the weekly_hours
445
+ // for this date. A null side inherits from the first weekly entry
446
+ // for the same weekday (most common: "close early today" leaves
447
+ // `open` null + sets `close` to 15:00).
448
+ var inheritedOpen = base.length ? base[0].open : null;
449
+ var inheritedClose = base.length ? base[0].close : null;
450
+ var openFinal = override.open != null ? override.open : inheritedOpen;
451
+ var closeFinal = override.close != null ? override.close : inheritedClose;
452
+ if (openFinal == null || closeFinal == null) {
453
+ // The operator authored an exception with one side null and no
454
+ // matching weekly_hours to inherit from — interpret as closed
455
+ // (no usable window). Drop-silent — the schedule renderer shows
456
+ // the unbalanced override; the lookup just refuses to serve a
457
+ // half-built window.
458
+ return [];
459
+ }
460
+ return [{ open: openFinal, close: closeFinal }];
461
+ }
462
+
463
+ return {
464
+ SLUG_RE: SLUG_RE,
465
+ TZ_RE: TZ_RE,
466
+ HHMM_RE: HHMM_RE,
467
+ YMD_RE: YMD_RE,
468
+ MAX_WEEKLY_HOURS: MAX_WEEKLY_HOURS,
469
+
470
+ // Register a schedule. Holidays + exceptions can be supplied
471
+ // inline; both go into `business_hour_exceptions` (the table
472
+ // doesn't distinguish — the `kind` column does).
473
+ //
474
+ // Re-calling with the same slug refuses — the audit trail is
475
+ // threaded by slug, mutations route through `addException` /
476
+ // `removeException` / `addHoliday` / `archiveSchedule` instead.
477
+ defineSchedule: async function (input) {
478
+ if (!input || typeof input !== "object") {
479
+ throw new TypeError("businessHours.defineSchedule: input object required");
480
+ }
481
+ var slug = _slug(input.slug);
482
+ var timezone = _tz(input.timezone, "timezone");
483
+ var weeklyHours = _weeklyHours(input.weekly_hours || []);
484
+
485
+ var holidays = input.holidays == null ? [] : input.holidays;
486
+ if (!Array.isArray(holidays)) {
487
+ throw new TypeError("businessHours.defineSchedule: holidays must be an array");
488
+ }
489
+ var exceptions = input.exceptions == null ? [] : input.exceptions;
490
+ if (!Array.isArray(exceptions)) {
491
+ throw new TypeError("businessHours.defineSchedule: exceptions must be an array");
492
+ }
493
+
494
+ // Validate every holiday + exception up-front so a malformed
495
+ // entry doesn't leave a half-defined schedule on disk.
496
+ var validatedHolidays = [];
497
+ var seenDates = {};
498
+ for (var i = 0; i < holidays.length; i += 1) {
499
+ var h = holidays[i];
500
+ if (!h || typeof h !== "object") {
501
+ throw new TypeError("businessHours.defineSchedule: holidays[" + i + "] must be an object {date, name}");
502
+ }
503
+ var hDate = _ymd(h.date, "holidays[" + i + "].date");
504
+ var hName = _name(h.name, "holidays[" + i + "].name");
505
+ if (seenDates[hDate]) {
506
+ throw new TypeError(
507
+ "businessHours.defineSchedule: holidays[" + i + "] duplicates date " + hDate
508
+ );
509
+ }
510
+ seenDates[hDate] = true;
511
+ validatedHolidays.push({ date: hDate, name: hName });
512
+ }
513
+ var validatedExceptions = [];
514
+ for (var j = 0; j < exceptions.length; j += 1) {
515
+ var e = exceptions[j];
516
+ if (!e || typeof e !== "object") {
517
+ throw new TypeError("businessHours.defineSchedule: exceptions[" + j + "] must be an object");
518
+ }
519
+ var eDate = _ymd(e.date, "exceptions[" + j + "].date");
520
+ if (seenDates[eDate]) {
521
+ throw new TypeError(
522
+ "businessHours.defineSchedule: exceptions[" + j + "] duplicates date " + eDate
523
+ );
524
+ }
525
+ seenDates[eDate] = true;
526
+ var eOpen = e.open == null ? null : _hhmm(e.open, "exceptions[" + j + "].open");
527
+ var eClose = e.close == null ? null : _hhmm(e.close, "exceptions[" + j + "].close");
528
+ var eClosed = e.closed === true;
529
+ if (eOpen && eClose && _hhmmToMinutes(eClose) <= _hhmmToMinutes(eOpen)) {
530
+ throw new TypeError(
531
+ "businessHours.defineSchedule: exceptions[" + j + "] — close must be strictly after open"
532
+ );
533
+ }
534
+ if (!eClosed && eOpen == null && eClose == null) {
535
+ throw new TypeError(
536
+ "businessHours.defineSchedule: exceptions[" + j + "] — must set closed=true OR at least one of open/close"
537
+ );
538
+ }
539
+ validatedExceptions.push({
540
+ date: eDate, open: eOpen, close: eClose, closed: eClosed,
541
+ });
542
+ }
543
+
544
+ var existing = await _getScheduleRaw(slug);
545
+ if (existing) {
546
+ var err = new Error(
547
+ "businessHours.defineSchedule: refused — schedule '" + slug + "' already exists" +
548
+ (existing.archived_at != null ? " (archived)" : "") +
549
+ ". Mutations route through addException / addHoliday / archiveSchedule"
550
+ );
551
+ err.code = "BUSINESS_HOURS_SCHEDULE_EXISTS";
552
+ throw err;
553
+ }
554
+
555
+ var ts = _now();
556
+ await query(
557
+ "INSERT INTO business_hour_schedules " +
558
+ "(slug, timezone, weekly_hours_json, archived_at, created_at, updated_at) " +
559
+ "VALUES (?1, ?2, ?3, NULL, ?4, ?4)",
560
+ [slug, timezone, JSON.stringify(weeklyHours), ts],
561
+ );
562
+ for (var p = 0; p < validatedHolidays.length; p += 1) {
563
+ var vh = validatedHolidays[p];
564
+ await query(
565
+ "INSERT INTO business_hour_exceptions " +
566
+ "(schedule_slug, date, open, close, closed, kind, name, created_at, updated_at) " +
567
+ "VALUES (?1, ?2, NULL, NULL, 1, 'holiday', ?3, ?4, ?4)",
568
+ [slug, vh.date, vh.name, ts],
569
+ );
570
+ }
571
+ for (var q = 0; q < validatedExceptions.length; q += 1) {
572
+ var ve = validatedExceptions[q];
573
+ await query(
574
+ "INSERT INTO business_hour_exceptions " +
575
+ "(schedule_slug, date, open, close, closed, kind, name, created_at, updated_at) " +
576
+ "VALUES (?1, ?2, ?3, ?4, ?5, 'exception', NULL, ?6, ?6)",
577
+ [slug, ve.date, ve.open, ve.close, ve.closed ? 1 : 0, ts],
578
+ );
579
+ }
580
+
581
+ return await this.getSchedule(slug);
582
+ },
583
+
584
+ // Read one. Returns `null` when no such schedule exists; archived
585
+ // schedules ARE returned (audit dashboards want them). The lookup
586
+ // paths (`isOpenAt` etc.) filter archived rows themselves.
587
+ getSchedule: async function (slug) {
588
+ var s = _slug(slug);
589
+ var raw = await _getScheduleRaw(s);
590
+ if (!raw) return null;
591
+ var hydrated = _hydrateSchedule(raw);
592
+ var byDate = await _getExceptionsByDate(s);
593
+ var holidays = [];
594
+ var exceptions = [];
595
+ var dates = Object.keys(byDate).sort();
596
+ for (var i = 0; i < dates.length; i += 1) {
597
+ var ex = byDate[dates[i]];
598
+ if (ex.kind === "holiday") holidays.push({ date: ex.date, name: ex.name });
599
+ else exceptions.push({
600
+ date: ex.date, open: ex.open, close: ex.close, closed: ex.closed,
601
+ });
602
+ }
603
+ hydrated.holidays = holidays;
604
+ hydrated.exceptions = exceptions;
605
+ return hydrated;
606
+ },
607
+
608
+ // List schedules. Default returns every non-archived schedule;
609
+ // `include_archived: true` includes archived rows for an audit
610
+ // dashboard. Slug ASC ordering for determinism.
611
+ listSchedules: async function (listOpts) {
612
+ listOpts = listOpts || {};
613
+ var includeArchived = listOpts.include_archived === true;
614
+ var sql = "SELECT * FROM business_hour_schedules";
615
+ if (!includeArchived) sql += " WHERE archived_at IS NULL";
616
+ sql += " ORDER BY slug ASC";
617
+ var r = await query(sql, []);
618
+ var out = [];
619
+ for (var i = 0; i < r.rows.length; i += 1) out.push(_hydrateSchedule(r.rows[i]));
620
+ return out;
621
+ },
622
+
623
+ // Soft-delete — stamps archived_at. The row stays on disk for
624
+ // audit; archived schedules drop out of `isOpenAt` / `nextOpenAt`
625
+ // / `nextCloseAt` / `weekSummary` lookups. Idempotent.
626
+ archiveSchedule: async function (slug) {
627
+ var s = _slug(slug);
628
+ var current = await _getScheduleRaw(s);
629
+ if (!current) return null;
630
+ if (current.archived_at != null) return await this.getSchedule(s);
631
+ var ts = _now();
632
+ await query(
633
+ "UPDATE business_hour_schedules SET archived_at = ?1, updated_at = ?1 WHERE slug = ?2",
634
+ [ts, s],
635
+ );
636
+ return await this.getSchedule(s);
637
+ },
638
+
639
+ // Add or replace a holiday on a date. Upsert semantics — re-
640
+ // calling on the same date REPLACES the prior holiday name (and
641
+ // converts an exception row to a holiday row if needed). Archived
642
+ // schedules refuse all mutations.
643
+ addHoliday: async function (input) {
644
+ if (!input || typeof input !== "object") {
645
+ throw new TypeError("businessHours.addHoliday: input object required");
646
+ }
647
+ var slug = _slug(input.slug);
648
+ var date = _ymd(input.date, "date");
649
+ var name = _name(input.name, "name");
650
+ var current = await _getScheduleRaw(slug);
651
+ if (!current) return null;
652
+ if (current.archived_at != null) {
653
+ var refused = new Error("businessHours.addHoliday: refused — schedule is archived");
654
+ refused.code = "BUSINESS_HOURS_SCHEDULE_ARCHIVED";
655
+ throw refused;
656
+ }
657
+ var ts = _now();
658
+ // Idempotent upsert — DELETE then INSERT keeps the AUTOINCREMENT
659
+ // ID monotonic and avoids relying on UPSERT semantics that vary
660
+ // across D1 driver versions.
661
+ await query(
662
+ "DELETE FROM business_hour_exceptions WHERE schedule_slug = ?1 AND date = ?2",
663
+ [slug, date],
664
+ );
665
+ await query(
666
+ "INSERT INTO business_hour_exceptions " +
667
+ "(schedule_slug, date, open, close, closed, kind, name, created_at, updated_at) " +
668
+ "VALUES (?1, ?2, NULL, NULL, 1, 'holiday', ?3, ?4, ?4)",
669
+ [slug, date, name, ts],
670
+ );
671
+ await query(
672
+ "UPDATE business_hour_schedules SET updated_at = ?1 WHERE slug = ?2",
673
+ [ts, slug],
674
+ );
675
+ return await this.getSchedule(slug);
676
+ },
677
+
678
+ // Add or replace an exception on a date. Either `closed: true` OR
679
+ // at least one of (`open`, `close`) must be supplied. A null side
680
+ // inherits from the weekly_hours at lookup time. Upsert semantics.
681
+ addException: async function (input) {
682
+ if (!input || typeof input !== "object") {
683
+ throw new TypeError("businessHours.addException: input object required");
684
+ }
685
+ var slug = _slug(input.slug);
686
+ var date = _ymd(input.date, "date");
687
+ var open = input.open == null ? null : _hhmm(input.open, "open");
688
+ var close = input.close == null ? null : _hhmm(input.close, "close");
689
+ var closed = input.closed === true;
690
+ if (open && close && _hhmmToMinutes(close) <= _hhmmToMinutes(open)) {
691
+ throw new TypeError(
692
+ "businessHours.addException: close must be strictly after open"
693
+ );
694
+ }
695
+ if (!closed && open == null && close == null) {
696
+ throw new TypeError(
697
+ "businessHours.addException: must set closed=true OR at least one of open/close"
698
+ );
699
+ }
700
+ var current = await _getScheduleRaw(slug);
701
+ if (!current) return null;
702
+ if (current.archived_at != null) {
703
+ var refused = new Error("businessHours.addException: refused — schedule is archived");
704
+ refused.code = "BUSINESS_HOURS_SCHEDULE_ARCHIVED";
705
+ throw refused;
706
+ }
707
+ var ts = _now();
708
+ await query(
709
+ "DELETE FROM business_hour_exceptions WHERE schedule_slug = ?1 AND date = ?2",
710
+ [slug, date],
711
+ );
712
+ await query(
713
+ "INSERT INTO business_hour_exceptions " +
714
+ "(schedule_slug, date, open, close, closed, kind, name, created_at, updated_at) " +
715
+ "VALUES (?1, ?2, ?3, ?4, ?5, 'exception', NULL, ?6, ?6)",
716
+ [slug, date, open, close, closed ? 1 : 0, ts],
717
+ );
718
+ await query(
719
+ "UPDATE business_hour_schedules SET updated_at = ?1 WHERE slug = ?2",
720
+ [ts, slug],
721
+ );
722
+ return await this.getSchedule(slug);
723
+ },
724
+
725
+ // Remove ANY override (holiday or exception) on a specific date.
726
+ // Returns the refreshed schedule, or `null` when the schedule
727
+ // doesn't exist. Idempotent — removing a date that had no
728
+ // override is a no-op that still bumps `updated_at` so the audit
729
+ // trail records the operator action.
730
+ removeException: async function (input) {
731
+ if (!input || typeof input !== "object") {
732
+ throw new TypeError("businessHours.removeException: input object required");
733
+ }
734
+ var slug = _slug(input.slug);
735
+ var date = _ymd(input.date, "date");
736
+ var current = await _getScheduleRaw(slug);
737
+ if (!current) return null;
738
+ if (current.archived_at != null) {
739
+ var refused = new Error("businessHours.removeException: refused — schedule is archived");
740
+ refused.code = "BUSINESS_HOURS_SCHEDULE_ARCHIVED";
741
+ throw refused;
742
+ }
743
+ var ts = _now();
744
+ await query(
745
+ "DELETE FROM business_hour_exceptions WHERE schedule_slug = ?1 AND date = ?2",
746
+ [slug, date],
747
+ );
748
+ await query(
749
+ "UPDATE business_hour_schedules SET updated_at = ?1 WHERE slug = ?2",
750
+ [ts, slug],
751
+ );
752
+ return await this.getSchedule(slug);
753
+ },
754
+
755
+ // Snapshot of the weekly rhythm, day-by-day. Returns an array of
756
+ // length 7 (Sun=0..Sat=6); each entry carries the list of windows
757
+ // for that weekday in `HH:MM-HH:MM` form, or `[]` for closed.
758
+ // Holidays + exceptions are NOT applied here — `weekSummary` is
759
+ // the "what's the operator's nominal weekly schedule" view; the
760
+ // banner UI fetches it once and renders "Open Mon-Fri 09:00-17:00,
761
+ // closed Sat/Sun." Use `isOpenAt` / `nextOpenAt` for the
762
+ // exception-aware live state.
763
+ weekSummary: async function (input) {
764
+ if (!input || typeof input !== "object") {
765
+ throw new TypeError("businessHours.weekSummary: input object required");
766
+ }
767
+ var slug = _slug(input.slug);
768
+ var schedule = await this.getSchedule(slug);
769
+ if (!schedule) return null;
770
+ var byDay = [[], [], [], [], [], [], []];
771
+ for (var i = 0; i < schedule.weekly_hours.length; i += 1) {
772
+ var w = schedule.weekly_hours[i];
773
+ byDay[w.day].push({ open: w.open, close: w.close });
774
+ }
775
+ for (var d = 0; d < 7; d += 1) {
776
+ byDay[d].sort(function (a, b) { return _hhmmToMinutes(a.open) - _hhmmToMinutes(b.open); });
777
+ }
778
+ return {
779
+ slug: schedule.slug,
780
+ timezone: schedule.timezone,
781
+ days: byDay,
782
+ };
783
+ },
784
+
785
+ // Is the schedule currently open at `when` (epoch-ms; defaults to
786
+ // `Date.now()` — the monotonic clock pattern lets tests pin a
787
+ // moment without stubbing the global clock).
788
+ isOpenAt: async function (input) {
789
+ if (!input || typeof input !== "object") {
790
+ throw new TypeError("businessHours.isOpenAt: input object required");
791
+ }
792
+ var slug = _slug(input.slug);
793
+ var when = input.when == null ? _now() : _epochMs(input.when, "when");
794
+
795
+ var schedule = await this.getSchedule(slug);
796
+ if (!schedule || schedule.archived_at != null) return false;
797
+
798
+ var wall = _wallClockIn(schedule.timezone, when);
799
+ var ymd = _ymdString(wall);
800
+ var overrides = {};
801
+ var i;
802
+ for (i = 0; i < (schedule.holidays || []).length; i += 1) {
803
+ overrides[schedule.holidays[i].date] = {
804
+ kind: "holiday", name: schedule.holidays[i].name,
805
+ };
806
+ }
807
+ for (i = 0; i < (schedule.exceptions || []).length; i += 1) {
808
+ var ex = schedule.exceptions[i];
809
+ overrides[ex.date] = {
810
+ kind: "exception", open: ex.open, close: ex.close, closed: ex.closed,
811
+ };
812
+ }
813
+ var windows = _windowsForDate(schedule, ymd, overrides);
814
+ var nowMinutes = wall.hh * 60 + wall.mm;
815
+ for (i = 0; i < windows.length; i += 1) {
816
+ var openM = _hhmmToMinutes(windows[i].open);
817
+ var closeM = _hhmmToMinutes(windows[i].close);
818
+ if (nowMinutes >= openM && nowMinutes < closeM) return true;
819
+ }
820
+ return false;
821
+ },
822
+
823
+ // Next open-moment at or after `when`. Returns `{ at, date,
824
+ // open }` where `at` is the epoch-ms of the opening transition,
825
+ // `date` is the YYYY-MM-DD in the schedule's timezone, and `open`
826
+ // is the HH:MM. Returns `null` when no opening is found within
827
+ // SEARCH_DAYS (operator authored a fully-closed schedule, or the
828
+ // far horizon is closed by accumulated overrides — the lookup
829
+ // exits gracefully rather than walking forever).
830
+ //
831
+ // If `when` itself falls inside an open window, returns `when`'s
832
+ // own opening for that window (so the caller can treat "open now"
833
+ // as "the open transition was at <today 09:00>").
834
+ nextOpenAt: async function (input) {
835
+ if (!input || typeof input !== "object") {
836
+ throw new TypeError("businessHours.nextOpenAt: input object required");
837
+ }
838
+ var slug = _slug(input.slug);
839
+ var when = input.when == null ? _now() : _epochMs(input.when, "when");
840
+
841
+ var schedule = await this.getSchedule(slug);
842
+ if (!schedule || schedule.archived_at != null) return null;
843
+
844
+ var overrides = {};
845
+ var i;
846
+ for (i = 0; i < (schedule.holidays || []).length; i += 1) {
847
+ overrides[schedule.holidays[i].date] = {
848
+ kind: "holiday", name: schedule.holidays[i].name,
849
+ };
850
+ }
851
+ for (i = 0; i < (schedule.exceptions || []).length; i += 1) {
852
+ var ex = schedule.exceptions[i];
853
+ overrides[ex.date] = {
854
+ kind: "exception", open: ex.open, close: ex.close, closed: ex.closed,
855
+ };
856
+ }
857
+
858
+ var wall = _wallClockIn(schedule.timezone, when);
859
+ var cur = { y: wall.y, m: wall.m, d: wall.d };
860
+ var nowMinutes = wall.hh * 60 + wall.mm;
861
+ for (var day = 0; day < SEARCH_DAYS; day += 1) {
862
+ var ymd = _ymdString(cur);
863
+ var windows = _windowsForDate(schedule, ymd, overrides);
864
+ // Sort windows by open ASC so split-shift schedules return the
865
+ // earliest still-future opening of the day.
866
+ windows.sort(function (a, b) { return _hhmmToMinutes(a.open) - _hhmmToMinutes(b.open); });
867
+ for (i = 0; i < windows.length; i += 1) {
868
+ var openM = _hhmmToMinutes(windows[i].open);
869
+ var closeM = _hhmmToMinutes(windows[i].close);
870
+ if (day === 0) {
871
+ // Already open inside this window — return this window's
872
+ // open transition (callers building "we're open until X"
873
+ // banners pair this with `nextCloseAt`).
874
+ if (nowMinutes >= openM && nowMinutes < closeM) {
875
+ return {
876
+ at: _wallClockToEpochMs(schedule.timezone, ymd, windows[i].open),
877
+ date: ymd,
878
+ open: windows[i].open,
879
+ };
880
+ }
881
+ // Open later today — return this future opening.
882
+ if (openM > nowMinutes) {
883
+ return {
884
+ at: _wallClockToEpochMs(schedule.timezone, ymd, windows[i].open),
885
+ date: ymd,
886
+ open: windows[i].open,
887
+ };
888
+ }
889
+ } else {
890
+ return {
891
+ at: _wallClockToEpochMs(schedule.timezone, ymd, windows[i].open),
892
+ date: ymd,
893
+ open: windows[i].open,
894
+ };
895
+ }
896
+ }
897
+ cur = _addCalendarDays(cur, 1);
898
+ }
899
+ return null;
900
+ },
901
+
902
+ // Next close-moment AT or after `when`. Returns `{ at, date,
903
+ // close }` for the close transition of the currently-open window
904
+ // OR (when closed) the close of the next opening. Returns `null`
905
+ // when no opening is found within SEARCH_DAYS.
906
+ nextCloseAt: async function (input) {
907
+ if (!input || typeof input !== "object") {
908
+ throw new TypeError("businessHours.nextCloseAt: input object required");
909
+ }
910
+ var slug = _slug(input.slug);
911
+ var when = input.when == null ? _now() : _epochMs(input.when, "when");
912
+
913
+ var schedule = await this.getSchedule(slug);
914
+ if (!schedule || schedule.archived_at != null) return null;
915
+
916
+ var overrides = {};
917
+ var i;
918
+ for (i = 0; i < (schedule.holidays || []).length; i += 1) {
919
+ overrides[schedule.holidays[i].date] = {
920
+ kind: "holiday", name: schedule.holidays[i].name,
921
+ };
922
+ }
923
+ for (i = 0; i < (schedule.exceptions || []).length; i += 1) {
924
+ var ex = schedule.exceptions[i];
925
+ overrides[ex.date] = {
926
+ kind: "exception", open: ex.open, close: ex.close, closed: ex.closed,
927
+ };
928
+ }
929
+
930
+ var wall = _wallClockIn(schedule.timezone, when);
931
+ var cur = { y: wall.y, m: wall.m, d: wall.d };
932
+ var nowMinutes = wall.hh * 60 + wall.mm;
933
+ for (var day = 0; day < SEARCH_DAYS; day += 1) {
934
+ var ymd = _ymdString(cur);
935
+ var windows = _windowsForDate(schedule, ymd, overrides);
936
+ windows.sort(function (a, b) { return _hhmmToMinutes(a.open) - _hhmmToMinutes(b.open); });
937
+ for (i = 0; i < windows.length; i += 1) {
938
+ var openM = _hhmmToMinutes(windows[i].open);
939
+ var closeM = _hhmmToMinutes(windows[i].close);
940
+ if (day === 0) {
941
+ // Inside this window — return its close.
942
+ if (nowMinutes >= openM && nowMinutes < closeM) {
943
+ return {
944
+ at: _wallClockToEpochMs(schedule.timezone, ymd, windows[i].close),
945
+ date: ymd,
946
+ close: windows[i].close,
947
+ };
948
+ }
949
+ // Before this window — return its close (when caller is
950
+ // about to be open, the next close is THIS window's close).
951
+ if (openM > nowMinutes) {
952
+ return {
953
+ at: _wallClockToEpochMs(schedule.timezone, ymd, windows[i].close),
954
+ date: ymd,
955
+ close: windows[i].close,
956
+ };
957
+ }
958
+ } else {
959
+ return {
960
+ at: _wallClockToEpochMs(schedule.timezone, ymd, windows[i].close),
961
+ date: ymd,
962
+ close: windows[i].close,
963
+ };
964
+ }
965
+ }
966
+ cur = _addCalendarDays(cur, 1);
967
+ }
968
+ return null;
969
+ },
970
+ };
971
+ }
972
+
973
+ module.exports = {
974
+ create: create,
975
+ SLUG_RE: SLUG_RE,
976
+ TZ_RE: TZ_RE,
977
+ HHMM_RE: HHMM_RE,
978
+ YMD_RE: YMD_RE,
979
+ MAX_WEEKLY_HOURS: MAX_WEEKLY_HOURS,
980
+ };