@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.
- package/CHANGELOG.md +10 -0
- package/lib/assembly-instructions.js +777 -0
- package/lib/auto-replenish.js +933 -0
- package/lib/business-hours.js +980 -0
- package/lib/click-and-collect.js +711 -0
- package/lib/clickstream.js +713 -0
- package/lib/cost-layers.js +774 -0
- package/lib/credit-limits.js +752 -0
- package/lib/currency-rounding.js +525 -0
- package/lib/customer-activity.js +862 -0
- package/lib/customer-notes.js +712 -0
- package/lib/customer-risk-profile.js +593 -0
- package/lib/customer-surveys.js +1012 -0
- package/lib/damage-photos.js +473 -0
- package/lib/discount-allocation.js +557 -0
- package/lib/dropship-forwarding.js +645 -0
- package/lib/email-templates.js +817 -0
- package/lib/index.js +45 -0
- package/lib/inventory-allocations.js +559 -0
- package/lib/inventory-writeoffs.js +636 -0
- package/lib/knowledge-base.js +1104 -0
- package/lib/locale-router.js +1077 -0
- package/lib/operator-roles.js +768 -0
- package/lib/order-escalation.js +951 -0
- package/lib/order-ratings.js +495 -0
- package/lib/order-tags.js +944 -0
- package/lib/packing-slips.js +810 -0
- package/lib/payment-retries.js +816 -0
- package/lib/pick-lists.js +639 -0
- package/lib/pixel-events.js +995 -0
- package/lib/preorder.js +595 -0
- package/lib/print-queue.js +681 -0
- package/lib/product-qa.js +749 -0
- package/lib/promo-bundles.js +835 -0
- package/lib/push-notifications.js +937 -0
- package/lib/refund-automation.js +853 -0
- package/lib/reorder-reminders.js +798 -0
- package/lib/robots-config.js +753 -0
- package/lib/seller-signup.js +1052 -0
- package/lib/site-redirects.js +690 -0
- package/lib/sitemap-generator.js +717 -0
- package/lib/subscription-gifts.js +710 -0
- package/lib/tax-cert-renewals.js +632 -0
- package/lib/theme-assets.js +711 -0
- package/lib/tier-benefits.js +776 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +2 -2
- package/lib/vendor/blamejs/lib/metrics.js +68 -4
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
- package/lib/wishlist-alerts.js +842 -0
- package/lib/wishlist-sharing.js +718 -0
- 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
|
+
};
|