@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,718 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.wishlistSharing
4
+ * @title Wishlist sharing — share-a-wishlist links + group / family
5
+ * wishlists, layered on the wishlist primitive
6
+ *
7
+ * @intro
8
+ * Two flavours of sharing on top of the existing wishlist:
9
+ *
10
+ * 1. Share links — the owner mints a token-bearing URL the
11
+ * storefront serves to anyone who follows it. The link
12
+ * carries a privacy hint (`public` / `unlisted` /
13
+ * `friends_only`); `viewShared` resolves the token and
14
+ * returns the owner's wishlist entries plus viewer-safe
15
+ * metadata. The plaintext token is shown EXACTLY ONCE at
16
+ * `createShareLink` time — storage carries only the
17
+ * SHA3-512 namespace-hash. `revokeShareLink` flips an
18
+ * explicit `revoked_at` so subsequent resolves refuse.
19
+ * `recordView` increments the share's running view counter
20
+ * and (optionally) records a hashed session breadcrumb for
21
+ * the owner's analytics surface.
22
+ *
23
+ * 2. Group / family wishlists — the owner creates a group with
24
+ * a title, gets back a plaintext invite token, and shares it
25
+ * with the people they want to co-own the list (member
26
+ * emails captured at create time for the owner's records;
27
+ * the actual co-ownership row materialises when a member
28
+ * calls `joinGroupWishlist` with the token + their
29
+ * `customer_id`). The wishlist itself remains keyed to the
30
+ * underlying `wishlist_entries` table — `wishlist_groups`
31
+ * is the membership envelope. UNIQUE(wishlist_id,
32
+ * customer_id) refuses a double-join.
33
+ *
34
+ * Distinct from gift registry: registry is event-bound (wedding,
35
+ * baby, etc.) with quantity-desired + purchase aggregation; share
36
+ * links + group wishlists are durable, no event, no purchase
37
+ * ledger.
38
+ *
39
+ * Composes:
40
+ * - `b.crypto.generateBytes` — 32-byte uniform draw for both
41
+ * share-link + group-invite
42
+ * plaintext tokens.
43
+ * - `b.crypto.namespaceHash` — SHA3-512 hash under the
44
+ * `wishlist-share-token` /
45
+ * `wishlist-group-token` /
46
+ * `wishlist-share-viewer`
47
+ * namespaces; the only token
48
+ * material persisted.
49
+ * - `b.guardUuid` — UUID-shape validation on
50
+ * every customer / link / group
51
+ * id at the entry point.
52
+ * - `b.uuid.v7` — row PKs (lexicographic +
53
+ * monotonic so an audit read
54
+ * sorts deterministically on
55
+ * tied `created_at`).
56
+ *
57
+ * Storage: `migrations-d1/0150_wishlist_sharing.sql` —
58
+ * `wishlist_shares` + `wishlist_groups` + `wishlist_group_members`
59
+ * + `wishlist_share_views`.
60
+ *
61
+ * @primitive wishlistSharing
62
+ * @related wishlist, giftRegistry, b.crypto, b.uuid, b.guardUuid
63
+ */
64
+
65
+ var bShop;
66
+ function _b() {
67
+ if (!bShop) bShop = require("./index");
68
+ return bShop.framework;
69
+ }
70
+
71
+ // ---- constants ----------------------------------------------------------
72
+
73
+ var SHARE_TOKEN_NAMESPACE = "wishlist-share-token";
74
+ var GROUP_TOKEN_NAMESPACE = "wishlist-group-token";
75
+ var VIEWER_SESSION_NAMESPACE = "wishlist-share-viewer";
76
+
77
+ var TOKEN_BYTE_LEN = 32;
78
+ var TOKEN_PLAINTEXT_RE = /^[A-Za-z0-9_-]{43}$/;
79
+
80
+ var PRIVACIES = Object.freeze(["public", "unlisted", "friends_only"]);
81
+ var ROLES = Object.freeze(["owner", "member"]);
82
+
83
+ var MAX_TITLE_LEN = 200;
84
+ var MAX_REASON_LEN = 500;
85
+ var MAX_EMAIL_LEN = 254;
86
+ var MAX_MEMBER_EMAILS = 50;
87
+
88
+ var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
89
+ var CONTROL_BYTE_STRICT_RE = /[\x00-\x1f\x7f]/;
90
+
91
+ // Conservative single-line email shape — defence in depth against
92
+ // header-injection / control-byte smuggling when the operator
93
+ // surfaces member_emails on the group landing page or in an
94
+ // invitation email template. The real-world RFC 5321 grammar is
95
+ // looser; the primitive only validates shape (a parser is the
96
+ // SMTP gateway's job).
97
+ var EMAIL_RE = /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$/;
98
+
99
+ // ---- monotonic clock ----------------------------------------------------
100
+ //
101
+ // Share-link creation, view recording, group join/leave all persist
102
+ // epoch-ms timestamps. The strict-monotonic clock here guarantees
103
+ // that two same-millisecond `_now()` calls produce distinct
104
+ // integers so the row-ordering on `created_at` / `occurred_at` /
105
+ // `joined_at` is deterministic without a tiebreaker column. Tests
106
+ // that mint + revoke / join + leave in tight loops rely on this for
107
+ // ordering assertions.
108
+ var _lastTs = 0;
109
+ function _now() {
110
+ var t = Date.now();
111
+ if (t <= _lastTs) { t = _lastTs + 1; }
112
+ _lastTs = t;
113
+ return t;
114
+ }
115
+
116
+ // ---- validators ---------------------------------------------------------
117
+
118
+ function _uuid(s, label) {
119
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
120
+ catch (e) { throw new TypeError("wishlistSharing: " + label + " — " + (e && e.message || "invalid UUID")); }
121
+ }
122
+
123
+ function _optUuid(s, label) {
124
+ if (s == null || s === "") return null;
125
+ return _uuid(s, label);
126
+ }
127
+
128
+ function _title(s, label) {
129
+ if (s == null || s === "") return "";
130
+ if (typeof s !== "string" || s.length > MAX_TITLE_LEN) {
131
+ throw new TypeError("wishlistSharing: " + label + " must be a string <= " + MAX_TITLE_LEN + " chars");
132
+ }
133
+ if (CONTROL_BYTE_RE.test(s)) {
134
+ throw new TypeError("wishlistSharing: " + label + " must not contain control bytes");
135
+ }
136
+ return s;
137
+ }
138
+
139
+ function _requiredTitle(s, label) {
140
+ if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
141
+ throw new TypeError("wishlistSharing: " + label + " must be a non-empty string <= " + MAX_TITLE_LEN + " chars");
142
+ }
143
+ if (CONTROL_BYTE_RE.test(s)) {
144
+ throw new TypeError("wishlistSharing: " + label + " must not contain control bytes");
145
+ }
146
+ return s;
147
+ }
148
+
149
+ function _privacy(s) {
150
+ if (typeof s !== "string" || PRIVACIES.indexOf(s) < 0) {
151
+ throw new TypeError("wishlistSharing: privacy must be one of " + PRIVACIES.join(", "));
152
+ }
153
+ return s;
154
+ }
155
+
156
+ function _expiresAt(n) {
157
+ if (n == null) return null;
158
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 0) {
159
+ throw new TypeError("wishlistSharing: expires_at must be a non-negative integer (ms epoch) or null");
160
+ }
161
+ return n;
162
+ }
163
+
164
+ function _reason(s) {
165
+ if (typeof s !== "string" || !s.length || s.length > MAX_REASON_LEN) {
166
+ throw new TypeError("wishlistSharing: reason must be a non-empty string <= " + MAX_REASON_LEN + " chars");
167
+ }
168
+ if (CONTROL_BYTE_RE.test(s)) {
169
+ throw new TypeError("wishlistSharing: reason must not contain control bytes");
170
+ }
171
+ return s;
172
+ }
173
+
174
+ function _email(s, label) {
175
+ if (typeof s !== "string" || !s.length || s.length > MAX_EMAIL_LEN || !EMAIL_RE.test(s)) {
176
+ throw new TypeError("wishlistSharing: " + label + " must be a valid email <= " + MAX_EMAIL_LEN + " chars");
177
+ }
178
+ return s;
179
+ }
180
+
181
+ function _memberEmails(arr) {
182
+ if (arr == null) return [];
183
+ if (!Array.isArray(arr)) {
184
+ throw new TypeError("wishlistSharing: member_emails must be an array");
185
+ }
186
+ if (arr.length > MAX_MEMBER_EMAILS) {
187
+ throw new TypeError("wishlistSharing: member_emails must contain <= " + MAX_MEMBER_EMAILS + " entries");
188
+ }
189
+ var out = [];
190
+ var seen = Object.create(null);
191
+ for (var i = 0; i < arr.length; i += 1) {
192
+ var e = _email(arr[i], "member_emails[" + i + "]");
193
+ var lower = e.toLowerCase();
194
+ if (seen[lower]) {
195
+ throw new TypeError("wishlistSharing: member_emails[" + i + "] duplicates a previous entry");
196
+ }
197
+ seen[lower] = true;
198
+ out.push(e);
199
+ }
200
+ return out;
201
+ }
202
+
203
+ function _viewerSessionId(s) {
204
+ if (s == null || s === "") return null;
205
+ if (typeof s !== "string" || s.length > 1024) {
206
+ throw new TypeError("wishlistSharing: viewer_session_id must be a non-empty string <= 1024 chars");
207
+ }
208
+ // Session ids are opaque to this primitive; refuse control bytes
209
+ // so the hashed breadcrumb has no log-injection vector even if a
210
+ // downstream tool ever rendered the raw value.
211
+ if (CONTROL_BYTE_STRICT_RE.test(s)) {
212
+ throw new TypeError("wishlistSharing: viewer_session_id must not contain control bytes");
213
+ }
214
+ return s;
215
+ }
216
+
217
+ // ---- token generation + hashing -----------------------------------------
218
+
219
+ // 32 random bytes -> 43-char base64url (no padding). Manual encode
220
+ // so the primitive doesn't depend on a Buffer-side flag rename
221
+ // across Node minors.
222
+ function _generateToken() {
223
+ var buf = _b().crypto.generateBytes(TOKEN_BYTE_LEN);
224
+ return buf.toString("base64")
225
+ .replace(/\+/g, "-")
226
+ .replace(/\//g, "_")
227
+ .replace(/=+$/, "");
228
+ }
229
+
230
+ function _canonicalToken(input) {
231
+ if (typeof input !== "string" || !input.length) {
232
+ throw new TypeError("wishlistSharing: token must be a non-empty string");
233
+ }
234
+ if (!TOKEN_PLAINTEXT_RE.test(input)) {
235
+ throw new TypeError("wishlistSharing: token must be 43 base64url characters");
236
+ }
237
+ return input;
238
+ }
239
+
240
+ function _hashShareToken(plaintext) {
241
+ return _b().crypto.namespaceHash(SHARE_TOKEN_NAMESPACE, plaintext);
242
+ }
243
+
244
+ function _hashGroupToken(plaintext) {
245
+ return _b().crypto.namespaceHash(GROUP_TOKEN_NAMESPACE, plaintext);
246
+ }
247
+
248
+ function _hashViewerSession(sessionId) {
249
+ return _b().crypto.namespaceHash(VIEWER_SESSION_NAMESPACE, sessionId);
250
+ }
251
+
252
+ // ---- factory ------------------------------------------------------------
253
+
254
+ function create(opts) {
255
+ opts = opts || {};
256
+ var query = opts.query;
257
+ if (!query) {
258
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
259
+ }
260
+ // The wishlist primitive is composed for `viewShared`: the owner's
261
+ // entries are surfaced via `wishlist.listForCustomer`. When the
262
+ // operator hasn't wired one explicitly, lazy-instantiate against
263
+ // the same `query` handle so the shared instance is consistent.
264
+ var wishlistInstance = opts.wishlist || null;
265
+ function _wishlist() {
266
+ if (!wishlistInstance) {
267
+ wishlistInstance = require("./wishlist").create({ query: query });
268
+ }
269
+ return wishlistInstance;
270
+ }
271
+
272
+ // ---- internal decoders ----------------------------------------------
273
+
274
+ function _decodeShare(row) {
275
+ if (!row) return null;
276
+ return {
277
+ id: row.id,
278
+ owner_customer_id: row.owner_customer_id,
279
+ title: row.title,
280
+ privacy: row.privacy,
281
+ expires_at: row.expires_at != null ? Number(row.expires_at) : null,
282
+ view_count: Number(row.view_count || 0),
283
+ revoked_at: row.revoked_at != null ? Number(row.revoked_at) : null,
284
+ revoked_reason: row.revoked_reason,
285
+ created_at: Number(row.created_at),
286
+ };
287
+ }
288
+
289
+ function _decodeGroup(row) {
290
+ if (!row) return null;
291
+ return {
292
+ id: row.id,
293
+ owner_customer_id: row.owner_customer_id,
294
+ title: row.title,
295
+ archived_at: row.archived_at != null ? Number(row.archived_at) : null,
296
+ created_at: Number(row.created_at),
297
+ };
298
+ }
299
+
300
+ function _decodeMember(row) {
301
+ return {
302
+ id: row.id,
303
+ wishlist_id: row.wishlist_id,
304
+ customer_id: row.customer_id,
305
+ role: row.role,
306
+ invite_email: row.invite_email,
307
+ joined_at: Number(row.joined_at),
308
+ };
309
+ }
310
+
311
+ // ---- createShareLink -------------------------------------------------
312
+
313
+ async function createShareLink(input) {
314
+ if (!input || typeof input !== "object") {
315
+ throw new TypeError("wishlistSharing.createShareLink: input object required");
316
+ }
317
+ var ownerId = _uuid(input.owner_customer_id, "owner_customer_id");
318
+ var title = _title(input.title, "title");
319
+ var expires = _expiresAt(input.expires_at);
320
+ var privacy = _privacy(input.privacy);
321
+
322
+ var id = _b().uuid.v7();
323
+ var plaintext = _generateToken();
324
+ var tokenHash = _hashShareToken(plaintext);
325
+ var ts = _now();
326
+
327
+ await query(
328
+ "INSERT INTO wishlist_shares " +
329
+ "(id, owner_customer_id, token_hash, title, privacy, expires_at, view_count, revoked_at, revoked_reason, created_at) " +
330
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0, NULL, NULL, ?7)",
331
+ [id, ownerId, tokenHash, title, privacy, expires, ts],
332
+ );
333
+
334
+ // Plaintext token is returned EXACTLY ONCE here. The storage
335
+ // column carries only the SHA3-512 namespace-hash; subsequent
336
+ // reads of the share row never see the plaintext again.
337
+ return {
338
+ id: id,
339
+ owner_customer_id: ownerId,
340
+ title: title,
341
+ privacy: privacy,
342
+ expires_at: expires,
343
+ created_at: ts,
344
+ plaintext_token: plaintext,
345
+ };
346
+ }
347
+
348
+ // ---- revokeShareLink -------------------------------------------------
349
+
350
+ async function revokeShareLink(input) {
351
+ if (!input || typeof input !== "object") {
352
+ throw new TypeError("wishlistSharing.revokeShareLink: input object required");
353
+ }
354
+ var linkId = _uuid(input.link_id, "link_id");
355
+ var reason = _reason(input.reason);
356
+ var ts = _now();
357
+
358
+ var r = await query(
359
+ "UPDATE wishlist_shares SET revoked_at = ?1, revoked_reason = ?2 " +
360
+ "WHERE id = ?3 AND revoked_at IS NULL",
361
+ [ts, reason, linkId],
362
+ );
363
+ if (Number(r.rowCount || 0) === 0) {
364
+ // Either missing or already revoked — disambiguate so the
365
+ // caller can distinguish "no such link" from "already
366
+ // revoked, here's the existing row".
367
+ var existing = (await query(
368
+ "SELECT * FROM wishlist_shares WHERE id = ?1",
369
+ [linkId],
370
+ )).rows[0];
371
+ if (!existing) return null;
372
+ return _decodeShare(existing);
373
+ }
374
+ var row = (await query(
375
+ "SELECT * FROM wishlist_shares WHERE id = ?1",
376
+ [linkId],
377
+ )).rows[0];
378
+ return _decodeShare(row);
379
+ }
380
+
381
+ // ---- viewShared ------------------------------------------------------
382
+
383
+ async function viewShared(input) {
384
+ if (!input || typeof input !== "object") {
385
+ throw new TypeError("wishlistSharing.viewShared: input object required");
386
+ }
387
+ var token = _canonicalToken(input.token);
388
+ var viewerCustomerId = _optUuid(input.viewer_customer_id, "viewer_customer_id");
389
+ var hash = _hashShareToken(token);
390
+
391
+ var r = await query(
392
+ "SELECT * FROM wishlist_shares WHERE token_hash = ?1",
393
+ [hash],
394
+ );
395
+ if (!r.rows.length) {
396
+ var miss = new Error("wishlistSharing.viewShared: share link not found");
397
+ miss.code = "WISHLIST_SHARE_NOT_FOUND";
398
+ throw miss;
399
+ }
400
+ var share = r.rows[0];
401
+
402
+ if (share.revoked_at != null) {
403
+ var revoked = new Error("wishlistSharing.viewShared: share link has been revoked");
404
+ revoked.code = "WISHLIST_SHARE_REVOKED";
405
+ throw revoked;
406
+ }
407
+ var nowTs = _now();
408
+ if (share.expires_at != null && Number(share.expires_at) < nowTs) {
409
+ var expired = new Error("wishlistSharing.viewShared: share link has expired");
410
+ expired.code = "WISHLIST_SHARE_EXPIRED";
411
+ throw expired;
412
+ }
413
+
414
+ // `friends_only` requires the resolver to identify the viewer.
415
+ // The primitive doesn't carry a friends graph itself — the
416
+ // operator's identity layer is expected to pass the
417
+ // authenticated viewer id. Refusing on missing id closes the
418
+ // unauthenticated-browse hole; the operator's authz layer
419
+ // narrows further (e.g. matching the viewer against the owner's
420
+ // follow / friend set).
421
+ if (share.privacy === "friends_only" && !viewerCustomerId) {
422
+ var friendsOnly = new Error("wishlistSharing.viewShared: share is friends_only — viewer_customer_id required");
423
+ friendsOnly.code = "WISHLIST_SHARE_FRIENDS_ONLY";
424
+ throw friendsOnly;
425
+ }
426
+
427
+ // Surface the owner's wishlist entries. The wishlist primitive's
428
+ // pagination cursor is keyed off the owner's view of their own
429
+ // list — a viewer browsing a shared link doesn't get
430
+ // pagination tokens (the share is a one-shot read). The
431
+ // primitive returns the first page only; an operator who needs
432
+ // pagination on the share-view path can wrap this and re-issue
433
+ // through `wishlist.listForCustomer` directly.
434
+ var wl = _wishlist();
435
+ var page = await wl.listForCustomer(share.owner_customer_id, { limit: 100 });
436
+
437
+ return {
438
+ share: {
439
+ id: share.id,
440
+ owner_customer_id: share.owner_customer_id,
441
+ title: share.title,
442
+ privacy: share.privacy,
443
+ expires_at: share.expires_at != null ? Number(share.expires_at) : null,
444
+ created_at: Number(share.created_at),
445
+ view_count: Number(share.view_count || 0),
446
+ },
447
+ entries: page.rows,
448
+ };
449
+ }
450
+
451
+ // ---- recordView ------------------------------------------------------
452
+
453
+ async function recordView(input) {
454
+ if (!input || typeof input !== "object") {
455
+ throw new TypeError("wishlistSharing.recordView: input object required");
456
+ }
457
+ var token = _canonicalToken(input.token);
458
+ var viewerSession = _viewerSessionId(input.viewer_session_id);
459
+ var hash = _hashShareToken(token);
460
+
461
+ var shareRow = (await query(
462
+ "SELECT * FROM wishlist_shares WHERE token_hash = ?1",
463
+ [hash],
464
+ )).rows[0];
465
+ if (!shareRow) {
466
+ var miss = new Error("wishlistSharing.recordView: share link not found");
467
+ miss.code = "WISHLIST_SHARE_NOT_FOUND";
468
+ throw miss;
469
+ }
470
+ if (shareRow.revoked_at != null) {
471
+ var revoked = new Error("wishlistSharing.recordView: share link has been revoked");
472
+ revoked.code = "WISHLIST_SHARE_REVOKED";
473
+ throw revoked;
474
+ }
475
+ var nowTs = _now();
476
+ if (shareRow.expires_at != null && Number(shareRow.expires_at) < nowTs) {
477
+ var expired = new Error("wishlistSharing.recordView: share link has expired");
478
+ expired.code = "WISHLIST_SHARE_EXPIRED";
479
+ throw expired;
480
+ }
481
+
482
+ var viewerHash = viewerSession ? _hashViewerSession(viewerSession) : null;
483
+ var viewId = _b().uuid.v7();
484
+
485
+ await query(
486
+ "INSERT INTO wishlist_share_views (id, share_id, viewer_session_id_hash, occurred_at) " +
487
+ "VALUES (?1, ?2, ?3, ?4)",
488
+ [viewId, shareRow.id, viewerHash, nowTs],
489
+ );
490
+ await query(
491
+ "UPDATE wishlist_shares SET view_count = view_count + 1 WHERE id = ?1",
492
+ [shareRow.id],
493
+ );
494
+ return {
495
+ view_id: viewId,
496
+ share_id: shareRow.id,
497
+ occurred_at: nowTs,
498
+ };
499
+ }
500
+
501
+ // ---- listSharesForOwner ---------------------------------------------
502
+
503
+ async function listSharesForOwner(customerId) {
504
+ var ownerId = _uuid(customerId, "customer_id");
505
+ var r = await query(
506
+ "SELECT * FROM wishlist_shares WHERE owner_customer_id = ?1 " +
507
+ "ORDER BY created_at DESC, id DESC",
508
+ [ownerId],
509
+ );
510
+ var out = [];
511
+ for (var i = 0; i < r.rows.length; i += 1) out.push(_decodeShare(r.rows[i]));
512
+ return out;
513
+ }
514
+
515
+ // ---- createGroupWishlist --------------------------------------------
516
+
517
+ async function createGroupWishlist(input) {
518
+ if (!input || typeof input !== "object") {
519
+ throw new TypeError("wishlistSharing.createGroupWishlist: input object required");
520
+ }
521
+ var ownerId = _uuid(input.owner_customer_id, "owner_customer_id");
522
+ var title = _requiredTitle(input.title, "title");
523
+ var emails = _memberEmails(input.member_emails);
524
+
525
+ var id = _b().uuid.v7();
526
+ var plaintext = _generateToken();
527
+ var tokenHash = _hashGroupToken(plaintext);
528
+ var ts = _now();
529
+
530
+ await query(
531
+ "INSERT INTO wishlist_groups " +
532
+ "(id, owner_customer_id, title, token_hash, archived_at, created_at) " +
533
+ "VALUES (?1, ?2, ?3, ?4, NULL, ?5)",
534
+ [id, ownerId, title, tokenHash, ts],
535
+ );
536
+
537
+ // The creator is materialised as the `owner` member row up front
538
+ // so `groupWishlistsForCustomer(ownerId)` and the member-list
539
+ // reads have a single uniform shape regardless of whether anyone
540
+ // else has joined yet.
541
+ var ownerMemberId = _b().uuid.v7();
542
+ await query(
543
+ "INSERT INTO wishlist_group_members " +
544
+ "(id, wishlist_id, customer_id, role, invite_email, joined_at) " +
545
+ "VALUES (?1, ?2, ?3, 'owner', NULL, ?4)",
546
+ [ownerMemberId, id, ownerId, ts],
547
+ );
548
+
549
+ // Invited member emails are captured for the owner's records —
550
+ // the row materialises when each invitee actually
551
+ // `joinGroupWishlist`s with the plaintext token. Storing them
552
+ // here gives the operator a "you invited Carol but she hasn't
553
+ // joined yet" surface without round-tripping a separate
554
+ // invitations table.
555
+ var emailRows = [];
556
+ for (var i = 0; i < emails.length; i += 1) {
557
+ emailRows.push({ email: emails[i] });
558
+ }
559
+
560
+ return {
561
+ id: id,
562
+ owner_customer_id: ownerId,
563
+ title: title,
564
+ created_at: ts,
565
+ invited_emails: emails,
566
+ plaintext_token: plaintext,
567
+ };
568
+ }
569
+
570
+ // ---- joinGroupWishlist -----------------------------------------------
571
+
572
+ async function joinGroupWishlist(input) {
573
+ if (!input || typeof input !== "object") {
574
+ throw new TypeError("wishlistSharing.joinGroupWishlist: input object required");
575
+ }
576
+ var token = _canonicalToken(input.token);
577
+ var customerId = _uuid(input.customer_id, "customer_id");
578
+ var hash = _hashGroupToken(token);
579
+
580
+ var groupRow = (await query(
581
+ "SELECT * FROM wishlist_groups WHERE token_hash = ?1",
582
+ [hash],
583
+ )).rows[0];
584
+ if (!groupRow) {
585
+ var miss = new Error("wishlistSharing.joinGroupWishlist: group not found");
586
+ miss.code = "WISHLIST_GROUP_NOT_FOUND";
587
+ throw miss;
588
+ }
589
+ if (groupRow.archived_at != null) {
590
+ var archived = new Error("wishlistSharing.joinGroupWishlist: group has been archived");
591
+ archived.code = "WISHLIST_GROUP_ARCHIVED";
592
+ throw archived;
593
+ }
594
+
595
+ // Refuse duplicate join — the UNIQUE constraint refuses it
596
+ // anyway, but a clean TypeError reads better than an opaque
597
+ // SQLITE_CONSTRAINT at the application layer.
598
+ var existing = (await query(
599
+ "SELECT id, role FROM wishlist_group_members " +
600
+ "WHERE wishlist_id = ?1 AND customer_id = ?2 LIMIT 1",
601
+ [groupRow.id, customerId],
602
+ )).rows[0];
603
+ if (existing) {
604
+ var dupe = new Error("wishlistSharing.joinGroupWishlist: customer is already a " + existing.role + " of this group");
605
+ dupe.code = "WISHLIST_GROUP_ALREADY_JOINED";
606
+ throw dupe;
607
+ }
608
+
609
+ var id = _b().uuid.v7();
610
+ var ts = _now();
611
+ await query(
612
+ "INSERT INTO wishlist_group_members " +
613
+ "(id, wishlist_id, customer_id, role, invite_email, joined_at) " +
614
+ "VALUES (?1, ?2, ?3, 'member', NULL, ?4)",
615
+ [id, groupRow.id, customerId, ts],
616
+ );
617
+ return {
618
+ id: id,
619
+ wishlist_id: groupRow.id,
620
+ customer_id: customerId,
621
+ role: "member",
622
+ joined_at: ts,
623
+ };
624
+ }
625
+
626
+ // ---- leaveGroupWishlist ----------------------------------------------
627
+
628
+ async function leaveGroupWishlist(input) {
629
+ if (!input || typeof input !== "object") {
630
+ throw new TypeError("wishlistSharing.leaveGroupWishlist: input object required");
631
+ }
632
+ var wishlistId = _uuid(input.wishlist_id, "wishlist_id");
633
+ var customerId = _uuid(input.customer_id, "customer_id");
634
+
635
+ // The owner row is FSM-fixed for the lifetime of the group —
636
+ // an owner who wants to leave archives the group instead.
637
+ // Refuse the misuse up front rather than silently no-op.
638
+ var existing = (await query(
639
+ "SELECT id, role FROM wishlist_group_members " +
640
+ "WHERE wishlist_id = ?1 AND customer_id = ?2 LIMIT 1",
641
+ [wishlistId, customerId],
642
+ )).rows[0];
643
+ if (!existing) {
644
+ return { removed: false };
645
+ }
646
+ if (existing.role === "owner") {
647
+ throw new TypeError("wishlistSharing.leaveGroupWishlist: owner cannot leave their own group; archive it instead");
648
+ }
649
+
650
+ var r = await query(
651
+ "DELETE FROM wishlist_group_members WHERE id = ?1",
652
+ [existing.id],
653
+ );
654
+ return { removed: Number(r.rowCount || 0) > 0 };
655
+ }
656
+
657
+ // ---- groupWishlistsForCustomer --------------------------------------
658
+
659
+ async function groupWishlistsForCustomer(customerId) {
660
+ var custId = _uuid(customerId, "customer_id");
661
+ // Surface every group the customer is a member of (owner or
662
+ // member). Joined order via `joined_at DESC` so the most
663
+ // recently engaged group lands first in the customer's account
664
+ // page.
665
+ var r = await query(
666
+ "SELECT g.*, m.role AS member_role, m.joined_at AS member_joined_at " +
667
+ "FROM wishlist_groups g " +
668
+ "INNER JOIN wishlist_group_members m ON m.wishlist_id = g.id " +
669
+ "WHERE m.customer_id = ?1 " +
670
+ "ORDER BY m.joined_at DESC, g.id DESC",
671
+ [custId],
672
+ );
673
+ var out = [];
674
+ for (var i = 0; i < r.rows.length; i += 1) {
675
+ var decoded = _decodeGroup(r.rows[i]);
676
+ decoded.role = r.rows[i].member_role;
677
+ decoded.joined_at = Number(r.rows[i].member_joined_at);
678
+ out.push(decoded);
679
+ }
680
+ return out;
681
+ }
682
+
683
+ // ---- listMembers (read-only convenience) ----------------------------
684
+
685
+ async function listGroupMembers(wishlistId) {
686
+ var id = _uuid(wishlistId, "wishlist_id");
687
+ var r = await query(
688
+ "SELECT * FROM wishlist_group_members WHERE wishlist_id = ?1 " +
689
+ "ORDER BY joined_at ASC, id ASC",
690
+ [id],
691
+ );
692
+ var out = [];
693
+ for (var i = 0; i < r.rows.length; i += 1) out.push(_decodeMember(r.rows[i]));
694
+ return out;
695
+ }
696
+
697
+ return {
698
+ PRIVACIES: PRIVACIES.slice(),
699
+ ROLES: ROLES.slice(),
700
+
701
+ createShareLink: createShareLink,
702
+ revokeShareLink: revokeShareLink,
703
+ viewShared: viewShared,
704
+ recordView: recordView,
705
+ listSharesForOwner: listSharesForOwner,
706
+ createGroupWishlist: createGroupWishlist,
707
+ joinGroupWishlist: joinGroupWishlist,
708
+ leaveGroupWishlist: leaveGroupWishlist,
709
+ groupWishlistsForCustomer: groupWishlistsForCustomer,
710
+ listGroupMembers: listGroupMembers,
711
+ };
712
+ }
713
+
714
+ module.exports = {
715
+ create: create,
716
+ PRIVACIES: PRIVACIES,
717
+ ROLES: ROLES,
718
+ };