@blamejs/blamejs-shop 0.1.20 → 0.1.23
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 +6 -0
- package/README.md +7 -3
- package/SECURITY.md +12 -0
- package/lib/admin.js +1035 -138
- package/lib/checkout.js +192 -22
- package/lib/collections.js +4 -3
- package/lib/customers.js +102 -0
- package/lib/giftcards.js +40 -0
- package/lib/order.js +22 -0
- package/lib/storefront.js +400 -53
- package/lib/vendor/MANIFEST.json +3 -3
- package/lib/vendor/blamejs/CHANGELOG.md +10 -0
- package/lib/vendor/blamejs/README.md +4 -0
- package/lib/vendor/blamejs/api-snapshot.json +129 -2
- package/lib/vendor/blamejs/index.js +12 -0
- package/lib/vendor/blamejs/lib/json-merge-patch.js +93 -0
- package/lib/vendor/blamejs/lib/json-patch.js +206 -0
- package/lib/vendor/blamejs/lib/json-path.js +638 -0
- package/lib/vendor/blamejs/lib/json-pointer.js +109 -0
- package/lib/vendor/blamejs/lib/jtd.js +234 -0
- package/lib/vendor/blamejs/lib/link-header.js +169 -0
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.57.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.12.58.json +22 -0
- package/lib/vendor/blamejs/release-notes/v0.12.60.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.12.61.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.12.62.json +18 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +18 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/json-merge-patch.test.js +81 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/json-patch.test.js +184 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/json-path.test.js +113 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/jtd.test.js +52 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/link-header.test.js +96 -0
- 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
|
-
//
|
|
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:
|
|
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
|
-
|
|
233
|
-
|
|
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:
|
|
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:
|
|
371
|
-
lines:
|
|
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.
|
package/lib/collections.js
CHANGED
|
@@ -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 (
|
|
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
|
|