@blamejs/blamejs-shop 0.1.20 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +6 -2
  3. package/SECURITY.md +12 -0
  4. package/lib/admin.js +944 -1
  5. package/lib/checkout.js +192 -22
  6. package/lib/collections.js +4 -3
  7. package/lib/customers.js +102 -0
  8. package/lib/giftcards.js +40 -0
  9. package/lib/order.js +22 -0
  10. package/lib/storefront.js +400 -53
  11. package/lib/vendor/MANIFEST.json +3 -3
  12. package/lib/vendor/blamejs/CHANGELOG.md +10 -0
  13. package/lib/vendor/blamejs/README.md +4 -0
  14. package/lib/vendor/blamejs/api-snapshot.json +129 -2
  15. package/lib/vendor/blamejs/index.js +12 -0
  16. package/lib/vendor/blamejs/lib/json-merge-patch.js +93 -0
  17. package/lib/vendor/blamejs/lib/json-patch.js +206 -0
  18. package/lib/vendor/blamejs/lib/json-path.js +638 -0
  19. package/lib/vendor/blamejs/lib/json-pointer.js +109 -0
  20. package/lib/vendor/blamejs/lib/jtd.js +234 -0
  21. package/lib/vendor/blamejs/lib/link-header.js +169 -0
  22. package/lib/vendor/blamejs/package.json +1 -1
  23. package/lib/vendor/blamejs/release-notes/v0.12.57.json +18 -0
  24. package/lib/vendor/blamejs/release-notes/v0.12.58.json +22 -0
  25. package/lib/vendor/blamejs/release-notes/v0.12.60.json +18 -0
  26. package/lib/vendor/blamejs/release-notes/v0.12.61.json +18 -0
  27. package/lib/vendor/blamejs/release-notes/v0.12.62.json +18 -0
  28. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +18 -0
  29. package/lib/vendor/blamejs/test/layer-0-primitives/json-merge-patch.test.js +81 -0
  30. package/lib/vendor/blamejs/test/layer-0-primitives/json-patch.test.js +184 -0
  31. package/lib/vendor/blamejs/test/layer-0-primitives/json-path.test.js +113 -0
  32. package/lib/vendor/blamejs/test/layer-0-primitives/jtd.test.js +52 -0
  33. package/lib/vendor/blamejs/test/layer-0-primitives/link-header.test.js +96 -0
  34. package/package.json +4 -2
