@blamejs/blamejs-shop 0.3.46 → 0.3.48
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 +4 -0
- package/lib/admin.js +409 -26
- package/lib/asset-manifest.json +1 -1
- package/lib/catalog.js +63 -0
- package/lib/collections.js +34 -0
- package/lib/storefront.js +280 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.3.x
|
|
10
10
|
|
|
11
|
+
- v0.3.48 (2026-06-02) — **Customers can export or request deletion of their personal data, and the admin console gets a privacy-request queue to fulfil it.** The framework had a complete data-subject-request engine but nothing reached it, so a store handling EU/California personal data had no way for a shopper to exercise their right of access or erasure and no operator path to fulfil one. A signed-in customer can now request a full export of their data — account, addresses, orders, subscriptions, loyalty, and support history, streamed as a downloadable JSON bundle — and can file a deletion request. The admin console gets a Privacy requests screen where an operator reviews each request, downloads the export, previews a deletion (a dry run that changes nothing), and then executes it. Erasure anonymizes the customer's account and archives their addresses and subscriptions while retaining orders, loyalty ledger, and support tickets that must be kept for tax and legal records. A customer can only ever see or act on their own requests; deletion is always filed by the customer and carried out by an operator. **Added:** *Customer data export and deletion requests* — A signed-in customer gets a Privacy & data area under their account where they can request a full export of their personal data and file a deletion request. The export is streamed as a downloadable JSON bundle covering their account, addresses, orders, subscriptions, loyalty balance and ledger, and support history. A customer can only access their own requests and downloads; a request belonging to anyone else returns not-found. · *Privacy-request queue in the admin console* — A new Privacy requests screen lists every data-subject request with its status. An operator can download a request's export, preview a deletion as a dry run that reports what would change without modifying anything, and then execute the deletion behind a confirmation step. Deletion anonymizes the customer's account and archives their addresses and subscriptions, while orders, the loyalty ledger, and support tickets are retained for tax and legal record-keeping. Every customer- and operator-entered field shown in either console is HTML-escaped.
|
|
12
|
+
|
|
13
|
+
- v0.3.47 (2026-06-01) — **Admin product detail, collections list, and order shipment panels load with fewer database round trips.** Three admin-console screens re-derived data with one database query per item: the product-detail screen ran a query per variant per currency for current and historical prices, the collections list pulled up to 200 member rows for each collection just to count them, and the order-detail shipment panel hydrated each shipment and its labels sequentially. These now read in batched or parallel queries. The displayed data is unchanged, with one correctness improvement: a manual collection with more than 200 members now shows its true product count instead of a number capped at 200. **Changed:** *Batched admin product-detail price hydration* — The admin product-detail screen now loads every variant's current and historical prices across all currencies in a single batched query instead of one query per variant per currency. The screen renders identically. · *Collections list counts without pulling member rows* — The admin collections list now counts a manual collection's members with a COUNT query instead of fetching up to 200 member rows per collection. A manual collection with more than 200 members now shows its true count (previously the displayed count was capped at 200); rule-based collections keep their existing bounded preview count. · *Parallel shipment hydration on order detail* — The order-detail shipment panel now hydrates each shipment and its labels in parallel rather than one after another, so an order with several shipments opens faster.
|
|
14
|
+
|
|
11
15
|
- v0.3.46 (2026-06-01) — **Storefront pages render in far fewer database round trips, and the search collection filter is consistent across cache tiers.** The dynamically rendered storefront (the path that serves returning, signed-in, or non-default-currency shoppers) re-derived product data with one database query per item — a home or search page could issue dozens to hundreds of round trips, where the cached edge already did the same work in a single batched query. The home grid, search results, product page, related-products rail, and cart now read product, variant, price, inventory, and media data in batched queries that mirror the edge exactly, so those pages render with far fewer round trips and identical output. As part of this, the search 'collection' filter now reflects manual collection membership consistently whether a page is served from the cache or rendered dynamically. **Changed:** *Batched product reads on storefront pages* — The home grid, search results, product detail, related-products rail, and cart now load product/variant/price/inventory/media data in batched database queries instead of one query per item. On a cache miss, a signed-in session, or a non-default currency the dynamic render path previously issued a query per product (and per variant); these pages now match the single-query shape the cached edge already used. Rendered pages are unchanged. · *Consistent search collection filter across cache tiers* — The 'collection' filter on the search page now reflects manual collection membership on both the cached and the dynamically rendered paths. Previously the dynamic path could additionally surface smart-collection rule matches in that filter, so a shopper could see different collection filter options depending on which path served the page. If you relied on smart (rule-based) collections appearing as search filters, they now appear only where a product is an explicit member; this aligns the two render paths.
|
|
12
16
|
|
|
13
17
|
- v0.3.45 (2026-06-01) — **Tax, VAT, bulk price changes, and customer emails now compute money in exact integer arithmetic.** Several money calculations were performed with floating-point intermediates. On a large order subtotal the sales-tax math could lose a minor unit, and the customer-facing wishlist emails formatted prices by dividing by 100 — which is wrong for zero-decimal currencies like JPY (¥1,050 rendered as 10.5) and three-decimal currencies like BHD, and dropped trailing zeros ($10.00 shown as 10). All of these now run through the framework's exact BigInt money primitive: sales-tax and VAT/GST extraction, the per-rate owed figure on a tax filing, the admin bulk price-adjust, the wishlist price-drop and digest emails, and subscription monthly-revenue normalization. Prices in customer emails are now rendered with the correct currency exponent and symbol for every currency. **Changed:** *Bulk price-adjust rounds to even on exact half-unit ties* — The admin bulk price-adjust now rounds a price that lands exactly halfway between two minor units to the nearest even unit, matching the rounding used everywhere else in the money surface, instead of always rounding the half up. This only changes the result on an exact tie and by at most one minor unit (for example a price computed as 200.5 now becomes 200 rather than 201). If a pricing regime requires rounding halves up, that is a one-line option change. **Fixed:** *Exact sales-tax and VAT computation* — Sales tax, VAT-inclusive extraction, and VAT-exclusive addition are computed in exact integer arithmetic rather than with a floating-point intermediate, so the tax on a large order total is no longer off by a minor unit. The per-rate owed figure on a tax filing is likewise computed exactly, which matters when a filing window aggregates a very large taxable total. · *Currency-correct prices in wishlist emails* — Price-drop alert emails and the wishlist digest now format prices with each currency's own decimal places and symbol. Zero-decimal currencies (such as JPY) and three-decimal currencies (such as BHD) render correctly, and whole amounts keep their trailing zeros — for example $10.00 instead of 10. · *Exact subscription monthly-revenue normalization* — Normalizing a weekly or annual plan to a monthly figure for revenue reporting now uses exact rational arithmetic instead of a floating-point cadence factor, so the dashboard total is stable and does not drift by a cent between refreshes.
|
package/lib/admin.js
CHANGED
|
@@ -511,6 +511,7 @@ function mount(router, deps) {
|
|
|
511
511
|
var splitShipments = deps.splitShipments || null; // order split-shipment planner disabled when absent
|
|
512
512
|
var salesTaxFilings = deps.salesTaxFilings || null; // sales-tax-filing remittance console disabled when absent
|
|
513
513
|
var supportTickets = deps.supportTickets || null; // support-ticket queue + thread console disabled when absent
|
|
514
|
+
var complianceExport = deps.complianceExport || null; // DSR queue (export/erasure) disabled when absent
|
|
514
515
|
var orderExchanges = deps.orderExchanges || null; // exchange queue + FSM-action console disabled when absent
|
|
515
516
|
var orderRatings = deps.orderRatings || null; // per-order rating moderation queue (flag/clear + public reply) disabled when absent
|
|
516
517
|
var preorder = deps.preorder || null; // pre-order campaign console (define/launch/close) disabled when absent
|
|
@@ -521,7 +522,7 @@ function mount(router, deps) {
|
|
|
521
522
|
// `reports` is always present in the nav (read-only sales summary needs no
|
|
522
523
|
// extra dep); its route mounts unconditionally and renders an unconfigured
|
|
523
524
|
// notice when the salesReports primitive isn't wired.
|
|
524
|
-
var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, orderExport: !!orderExport };
|
|
525
|
+
var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, complianceExport: !!complianceExport, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, orderExport: !!orderExport };
|
|
525
526
|
|
|
526
527
|
try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
|
|
527
528
|
|
|
@@ -660,21 +661,15 @@ function mount(router, deps) {
|
|
|
660
661
|
var p = await catalog.products.get(id);
|
|
661
662
|
if (!p) return null;
|
|
662
663
|
var variants = await catalog.variants.listForProduct(id);
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
current: await catalog.prices.current(v.id, cur),
|
|
673
|
-
history: await catalog.prices.history(v.id, cur),
|
|
674
|
-
});
|
|
675
|
-
}
|
|
676
|
-
pricesByVariant[v.id] = { currencies: perCurrency };
|
|
677
|
-
}
|
|
664
|
+
// Every variant's price-per-currency (current + history) in one read.
|
|
665
|
+
// batch.pricesForVariants returns `{ variantId: { currencies:
|
|
666
|
+
// [{ currency, current, history }] } }` byte-identical to the prior
|
|
667
|
+
// per-variant → per-currency (prices.current + prices.history) loop:
|
|
668
|
+
// currencies ASC, current = open row (effective_until IS NULL), history
|
|
669
|
+
// = rows DESC. Unpriced variants map to `{ currencies: [] }`.
|
|
670
|
+
var pricesByVariant = await catalog.batch.pricesForVariants(
|
|
671
|
+
variants.map(function (v) { return v.id; }),
|
|
672
|
+
);
|
|
678
673
|
var media = await catalog.media.listForProduct(id);
|
|
679
674
|
return { product: p, variants: variants, prices_by_variant: pricesByVariant, media: media };
|
|
680
675
|
}
|
|
@@ -1680,9 +1675,14 @@ function mount(router, deps) {
|
|
|
1680
1675
|
if (orderTracking) {
|
|
1681
1676
|
try {
|
|
1682
1677
|
var shipRows = await orderTracking.listForOrder(o.id);
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1678
|
+
// Hydrate every shipment + its labels in parallel — the set is
|
|
1679
|
+
// bounded by shipments-per-order (a handful), so Promise.all
|
|
1680
|
+
// collapses the sequential per-shipment getShipment +
|
|
1681
|
+
// labelsForShipment round trips into one wait. Order is
|
|
1682
|
+
// preserved (Promise.all keeps array position).
|
|
1683
|
+
shipments = await Promise.all(shipRows.map(async function (sRow) {
|
|
1684
|
+
var full = await orderTracking.getShipment(sRow.id);
|
|
1685
|
+
var ship = full || sRow;
|
|
1686
1686
|
// Carrier-label records for the shipment. Best-effort: the
|
|
1687
1687
|
// shipping_labels table may be unmigrated, and the labels
|
|
1688
1688
|
// primitive may not be wired — a read failure leaves the
|
|
@@ -1691,8 +1691,8 @@ function mount(router, deps) {
|
|
|
1691
1691
|
try { ship.labels = await shippingLabels.labelsForShipment(ship.id); }
|
|
1692
1692
|
catch (_le) { ship.labels = []; }
|
|
1693
1693
|
}
|
|
1694
|
-
|
|
1695
|
-
}
|
|
1694
|
+
return ship;
|
|
1695
|
+
}));
|
|
1696
1696
|
} catch (_e) { shipments = []; }
|
|
1697
1697
|
}
|
|
1698
1698
|
// Split-shipment plans for the order. Best-effort, same rationale
|
|
@@ -4102,6 +4102,211 @@ function mount(router, deps) {
|
|
|
4102
4102
|
_supportTransition("reopen", "reopened");
|
|
4103
4103
|
}
|
|
4104
4104
|
|
|
4105
|
+
// ---- subject-access-request (DSR) queue -----------------------------
|
|
4106
|
+
// The operator side of the GDPR / CCPA / LGPD lifecycle: list + detail +
|
|
4107
|
+
// fulfil/dispatch/preview/execute/dismiss + a streaming export download.
|
|
4108
|
+
// Content-negotiated like the other screens: bearer → JSON; signed-in
|
|
4109
|
+
// browser → the HTML console. The primitive throws TypeError ONLY (no
|
|
4110
|
+
// `.code`-bearing FSM error — confirmed across lib/compliance-export.js):
|
|
4111
|
+
// a malformed id, a not-found row, and a bad-status transition are ALL
|
|
4112
|
+
// TypeError, so the catch maps TypeError → a 4xx notice and there is no
|
|
4113
|
+
// `.code` branch to order. If a future status-conflict error gains a
|
|
4114
|
+
// `.code`, add the branch BEFORE the TypeError catch so it isn't collapsed.
|
|
4115
|
+
if (complianceExport) {
|
|
4116
|
+
var dsr = complianceExport;
|
|
4117
|
+
var dsrReaders = deps.complianceExportReaders || {};
|
|
4118
|
+
var dsrSections = deps.complianceExportSections || {};
|
|
4119
|
+
var streamDsr = deps.streamDsrBundle;
|
|
4120
|
+
|
|
4121
|
+
// Resolve the ?status= chip into a list. "all" / a bad chip → the whole
|
|
4122
|
+
// board; a real status → the filtered list.
|
|
4123
|
+
async function _dsrRows(filter) {
|
|
4124
|
+
if (filter && filter !== "all" && DSR_STATUS_FILTERS.indexOf(filter) !== -1) {
|
|
4125
|
+
return { rows: await dsr.listRequests({ status: filter, limit: 200 }), filter: filter };
|
|
4126
|
+
}
|
|
4127
|
+
return { rows: await dsr.listRequests({ limit: 200 }), filter: "all" };
|
|
4128
|
+
}
|
|
4129
|
+
|
|
4130
|
+
router.get("/admin/dsr", _pageOrApi(true,
|
|
4131
|
+
R(async function (req, res) {
|
|
4132
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
4133
|
+
var filter = (url && url.searchParams.get("status")) || null;
|
|
4134
|
+
var resolved = await _dsrRows(filter);
|
|
4135
|
+
_json(res, 200, { rows: resolved.rows, status: resolved.filter });
|
|
4136
|
+
}),
|
|
4137
|
+
async function (req, res) {
|
|
4138
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
4139
|
+
var filter = (url && url.searchParams.get("status")) || null;
|
|
4140
|
+
var resolved = await _dsrRows(filter);
|
|
4141
|
+
_sendHtml(res, 200, renderAdminDsr({
|
|
4142
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
4143
|
+
requests: resolved.rows, status: resolved.filter,
|
|
4144
|
+
}));
|
|
4145
|
+
},
|
|
4146
|
+
));
|
|
4147
|
+
|
|
4148
|
+
// Resolve a request by :id, surfacing a malformed / unknown id as a 404
|
|
4149
|
+
// (defensive request-shape reader, never a 500). getRequest throws
|
|
4150
|
+
// TypeError on a bad uuid → 404; a null row → 404.
|
|
4151
|
+
async function _dsrOr404(req, res, isJson) {
|
|
4152
|
+
var row;
|
|
4153
|
+
try { row = await dsr.getRequest(req.params.id); }
|
|
4154
|
+
catch (e) {
|
|
4155
|
+
if (e instanceof TypeError) {
|
|
4156
|
+
if (isJson) { _problem(res, 404, "dsr-request-not-found"); return null; }
|
|
4157
|
+
_sendHtml(res, 404, renderAdminDsr({ shop_name: deps.shop_name, nav_available: navAvailable, requests: [], status: "all", notice: "Request not found." }));
|
|
4158
|
+
return null;
|
|
4159
|
+
}
|
|
4160
|
+
throw e;
|
|
4161
|
+
}
|
|
4162
|
+
if (!row) {
|
|
4163
|
+
if (isJson) { _problem(res, 404, "dsr-request-not-found"); return null; }
|
|
4164
|
+
_sendHtml(res, 404, renderAdminDsr({ shop_name: deps.shop_name, nav_available: navAvailable, requests: [], status: "all", notice: "Request not found." }));
|
|
4165
|
+
return null;
|
|
4166
|
+
}
|
|
4167
|
+
return row;
|
|
4168
|
+
}
|
|
4169
|
+
|
|
4170
|
+
router.get("/admin/dsr/:id", _pageOrApi(true,
|
|
4171
|
+
R(async function (req, res) {
|
|
4172
|
+
var row = await _dsrOr404(req, res, true); if (!row) return;
|
|
4173
|
+
var history = await dsr.auditForCustomer(row.customer_id);
|
|
4174
|
+
_json(res, 200, { request: row, history: history });
|
|
4175
|
+
}),
|
|
4176
|
+
async function (req, res) {
|
|
4177
|
+
var row = await _dsrOr404(req, res, false); if (!row) return;
|
|
4178
|
+
var history = await dsr.auditForCustomer(row.customer_id);
|
|
4179
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
4180
|
+
// A deletion request's ?preview=1 renders the dry-run blast radius —
|
|
4181
|
+
// processDeletion({ dry_run: true }) NEVER mutates (the primitive's
|
|
4182
|
+
// only safety net). A bad-status row (already terminal) throws
|
|
4183
|
+
// TypeError → drop the preview, render the detail without it.
|
|
4184
|
+
var preview = null;
|
|
4185
|
+
if (url && url.searchParams.get("preview") === "1" && row.request_kind === "deletion") {
|
|
4186
|
+
try { preview = await dsr.processDeletion({ request_id: row.id, dry_run: true }); }
|
|
4187
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; preview = null; }
|
|
4188
|
+
}
|
|
4189
|
+
_sendHtml(res, 200, renderAdminDsrDetail({
|
|
4190
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
4191
|
+
request: row, history: history, preview: preview,
|
|
4192
|
+
moved: url && url.searchParams.get("moved"),
|
|
4193
|
+
notice: url && url.searchParams.get("err") ? "That action couldn't be completed for this request." : null,
|
|
4194
|
+
}));
|
|
4195
|
+
},
|
|
4196
|
+
));
|
|
4197
|
+
|
|
4198
|
+
// Streaming export download. Gated: the row must be an export in status
|
|
4199
|
+
// fulfilled / delivered (an un-fulfilled / deletion / unknown row → 404).
|
|
4200
|
+
// Status + ownership are validated BEFORE the first write (the bundle
|
|
4201
|
+
// streams header-first; status can't change mid-stream). The route
|
|
4202
|
+
// re-reads the readers rather than re-running fulfillRequest (which would
|
|
4203
|
+
// re-flip status on every download).
|
|
4204
|
+
router.get("/admin/dsr/:id/export.json", _pageOrApi(true,
|
|
4205
|
+
R(async function (req, res) {
|
|
4206
|
+
var row = await _dsrOr404(req, res, true); if (!row) return;
|
|
4207
|
+
if (row.request_kind !== "export" || (row.status !== "fulfilled" && row.status !== "delivered")) {
|
|
4208
|
+
return _problem(res, 404, "dsr-export-not-ready");
|
|
4209
|
+
}
|
|
4210
|
+
var sections = dsrSections[row.scope] || dsrSections.full || [];
|
|
4211
|
+
await streamDsr(res, dsrReaders, sections, row);
|
|
4212
|
+
}),
|
|
4213
|
+
async function (req, res) {
|
|
4214
|
+
var row = await _dsrOr404(req, res, false); if (!row) return;
|
|
4215
|
+
if (row.request_kind !== "export" || (row.status !== "fulfilled" && row.status !== "delivered")) {
|
|
4216
|
+
return _sendHtml(res, 404, renderAdminDsr({ shop_name: deps.shop_name, nav_available: navAvailable, requests: [], status: "all", notice: "Export not ready for download." }));
|
|
4217
|
+
}
|
|
4218
|
+
var sections = dsrSections[row.scope] || dsrSections.full || [];
|
|
4219
|
+
await streamDsr(res, dsrReaders, sections, row);
|
|
4220
|
+
},
|
|
4221
|
+
));
|
|
4222
|
+
|
|
4223
|
+
// Per-request action helper: run `opFn(id, body)`, then PRG back to the
|
|
4224
|
+
// detail. The primitive throws TypeError for not-found / bad-status / bad
|
|
4225
|
+
// input — all become an ?err=1 notice, never a 500. There is no
|
|
4226
|
+
// `.code`-bearing error to branch on (see the block-head comment).
|
|
4227
|
+
function _dsrAction(jsonHandler, auditEvent, opFn) {
|
|
4228
|
+
return _pageOrApi(false, jsonHandler, async function (req, res) {
|
|
4229
|
+
var id = req.params.id;
|
|
4230
|
+
try { await opFn(id, req.body || {}); }
|
|
4231
|
+
catch (e) {
|
|
4232
|
+
if (e instanceof TypeError) {
|
|
4233
|
+
return _redirect(res, "/admin/dsr/" + encodeURIComponent(id) + "?err=1");
|
|
4234
|
+
}
|
|
4235
|
+
throw e;
|
|
4236
|
+
}
|
|
4237
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + "." + auditEvent, outcome: "success", metadata: { id: id } });
|
|
4238
|
+
_redirect(res, "/admin/dsr/" + encodeURIComponent(id) + "?moved=1");
|
|
4239
|
+
});
|
|
4240
|
+
}
|
|
4241
|
+
|
|
4242
|
+
router.post("/admin/dsr/:id/fulfill", _dsrAction(
|
|
4243
|
+
R(async function (req, res) {
|
|
4244
|
+
try { var bundle = await dsr.fulfillRequest({ request_id: req.params.id }); _json(res, 200, bundle); }
|
|
4245
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
4246
|
+
}),
|
|
4247
|
+
"dsr.fulfill",
|
|
4248
|
+
function (id) { return dsr.fulfillRequest({ request_id: id }); },
|
|
4249
|
+
));
|
|
4250
|
+
|
|
4251
|
+
router.post("/admin/dsr/:id/dispatch", _dsrAction(
|
|
4252
|
+
R(async function (req, res) {
|
|
4253
|
+
var body = req.body || {};
|
|
4254
|
+
try { var row = await dsr.dispatchExport({ request_id: req.params.id, delivery_method: body.delivery_method, delivery_address: body.delivery_address }); _json(res, 200, row); }
|
|
4255
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
4256
|
+
}),
|
|
4257
|
+
"dsr.dispatch",
|
|
4258
|
+
function (id, body) { return dsr.dispatchExport({ request_id: id, delivery_method: body.delivery_method, delivery_address: body.delivery_address }); },
|
|
4259
|
+
));
|
|
4260
|
+
|
|
4261
|
+
router.post("/admin/dsr/:id/dismiss", _dsrAction(
|
|
4262
|
+
R(async function (req, res) {
|
|
4263
|
+
var body = req.body || {};
|
|
4264
|
+
try { var row = await dsr.dismissRequest({ request_id: req.params.id, dismiss_reason: body.dismiss_reason }); _json(res, 200, row); }
|
|
4265
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
4266
|
+
}),
|
|
4267
|
+
"dsr.dismiss",
|
|
4268
|
+
function (id, body) { return dsr.dismissRequest({ request_id: id, dismiss_reason: body.dismiss_reason }); },
|
|
4269
|
+
));
|
|
4270
|
+
|
|
4271
|
+
// Execute erasure — irreversible, so it routes through the confirm
|
|
4272
|
+
// interstitial. The detail's "Execute erasure…" button POSTs here; this
|
|
4273
|
+
// browser handler renders renderAdminConfirm, whose Confirm button POSTs
|
|
4274
|
+
// back here too (now carrying the confirmed flag via a hidden field), at
|
|
4275
|
+
// which point processDeletion runs for real (dry_run: false). The bearer
|
|
4276
|
+
// JSON path executes directly (tooling has no interstitial).
|
|
4277
|
+
router.post("/admin/dsr/:id/delete/confirm", _pageOrApi(false,
|
|
4278
|
+
R(async function (req, res) {
|
|
4279
|
+
try { var result = await dsr.processDeletion({ request_id: req.params.id, dry_run: false }); _json(res, 200, result); }
|
|
4280
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
4281
|
+
}),
|
|
4282
|
+
async function (req, res) {
|
|
4283
|
+
var id = req.params.id;
|
|
4284
|
+
var body = req.body || {};
|
|
4285
|
+
if (body.confirmed !== "1") {
|
|
4286
|
+
// First POST (from the detail button) — render the consequence.
|
|
4287
|
+
return _sendHtml(res, 200, renderAdminConfirm({
|
|
4288
|
+
shop_name: deps.shop_name, nav_available: navAvailable, active: "dsr",
|
|
4289
|
+
heading: "Execute erasure",
|
|
4290
|
+
consequence: "This permanently anonymizes the customer and archives their addresses + subscriptions. It cannot be undone.",
|
|
4291
|
+
detail: "Orders, the loyalty ledger, and support tickets are retained for legal/accounting reasons. Continue?",
|
|
4292
|
+
action: "/admin/dsr/" + encodeURIComponent(id) + "/delete/confirm",
|
|
4293
|
+
fields: { confirmed: "1" },
|
|
4294
|
+
confirm_label: "Execute erasure",
|
|
4295
|
+
cancel_href: "/admin/dsr/" + encodeURIComponent(id),
|
|
4296
|
+
}));
|
|
4297
|
+
}
|
|
4298
|
+
// Confirmed — run it for real, then PRG to the detail.
|
|
4299
|
+
try { await dsr.processDeletion({ request_id: id, dry_run: false }); }
|
|
4300
|
+
catch (e) {
|
|
4301
|
+
if (e instanceof TypeError) return _redirect(res, "/admin/dsr/" + encodeURIComponent(id) + "?err=1");
|
|
4302
|
+
throw e;
|
|
4303
|
+
}
|
|
4304
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".dsr.delete", outcome: "success", metadata: { id: id } });
|
|
4305
|
+
_redirect(res, "/admin/dsr/" + encodeURIComponent(id) + "?moved=1");
|
|
4306
|
+
},
|
|
4307
|
+
));
|
|
4308
|
+
}
|
|
4309
|
+
|
|
4105
4310
|
// ---- order exchanges ------------------------------------------------
|
|
4106
4311
|
//
|
|
4107
4312
|
// The operator side of a customer-requested item swap: the FIFO queue of
|
|
@@ -4658,15 +4863,16 @@ function mount(router, deps) {
|
|
|
4658
4863
|
|
|
4659
4864
|
async function _listForBrowser(filter) {
|
|
4660
4865
|
var rows = await collections.list(filter || {});
|
|
4661
|
-
// Annotate each row with its current size
|
|
4662
|
-
//
|
|
4663
|
-
//
|
|
4866
|
+
// Annotate each row with its current size via collections.countIn:
|
|
4867
|
+
// manual → an exact COUNT(*) (one indexed aggregate, no row
|
|
4868
|
+
// materialisation); smart → the bounded matched-preview count. The
|
|
4869
|
+
// loop is bounded by the collection count (operators have tens, not
|
|
4664
4870
|
// thousands), so this is not an N+1 over an unbounded set.
|
|
4665
4871
|
for (var i = 0; i < rows.length; i += 1) {
|
|
4666
4872
|
var count = null;
|
|
4667
4873
|
try {
|
|
4668
|
-
var
|
|
4669
|
-
count =
|
|
4874
|
+
var c = await collections.countIn(rows[i].slug);
|
|
4875
|
+
count = c.exact != null ? c.exact : c.approx;
|
|
4670
4876
|
} catch (_e) { count = null; }
|
|
4671
4877
|
rows[i]._count = count;
|
|
4672
4878
|
}
|
|
@@ -9700,6 +9906,7 @@ var ADMIN_NAV_ITEMS = [
|
|
|
9700
9906
|
{ key: "segments", href: "/admin/segments", label: "Segments", requires: "customerSegments" },
|
|
9701
9907
|
{ key: "returns", href: "/admin/returns", label: "Returns", requires: "returns" },
|
|
9702
9908
|
{ key: "support", href: "/admin/support", label: "Support", requires: "supportTickets" },
|
|
9909
|
+
{ key: "dsr", href: "/admin/dsr", label: "Privacy requests", requires: "complianceExport" },
|
|
9703
9910
|
{ key: "exchanges", href: "/admin/exchanges", label: "Exchanges", requires: "orderExchanges" },
|
|
9704
9911
|
{ key: "ratings", href: "/admin/ratings", label: "Ratings", requires: "orderRatings" },
|
|
9705
9912
|
{ key: "reviews", href: "/admin/reviews", label: "Reviews", requires: "reviews" },
|
|
@@ -11617,6 +11824,182 @@ function renderAdminSupportTicket(opts) {
|
|
|
11617
11824
|
return _renderAdminShell(opts.shop_name, "Ticket", body, "support", opts.nav_available);
|
|
11618
11825
|
}
|
|
11619
11826
|
|
|
11827
|
+
// ---- subject-access-request (DSR) queue --------------------------------
|
|
11828
|
+
//
|
|
11829
|
+
// The operator side of the GDPR / CCPA / LGPD request lifecycle: a
|
|
11830
|
+
// status-filterable list of export + deletion requests, one request's
|
|
11831
|
+
// detail with its per-customer audit history + the action forms (fulfil +
|
|
11832
|
+
// download an export, mark delivered, preview a deletion dry-run, execute
|
|
11833
|
+
// the erasure behind a confirm interstitial, dismiss). Models the support
|
|
11834
|
+
// console shape. Every dynamic value — including operator/customer free
|
|
11835
|
+
// text (reason / dismiss_reason / delivery_address) — is _htmlEscape'd:
|
|
11836
|
+
// the primitive bounds length + strips control bytes but does NOT strip
|
|
11837
|
+
// HTML, so escape-at-render is the XSS gate.
|
|
11838
|
+
var DSR_STATUS_FILTERS = ["all", "received", "processing", "fulfilled", "delivered", "dismissed"];
|
|
11839
|
+
|
|
11840
|
+
function _dsrPillClass(status) {
|
|
11841
|
+
if (status === "fulfilled" || status === "delivered") return "paid";
|
|
11842
|
+
if (status === "dismissed") return "cancelled";
|
|
11843
|
+
return "pending"; // received / processing
|
|
11844
|
+
}
|
|
11845
|
+
|
|
11846
|
+
// Operator queue. Status chips filter the board; each row links to the
|
|
11847
|
+
// request detail. Renders the customer (short id), kind, jurisdiction,
|
|
11848
|
+
// scope, status pill, and when it was requested.
|
|
11849
|
+
function renderAdminDsr(opts) {
|
|
11850
|
+
opts = opts || {};
|
|
11851
|
+
var requests = opts.requests || [];
|
|
11852
|
+
var active = opts.status || "all";
|
|
11853
|
+
var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
11854
|
+
|
|
11855
|
+
var chips = "<div class=\"order-filters\">" +
|
|
11856
|
+
DSR_STATUS_FILTERS.map(function (s) {
|
|
11857
|
+
return "<a class=\"chip" + (active === s ? " chip--on" : "") + "\" href=\"/admin/dsr?status=" + encodeURIComponent(s) + "\">" + _htmlEscape(s) + "</a>";
|
|
11858
|
+
}).join("") +
|
|
11859
|
+
"</div>";
|
|
11860
|
+
|
|
11861
|
+
var rows = requests.map(function (r) {
|
|
11862
|
+
return "<tr>" +
|
|
11863
|
+
"<td><a class=\"order-id\" href=\"/admin/dsr/" + _htmlEscape(r.id) + "\">" + _htmlEscape(String(r.customer_id).slice(0, 8)) + "</a></td>" +
|
|
11864
|
+
"<td>" + _htmlEscape(r.request_kind) + "</td>" +
|
|
11865
|
+
"<td>" + _htmlEscape(r.jurisdiction) + "</td>" +
|
|
11866
|
+
"<td>" + _htmlEscape(r.scope || "—") + "</td>" +
|
|
11867
|
+
"<td><span class=\"status-pill " + _dsrPillClass(r.status) + "\">" + _htmlEscape(r.status) + "</span></td>" +
|
|
11868
|
+
"<td>" + _htmlEscape(_fmtDate(r.requested_at)) + "</td>" +
|
|
11869
|
+
"</tr>";
|
|
11870
|
+
}).join("");
|
|
11871
|
+
|
|
11872
|
+
var table = requests.length
|
|
11873
|
+
? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Customer</th><th scope=\"col\">Kind</th><th scope=\"col\">Jurisdiction</th><th scope=\"col\">Scope</th><th scope=\"col\">Status</th><th scope=\"col\">Requested</th></tr></thead><tbody>" + rows + "</tbody></table></div>"
|
|
11874
|
+
: "<p class=\"empty\">No “" + _htmlEscape(active) + "” privacy requests.</p>";
|
|
11875
|
+
|
|
11876
|
+
var body = "<section><h2>Privacy requests</h2>" +
|
|
11877
|
+
"<p class=\"meta\">Subject-access requests (data export) and erasure requests under GDPR / CCPA / LGPD.</p>" +
|
|
11878
|
+
notice + chips + table + "</section>";
|
|
11879
|
+
return _renderAdminShell(opts.shop_name, "Privacy requests", body, "dsr", opts.nav_available);
|
|
11880
|
+
}
|
|
11881
|
+
|
|
11882
|
+
// Request detail — the lifecycle, the customer's full request history, and
|
|
11883
|
+
// the action forms gated on kind + status. A deletion dry-run preview
|
|
11884
|
+
// (?preview=1) renders the per-domain blast-radius counts WITHOUT mutating.
|
|
11885
|
+
function renderAdminDsrDetail(opts) {
|
|
11886
|
+
opts = opts || {};
|
|
11887
|
+
var r = opts.request;
|
|
11888
|
+
var history = opts.history || [];
|
|
11889
|
+
var preview = opts.preview || null; // processDeletion dry-run result
|
|
11890
|
+
var moved = opts.moved ? "<div class=\"banner banner--ok\">Request updated.</div>" : "";
|
|
11891
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
11892
|
+
|
|
11893
|
+
function _field(label, value) {
|
|
11894
|
+
return "<p><span class=\"meta\">" + _htmlEscape(label) + "</span><br>" + (value ? _htmlEscape(String(value)) : "<span class=\"meta\">—</span>") + "</p>";
|
|
11895
|
+
}
|
|
11896
|
+
|
|
11897
|
+
var isExport = r.request_kind === "export";
|
|
11898
|
+
var isDeletion = r.request_kind === "deletion";
|
|
11899
|
+
var openState = r.status === "received" || r.status === "processing";
|
|
11900
|
+
|
|
11901
|
+
// Action forms.
|
|
11902
|
+
var actions = "";
|
|
11903
|
+
if (isExport && openState) {
|
|
11904
|
+
actions +=
|
|
11905
|
+
"<form method=\"post\" action=\"/admin/dsr/" + _htmlEscape(r.id) + "/fulfill\" class=\"form-inline\">" +
|
|
11906
|
+
"<button class=\"btn\" type=\"submit\">Fulfil request</button>" +
|
|
11907
|
+
"</form>";
|
|
11908
|
+
}
|
|
11909
|
+
if (isExport && (r.status === "fulfilled" || r.status === "delivered")) {
|
|
11910
|
+
actions +=
|
|
11911
|
+
"<a class=\"btn\" href=\"/admin/dsr/" + _htmlEscape(r.id) + "/export.json\">Download export</a>";
|
|
11912
|
+
}
|
|
11913
|
+
if (isExport && r.status === "fulfilled") {
|
|
11914
|
+
actions +=
|
|
11915
|
+
"<form method=\"post\" action=\"/admin/dsr/" + _htmlEscape(r.id) + "/dispatch\" class=\"return-action\">" +
|
|
11916
|
+
"<h4>Mark delivered</h4>" +
|
|
11917
|
+
_setupField("Delivery method", "delivery_method", r.delivery_method || "", "text", "How the bundle reached the requester (e.g. email, secure-link).", " maxlength=\"64\" required") +
|
|
11918
|
+
_setupField("Delivery address", "delivery_address", r.delivery_address || "", "text", "Where it was sent (email address / link / postal address).", " maxlength=\"1000\" required") +
|
|
11919
|
+
"<button class=\"btn\" type=\"submit\">Mark delivered</button>" +
|
|
11920
|
+
"</form>";
|
|
11921
|
+
}
|
|
11922
|
+
if (isDeletion && openState) {
|
|
11923
|
+
actions +=
|
|
11924
|
+
"<a class=\"btn\" href=\"/admin/dsr/" + _htmlEscape(r.id) + "?preview=1\">Preview erasure (dry run)</a>";
|
|
11925
|
+
// Execute routes through the confirm interstitial (irreversible; the CSP
|
|
11926
|
+
// forbids a client confirm()). The button POSTs to /delete/confirm which
|
|
11927
|
+
// renders renderAdminConfirm; that page's Confirm POSTs the real execute.
|
|
11928
|
+
actions +=
|
|
11929
|
+
"<form method=\"post\" action=\"/admin/dsr/" + _htmlEscape(r.id) + "/delete/confirm\" class=\"form-inline\">" +
|
|
11930
|
+
"<button class=\"btn btn--danger\" type=\"submit\">Execute erasure…</button>" +
|
|
11931
|
+
"</form>";
|
|
11932
|
+
}
|
|
11933
|
+
if (r.status !== "delivered" && r.status !== "dismissed") {
|
|
11934
|
+
actions +=
|
|
11935
|
+
"<form method=\"post\" action=\"/admin/dsr/" + _htmlEscape(r.id) + "/dismiss\" class=\"return-action\">" +
|
|
11936
|
+
"<h4>Dismiss</h4>" +
|
|
11937
|
+
"<label class=\"form-field\"><span>Reason</span><textarea name=\"dismiss_reason\" rows=\"3\" maxlength=\"4000\" required></textarea></label>" +
|
|
11938
|
+
"<button class=\"btn btn--danger\" type=\"submit\">Dismiss request</button>" +
|
|
11939
|
+
"</form>";
|
|
11940
|
+
}
|
|
11941
|
+
var actionsHtml = actions
|
|
11942
|
+
? "<div class=\"return-actions\">" + actions + "</div>"
|
|
11943
|
+
: "<span class=\"meta\">This request is in a terminal state — no further actions.</span>";
|
|
11944
|
+
|
|
11945
|
+
// Deletion dry-run preview panel (per-domain counts; never mutates).
|
|
11946
|
+
var previewHtml = "";
|
|
11947
|
+
if (preview) {
|
|
11948
|
+
var dRows = (preview.domains || []).map(function (d) {
|
|
11949
|
+
var note = d.note ? " <span class=\"meta\">(" + _htmlEscape(d.note) + ")</span>" : "";
|
|
11950
|
+
return "<tr><td>" + _htmlEscape(d.table || d.domain) + "</td><td>" + _htmlEscape(String(d.deleted)) + note + "</td></tr>";
|
|
11951
|
+
}).join("");
|
|
11952
|
+
var absent = (preview.domains_absent || []).map(function (n) { return _htmlEscape(n); }).join(", ");
|
|
11953
|
+
previewHtml =
|
|
11954
|
+
"<div class=\"panel mt\"><h3 class=\"subhead\">Erasure preview (dry run — nothing was changed)</h3>" +
|
|
11955
|
+
(dRows
|
|
11956
|
+
? "<table><thead><tr><th scope=\"col\">Table</th><th scope=\"col\">Affected</th></tr></thead><tbody>" + dRows + "</tbody></table>"
|
|
11957
|
+
: "<p class=\"empty\">No per-domain handlers wired.</p>") +
|
|
11958
|
+
(absent ? "<p class=\"meta\">Domains without a deletion handler: " + absent + "</p>" : "") +
|
|
11959
|
+
"<p class=\"meta\">Total affected: " + _htmlEscape(String(preview.total_affected)) + "</p>" +
|
|
11960
|
+
"</div>";
|
|
11961
|
+
}
|
|
11962
|
+
|
|
11963
|
+
var histRows = history.map(function (h) {
|
|
11964
|
+
return "<tr>" +
|
|
11965
|
+
"<td>" + _htmlEscape(String(h.id).slice(0, 8)) + "</td>" +
|
|
11966
|
+
"<td>" + _htmlEscape(h.request_kind) + "</td>" +
|
|
11967
|
+
"<td><span class=\"status-pill " + _dsrPillClass(h.status) + "\">" + _htmlEscape(h.status) + "</span></td>" +
|
|
11968
|
+
"<td>" + _htmlEscape(_fmtDate(h.requested_at)) + "</td>" +
|
|
11969
|
+
"</tr>";
|
|
11970
|
+
}).join("");
|
|
11971
|
+
var historyHtml = history.length
|
|
11972
|
+
? "<table><thead><tr><th scope=\"col\">Request</th><th scope=\"col\">Kind</th><th scope=\"col\">Status</th><th scope=\"col\">Requested</th></tr></thead><tbody>" + histRows + "</tbody></table>"
|
|
11973
|
+
: "<p class=\"empty\">No prior requests.</p>";
|
|
11974
|
+
|
|
11975
|
+
var body =
|
|
11976
|
+
"<section class=\"mw-48\">" +
|
|
11977
|
+
"<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/dsr\">← Privacy requests</a></div>" +
|
|
11978
|
+
"<h2>" + _htmlEscape(r.request_kind === "export" ? "Data export" : "Erasure") + " request " +
|
|
11979
|
+
"<span class=\"status-pill " + _dsrPillClass(r.status) + "\">" + _htmlEscape(r.status) + "</span></h2>" +
|
|
11980
|
+
"<p class=\"meta\">Requested " + _htmlEscape(_fmtDate(r.requested_at)) +
|
|
11981
|
+
" · by " + _htmlEscape(r.requested_by) +
|
|
11982
|
+
" · customer <code>" + _htmlEscape(String(r.customer_id).slice(0, 8)) + "</code></p>" +
|
|
11983
|
+
moved + notice +
|
|
11984
|
+
"<div class=\"two-col\">" +
|
|
11985
|
+
"<div class=\"panel\"><h3 class=\"subhead\">Details</h3>" +
|
|
11986
|
+
_field("Jurisdiction", r.jurisdiction) +
|
|
11987
|
+
_field("Scope", r.scope) +
|
|
11988
|
+
_field("Reason", r.reason) +
|
|
11989
|
+
_field("Dismiss reason", r.dismiss_reason) +
|
|
11990
|
+
_field("Delivery method", r.delivery_method) +
|
|
11991
|
+
_field("Delivery address", r.delivery_address) +
|
|
11992
|
+
_field("Fulfilled", r.fulfilled_at ? _fmtDate(r.fulfilled_at) : null) +
|
|
11993
|
+
_field("Delivered", r.delivered_at ? _fmtDate(r.delivered_at) : null) +
|
|
11994
|
+
"</div>" +
|
|
11995
|
+
"<div class=\"panel\"><h3 class=\"subhead\">Customer request history</h3>" + historyHtml + "</div>" +
|
|
11996
|
+
"</div>" +
|
|
11997
|
+
previewHtml +
|
|
11998
|
+
"<div class=\"panel mt\"><h3 class=\"subhead\">Actions</h3>" + actionsHtml + "</div>" +
|
|
11999
|
+
"</section>";
|
|
12000
|
+
return _renderAdminShell(opts.shop_name, "Privacy request", body, "dsr", opts.nav_available);
|
|
12001
|
+
}
|
|
12002
|
+
|
|
11620
12003
|
// The non-terminal exchange states the operator can filter the queue by —
|
|
11621
12004
|
// lifecycle order (the terminal closed / rejected don't appear in the open
|
|
11622
12005
|
// queue, so they're not filter chips).
|
package/lib/asset-manifest.json
CHANGED
package/lib/catalog.js
CHANGED
|
@@ -1347,6 +1347,69 @@ function _batchModule(query) {
|
|
|
1347
1347
|
for (var i = 0; i < r.rows.length; i += 1) out[r.rows[i].id] = r.rows[i];
|
|
1348
1348
|
return out;
|
|
1349
1349
|
},
|
|
1350
|
+
|
|
1351
|
+
// Every price row (current + history) for a set of variant ids in
|
|
1352
|
+
// one query, grouped into the per-variant price model the admin
|
|
1353
|
+
// product-detail screen builds (backs PERF-4). Replaces the admin's
|
|
1354
|
+
// triple-nested per-variant → per-currency → (prices.current +
|
|
1355
|
+
// prices.history) loop with a single read. Returns a map
|
|
1356
|
+
// { variantId: { currencies: [{ currency, current, history }] } }
|
|
1357
|
+
// byte-identical to the per-item build:
|
|
1358
|
+
// - `currencies` is one entry per distinct currency, ordered ASC
|
|
1359
|
+
// (the order `prices.currencies` returns).
|
|
1360
|
+
// - `current` is the open price row (effective_until IS NULL) for
|
|
1361
|
+
// that currency, or null — matching `prices.current`.
|
|
1362
|
+
// - `history` is every row for that currency ordered effective_from
|
|
1363
|
+
// DESC — matching `prices.history`.
|
|
1364
|
+
// EVERY input variant id is present in the map; a variant with no
|
|
1365
|
+
// prices maps to `{ currencies: [] }` (the per-item path's empty
|
|
1366
|
+
// build). Price rows are the raw `SELECT *` shape (no arithmetic —
|
|
1367
|
+
// amount_minor is the integer minor-unit value passed through).
|
|
1368
|
+
pricesForVariants: async function (variantIds) {
|
|
1369
|
+
if (!Array.isArray(variantIds) || variantIds.length === 0) return {};
|
|
1370
|
+
var unique = [];
|
|
1371
|
+
var seen = Object.create(null);
|
|
1372
|
+
for (var n = 0; n < variantIds.length; n += 1) {
|
|
1373
|
+
var vid = variantIds[n];
|
|
1374
|
+
if (typeof vid !== "string" || !vid.length) continue;
|
|
1375
|
+
var canon = _id(vid, "variant id");
|
|
1376
|
+
if (seen[canon]) continue;
|
|
1377
|
+
seen[canon] = true;
|
|
1378
|
+
unique.push(canon);
|
|
1379
|
+
}
|
|
1380
|
+
var out = {};
|
|
1381
|
+
// Seed every requested variant so an unpriced variant still maps to
|
|
1382
|
+
// an empty currencies list (the per-item loop's `{ currencies: [] }`).
|
|
1383
|
+
for (var s = 0; s < unique.length; s += 1) out[unique[s]] = { currencies: [] };
|
|
1384
|
+
if (!unique.length) return out;
|
|
1385
|
+
var placeholders = unique.map(function (_v, i) { return "?" + (i + 1); }).join(", ");
|
|
1386
|
+
// ORDER BY variant_id, currency ASC (matches prices.currencies'
|
|
1387
|
+
// ASC ordering), then effective_from DESC (matches prices.history).
|
|
1388
|
+
var r = await query(
|
|
1389
|
+
"SELECT * FROM prices WHERE variant_id IN (" + placeholders + ") " +
|
|
1390
|
+
"ORDER BY variant_id, currency, effective_from DESC",
|
|
1391
|
+
unique,
|
|
1392
|
+
);
|
|
1393
|
+
// Group variant → currency. The currency-ASC ordering means the
|
|
1394
|
+
// first time a (variant, currency) pair is seen drives its position
|
|
1395
|
+
// in the `currencies` array, reproducing the per-currency loop order.
|
|
1396
|
+
var byVariantCurrency = {};
|
|
1397
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
1398
|
+
var row = r.rows[i];
|
|
1399
|
+
var bucket = out[row.variant_id];
|
|
1400
|
+
if (!bucket) continue; // a price for a variant outside the input set
|
|
1401
|
+
var key = row.variant_id + "\u0000" + row.currency;
|
|
1402
|
+
var entry = byVariantCurrency[key];
|
|
1403
|
+
if (!entry) {
|
|
1404
|
+
entry = { currency: row.currency, current: null, history: [] };
|
|
1405
|
+
byVariantCurrency[key] = entry;
|
|
1406
|
+
bucket.currencies.push(entry);
|
|
1407
|
+
}
|
|
1408
|
+
entry.history.push(row);
|
|
1409
|
+
if (row.effective_until == null && entry.current == null) entry.current = row;
|
|
1410
|
+
}
|
|
1411
|
+
return out;
|
|
1412
|
+
},
|
|
1350
1413
|
};
|
|
1351
1414
|
}
|
|
1352
1415
|
|
package/lib/collections.js
CHANGED
|
@@ -891,6 +891,39 @@ function create(opts) {
|
|
|
891
891
|
return out;
|
|
892
892
|
}
|
|
893
893
|
|
|
894
|
+
// ---- countIn -----------------------------------------------------------
|
|
895
|
+
|
|
896
|
+
// The current size of a collection, cheaply. For a MANUAL collection
|
|
897
|
+
// this is an exact `COUNT(*)` over `collection_members` — one indexed
|
|
898
|
+
// aggregate, no row materialisation (the admin list previously pulled
|
|
899
|
+
// up to 200 member rows just to `.length` them). For a SMART collection
|
|
900
|
+
// there is no cheap SQL count (membership is rule-evaluated against the
|
|
901
|
+
// catalog application-side), so the bounded preview is kept: it walks
|
|
902
|
+
// the same matched set `productsIn` does, capped at MAX_LIMIT, and the
|
|
903
|
+
// result is labelled a preview.
|
|
904
|
+
//
|
|
905
|
+
// Returns `{ exact: n }` for manual (a true total) and `{ approx: n }`
|
|
906
|
+
// for smart (the bounded preview, ≤ MAX_LIMIT). The caller reads
|
|
907
|
+
// whichever key is present; the number shown is unchanged from the
|
|
908
|
+
// previous `productsIn(...).rows.length` for both flavours.
|
|
909
|
+
async function countIn(slug) {
|
|
910
|
+
_slug(slug);
|
|
911
|
+
var row = await _row(slug);
|
|
912
|
+
if (!row) throw new TypeError("collections.countIn: collection " + JSON.stringify(slug) + " not found");
|
|
913
|
+
if (row.type === "manual") {
|
|
914
|
+
var r = await query(
|
|
915
|
+
"SELECT COUNT(*) AS n FROM collection_members WHERE collection_slug = ?1",
|
|
916
|
+
[slug],
|
|
917
|
+
);
|
|
918
|
+
var n = r.rows[0] && r.rows[0].n;
|
|
919
|
+
return { exact: Number(n == null ? 0 : n) };
|
|
920
|
+
}
|
|
921
|
+
// Smart: the bounded preview — same matched set productsIn returns,
|
|
922
|
+
// capped at MAX_LIMIT (the cap the admin list already used).
|
|
923
|
+
var preview = await productsIn({ slug: slug, limit: MAX_LIMIT });
|
|
924
|
+
return { approx: (preview.rows || []).length };
|
|
925
|
+
}
|
|
926
|
+
|
|
894
927
|
return {
|
|
895
928
|
defineManual: defineManual,
|
|
896
929
|
defineSmart: defineSmart,
|
|
@@ -902,6 +935,7 @@ function create(opts) {
|
|
|
902
935
|
removeProduct: removeProduct,
|
|
903
936
|
reorderProducts: reorderProducts,
|
|
904
937
|
productsIn: productsIn,
|
|
938
|
+
countIn: countIn,
|
|
905
939
|
collectionsForProduct: collectionsForProduct,
|
|
906
940
|
evaluateRules: _evaluateRules,
|
|
907
941
|
};
|
package/lib/storefront.js
CHANGED
|
@@ -4515,6 +4515,163 @@ function renderSupportTicket(opts) {
|
|
|
4515
4515
|
});
|
|
4516
4516
|
}
|
|
4517
4517
|
|
|
4518
|
+
// ---- privacy & data (customer self-service DSR) ------------------------
|
|
4519
|
+
//
|
|
4520
|
+
// The signed-in customer files an export request (a copy of their data) OR
|
|
4521
|
+
// a deletion request (erasure), sees their own request history, and
|
|
4522
|
+
// downloads a fulfilled export bundle. Self-service FILES the request; an
|
|
4523
|
+
// operator reviews + executes it from the admin queue (identity
|
|
4524
|
+
// verification is a controller obligation under every jurisdiction). Every
|
|
4525
|
+
// dynamic value — including the customer's own free-text deletion reason
|
|
4526
|
+
// replayed in the history — is escaped (b.template.escapeHtml); the
|
|
4527
|
+
// primitive bounds length but does NOT strip HTML.
|
|
4528
|
+
var _DSR_JURISDICTIONS = [
|
|
4529
|
+
["gdpr", "GDPR (EU / UK)"],
|
|
4530
|
+
["ccpa", "CCPA (California)"],
|
|
4531
|
+
["lgpd", "LGPD (Brazil)"],
|
|
4532
|
+
["other", "Other"],
|
|
4533
|
+
];
|
|
4534
|
+
var _DSR_SCOPES = [
|
|
4535
|
+
["full", "Everything we hold on you"],
|
|
4536
|
+
["orders_only", "Orders only"],
|
|
4537
|
+
["identity_only", "Profile + addresses only"],
|
|
4538
|
+
];
|
|
4539
|
+
|
|
4540
|
+
function _dsrStatusBadge(status) {
|
|
4541
|
+
var esc = b.template.escapeHtml;
|
|
4542
|
+
return "<span class=\"pdp__badge dsr-status--" + esc(String(status)) + "\">" + esc(String(status)) + "</span>";
|
|
4543
|
+
}
|
|
4544
|
+
|
|
4545
|
+
function renderAccountPrivacy(opts) {
|
|
4546
|
+
var esc = b.template.escapeHtml;
|
|
4547
|
+
var history = opts.history || [];
|
|
4548
|
+
var notice = "";
|
|
4549
|
+
if (opts.ok === "export") {
|
|
4550
|
+
notice = "<p class=\"form-notice form-notice--ok\" role=\"status\">Your data export request has been filed. We'll prepare it and let you know when it's ready to download.</p>";
|
|
4551
|
+
} else if (opts.ok === "deletion") {
|
|
4552
|
+
notice = "<p class=\"form-notice form-notice--ok\" role=\"status\">Your erasure request has been filed. We'll verify your identity and confirm once it's complete.</p>";
|
|
4553
|
+
} else if (opts.notice) {
|
|
4554
|
+
notice = "<p class=\"form-notice form-notice--error\" role=\"alert\">" + esc(String(opts.notice)) + "</p>";
|
|
4555
|
+
}
|
|
4556
|
+
|
|
4557
|
+
var jurisdictionOpts = _DSR_JURISDICTIONS.map(function (j) {
|
|
4558
|
+
return "<option value=\"" + esc(j[0]) + "\">" + esc(j[1]) + "</option>";
|
|
4559
|
+
}).join("");
|
|
4560
|
+
var scopeOpts = _DSR_SCOPES.map(function (s) {
|
|
4561
|
+
return "<option value=\"" + esc(s[0]) + "\">" + esc(s[1]) + "</option>";
|
|
4562
|
+
}).join("");
|
|
4563
|
+
|
|
4564
|
+
var rowsHtml = "";
|
|
4565
|
+
for (var i = 0; i < history.length; i += 1) {
|
|
4566
|
+
var r = history[i];
|
|
4567
|
+
var date = r.requested_at ? new Date(Number(r.requested_at)).toISOString().slice(0, 10) : "";
|
|
4568
|
+
var kindLabel = r.request_kind === "export" ? "Data export" : "Erasure";
|
|
4569
|
+
var download = (r.request_kind === "export" && (r.status === "fulfilled" || r.status === "delivered"))
|
|
4570
|
+
? " · <a href=\"/account/privacy/" + esc(String(r.id)) + "/export.json\">Download</a>"
|
|
4571
|
+
: "";
|
|
4572
|
+
var reason = r.reason
|
|
4573
|
+
? "<p class=\"return-card__meta\">Reason: " + esc(String(r.reason)) + "</p>"
|
|
4574
|
+
: "";
|
|
4575
|
+
rowsHtml +=
|
|
4576
|
+
"<li class=\"return-card\">" +
|
|
4577
|
+
"<div class=\"return-card__head\">" +
|
|
4578
|
+
"<span class=\"return-card__rma\">" + esc(kindLabel) + "</span>" +
|
|
4579
|
+
_dsrStatusBadge(r.status) +
|
|
4580
|
+
"</div>" +
|
|
4581
|
+
"<p class=\"return-card__meta\">" + esc(String(r.jurisdiction).toUpperCase()) +
|
|
4582
|
+
(date ? " · <time datetime=\"" + esc(date) + "\">" + esc(date) + "</time>" : "") +
|
|
4583
|
+
download +
|
|
4584
|
+
"</p>" +
|
|
4585
|
+
reason +
|
|
4586
|
+
"</li>";
|
|
4587
|
+
}
|
|
4588
|
+
var historyInner = rowsHtml
|
|
4589
|
+
? "<ul class=\"return-list\">" + rowsHtml + "</ul>"
|
|
4590
|
+
: "<div class=\"account-empty\"><p class=\"account-empty__lede\">You haven't filed any privacy requests yet.</p></div>";
|
|
4591
|
+
|
|
4592
|
+
var body =
|
|
4593
|
+
"<section class=\"account-returns\">" +
|
|
4594
|
+
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
4595
|
+
"<li><a href=\"/account\">Account</a></li>" +
|
|
4596
|
+
"<li aria-current=\"page\">Privacy & data</li>" +
|
|
4597
|
+
"</ol></nav>" +
|
|
4598
|
+
"<h1 class=\"account-returns__title\">Privacy & data</h1>" +
|
|
4599
|
+
"<p class=\"section-head__lede\">Request a copy of the personal data we hold on you, or ask us to erase it. " +
|
|
4600
|
+
"Most requests are completed within the statutory window (GDPR: one month; CCPA: 45 days; LGPD: 15 days).</p>" +
|
|
4601
|
+
notice +
|
|
4602
|
+
"<div class=\"panel\">" +
|
|
4603
|
+
"<h2 class=\"pdp__variants-title\">Request a copy of your data</h2>" +
|
|
4604
|
+
"<form class=\"return-form form-stack\" method=\"post\" action=\"/account/privacy/export\">" +
|
|
4605
|
+
"<label class=\"form-field\"><span class=\"form-field__label\">Applicable law</span>" +
|
|
4606
|
+
"<select name=\"jurisdiction\" required>" + jurisdictionOpts + "</select></label>" +
|
|
4607
|
+
"<label class=\"form-field\"><span class=\"form-field__label\">What to include</span>" +
|
|
4608
|
+
"<select name=\"scope\" required>" + scopeOpts + "</select></label>" +
|
|
4609
|
+
"<button type=\"submit\" class=\"btn-primary\">Request my data</button>" +
|
|
4610
|
+
"</form>" +
|
|
4611
|
+
"</div>" +
|
|
4612
|
+
"<div class=\"panel\">" +
|
|
4613
|
+
"<h2 class=\"pdp__variants-title\">Erase your data</h2>" +
|
|
4614
|
+
"<p class=\"return-card__meta\">This files a request to permanently erase your personal data. " +
|
|
4615
|
+
"We'll verify your identity before acting on it.</p>" +
|
|
4616
|
+
"<a class=\"btn-secondary\" href=\"/account/delete\">Request erasure</a>" +
|
|
4617
|
+
"</div>" +
|
|
4618
|
+
"<h2 class=\"pdp__variants-title\">Your requests</h2>" +
|
|
4619
|
+
historyInner +
|
|
4620
|
+
"</section>";
|
|
4621
|
+
return _wrap({
|
|
4622
|
+
title: "Privacy & data",
|
|
4623
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
4624
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
4625
|
+
theme_css: opts.theme_css,
|
|
4626
|
+
body: body,
|
|
4627
|
+
});
|
|
4628
|
+
}
|
|
4629
|
+
|
|
4630
|
+
// Server-rendered erasure-confirm interstitial (the CSP forbids a client
|
|
4631
|
+
// confirm()). Filing requires a non-empty reason + a jurisdiction. A Cancel
|
|
4632
|
+
// link returns to the privacy page.
|
|
4633
|
+
function renderAccountDelete(opts) {
|
|
4634
|
+
var esc = b.template.escapeHtml;
|
|
4635
|
+
var v = opts.values || {};
|
|
4636
|
+
var notice = opts.notice
|
|
4637
|
+
? "<p class=\"form-notice form-notice--error\" role=\"alert\">" + esc(String(opts.notice)) + "</p>"
|
|
4638
|
+
: "";
|
|
4639
|
+
var jurisdictionOpts = _DSR_JURISDICTIONS.map(function (j) {
|
|
4640
|
+
var sel = v.jurisdiction === j[0] ? " selected" : "";
|
|
4641
|
+
return "<option value=\"" + esc(j[0]) + "\"" + sel + ">" + esc(j[1]) + "</option>";
|
|
4642
|
+
}).join("");
|
|
4643
|
+
var body =
|
|
4644
|
+
"<section class=\"return-form-page\">" +
|
|
4645
|
+
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
4646
|
+
"<li><a href=\"/account\">Account</a></li>" +
|
|
4647
|
+
"<li><a href=\"/account/privacy\">Privacy & data</a></li>" +
|
|
4648
|
+
"<li aria-current=\"page\">Erase my data</li>" +
|
|
4649
|
+
"</ol></nav>" +
|
|
4650
|
+
"<h1 class=\"return-form-page__title\">Request data erasure</h1>" +
|
|
4651
|
+
"<p class=\"form-notice\" role=\"note\">Filing this request asks us to permanently erase the personal data we hold on you. " +
|
|
4652
|
+
"Orders and other records we're legally required to keep are retained but de-linked from your identity. " +
|
|
4653
|
+
"We'll verify your identity before acting on it. This cannot be undone.</p>" +
|
|
4654
|
+
notice +
|
|
4655
|
+
"<form class=\"return-form form-stack\" method=\"post\" action=\"/account/delete\">" +
|
|
4656
|
+
"<label class=\"form-field\"><span class=\"form-field__label\">Applicable law</span>" +
|
|
4657
|
+
"<select name=\"jurisdiction\" required>" + jurisdictionOpts + "</select></label>" +
|
|
4658
|
+
"<label class=\"form-field\"><span class=\"form-field__label\">Reason for your request</span>" +
|
|
4659
|
+
"<textarea name=\"reason\" required maxlength=\"4000\" rows=\"4\">" + esc(v.reason == null ? "" : String(v.reason)) + "</textarea></label>" +
|
|
4660
|
+
"<div class=\"actions-row\">" +
|
|
4661
|
+
"<button type=\"submit\" class=\"btn-primary\">File erasure request</button>" +
|
|
4662
|
+
"<a class=\"btn-secondary\" href=\"/account/privacy\">Cancel</a>" +
|
|
4663
|
+
"</div>" +
|
|
4664
|
+
"</form>" +
|
|
4665
|
+
"</section>";
|
|
4666
|
+
return _wrap({
|
|
4667
|
+
title: "Erase my data",
|
|
4668
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
4669
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
4670
|
+
theme_css: opts.theme_css,
|
|
4671
|
+
body: body,
|
|
4672
|
+
});
|
|
4673
|
+
}
|
|
4674
|
+
|
|
4518
4675
|
// Loyalty transaction-type pill — reuses the `pdp__badge` class the
|
|
4519
4676
|
// theme already styles. The type is one of the ledger's closed enum
|
|
4520
4677
|
// (earn / redeem / expire / adjust / tier-bonus).
|
|
@@ -7736,6 +7893,7 @@ var ACCOUNT_DASH_PAGE =
|
|
|
7736
7893
|
" RAW_PREORDER_LINK\n" +
|
|
7737
7894
|
// begin: profile + passkey management actions
|
|
7738
7895
|
" <a class=\"btn-secondary\" href=\"/account/profile\">Edit profile</a>\n" +
|
|
7896
|
+
" <a class=\"btn-secondary\" href=\"/account/privacy\">Privacy & data</a>\n" +
|
|
7739
7897
|
" <a class=\"btn-secondary\" href=\"/account/passkeys\">Manage passkeys</a>\n" +
|
|
7740
7898
|
// end: profile + passkey management actions
|
|
7741
7899
|
" <form method=\"post\" action=\"/account/logout\"><button type=\"submit\" class=\"btn-ghost\">Sign out</button></form>\n" +
|
|
@@ -14144,6 +14302,128 @@ function mount(router, deps) {
|
|
|
14144
14302
|
});
|
|
14145
14303
|
}
|
|
14146
14304
|
|
|
14305
|
+
// Privacy & data — the signed-in customer's self-service DSR surface.
|
|
14306
|
+
// Filing an export or deletion request pins customer_id to the SESSION
|
|
14307
|
+
// (never the form); requested_by is the fixed "customer-self-service"
|
|
14308
|
+
// string (the session env carries no email — _currentCustomerEnv returns
|
|
14309
|
+
// { customer_id, exp } only — and the primitive requires a non-empty
|
|
14310
|
+
// requested_by). Self-service FILES the request; an operator reviews +
|
|
14311
|
+
// executes it from /admin/dsr (PD-3). The export download is
|
|
14312
|
+
// ownership-scoped (a stranger → 404) and streams the bundle.
|
|
14313
|
+
if (deps.complianceExport && deps.customers) {
|
|
14314
|
+
var dsr = deps.complianceExport;
|
|
14315
|
+
var dsrReaders = deps.complianceExportReaders || {};
|
|
14316
|
+
var dsrSections = deps.complianceExportSections || {};
|
|
14317
|
+
var streamDsr = deps.streamDsrBundle;
|
|
14318
|
+
|
|
14319
|
+
router.get("/account/privacy", async function (req, res) {
|
|
14320
|
+
var auth = _accountAuth(req, res); if (!auth) return;
|
|
14321
|
+
var history = [];
|
|
14322
|
+
try { history = await dsr.auditForCustomer(auth.customer_id); } catch (_e) { history = []; }
|
|
14323
|
+
var cartCount = await _cartCountForReq(req);
|
|
14324
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
14325
|
+
_send(res, 200, renderAccountPrivacy({
|
|
14326
|
+
history: history,
|
|
14327
|
+
ok: url && url.searchParams.get("ok"),
|
|
14328
|
+
shop_name: shopName,
|
|
14329
|
+
cart_count: cartCount,
|
|
14330
|
+
}));
|
|
14331
|
+
});
|
|
14332
|
+
|
|
14333
|
+
// File a data-export request. customer_id is pinned to the session.
|
|
14334
|
+
// A bad enum (jurisdiction / scope) throws TypeError → a 400 re-render
|
|
14335
|
+
// with a notice, never a 500. No row is created on a bad enum (the
|
|
14336
|
+
// primitive validates before INSERT).
|
|
14337
|
+
router.post("/account/privacy/export", async function (req, res) {
|
|
14338
|
+
var auth = _accountAuth(req, res); if (!auth) return;
|
|
14339
|
+
var body = req.body || {};
|
|
14340
|
+
try {
|
|
14341
|
+
await dsr.requestExport({
|
|
14342
|
+
customer_id: auth.customer_id,
|
|
14343
|
+
requested_by: "customer-self-service",
|
|
14344
|
+
jurisdiction: body.jurisdiction,
|
|
14345
|
+
scope: body.scope,
|
|
14346
|
+
});
|
|
14347
|
+
} catch (e) {
|
|
14348
|
+
if (e instanceof TypeError) {
|
|
14349
|
+
var history = [];
|
|
14350
|
+
try { history = await dsr.auditForCustomer(auth.customer_id); } catch (_e2) { history = []; }
|
|
14351
|
+
var cc = await _cartCountForReq(req);
|
|
14352
|
+
return _send(res, 400, renderAccountPrivacy({
|
|
14353
|
+
history: history,
|
|
14354
|
+
notice: "Please choose a valid law and scope and try again.",
|
|
14355
|
+
shop_name: shopName,
|
|
14356
|
+
cart_count: cc,
|
|
14357
|
+
}));
|
|
14358
|
+
}
|
|
14359
|
+
throw e;
|
|
14360
|
+
}
|
|
14361
|
+
res.status(303);
|
|
14362
|
+
res.setHeader && res.setHeader("location", "/account/privacy?ok=export");
|
|
14363
|
+
return res.end ? res.end() : res.send("");
|
|
14364
|
+
});
|
|
14365
|
+
|
|
14366
|
+
router.get("/account/delete", async function (req, res) {
|
|
14367
|
+
var auth = _accountAuth(req, res); if (!auth) return;
|
|
14368
|
+
var cartCount = await _cartCountForReq(req);
|
|
14369
|
+
_send(res, 200, renderAccountDelete({ shop_name: shopName, cart_count: cartCount }));
|
|
14370
|
+
});
|
|
14371
|
+
|
|
14372
|
+
// File a deletion request. customer_id is pinned to the session; this
|
|
14373
|
+
// FILES the request (status received) — it does NOT execute the erasure
|
|
14374
|
+
// (the operator reviews identity + runs processDeletion from the admin
|
|
14375
|
+
// queue). A bad enum / empty reason throws TypeError → a 400 re-render.
|
|
14376
|
+
router.post("/account/delete", async function (req, res) {
|
|
14377
|
+
var auth = _accountAuth(req, res); if (!auth) return;
|
|
14378
|
+
var body = req.body || {};
|
|
14379
|
+
try {
|
|
14380
|
+
await dsr.requestDeletion({
|
|
14381
|
+
customer_id: auth.customer_id,
|
|
14382
|
+
requested_by: "customer-self-service",
|
|
14383
|
+
jurisdiction: body.jurisdiction,
|
|
14384
|
+
reason: body.reason,
|
|
14385
|
+
});
|
|
14386
|
+
} catch (e) {
|
|
14387
|
+
if (e instanceof TypeError) {
|
|
14388
|
+
var cc = await _cartCountForReq(req);
|
|
14389
|
+
return _send(res, 400, renderAccountDelete({
|
|
14390
|
+
values: body,
|
|
14391
|
+
notice: "Please choose a valid law and give a reason, then try again.",
|
|
14392
|
+
shop_name: shopName,
|
|
14393
|
+
cart_count: cc,
|
|
14394
|
+
}));
|
|
14395
|
+
}
|
|
14396
|
+
throw e;
|
|
14397
|
+
}
|
|
14398
|
+
res.status(303);
|
|
14399
|
+
res.setHeader && res.setHeader("location", "/account/privacy?ok=deletion");
|
|
14400
|
+
return res.end ? res.end() : res.send("");
|
|
14401
|
+
});
|
|
14402
|
+
|
|
14403
|
+
// Ownership-scoped streaming export download. Resolve the row, verify
|
|
14404
|
+
// it belongs to THIS session customer (IDOR gate — a stranger / unknown
|
|
14405
|
+
// / malformed id → 404, never another customer's bundle), confirm it's
|
|
14406
|
+
// a fulfilled/delivered export, then stream. Status + ownership are
|
|
14407
|
+
// validated BEFORE the first write (the bundle streams header-first).
|
|
14408
|
+
router.get("/account/privacy/:id/export.json", async function (req, res) {
|
|
14409
|
+
var auth = _accountAuth(req, res); if (!auth) return;
|
|
14410
|
+
var row;
|
|
14411
|
+
try { row = await dsr.getRequest(req.params && req.params.id); }
|
|
14412
|
+
catch (e) {
|
|
14413
|
+
if (e instanceof TypeError) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return; }
|
|
14414
|
+
throw e;
|
|
14415
|
+
}
|
|
14416
|
+
if (!row || row.customer_id !== auth.customer_id ||
|
|
14417
|
+
row.request_kind !== "export" ||
|
|
14418
|
+
(row.status !== "fulfilled" && row.status !== "delivered")) {
|
|
14419
|
+
_send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
|
|
14420
|
+
return;
|
|
14421
|
+
}
|
|
14422
|
+
var sections = dsrSections[row.scope] || dsrSections.full || [];
|
|
14423
|
+
await streamDsr(res, dsrReaders, sections, row);
|
|
14424
|
+
});
|
|
14425
|
+
}
|
|
14426
|
+
|
|
14147
14427
|
// Loyalty — the signed-in customer's points balance + tier, the
|
|
14148
14428
|
// earn/redeem ledger, how points are earned, and (when a reward
|
|
14149
14429
|
// catalog + redemption primitive are wired) a redeem-a-reward
|
package/package.json
CHANGED