@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.
@@ -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
+ };