@blamejs/blamejs-shop 0.0.61 → 0.0.62
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 +2 -0
- package/lib/email-campaigns.js +844 -0
- package/lib/geolocation.js +651 -0
- package/lib/gift-registry.js +820 -0
- package/lib/index.js +10 -0
- package/lib/loyalty-redemption.js +673 -0
- package/lib/plan-changes.js +508 -0
- package/lib/refund-policy.js +965 -0
- package/lib/sms-dispatcher.js +7 -1
- package/lib/stock-transfers.js +777 -0
- package/lib/storefront-dashboards.js +863 -0
- package/lib/vendors.js +797 -0
- package/package.json +1 -1
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.giftRegistry
|
|
4
|
+
* @title Gift registry primitive — owner-curated list of desired
|
|
5
|
+
* items, purchased by friends/family
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* A registry is the customer-owned list of items they'd like for
|
|
9
|
+
* an upcoming occasion (wedding / baby / housewarming / birthday /
|
|
10
|
+
* graduation / anniversary / other). The owner creates the
|
|
11
|
+
* registry, adds items at the (sku, variant) tuple with a
|
|
12
|
+
* `quantity_desired` + optional `priority`, and shares a slug-keyed
|
|
13
|
+
* URL. Visitors browse the registry — `getBySlug` is the public
|
|
14
|
+
* read — and purchase items, recording a row against the registry
|
|
15
|
+
* that decrements the remaining-to-buy count on the matching item.
|
|
16
|
+
*
|
|
17
|
+
* Buyer privacy:
|
|
18
|
+
* A buyer can opt to remain anonymous (the default at the
|
|
19
|
+
* primitive layer when `reveal_buyer` is omitted). When false,
|
|
20
|
+
* `progressFor` / `getBySlug({ include_purchased })` MUST NOT
|
|
21
|
+
* surface `buyer_customer_id` to the registry owner — the field
|
|
22
|
+
* is stripped from the returned shape. The underlying row still
|
|
23
|
+
* carries the id so an operator can audit / process refunds; the
|
|
24
|
+
* primitive's read paths simply redact it for owner-facing views.
|
|
25
|
+
*
|
|
26
|
+
* Registry privacy:
|
|
27
|
+
* - `private` — only `listForOwner(owner_id)` surfaces it; the
|
|
28
|
+
* slug resolves through `getRegistry` but `getBySlug` returns
|
|
29
|
+
* null unless the caller has owner-level context. `searchPublic`
|
|
30
|
+
* never returns it.
|
|
31
|
+
* - `unlisted` — anyone who knows the slug can resolve it via
|
|
32
|
+
* `getBySlug`. `searchPublic` never returns it.
|
|
33
|
+
* - `public` — `searchPublic` returns it for matching queries;
|
|
34
|
+
* indexed by the storefront landing page.
|
|
35
|
+
*
|
|
36
|
+
* FSM:
|
|
37
|
+
* `active` -> `closed` is the only legal transition. `closeRegistry`
|
|
38
|
+
* flips the row, stamps `closed_at`, and refuses subsequent
|
|
39
|
+
* `addItem` / `removeItem` / `purchaseItem` writes. A closed
|
|
40
|
+
* registry is still readable so the owner can review what
|
|
41
|
+
* arrived and send thank-you notes; it just refuses further
|
|
42
|
+
* mutation.
|
|
43
|
+
*
|
|
44
|
+
* Composition:
|
|
45
|
+
* var reg = bShop.giftRegistry.create({ query: q, catalog: cat });
|
|
46
|
+
* await reg.createRegistry({
|
|
47
|
+
* owner_customer_id: ownerId,
|
|
48
|
+
* slug: "alice-and-bob-2026",
|
|
49
|
+
* title: "Alice & Bob's Wedding",
|
|
50
|
+
* occasion: "wedding",
|
|
51
|
+
* event_date: Date.now() + 90 * 86400000,
|
|
52
|
+
* recipient_name: "Alice & Bob",
|
|
53
|
+
* privacy: "public",
|
|
54
|
+
* });
|
|
55
|
+
* await reg.addItem({
|
|
56
|
+
* registry_slug: "alice-and-bob-2026",
|
|
57
|
+
* sku: "BLENDER-12CUP",
|
|
58
|
+
* quantity_desired: 1,
|
|
59
|
+
* priority: 1,
|
|
60
|
+
* });
|
|
61
|
+
* // Friend purchases:
|
|
62
|
+
* await reg.purchaseItem({
|
|
63
|
+
* registry_slug: "alice-and-bob-2026",
|
|
64
|
+
* item_id: itemId,
|
|
65
|
+
* quantity: 1,
|
|
66
|
+
* buyer_customer_id: friendId,
|
|
67
|
+
* buyer_message: "Congrats!",
|
|
68
|
+
* reveal_buyer: false,
|
|
69
|
+
* });
|
|
70
|
+
* // Owner reads aggregate:
|
|
71
|
+
* var prog = await reg.progressFor("alice-and-bob-2026");
|
|
72
|
+
* // prog.overall_percent === 100, prog.items[0].purchased === 1
|
|
73
|
+
*
|
|
74
|
+
* Storage: `migrations-d1/0086_gift_registry.sql` —
|
|
75
|
+
* `gift_registries` + `gift_registry_items` (ON DELETE CASCADE)
|
|
76
|
+
* + `gift_registry_purchases` (ON DELETE CASCADE on slug + item).
|
|
77
|
+
*
|
|
78
|
+
* Composes:
|
|
79
|
+
* - `b.guardUuid` — every customer / shipping_address id is
|
|
80
|
+
* UUID-shape-validated at the entry point.
|
|
81
|
+
* - `b.uuid.v7` — item + purchase row ids (lexicographic +
|
|
82
|
+
* monotonic so a tied `created_at` / `occurred_at` still sorts
|
|
83
|
+
* deterministically).
|
|
84
|
+
* - `b.safeSql` — column allow-list on `update(slug, patch)`.
|
|
85
|
+
*
|
|
86
|
+
* @primitive giftRegistry
|
|
87
|
+
* @related b.guardUuid, b.uuid, b.safeSql
|
|
88
|
+
*/
|
|
89
|
+
|
|
90
|
+
var bShop;
|
|
91
|
+
function _b() {
|
|
92
|
+
if (!bShop) bShop = require("./index");
|
|
93
|
+
return bShop.framework;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,198}[a-z0-9])?$/;
|
|
97
|
+
var MAX_TITLE_LEN = 200;
|
|
98
|
+
var MAX_NAME_LEN = 200;
|
|
99
|
+
var MAX_MESSAGE_LEN = 4000;
|
|
100
|
+
var MAX_BUYER_MSG = 1000;
|
|
101
|
+
var MAX_SKU_LEN = 200;
|
|
102
|
+
var MAX_LIMIT = 100;
|
|
103
|
+
var DEFAULT_LIMIT = 25;
|
|
104
|
+
var MAX_Q = 200;
|
|
105
|
+
var MIN_QUERY_LEN = 2;
|
|
106
|
+
|
|
107
|
+
var OCCASIONS = Object.freeze([
|
|
108
|
+
"wedding", "baby", "housewarming", "birthday", "graduation", "anniversary", "other",
|
|
109
|
+
]);
|
|
110
|
+
var PRIVACIES = Object.freeze(["private", "unlisted", "public"]);
|
|
111
|
+
var STATUSES = Object.freeze(["active", "closed"]);
|
|
112
|
+
|
|
113
|
+
// Columns mutable through `update(slug, patch)`. Slug + owner +
|
|
114
|
+
// occasion + status are immutable through the patch path — operators
|
|
115
|
+
// close via `closeRegistry`, and an operator who wants to re-key
|
|
116
|
+
// archives + recreates. `event_date` IS mutable (the wedding got
|
|
117
|
+
// moved); `privacy` IS mutable (operator widens the registry from
|
|
118
|
+
// unlisted to public after the announcement).
|
|
119
|
+
var ALLOWED_COLUMNS = Object.freeze([
|
|
120
|
+
"title", "event_date", "recipient_name", "shipping_address_id",
|
|
121
|
+
"message", "privacy",
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
// ---- validators ---------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
function _slug(s, label) {
|
|
127
|
+
if (typeof s !== "string" || !SLUG_RE.test(s)) {
|
|
128
|
+
throw new TypeError("giftRegistry: " + (label || "slug") +
|
|
129
|
+
" must be lowercase alnum + dash, no leading/trailing dash, 1..200 chars");
|
|
130
|
+
}
|
|
131
|
+
return s;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function _uuid(s, label) {
|
|
135
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
136
|
+
catch (e) { throw new TypeError("giftRegistry: " + label + " — " + (e && e.message || "invalid UUID")); }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function _optUuid(s, label) {
|
|
140
|
+
if (s == null || s === "") return null;
|
|
141
|
+
return _uuid(s, label);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function _title(s, label) {
|
|
145
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
|
|
146
|
+
throw new TypeError("giftRegistry: " + label + " must be a non-empty string <= " + MAX_TITLE_LEN + " chars");
|
|
147
|
+
}
|
|
148
|
+
// Refuse control bytes so a malicious title can't smuggle
|
|
149
|
+
// header-injection content into operator dashboards / email
|
|
150
|
+
// templates that render the title inline.
|
|
151
|
+
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(s)) {
|
|
152
|
+
throw new TypeError("giftRegistry: " + label + " must not contain control bytes");
|
|
153
|
+
}
|
|
154
|
+
return s;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function _recipientName(s) {
|
|
158
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_NAME_LEN) {
|
|
159
|
+
throw new TypeError("giftRegistry: recipient_name must be a non-empty string <= " + MAX_NAME_LEN + " chars");
|
|
160
|
+
}
|
|
161
|
+
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(s)) {
|
|
162
|
+
throw new TypeError("giftRegistry: recipient_name must not contain control bytes");
|
|
163
|
+
}
|
|
164
|
+
return s;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function _message(s, label, max) {
|
|
168
|
+
if (s == null) return "";
|
|
169
|
+
if (typeof s !== "string") {
|
|
170
|
+
throw new TypeError("giftRegistry: " + label + " must be a string");
|
|
171
|
+
}
|
|
172
|
+
if (s.length > max) {
|
|
173
|
+
throw new TypeError("giftRegistry: " + label + " must be <= " + max + " chars");
|
|
174
|
+
}
|
|
175
|
+
// CR/LF are valid in a multi-line message; refuse NUL + other
|
|
176
|
+
// non-printable control bytes only.
|
|
177
|
+
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(s)) {
|
|
178
|
+
throw new TypeError("giftRegistry: " + label + " must not contain control bytes");
|
|
179
|
+
}
|
|
180
|
+
return s;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function _occasion(s) {
|
|
184
|
+
if (typeof s !== "string" || OCCASIONS.indexOf(s) < 0) {
|
|
185
|
+
throw new TypeError("giftRegistry: occasion must be one of " + OCCASIONS.join(", "));
|
|
186
|
+
}
|
|
187
|
+
return s;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function _privacy(s) {
|
|
191
|
+
if (typeof s !== "string" || PRIVACIES.indexOf(s) < 0) {
|
|
192
|
+
throw new TypeError("giftRegistry: privacy must be one of " + PRIVACIES.join(", "));
|
|
193
|
+
}
|
|
194
|
+
return s;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function _eventDate(n, label) {
|
|
198
|
+
if (n == null) return null;
|
|
199
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n < 0) {
|
|
200
|
+
throw new TypeError("giftRegistry: " + label + " must be a non-negative integer epoch-ms or null");
|
|
201
|
+
}
|
|
202
|
+
return n;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function _sku(s) {
|
|
206
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_SKU_LEN) {
|
|
207
|
+
throw new TypeError("giftRegistry: sku must be a non-empty string <= " + MAX_SKU_LEN + " chars");
|
|
208
|
+
}
|
|
209
|
+
// SKUs are operator-authored identifiers; refuse control bytes
|
|
210
|
+
// (incl. CR/LF) so a SKU never smuggles header-injection content.
|
|
211
|
+
if (/[\x00-\x1f\x7f]/.test(s)) {
|
|
212
|
+
throw new TypeError("giftRegistry: sku must not contain control bytes");
|
|
213
|
+
}
|
|
214
|
+
return s;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function _qtyDesired(n) {
|
|
218
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
|
|
219
|
+
throw new TypeError("giftRegistry: quantity_desired must be a positive integer");
|
|
220
|
+
}
|
|
221
|
+
return n;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function _qtyPurchase(n) {
|
|
225
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
|
|
226
|
+
throw new TypeError("giftRegistry: quantity must be a positive integer");
|
|
227
|
+
}
|
|
228
|
+
return n;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function _priority(n) {
|
|
232
|
+
if (n == null) return 3;
|
|
233
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n < 1 || n > 5) {
|
|
234
|
+
throw new TypeError("giftRegistry: priority must be an integer in [1, 5]");
|
|
235
|
+
}
|
|
236
|
+
return n;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function _bool(v, label) {
|
|
240
|
+
if (v == null) return false;
|
|
241
|
+
if (typeof v !== "boolean") {
|
|
242
|
+
throw new TypeError("giftRegistry: " + label + " must be a boolean");
|
|
243
|
+
}
|
|
244
|
+
return v;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function _limit(n, label) {
|
|
248
|
+
if (n == null) return DEFAULT_LIMIT;
|
|
249
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_LIMIT) {
|
|
250
|
+
throw new TypeError("giftRegistry: " + label + " must be an integer in [1, " + MAX_LIMIT + "]");
|
|
251
|
+
}
|
|
252
|
+
return n;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function _now() { return Date.now(); }
|
|
256
|
+
|
|
257
|
+
// ---- factory ------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
function create(opts) {
|
|
260
|
+
opts = opts || {};
|
|
261
|
+
var query = opts.query;
|
|
262
|
+
if (!query) {
|
|
263
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
264
|
+
}
|
|
265
|
+
// `catalog` is accepted so a future additive primitive can resolve
|
|
266
|
+
// sku -> product metadata at registry-read time (e.g. surfacing
|
|
267
|
+
// out-of-stock items, price snapshots on the gift-giver view).
|
|
268
|
+
// The v1 surface validates sku as a non-empty string and trusts
|
|
269
|
+
// the cart/checkout primitives to refuse unknown skus at purchase
|
|
270
|
+
// time — the registry itself shouldn't refuse pre-launch products
|
|
271
|
+
// an operator wants to seed.
|
|
272
|
+
var catalog = opts.catalog || null;
|
|
273
|
+
void catalog;
|
|
274
|
+
|
|
275
|
+
// ---- internal helpers -------------------------------------------------
|
|
276
|
+
|
|
277
|
+
async function _row(slug) {
|
|
278
|
+
var r = await query("SELECT * FROM gift_registries WHERE slug = ?1", [slug]);
|
|
279
|
+
return r.rows[0] || null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function _itemRow(itemId) {
|
|
283
|
+
var r = await query(
|
|
284
|
+
"SELECT * FROM gift_registry_items WHERE id = ?1",
|
|
285
|
+
[itemId],
|
|
286
|
+
);
|
|
287
|
+
return r.rows[0] || null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Compute purchased qty per item via SUM aggregation. Single
|
|
291
|
+
// round-trip; the (registry_slug, item_id) index covers the GROUP BY.
|
|
292
|
+
async function _purchasedByItem(slug) {
|
|
293
|
+
var r = await query(
|
|
294
|
+
"SELECT item_id, SUM(quantity) AS purchased " +
|
|
295
|
+
"FROM gift_registry_purchases WHERE registry_slug = ?1 " +
|
|
296
|
+
"GROUP BY item_id",
|
|
297
|
+
[slug],
|
|
298
|
+
);
|
|
299
|
+
var byItem = Object.create(null);
|
|
300
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
301
|
+
byItem[r.rows[i].item_id] = Number(r.rows[i].purchased) || 0;
|
|
302
|
+
}
|
|
303
|
+
return byItem;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Strip the buyer identity from a purchase row when the buyer
|
|
307
|
+
// chose not to reveal. The row is still kept verbatim in storage
|
|
308
|
+
// for operator audit / refund-handling purposes; this redaction
|
|
309
|
+
// only applies to the owner-facing return shape.
|
|
310
|
+
function _redactPurchase(row) {
|
|
311
|
+
var reveal = row.reveal_buyer === 1 || row.reveal_buyer === true;
|
|
312
|
+
return {
|
|
313
|
+
id: row.id,
|
|
314
|
+
registry_slug: row.registry_slug,
|
|
315
|
+
item_id: row.item_id,
|
|
316
|
+
quantity: row.quantity,
|
|
317
|
+
buyer_customer_id: reveal ? row.buyer_customer_id : null,
|
|
318
|
+
buyer_message: row.buyer_message,
|
|
319
|
+
reveal_buyer: reveal,
|
|
320
|
+
occurred_at: row.occurred_at,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function _decodeRegistry(row) {
|
|
325
|
+
if (!row) return null;
|
|
326
|
+
return {
|
|
327
|
+
slug: row.slug,
|
|
328
|
+
owner_customer_id: row.owner_customer_id,
|
|
329
|
+
title: row.title,
|
|
330
|
+
occasion: row.occasion,
|
|
331
|
+
event_date: row.event_date,
|
|
332
|
+
recipient_name: row.recipient_name,
|
|
333
|
+
shipping_address_id: row.shipping_address_id,
|
|
334
|
+
message: row.message,
|
|
335
|
+
privacy: row.privacy,
|
|
336
|
+
status: row.status,
|
|
337
|
+
closed_at: row.closed_at,
|
|
338
|
+
created_at: row.created_at,
|
|
339
|
+
updated_at: row.updated_at,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function _decodeItem(row) {
|
|
344
|
+
return {
|
|
345
|
+
id: row.id,
|
|
346
|
+
registry_slug: row.registry_slug,
|
|
347
|
+
sku: row.sku,
|
|
348
|
+
variant_id: row.variant_id,
|
|
349
|
+
quantity_desired: row.quantity_desired,
|
|
350
|
+
priority: row.priority,
|
|
351
|
+
archived_at: row.archived_at,
|
|
352
|
+
created_at: row.created_at,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ---- createRegistry ---------------------------------------------------
|
|
357
|
+
|
|
358
|
+
async function createRegistry(input) {
|
|
359
|
+
if (!input || typeof input !== "object") {
|
|
360
|
+
throw new TypeError("giftRegistry.createRegistry: input object required");
|
|
361
|
+
}
|
|
362
|
+
var ownerId = _uuid(input.owner_customer_id, "owner_customer_id");
|
|
363
|
+
var slug = _slug(input.slug, "slug");
|
|
364
|
+
var title = _title(input.title, "title");
|
|
365
|
+
var occasion = _occasion(input.occasion);
|
|
366
|
+
var eventDate = _eventDate(input.event_date, "event_date");
|
|
367
|
+
var recipient = _recipientName(input.recipient_name);
|
|
368
|
+
var shipAddr = _optUuid(input.shipping_address_id, "shipping_address_id");
|
|
369
|
+
var message = _message(input.message, "message", MAX_MESSAGE_LEN);
|
|
370
|
+
var privacy = _privacy(input.privacy);
|
|
371
|
+
|
|
372
|
+
var existing = await _row(slug);
|
|
373
|
+
if (existing) {
|
|
374
|
+
throw new TypeError("giftRegistry.createRegistry: slug " + JSON.stringify(slug) + " already exists");
|
|
375
|
+
}
|
|
376
|
+
var ts = _now();
|
|
377
|
+
await query(
|
|
378
|
+
"INSERT INTO gift_registries " +
|
|
379
|
+
"(slug, owner_customer_id, title, occasion, event_date, recipient_name, shipping_address_id, message, privacy, status, closed_at, created_at, updated_at) " +
|
|
380
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'active', NULL, ?10, ?10)",
|
|
381
|
+
[slug, ownerId, title, occasion, eventDate, recipient, shipAddr, message, privacy, ts],
|
|
382
|
+
);
|
|
383
|
+
return _decodeRegistry(await _row(slug));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ---- getRegistry / getBySlug ------------------------------------------
|
|
387
|
+
|
|
388
|
+
async function getRegistry(slug) {
|
|
389
|
+
_slug(slug, "slug");
|
|
390
|
+
return _decodeRegistry(await _row(slug));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function getBySlug(slug, getOpts) {
|
|
394
|
+
_slug(slug, "slug");
|
|
395
|
+
getOpts = getOpts || {};
|
|
396
|
+
var includePurchased = getOpts.include_purchased === true;
|
|
397
|
+
var row = await _row(slug);
|
|
398
|
+
if (!row) return null;
|
|
399
|
+
var reg = _decodeRegistry(row);
|
|
400
|
+
// Items: surface non-archived by default; archived items are
|
|
401
|
+
// operator-internal (the owner already removed them from the
|
|
402
|
+
// public view). Each item carries its purchased-aggregate count
|
|
403
|
+
// so a gift-giver browsing the registry sees "0 of 2 purchased".
|
|
404
|
+
var itemsRes = await query(
|
|
405
|
+
"SELECT * FROM gift_registry_items " +
|
|
406
|
+
"WHERE registry_slug = ?1 AND archived_at IS NULL " +
|
|
407
|
+
"ORDER BY priority ASC, created_at ASC, id ASC",
|
|
408
|
+
[slug],
|
|
409
|
+
);
|
|
410
|
+
var purchasedMap = await _purchasedByItem(slug);
|
|
411
|
+
var items = [];
|
|
412
|
+
for (var i = 0; i < itemsRes.rows.length; i += 1) {
|
|
413
|
+
var it = _decodeItem(itemsRes.rows[i]);
|
|
414
|
+
it.purchased_quantity = purchasedMap[it.id] || 0;
|
|
415
|
+
items.push(it);
|
|
416
|
+
}
|
|
417
|
+
var out = {
|
|
418
|
+
registry: reg,
|
|
419
|
+
items: items,
|
|
420
|
+
};
|
|
421
|
+
if (includePurchased) {
|
|
422
|
+
var purchasesRes = await query(
|
|
423
|
+
"SELECT * FROM gift_registry_purchases " +
|
|
424
|
+
"WHERE registry_slug = ?1 " +
|
|
425
|
+
"ORDER BY occurred_at ASC, id ASC",
|
|
426
|
+
[slug],
|
|
427
|
+
);
|
|
428
|
+
var purchases = [];
|
|
429
|
+
for (var j = 0; j < purchasesRes.rows.length; j += 1) {
|
|
430
|
+
purchases.push(_redactPurchase(purchasesRes.rows[j]));
|
|
431
|
+
}
|
|
432
|
+
out.purchases = purchases;
|
|
433
|
+
}
|
|
434
|
+
return out;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ---- listForOwner -----------------------------------------------------
|
|
438
|
+
|
|
439
|
+
async function listForOwner(customerId) {
|
|
440
|
+
var ownerId = _uuid(customerId, "customer_id");
|
|
441
|
+
var r = await query(
|
|
442
|
+
"SELECT * FROM gift_registries WHERE owner_customer_id = ?1 " +
|
|
443
|
+
"ORDER BY updated_at DESC, slug DESC",
|
|
444
|
+
[ownerId],
|
|
445
|
+
);
|
|
446
|
+
var out = [];
|
|
447
|
+
for (var i = 0; i < r.rows.length; i += 1) out.push(_decodeRegistry(r.rows[i]));
|
|
448
|
+
return out;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ---- searchPublic -----------------------------------------------------
|
|
452
|
+
|
|
453
|
+
async function searchPublic(input) {
|
|
454
|
+
if (!input || typeof input !== "object") {
|
|
455
|
+
throw new TypeError("giftRegistry.searchPublic: input object required");
|
|
456
|
+
}
|
|
457
|
+
if (typeof input.q !== "string") {
|
|
458
|
+
throw new TypeError("giftRegistry.searchPublic: q must be a string");
|
|
459
|
+
}
|
|
460
|
+
var q = input.q.trim();
|
|
461
|
+
if (q.length < MIN_QUERY_LEN || q.length > MAX_Q) {
|
|
462
|
+
throw new TypeError("giftRegistry.searchPublic: q length must be in [" +
|
|
463
|
+
MIN_QUERY_LEN + ", " + MAX_Q + "]");
|
|
464
|
+
}
|
|
465
|
+
var limit = _limit(input.limit, "limit");
|
|
466
|
+
// Defense in depth: refuse control bytes in the query so a
|
|
467
|
+
// crafted q can't smuggle log-injection content into operator
|
|
468
|
+
// dashboards that render the term back unescaped.
|
|
469
|
+
if (/[\x00-\x1f\x7f]/.test(q)) {
|
|
470
|
+
throw new TypeError("giftRegistry.searchPublic: q must not contain control bytes");
|
|
471
|
+
}
|
|
472
|
+
// Escape LIKE metachars so a query containing `%` or `_` matches
|
|
473
|
+
// the literal character. The lower(...) wrapping gives
|
|
474
|
+
// case-insensitive matching against title + recipient_name
|
|
475
|
+
// without needing a separate normalized column.
|
|
476
|
+
var qLower = q.toLowerCase();
|
|
477
|
+
var pattern = "%" + qLower
|
|
478
|
+
.replace(/\\/g, "\\\\")
|
|
479
|
+
.replace(/%/g, "\\%")
|
|
480
|
+
.replace(/_/g, "\\_") + "%";
|
|
481
|
+
// Only `public` + `active` registries surface in searchPublic.
|
|
482
|
+
// Closed registries drop out (the event already happened); the
|
|
483
|
+
// owner can still view + share them through getBySlug.
|
|
484
|
+
var r = await query(
|
|
485
|
+
"SELECT * FROM gift_registries " +
|
|
486
|
+
"WHERE privacy = 'public' AND status = 'active' " +
|
|
487
|
+
"AND (lower(title) LIKE ?1 ESCAPE '\\' OR lower(recipient_name) LIKE ?1 ESCAPE '\\') " +
|
|
488
|
+
"ORDER BY " +
|
|
489
|
+
" CASE WHEN event_date IS NULL THEN 1 ELSE 0 END ASC, " +
|
|
490
|
+
" event_date ASC, slug ASC " +
|
|
491
|
+
"LIMIT ?2",
|
|
492
|
+
[pattern, limit],
|
|
493
|
+
);
|
|
494
|
+
var out = [];
|
|
495
|
+
for (var i = 0; i < r.rows.length; i += 1) out.push(_decodeRegistry(r.rows[i]));
|
|
496
|
+
return out;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ---- addItem ----------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
async function addItem(input) {
|
|
502
|
+
if (!input || typeof input !== "object") {
|
|
503
|
+
throw new TypeError("giftRegistry.addItem: input object required");
|
|
504
|
+
}
|
|
505
|
+
var slug = _slug(input.registry_slug, "registry_slug");
|
|
506
|
+
var sku = _sku(input.sku);
|
|
507
|
+
var variantId = _optUuid(input.variant_id, "variant_id");
|
|
508
|
+
var qty = _qtyDesired(input.quantity_desired);
|
|
509
|
+
var priority = _priority(input.priority);
|
|
510
|
+
|
|
511
|
+
var row = await _row(slug);
|
|
512
|
+
if (!row) {
|
|
513
|
+
throw new TypeError("giftRegistry.addItem: registry " + JSON.stringify(slug) + " not found");
|
|
514
|
+
}
|
|
515
|
+
if (row.status !== "active") {
|
|
516
|
+
throw new TypeError("giftRegistry.addItem: registry " + JSON.stringify(slug) + " is closed");
|
|
517
|
+
}
|
|
518
|
+
// Refuse duplicate (sku, variant) — the UNIQUE constraint would
|
|
519
|
+
// refuse it anyway, but a clean TypeError reads better than an
|
|
520
|
+
// opaque SQLITE_CONSTRAINT at the application layer.
|
|
521
|
+
var dup = await query(
|
|
522
|
+
"SELECT id FROM gift_registry_items " +
|
|
523
|
+
"WHERE registry_slug = ?1 AND sku = ?2 " +
|
|
524
|
+
"AND COALESCE(variant_id, '') = COALESCE(?3, '') " +
|
|
525
|
+
"AND archived_at IS NULL LIMIT 1",
|
|
526
|
+
[slug, sku, variantId],
|
|
527
|
+
);
|
|
528
|
+
if (dup.rows.length > 0) {
|
|
529
|
+
throw new TypeError("giftRegistry.addItem: sku " + JSON.stringify(sku) +
|
|
530
|
+
" is already on registry " + JSON.stringify(slug));
|
|
531
|
+
}
|
|
532
|
+
var id = _b().uuid.v7();
|
|
533
|
+
var ts = _now();
|
|
534
|
+
await query(
|
|
535
|
+
"INSERT INTO gift_registry_items " +
|
|
536
|
+
"(id, registry_slug, sku, variant_id, quantity_desired, priority, archived_at, created_at) " +
|
|
537
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7)",
|
|
538
|
+
[id, slug, sku, variantId, qty, priority, ts],
|
|
539
|
+
);
|
|
540
|
+
await query(
|
|
541
|
+
"UPDATE gift_registries SET updated_at = ?1 WHERE slug = ?2",
|
|
542
|
+
[ts, slug],
|
|
543
|
+
);
|
|
544
|
+
return _decodeItem({
|
|
545
|
+
id: id,
|
|
546
|
+
registry_slug: slug,
|
|
547
|
+
sku: sku,
|
|
548
|
+
variant_id: variantId,
|
|
549
|
+
quantity_desired: qty,
|
|
550
|
+
priority: priority,
|
|
551
|
+
archived_at: null,
|
|
552
|
+
created_at: ts,
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// ---- removeItem -------------------------------------------------------
|
|
557
|
+
|
|
558
|
+
async function removeItem(input) {
|
|
559
|
+
if (!input || typeof input !== "object") {
|
|
560
|
+
throw new TypeError("giftRegistry.removeItem: input object required");
|
|
561
|
+
}
|
|
562
|
+
var slug = _slug(input.registry_slug, "registry_slug");
|
|
563
|
+
var itemId = _uuid(input.item_id, "item_id");
|
|
564
|
+
|
|
565
|
+
var row = await _row(slug);
|
|
566
|
+
if (!row) {
|
|
567
|
+
throw new TypeError("giftRegistry.removeItem: registry " + JSON.stringify(slug) + " not found");
|
|
568
|
+
}
|
|
569
|
+
if (row.status !== "active") {
|
|
570
|
+
throw new TypeError("giftRegistry.removeItem: registry " + JSON.stringify(slug) + " is closed");
|
|
571
|
+
}
|
|
572
|
+
// Soft-delete via archived_at — the item row is preserved so
|
|
573
|
+
// existing purchase rows retain a valid FK referent. The reads
|
|
574
|
+
// (`getBySlug`, `progressFor`) filter out archived items by
|
|
575
|
+
// default.
|
|
576
|
+
var ts = _now();
|
|
577
|
+
var r = await query(
|
|
578
|
+
"UPDATE gift_registry_items SET archived_at = ?1 " +
|
|
579
|
+
"WHERE id = ?2 AND registry_slug = ?3 AND archived_at IS NULL",
|
|
580
|
+
[ts, itemId, slug],
|
|
581
|
+
);
|
|
582
|
+
if (Number(r.rowCount || 0) === 0) {
|
|
583
|
+
return { removed: false };
|
|
584
|
+
}
|
|
585
|
+
await query(
|
|
586
|
+
"UPDATE gift_registries SET updated_at = ?1 WHERE slug = ?2",
|
|
587
|
+
[ts, slug],
|
|
588
|
+
);
|
|
589
|
+
return { removed: true };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// ---- purchaseItem -----------------------------------------------------
|
|
593
|
+
|
|
594
|
+
async function purchaseItem(input) {
|
|
595
|
+
if (!input || typeof input !== "object") {
|
|
596
|
+
throw new TypeError("giftRegistry.purchaseItem: input object required");
|
|
597
|
+
}
|
|
598
|
+
var slug = _slug(input.registry_slug, "registry_slug");
|
|
599
|
+
var itemId = _uuid(input.item_id, "item_id");
|
|
600
|
+
var qty = _qtyPurchase(input.quantity);
|
|
601
|
+
var buyerId = _optUuid(input.buyer_customer_id, "buyer_customer_id");
|
|
602
|
+
var buyerMsg = _message(input.buyer_message, "buyer_message", MAX_BUYER_MSG);
|
|
603
|
+
var reveal = _bool(input.reveal_buyer, "reveal_buyer");
|
|
604
|
+
|
|
605
|
+
// When the buyer wants to reveal, they MUST be a registered
|
|
606
|
+
// customer — an anonymous reveal makes no sense (there's nothing
|
|
607
|
+
// to surface to the owner). Refuse the inconsistent pair up
|
|
608
|
+
// front rather than silently storing reveal=true with a NULL
|
|
609
|
+
// buyer.
|
|
610
|
+
if (reveal && !buyerId) {
|
|
611
|
+
throw new TypeError("giftRegistry.purchaseItem: reveal_buyer=true requires buyer_customer_id");
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
var reg = await _row(slug);
|
|
615
|
+
if (!reg) {
|
|
616
|
+
throw new TypeError("giftRegistry.purchaseItem: registry " + JSON.stringify(slug) + " not found");
|
|
617
|
+
}
|
|
618
|
+
if (reg.status !== "active") {
|
|
619
|
+
throw new TypeError("giftRegistry.purchaseItem: registry " + JSON.stringify(slug) + " is closed");
|
|
620
|
+
}
|
|
621
|
+
var item = await _itemRow(itemId);
|
|
622
|
+
if (!item || item.registry_slug !== slug) {
|
|
623
|
+
throw new TypeError("giftRegistry.purchaseItem: item " + JSON.stringify(itemId) +
|
|
624
|
+
" not found on registry " + JSON.stringify(slug));
|
|
625
|
+
}
|
|
626
|
+
if (item.archived_at != null) {
|
|
627
|
+
throw new TypeError("giftRegistry.purchaseItem: item " + JSON.stringify(itemId) + " has been removed");
|
|
628
|
+
}
|
|
629
|
+
// Refuse over-purchase: aggregate prior purchases + this one
|
|
630
|
+
// must not exceed `quantity_desired`. The owner's wish-list is
|
|
631
|
+
// authoritative — a fourth blender is not a gift, it's a return
|
|
632
|
+
// pending.
|
|
633
|
+
var priorRes = await query(
|
|
634
|
+
"SELECT COALESCE(SUM(quantity), 0) AS sum FROM gift_registry_purchases " +
|
|
635
|
+
"WHERE registry_slug = ?1 AND item_id = ?2",
|
|
636
|
+
[slug, itemId],
|
|
637
|
+
);
|
|
638
|
+
var prior = Number((priorRes.rows[0] || {}).sum || 0);
|
|
639
|
+
if (prior + qty > item.quantity_desired) {
|
|
640
|
+
var over = new Error(
|
|
641
|
+
"giftRegistry.purchaseItem: quantity " + qty +
|
|
642
|
+
" exceeds remaining " + (item.quantity_desired - prior) +
|
|
643
|
+
" on item " + itemId,
|
|
644
|
+
);
|
|
645
|
+
over.code = "GIFT_REGISTRY_OVER_PURCHASE";
|
|
646
|
+
throw over;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
var id = _b().uuid.v7();
|
|
650
|
+
var ts = _now();
|
|
651
|
+
await query(
|
|
652
|
+
"INSERT INTO gift_registry_purchases " +
|
|
653
|
+
"(id, registry_slug, item_id, quantity, buyer_customer_id, buyer_message, reveal_buyer, occurred_at) " +
|
|
654
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
|
655
|
+
[id, slug, itemId, qty, buyerId, buyerMsg, reveal ? 1 : 0, ts],
|
|
656
|
+
);
|
|
657
|
+
await query(
|
|
658
|
+
"UPDATE gift_registries SET updated_at = ?1 WHERE slug = ?2",
|
|
659
|
+
[ts, slug],
|
|
660
|
+
);
|
|
661
|
+
return _redactPurchase({
|
|
662
|
+
id: id,
|
|
663
|
+
registry_slug: slug,
|
|
664
|
+
item_id: itemId,
|
|
665
|
+
quantity: qty,
|
|
666
|
+
buyer_customer_id: buyerId,
|
|
667
|
+
buyer_message: buyerMsg,
|
|
668
|
+
reveal_buyer: reveal ? 1 : 0,
|
|
669
|
+
occurred_at: ts,
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ---- progressFor ------------------------------------------------------
|
|
674
|
+
|
|
675
|
+
async function progressFor(slug) {
|
|
676
|
+
_slug(slug, "slug");
|
|
677
|
+
var reg = await _row(slug);
|
|
678
|
+
if (!reg) return null;
|
|
679
|
+
var itemsRes = await query(
|
|
680
|
+
"SELECT id, sku, variant_id, quantity_desired, priority FROM gift_registry_items " +
|
|
681
|
+
"WHERE registry_slug = ?1 AND archived_at IS NULL " +
|
|
682
|
+
"ORDER BY priority ASC, created_at ASC, id ASC",
|
|
683
|
+
[slug],
|
|
684
|
+
);
|
|
685
|
+
var purchasedMap = await _purchasedByItem(slug);
|
|
686
|
+
var totalDesired = 0;
|
|
687
|
+
var totalPurchased = 0;
|
|
688
|
+
var items = [];
|
|
689
|
+
for (var i = 0; i < itemsRes.rows.length; i += 1) {
|
|
690
|
+
var r = itemsRes.rows[i];
|
|
691
|
+
var purchased = purchasedMap[r.id] || 0;
|
|
692
|
+
// Cap at desired so an aggregate that somehow over-shoots
|
|
693
|
+
// (shouldn't happen because purchaseItem refuses, but the
|
|
694
|
+
// aggregate is defensive against operator-side hand edits)
|
|
695
|
+
// doesn't push overall_percent above 100.
|
|
696
|
+
if (purchased > r.quantity_desired) purchased = r.quantity_desired;
|
|
697
|
+
totalDesired += r.quantity_desired;
|
|
698
|
+
totalPurchased += purchased;
|
|
699
|
+
items.push({
|
|
700
|
+
item_id: r.id,
|
|
701
|
+
sku: r.sku,
|
|
702
|
+
variant_id: r.variant_id,
|
|
703
|
+
quantity_desired: r.quantity_desired,
|
|
704
|
+
purchased: purchased,
|
|
705
|
+
remaining: r.quantity_desired - purchased,
|
|
706
|
+
percent: r.quantity_desired === 0 ? 0
|
|
707
|
+
: Math.round((purchased / r.quantity_desired) * 100),
|
|
708
|
+
priority: r.priority,
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
var overall = totalDesired === 0 ? 0
|
|
712
|
+
: Math.round((totalPurchased / totalDesired) * 100);
|
|
713
|
+
return {
|
|
714
|
+
slug: slug,
|
|
715
|
+
total_desired: totalDesired,
|
|
716
|
+
total_purchased: totalPurchased,
|
|
717
|
+
overall_percent: overall,
|
|
718
|
+
items: items,
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// ---- closeRegistry ----------------------------------------------------
|
|
723
|
+
|
|
724
|
+
async function closeRegistry(slug) {
|
|
725
|
+
_slug(slug, "slug");
|
|
726
|
+
var ts = _now();
|
|
727
|
+
var r = await query(
|
|
728
|
+
"UPDATE gift_registries SET status = 'closed', closed_at = ?1, updated_at = ?1 " +
|
|
729
|
+
"WHERE slug = ?2 AND status = 'active'",
|
|
730
|
+
[ts, slug],
|
|
731
|
+
);
|
|
732
|
+
if (Number(r.rowCount || 0) === 0) {
|
|
733
|
+
// Either missing or already closed — disambiguate so the
|
|
734
|
+
// caller can distinguish "no such registry" from "already
|
|
735
|
+
// closed, here's the existing row".
|
|
736
|
+
var existing = await _row(slug);
|
|
737
|
+
if (!existing) return null;
|
|
738
|
+
return _decodeRegistry(existing);
|
|
739
|
+
}
|
|
740
|
+
return _decodeRegistry(await _row(slug));
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// ---- update -----------------------------------------------------------
|
|
744
|
+
|
|
745
|
+
async function update(slug, patch) {
|
|
746
|
+
_slug(slug, "slug");
|
|
747
|
+
if (!patch || typeof patch !== "object") {
|
|
748
|
+
throw new TypeError("giftRegistry.update: patch object required");
|
|
749
|
+
}
|
|
750
|
+
var existing = await _row(slug);
|
|
751
|
+
if (!existing) return null;
|
|
752
|
+
if (existing.status !== "active") {
|
|
753
|
+
throw new TypeError("giftRegistry.update: registry " + JSON.stringify(slug) + " is closed");
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
var sets = [];
|
|
757
|
+
var params = [];
|
|
758
|
+
var idx = 1;
|
|
759
|
+
function _addSet(col, val) {
|
|
760
|
+
_b().safeSql.assertOneOf(col, ALLOWED_COLUMNS);
|
|
761
|
+
sets.push(_b().safeSql.quoteIdentifier(col, "sqlite") + " = ?" + (idx++));
|
|
762
|
+
params.push(val);
|
|
763
|
+
}
|
|
764
|
+
if (patch.title !== undefined) {
|
|
765
|
+
_addSet("title", _title(patch.title, "title"));
|
|
766
|
+
}
|
|
767
|
+
if (patch.event_date !== undefined) {
|
|
768
|
+
_addSet("event_date", _eventDate(patch.event_date, "event_date"));
|
|
769
|
+
}
|
|
770
|
+
if (patch.recipient_name !== undefined) {
|
|
771
|
+
_addSet("recipient_name", _recipientName(patch.recipient_name));
|
|
772
|
+
}
|
|
773
|
+
if (patch.shipping_address_id !== undefined) {
|
|
774
|
+
_addSet("shipping_address_id", _optUuid(patch.shipping_address_id, "shipping_address_id"));
|
|
775
|
+
}
|
|
776
|
+
if (patch.message !== undefined) {
|
|
777
|
+
_addSet("message", _message(patch.message, "message", MAX_MESSAGE_LEN));
|
|
778
|
+
}
|
|
779
|
+
if (patch.privacy !== undefined) {
|
|
780
|
+
_addSet("privacy", _privacy(patch.privacy));
|
|
781
|
+
}
|
|
782
|
+
if (sets.length === 0) {
|
|
783
|
+
throw new TypeError("giftRegistry.update: patch contained no updatable fields");
|
|
784
|
+
}
|
|
785
|
+
var ts = _now();
|
|
786
|
+
sets.push("updated_at = ?" + (idx++));
|
|
787
|
+
params.push(ts);
|
|
788
|
+
params.push(slug);
|
|
789
|
+
await query(
|
|
790
|
+
"UPDATE gift_registries SET " + sets.join(", ") + " WHERE slug = ?" + idx,
|
|
791
|
+
params,
|
|
792
|
+
);
|
|
793
|
+
return _decodeRegistry(await _row(slug));
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
return {
|
|
797
|
+
OCCASIONS: OCCASIONS.slice(),
|
|
798
|
+
PRIVACIES: PRIVACIES.slice(),
|
|
799
|
+
STATUSES: STATUSES.slice(),
|
|
800
|
+
|
|
801
|
+
createRegistry: createRegistry,
|
|
802
|
+
addItem: addItem,
|
|
803
|
+
removeItem: removeItem,
|
|
804
|
+
getRegistry: getRegistry,
|
|
805
|
+
getBySlug: getBySlug,
|
|
806
|
+
listForOwner: listForOwner,
|
|
807
|
+
searchPublic: searchPublic,
|
|
808
|
+
purchaseItem: purchaseItem,
|
|
809
|
+
progressFor: progressFor,
|
|
810
|
+
closeRegistry: closeRegistry,
|
|
811
|
+
update: update,
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
module.exports = {
|
|
816
|
+
create: create,
|
|
817
|
+
OCCASIONS: OCCASIONS,
|
|
818
|
+
PRIVACIES: PRIVACIES,
|
|
819
|
+
STATUSES: STATUSES,
|
|
820
|
+
};
|