package/lib/checkout.js CHANGED
@@ -106,6 +106,93 @@ function create(deps) {
106
106
  // customers.hashEmail keying the customers table) and stored on the
107
107
  // order so a later verified-email sign-in can claim the guest order.
108
108
  var customers = deps.customers || null;
109
+ // Optional gift-card credit. `giftcards` owns the bearer credential
110
+ // (lookup + the atomic balance decrement); `giftCardLedger` writes
111
+ // the audit row. Gift-card redemption at checkout disables when
112
+ // either is absent — the rest of the flow is unchanged.
113
+ var giftcards = deps.giftcards || null;
114
+ var giftCardLedger = deps.giftCardLedger || null;
115
+
116
+ // Validate a gift-card code against a priced quote: the card exists,
117
+ // is active, not expired, and matches the order currency. Returns
118
+ // the credit to apply (capped at the grand total so an order total
119
+ // can never go negative) plus the resolved card row. Throws a
120
+ // structured (non-TypeError) error on a code the customer typed that
121
+ // can't be applied so callers surface a clean message; returns null
122
+ // when no code was supplied or gift-card redemption isn't wired.
123
+ async function _resolveGiftCard(code, quote) {
124
+ if (code == null || code === "") return null;
125
+ if (!giftcards) {
126
+ var unwired = new Error("checkout: gift-card redemption is not configured");
127
+ unwired.code = "GIFTCARD_NOT_CONFIGURED";
128
+ throw unwired;
129
+ }
130
+ // _lookup throws TypeError on a malformed code (wrong length /
131
+ // out-of-alphabet). Treat that as a generic "not recognized" so
132
+ // the storefront never distinguishes "doesn't exist" from
133
+ // "malformed" — both are the same dead end to a customer.
134
+ var card;
135
+ try {
136
+ card = await giftcards.lookup(code);
137
+ } catch (e) {
138
+ if (e instanceof TypeError) card = null;
139
+ else throw e;
140
+ }
141
+ if (!card) {
142
+ var miss = new Error("checkout: gift card not recognized");
143
+ miss.code = "GIFTCARD_NOT_FOUND";
144
+ throw miss;
145
+ }
146
+ if (card.status !== "active") {
147
+ var inactive = new Error("checkout: gift card is " + card.status);
148
+ inactive.code = "GIFTCARD_NOT_ACTIVE";
149
+ throw inactive;
150
+ }
151
+ if (card.expires_at != null && card.expires_at <= Date.now()) {
152
+ var expired = new Error("checkout: gift card is expired");
153
+ expired.code = "GIFTCARD_EXPIRED";
154
+ throw expired;
155
+ }
156
+ if (card.currency !== quote.currency) {
157
+ var mismatch = new Error("checkout: gift card currency " + card.currency +
158
+ " does not match order currency " + quote.currency);
159
+ mismatch.code = "GIFTCARD_CURRENCY_MISMATCH";
160
+ throw mismatch;
161
+ }
162
+ // Credit is the lesser of the card balance and the grand total —
163
+ // a card worth more than the order leaves the remainder on the
164
+ // card; a card worth less reduces the amount due, never below 0.
165
+ var grand = quote.totals.grand_total_minor;
166
+ var applied = card.balance_minor < grand ? card.balance_minor : grand;
167
+ // `code_plain` is the bearer code the redeem decrement re-hashes;
168
+ // it lives only in this in-memory resolution, never persisted.
169
+ return { card: card, code_plain: code, applied_minor: applied, balance_minor: card.balance_minor };
170
+ }
171
+
172
+ // Burn the resolved credit against the created order. The
173
+ // `giftcards.redeem` decrement is the authoritative double-spend
174
+ // guard — its `balance_minor >= ?` SQL predicate refuses a second
175
+ // spend that would overdraw, and a re-quote of the same card on a
176
+ // new order only ever debits the remaining balance. The ledger
177
+ // debit rides alongside as the operator-facing audit row, keyed on
178
+ // the order id. Called once per order, immediately after the order
179
+ // row exists, so a card is never burned for an order that failed to
180
+ // create.
181
+ async function _redeemGiftCard(resolved, orderId) {
182
+ var redemption = await giftcards.redeem({
183
+ code: resolved.code_plain,
184
+ order_id: orderId,
185
+ amount_minor: resolved.applied_minor,
186
+ });
187
+ if (giftCardLedger) {
188
+ await giftCardLedger.debit({
189
+ gift_card_id: resolved.card.id,
190
+ order_id: orderId,
191
+ amount_minor: resolved.applied_minor,
192
+ });
193
+ }
194
+ return redemption;
195
+ }
109
196
 
110
197
  // Compose a quote from a cart + ship-to + (optional) selected
111
198
  // shipping service. Pure read — no DB writes.
@@ -200,9 +287,63 @@ function create(deps) {
200
287
  throw new TypeError("checkout.confirm: grand_total_minor must be > 0 (zero-total orders use a separate freebie flow)");
201
288
  }
202
289
 
203
- // Create the PaymentIntent (idempotent via header).
290
+ // Resolve an optional gift-card credit BEFORE any charge so a
291
+ // bad code fails the checkout without touching Stripe. The
292
+ // credit reduces the amount due; the order still records the
293
+ // full grand total it owed.
294
+ var gc = await _resolveGiftCard(input.gift_card_code, quote);
295
+ var amountDue = quote.totals.grand_total_minor - (gc ? gc.applied_minor : 0);
296
+
297
+ var orderLines = quote.lines.map(function (l) {
298
+ return {
299
+ variant_id: l.variant_id,
300
+ sku: l.sku,
301
+ qty: l.qty,
302
+ unit_amount_minor: l.unit_amount_minor,
303
+ unit_currency: l.unit_currency,
304
+ };
305
+ });
306
+ var cartRow = await cart.get(quote.cart_id);
307
+ // Hash of the buyer email (same key as the customers table) so a
308
+ // later verified-email sign-in can claim this guest order.
309
+ var emailHash = customers ? customers.hashEmail(email) : null;
310
+
311
+ // Zero amount due — the gift card fully covers the order. No
312
+ // PaymentIntent (Stripe refuses a zero-amount intent); create
313
+ // the order, burn the card against it, and advance it straight
314
+ // to paid via the FSM.
315
+ if (amountDue === 0) {
316
+ var paidOrder = await order.createFromCart({
317
+ cart_id: quote.cart_id,
318
+ session_id: cartRow.session_id,
319
+ customer_id: cartRow.customer_id || null,
320
+ currency: quote.currency,
321
+ subtotal_minor: quote.totals.subtotal_minor,
322
+ discount_minor: quote.totals.discount_minor,
323
+ tax_minor: quote.totals.tax_minor,
324
+ shipping_minor: quote.totals.shipping_minor,
325
+ grand_total_minor: quote.totals.grand_total_minor,
326
+ payment_intent_id: null,
327
+ ship_to: input.ship_to,
328
+ customer_email_hash: emailHash,
329
+ lines: orderLines,
330
+ });
331
+ await _redeemGiftCard(gc, paidOrder.id);
332
+ await cart.setStatus(quote.cart_id, "converted");
333
+ var settled = await order.transition(paidOrder.id, "mark_paid", {
334
+ reason: "gift_card:full",
335
+ });
336
+ return {
337
+ order: settled,
338
+ payment_intent: null,
339
+ gift_card: { applied_minor: gc.applied_minor, amount_due_minor: 0 },
340
+ };
341
+ }
342
+
343
+ // Create the PaymentIntent for the amount DUE after the credit
344
+ // (idempotent via header).
204
345
  var pi = await payment.createPaymentIntent({
205
- amount_minor: quote.totals.grand_total_minor,
346
+ amount_minor: amountDue,
206
347
  currency: quote.currency.toLowerCase(),
207
348
  receipt_email: email,
208
349
  description: "blamejs.shop order — cart " + quote.cart_id,
@@ -212,11 +353,11 @@ function create(deps) {
212
353
  subtotal_minor: String(quote.totals.subtotal_minor),
213
354
  tax_minor: String(quote.totals.tax_minor),
214
355
  shipping_minor: String(quote.totals.shipping_minor),
356
+ gift_card_applied_minor: String(gc ? gc.applied_minor : 0),
215
357
  },
216
358
  }, idempotencyKey);
217
359
 
218
360
  // Create the local order in `pending` state with the PI linked.
219
- var cartRow = await cart.get(quote.cart_id);
220
361
  var createdOrder = await order.createFromCart({
221
362
  cart_id: quote.cart_id,
222
363
  session_id: cartRow.session_id,
@@ -229,20 +370,15 @@ function create(deps) {
229
370
  grand_total_minor: quote.totals.grand_total_minor,
230
371
  payment_intent_id: pi.id,
231
372
  ship_to: input.ship_to,
232
- // Hash of the buyer email (same key as the customers table) so a
233
- // later verified-email sign-in can claim this guest order.
234
- customer_email_hash: customers ? customers.hashEmail(email) : null,
235
- lines: quote.lines.map(function (l) {
236
- return {
237
- variant_id: l.variant_id,
238
- sku: l.sku,
239
- qty: l.qty,
240
- unit_amount_minor: l.unit_amount_minor,
241
- unit_currency: l.unit_currency,
242
- };
243
- }),
373
+ customer_email_hash: emailHash,
374
+ lines: orderLines,
244
375
  });
245
376
 
377
+ // Burn the gift-card credit against the created order. Runs
378
+ // after the order row exists so a failed order never burns the
379
+ // card; the redeem decrement is the double-spend guard.
380
+ if (gc) await _redeemGiftCard(gc, createdOrder.id);
381
+
246
382
  // Mark the cart converted so a refresh of the storefront
247
383
  // doesn't accidentally re-quote the same cart.
248
384
  await cart.setStatus(quote.cart_id, "converted");
@@ -254,6 +390,7 @@ function create(deps) {
254
390
  client_secret: pi.client_secret,
255
391
  status: pi.status,
256
392
  },
393
+ gift_card: gc ? { applied_minor: gc.applied_minor, amount_due_minor: amountDue } : null,
257
394
  };
258
395
  },
259
396
 
@@ -347,14 +484,48 @@ function create(deps) {
347
484
  if (quote.totals.grand_total_minor <= 0) {
348
485
  throw new TypeError("checkout.createPaypalOrder: grand_total_minor must be > 0");
349
486
  }
487
+ // Resolve an optional gift-card credit before opening the PayPal
488
+ // order so a bad code fails without a remote round-trip.
489
+ var gc = await _resolveGiftCard(input.gift_card_code, quote);
490
+ var amountDue = quote.totals.grand_total_minor - (gc ? gc.applied_minor : 0);
491
+ var cartRow = await cart.get(quote.cart_id);
492
+ var emailHash = customers ? customers.hashEmail(email) : null;
493
+ var ppLines = quote.lines.map(function (l) {
494
+ return { variant_id: l.variant_id, sku: l.sku, qty: l.qty, unit_amount_minor: l.unit_amount_minor, unit_currency: l.unit_currency };
495
+ });
496
+
497
+ // Gift card fully covers the order — no PayPal order (PayPal
498
+ // refuses a zero-amount order). Create + burn + mark paid, same
499
+ // as the Stripe full-coverage path.
500
+ if (amountDue === 0) {
501
+ var paidOrder = await order.createFromCart({
502
+ cart_id: quote.cart_id,
503
+ session_id: cartRow.session_id,
504
+ customer_id: cartRow.customer_id || null,
505
+ currency: quote.currency,
506
+ subtotal_minor: quote.totals.subtotal_minor,
507
+ discount_minor: quote.totals.discount_minor,
508
+ tax_minor: quote.totals.tax_minor,
509
+ shipping_minor: quote.totals.shipping_minor,
510
+ grand_total_minor: quote.totals.grand_total_minor,
511
+ payment_intent_id: null,
512
+ ship_to: input.ship_to,
513
+ customer_email_hash: emailHash,
514
+ lines: ppLines,
515
+ });
516
+ await _redeemGiftCard(gc, paidOrder.id);
517
+ await cart.setStatus(quote.cart_id, "converted");
518
+ var settled = await order.transition(paidOrder.id, "mark_paid", { reason: "gift_card:full" });
519
+ return { order: settled, paypal_order_id: null, status: "PAID_BY_GIFT_CARD", gift_card: { applied_minor: gc.applied_minor, amount_due_minor: 0 } };
520
+ }
521
+
350
522
  var ppOrder = await paypal.createOrder({
351
- amount_minor: quote.totals.grand_total_minor,
523
+ amount_minor: amountDue,
352
524
  currency: quote.currency.toUpperCase(),
353
525
  order_id: quote.cart_id,
354
526
  return_url: input.return_url || undefined,
355
527
  cancel_url: input.cancel_url || undefined,
356
528
  }, idempotencyKey);
357
- var cartRow = await cart.get(quote.cart_id);
358
529
  var createdOrder = await order.createFromCart({
359
530
  cart_id: quote.cart_id,
360
531
  session_id: cartRow.session_id,
@@ -367,13 +538,12 @@ function create(deps) {
367
538
  grand_total_minor: quote.totals.grand_total_minor,
368
539
  payment_intent_id: ppOrder.id, // the PayPal order id (opaque); links the webhook + capture
369
540
  ship_to: input.ship_to,
370
- customer_email_hash: customers ? customers.hashEmail(email) : null,
371
- lines: quote.lines.map(function (l) {
372
- return { variant_id: l.variant_id, sku: l.sku, qty: l.qty, unit_amount_minor: l.unit_amount_minor, unit_currency: l.unit_currency };
373
- }),
541
+ customer_email_hash: emailHash,
542
+ lines: ppLines,
374
543
  });
544
+ if (gc) await _redeemGiftCard(gc, createdOrder.id);
375
545
  await cart.setStatus(quote.cart_id, "converted");
376
- return { order: createdOrder, paypal_order_id: ppOrder.id, status: ppOrder.status };
546
+ return { order: createdOrder, paypal_order_id: ppOrder.id, status: ppOrder.status, gift_card: gc ? { applied_minor: gc.applied_minor, amount_due_minor: amountDue } : null };
377
547
  },
378
548
 
379
549
  // Capture an approved PayPal order, then advance the local order to paid.
@@ -56,7 +56,7 @@
56
56
  * - `defineSmart({ slug, title, description?, rules,
57
57
  * sort_strategy })`
58
58
  * - `get(slug)`
59
- * - `list({ active_only? })`
59
+ * - `list({ active_only? | archived_only? })`
60
60
  * - `update(slug, patch)` — title / description / hero_image_url
61
61
  * / sort_strategy / rules (smart only).
62
62
  * - `archive(slug)` — soft delete via archived_at column.
@@ -491,10 +491,11 @@ function create(opts) {
491
491
 
492
492
  async function list(input) {
493
493
  input = input || {};
494
- var activeOnly = input.active_only === true;
495
494
  var sql;
496
- if (activeOnly) {
495
+ if (input.active_only === true) {
497
496
  sql = "SELECT * FROM collections WHERE archived_at IS NULL ORDER BY updated_at DESC, slug DESC";
497
+ } else if (input.archived_only === true) {
498
+ sql = "SELECT * FROM collections WHERE archived_at IS NOT NULL ORDER BY updated_at DESC, slug DESC";
498
499
  } else {
499
500
  sql = "SELECT * FROM collections ORDER BY updated_at DESC, slug DESC";
500
501
  }
package/lib/customers.js CHANGED
@@ -45,6 +45,13 @@ var MAX_CRED_FIELD_BYTES = 4096;
45
45
  var TRANSPORTS_RE = /^[a-z]+(?:,[a-z]+)*$/;
46
46
  var CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
47
47
 
48
+ // Pagination cursor for the operator-facing `list` — newest first by
49
+ // (created_at, id), HMAC-tagged via b.pagination so an operator can't
50
+ // hand-craft one to skip rows. Same orderKey-mismatch refusal the order
51
+ // primitive uses for its customer-scoped history.
52
+ var CUSTOMERS_ORDER_KEY = ["created_at:desc", "id:desc"];
53
+ var MAX_LIST_LIMIT = 100;
54
+
48
55
  // ---- validators ---------------------------------------------------------
49
56
 
50
57
  function _uuid(s, label) {
@@ -143,6 +150,18 @@ function create(opts) {
143
150
  if (!query) {
144
151
  query = function (sql, params) { return b.externalDb.query(sql, params); };
145
152
  }
153
+ // Pagination cursors for `list` are HMAC-tagged via b.pagination so an
154
+ // operator can't hand-craft one to skip past a row or replay across
155
+ // deployments. The secret defaults to a dev-only placeholder so the
156
+ // primitive boots in tests; the deployment supplies a derived value
157
+ // (typically b.crypto.namespaceHash("customers-cursor", D1_BRIDGE_SECRET)).
158
+ if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
159
+ if (process.env.NODE_ENV === "production") {
160
+ throw new Error("customers.create: opts.cursorSecret is required in production");
161
+ }
162
+ opts.cursorSecret = "customers-cursor-secret-dev-only";
163
+ }
164
+ var cursorSecret = opts.cursorSecret;
146
165
 
147
166
  function _hashEmail(email) {
148
167
  return b.crypto.namespaceHash(EMAIL_NAMESPACE, email);
@@ -206,6 +225,89 @@ function create(opts) {
206
225
  return r.rows[0] || null;
207
226
  },
208
227
 
228
+ // Operator-facing paginated list of customers, newest first by
229
+ // (created_at, id). Tuple cursor (created_at, id) ordered DESC —
230
+ // mirrors order.listForCustomer's HMAC-tagged cursor shape (same
231
+ // b.pagination tag, same orderKey mismatch-on-tamper refusal). The
232
+ // raw email is never stored, so rows carry only the displayable
233
+ // columns (id, email_hash, display_name, created_at, updated_at).
234
+ // Returns { rows, next_cursor }.
235
+ list: async function (listOpts) {
236
+ listOpts = listOpts || {};
237
+ var limit = listOpts.limit == null ? 50 : listOpts.limit;
238
+ if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIST_LIMIT) {
239
+ throw new TypeError("customers.list: limit must be 1..." + MAX_LIST_LIMIT);
240
+ }
241
+ var cursorVals = null;
242
+ if (listOpts.cursor != null) {
243
+ if (typeof listOpts.cursor !== "string") {
244
+ throw new TypeError("customers.list: cursor must be an opaque string or null");
245
+ }
246
+ try {
247
+ var state = b.pagination.decodeCursor(listOpts.cursor, cursorSecret);
248
+ if (JSON.stringify(state.orderKey) !== JSON.stringify(CUSTOMERS_ORDER_KEY)) {
249
+ throw new TypeError("customers.list: cursor orderKey mismatch");
250
+ }
251
+ cursorVals = state.vals;
252
+ } catch (e) {
253
+ if (e instanceof TypeError) throw e;
254
+ throw new TypeError("customers.list: cursor — " + (e && e.message || "malformed"));
255
+ }
256
+ }
257
+ var sql, params;
258
+ if (cursorVals) {
259
+ sql = "SELECT id, email_hash, display_name, created_at, updated_at FROM customers " +
260
+ "WHERE (created_at < ?1 OR (created_at = ?1 AND id < ?2)) " +
261
+ "ORDER BY created_at DESC, id DESC LIMIT ?3";
262
+ params = [cursorVals[0], cursorVals[1], limit];
263
+ } else {
264
+ sql = "SELECT id, email_hash, display_name, created_at, updated_at FROM customers " +
265
+ "ORDER BY created_at DESC, id DESC LIMIT ?1";
266
+ params = [limit];
267
+ }
268
+ var rows = (await query(sql, params)).rows;
269
+ var last = rows[rows.length - 1];
270
+ var next = null;
271
+ if (last && rows.length === limit) {
272
+ next = b.pagination.encodeCursor({
273
+ orderKey: CUSTOMERS_ORDER_KEY,
274
+ vals: [last.created_at, last.id],
275
+ forward: true,
276
+ }, cursorSecret);
277
+ }
278
+ return { rows: rows, next_cursor: next };
279
+ },
280
+
281
+ // Per-customer sign-in method counts WITHOUT an N+1: two bounded
282
+ // `IN (...)` aggregates over the passkey + oauth tables, returning
283
+ // { passkeys: {customer_id: count}, oauth: {customer_id: [provider,...]} }.
284
+ // Callers pass the (already-paginated) page's customer ids; an empty
285
+ // list short-circuits to empty maps so the SQL never sees `IN ()`.
286
+ signInMethodsByCustomer: async function (customerIds) {
287
+ if (!Array.isArray(customerIds)) {
288
+ throw new TypeError("customers.signInMethodsByCustomer: customerIds must be an array");
289
+ }
290
+ var passkeys = {}, oauth = {};
291
+ if (customerIds.length === 0) return { passkeys: passkeys, oauth: oauth };
292
+ var placeholders = customerIds.map(function (_id, i) { return "?" + (i + 1); }).join(", ");
293
+ var pk = (await query(
294
+ "SELECT customer_id, COUNT(*) AS n FROM customer_passkeys " +
295
+ "WHERE customer_id IN (" + placeholders + ") GROUP BY customer_id",
296
+ customerIds,
297
+ )).rows;
298
+ for (var i = 0; i < pk.length; i += 1) passkeys[pk[i].customer_id] = Number(pk[i].n);
299
+ var oa = (await query(
300
+ "SELECT customer_id, provider FROM customer_oauth_identities " +
301
+ "WHERE customer_id IN (" + placeholders + ") ORDER BY provider ASC",
302
+ customerIds,
303
+ )).rows;
304
+ for (var j = 0; j < oa.length; j += 1) {
305
+ if (!oauth[oa[j].customer_id]) oauth[oa[j].customer_id] = [];
306
+ oauth[oa[j].customer_id].push(oa[j].provider);
307
+ }
308
+ return { passkeys: passkeys, oauth: oauth };
309
+ },
310
+
209
311
  // ---- federated (OAuth / OIDC) sign-in -------------------------------
210
312
 
211
313
  // The customer linked to an external (provider, subject) identity, or
package/lib/giftcards.js CHANGED
@@ -379,6 +379,46 @@ function create(opts) {
379
379
  return after.rows[0] || null;
380
380
  },
381
381
 
382
+ // Operator-facing list of issued cards — drives the admin
383
+ // gift-card ledger console. Never returns the code hash or the
384
+ // recipient hash (operators identify a card by its `code_hint` +
385
+ // issued date, not the bearer credential). Newest first. The
386
+ // optional `status` filter narrows to one lifecycle state; the
387
+ // `limit` caps the page (defaults to 100, max 500). This is a
388
+ // read-only projection — no balance math, just the snapshot
389
+ // columns the card row already carries.
390
+ list: async function (opts4) {
391
+ opts4 = opts4 || {};
392
+ var limit = opts4.limit == null ? 100 : opts4.limit;
393
+ if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > 500) {
394
+ throw new TypeError("giftcards.list: limit must be an integer in [1, 500]");
395
+ }
396
+ var sql = "SELECT id, code_hint, currency, issued_minor, balance_minor, status, " +
397
+ "expires_at, created_at, updated_at FROM giftcards";
398
+ var params = [];
399
+ if (opts4.status != null) {
400
+ _status(opts4.status);
401
+ sql += " WHERE status = ?1";
402
+ params.push(opts4.status);
403
+ }
404
+ sql += " ORDER BY created_at DESC LIMIT ?" + (params.length + 1);
405
+ params.push(limit);
406
+ var r = await query(sql, params);
407
+ return r.rows;
408
+ },
409
+
410
+ // Fetch a single card by id for the admin detail view — same
411
+ // projection as `list` (no hash). Returns null on no match.
412
+ getById: async function (id) {
413
+ _uuid(id, "giftcard id");
414
+ var r = await query(
415
+ "SELECT id, code_hint, currency, issued_minor, balance_minor, status, " +
416
+ "expires_at, created_at, updated_at FROM giftcards WHERE id = ?1",
417
+ [id],
418
+ );
419
+ return r.rows.length ? r.rows[0] : null;
420
+ },
421
+
382
422
  listForCustomer: async function (customerId, opts3) {
383
423
  _uuid(customerId, "customer_id");
384
424
  opts3 = opts3 || {};
package/lib/order.js CHANGED
@@ -472,6 +472,28 @@ function create(opts) {
472
472
  )).rows;
473
473
  return rows.length > 0;
474
474
  },
475
+
476
+ // Order count per customer for a bounded set of customer ids, in ONE
477
+ // grouped query — the operator console renders an order-count column
478
+ // across a page of customers without an N+1. Returns a
479
+ // { customer_id: count } map; customers with zero orders are simply
480
+ // absent (the caller defaults to 0). An empty input short-circuits so
481
+ // the SQL never sees `IN ()`.
482
+ countsByCustomer: async function (customerIds) {
483
+ if (!Array.isArray(customerIds)) {
484
+ throw new TypeError("order.countsByCustomer: customerIds must be an array");
485
+ }
486
+ var out = {};
487
+ if (customerIds.length === 0) return out;
488
+ var placeholders = customerIds.map(function (_id, i) { return "?" + (i + 1); }).join(", ");
489
+ var rows = (await query(
490
+ "SELECT customer_id, COUNT(*) AS n FROM orders " +
491
+ "WHERE customer_id IN (" + placeholders + ") GROUP BY customer_id",
492
+ customerIds,
493
+ )).rows;
494
+ for (var i = 0; i < rows.length; i += 1) out[rows[i].customer_id] = Number(rows[i].n);
495
+ return out;
496
+ },
475
497
  };
476
498
  }
477
499