@blamejs/blamejs-shop 0.0.57 → 0.0.58
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -0
- package/lib/gift-options.js +596 -0
- package/lib/index.js +5 -0
- package/lib/quantity-discounts.js +781 -0
- package/lib/sales-reports.js +843 -0
- package/lib/subscription-controls.js +723 -0
- package/lib/support-tickets.js +898 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.0.x
|
|
10
10
|
|
|
11
|
+
- v0.0.58 (2026-05-22) — **Five new primitives: sales reports, quantity discounts, subscription controls, gift options, support tickets.** Five primitives ship in one release. `salesReports` aggregates orders for operator dashboards with cursor-paginated detail views + opt-in memoization. `quantityDiscounts` adds automatic tier pricing (buy N for X% off) scoped per SKU / product / collection / vendor / category / global, distinct from coupon codes. `subscriptionControls` layers pause / resume / skip-next / change-quantity / change-frequency / cancel / reactivate on top of the existing `subscriptions` surface with a full audit ledger. `giftOptions` adds gift wrapping (operator-configured wrap SKUs with fees) + gift message + recipient-name + gift-receipt toggle, with HTML-escape at every rendered field. `supportTickets` is a broader customer-service surface than `orderNotes` — pre-sale questions, account problems, complaints — with threaded messages, priority FSM, SLA timer, and per-operator assignment. **Added:** *`salesReports` primitive — order aggregations for operator dashboards* — `bShop.salesReports.create({ query?, cursorSecret? })` returns `{ revenueByDay, revenueByWeek, revenueByMonth, topProducts, topCustomers, revenueByCountry, revenueByPaymentMethod, customerCohort, aov, refundRate, funnel, purgeExpired }`. Each surface accepts `{ from, to, currency? }`; top-N surfaces add `{ limit, cursor? }` for HMAC-paginated detail. Opt-in memoization (`cache: true`) writes to a `sales_report_cache` row keyed on `(report_key, params_hash)` with operator-tuned TTL; default is fresh recompute so first-load dashboards never serve stale data. `customerCohort(cohort_month)` returns per-month retention for the cohort that first ordered in `cohort_month`. `funnel({ from, to })` counts checkout-started / payment-intent-created / paid / fulfilled / refunded. Migration `0042_sales_report_cache.sql` (id, report_key, params_hash, result_json, computed_at, expires_at + indexes on (report_key, expires_at), (params_hash)). · *`quantityDiscounts` primitive — automatic tier pricing per SKU / product / collection / vendor / category / global* — `bShop.quantityDiscounts.create({ query?, catalog })` returns `{ defineTier, getTiersForLine, applyToLine, applyToCart, update, archive, unarchive, list, tierBreakdown }`. Discount kinds: `percent_off (bps)`, `amount_off_each (minor)`, `amount_off_total (minor)`, `fixed_each_price (minor)`. `applyToLine` returns `{ original_unit_minor, discounted_unit_minor, line_subtotal_minor, line_discount_minor, applied_tier_id }` after walking applicable rules ordered by scope-specificity (sku > product > collection > vendor > category > global) and picking the best one. `exclusive: true` on a tier set short-circuits stacking. `defineTier` refuses overlapping `min_quantity` values within a single tier set so the schedule is unambiguous. Per-unit math composes `b.money.fromMinorUnits(...).multiply([num, den])` for half-even rounding consistent with the framework Money class. Migration `0044_quantity_discounts.sql` (`qd_tier_sets` + `qd_tiers` tables). · *`subscriptionControls` primitive — pause / resume / skip / change-quantity / change-frequency / cancel / reactivate* — `bShop.subscriptionControls.create({ query?, subscriptions })` returns `{ pause, resume, skipNext, changeQuantity, changeFrequency, cancel, reactivate, historyForSubscription, actorReport, scanAutoResume }`. `pause({ until?, reason })` flips active → paused; an `until` value schedules auto-resume via `scanAutoResume(now)`. `skipNext({ count, reason })` bumps `next_billing_at` by N periods without changing status; `changeFrequency({ new_frequency, reason })` recomputes `next_billing_at` on the new schedule (weekly / biweekly / monthly / quarterly / semiannual / annual). `cancel({ immediate? })` defaults to end-of-period (customer keeps access through the paid period); `immediate: true` ends now. `reactivate` allows cancelled → active within a 90-day grace window; refuses past. Every state change writes a `subscription_control_events` row with `actor_type`, `actor_id`, `before_json`, `after_json`, and a 280-char operator-prose `reason` so customer service can replay the full state arc. Migration `0045_subscription_controls.sql` (new event table + ALTER TABLE additions to `subscriptions` for `paused_at` / `paused_until` / `cancelled_at` / `quantity` / `frequency` / `next_billing_at`). · *`giftOptions` primitive — gift wrapping, gift message, gift receipt* — `bShop.giftOptions.create({ query?, catalog })` returns `{ defineWrap, listWraps, getWrap, updateWrap, archiveWrap, setForOrder, getForOrder, clearForOrder, feeForOrder, renderPackingSlipLine, analytics }`. `wrap_sku` references a real catalog SKU so wrap inventory + cost flow through normal channels. `setForOrder({ order_id, wrap_sku?, gift_message?, recipient_name?, hide_prices? })` validates `gift_message` (≤ 500 chars, control-byte-free, zero-width-char-free) + `recipient_name` (≤ 120 chars) + refuses unknown / archived wrap_sku. `renderPackingSlipLine({ order_id, locale })` returns `{ message_lines, recipient_name, hide_prices }` with every operator-input field HTML-escaped via `b.template.escapeHtml` (script-in-message / img-onerror payloads are rendered inert). `analytics({ from, to })` powers the operator dashboard (gift-option order count, top wrap_skus, gift-message rate). Migration `0046_gift_options.sql` (`gift_wraps` + per-order `gift_options` rows). · *`supportTickets` primitive — threaded customer-service surface with SLA timers* — `bShop.supportTickets.create({ query?, cursorSecret? })` returns `{ open, reply, transition, assign, unassign, addTag, removeTag, get, listForCustomer, listOpenAssignedTo, listUnassigned, thread, slaCheck, metrics }`. Distinct from `orderNotes` — a ticket isn't necessarily tied to an order. Categories: `pre_sale / order_issue / shipping / billing / refund / account / complaint / feature_request / other`. Priority: `low / normal / high / urgent`. Status FSM: `new → in_progress → waiting_customer → in_progress → resolved → closed`, with `reopened` re-entering `in_progress` and `closed` reachable from any state. Operator reply auto-flips `new → in_progress`. Email always hashed via `namespaceHash('support-ticket-email', ...)` before write. `slaCheck()` returns tickets breaching SLA (urgent 1h / high 4h / normal 24h / low 72h since last operator action). `metrics({ from, to })` covers per-category counts, average first-response time, resolution rate, escalation rate. Migration `0047_support_tickets.sql` (`support_tickets` + `support_messages` + `support_status_history` tables).
|
|
12
|
+
|
|
11
13
|
- v0.0.57 (2026-05-22) — **Ten new primitives: bundles, variants, multi-warehouse inventory, backorder, payment methods, order notes, fraud screening, order export, print-on-demand, save-for-later.** Ten primitives ship in one release. `bundles` registers kit-products that expand at cart resolution time. `variants` is the option-axis layer that lets a single product carry the cartesian product of color × size × style (etc.) with per-variant overrides. `inventoryLocations` splits stock across N warehouses + retail stores + drop-ship suppliers with a routing strategy. `backorder` lets out-of-stock SKUs still be ordered with an expected-ship date carried to the PDP. `paymentMethods` stores per-customer processor tokens (Stripe payment-method ids, PayPal billing-agreement ids, etc.) with strict refuses on PAN / CVV-shaped fields. `orderNotes` adds a threaded customer-service surface attached to each order with `internal` and `customer_visible` visibility. `fraudScreen` returns a `{score, decision, signals}` triage at checkout from a 13-signal heuristic. `orderExport` streams CSV / NDJSON / scheduled-export rows with CSV-injection refusal at every cell. `printOnDemand` is the supplier-agnostic POD passthrough (Printful / Printify / Gelato / Lulu / Gooten / Cloudprinter + `custom`). `saveForLater` parks a cart line on the customer's account without losing the snapshot price. **Added:** *`bundles` primitive — kit / multi-pack products that expand at cart resolution* — `bShop.bundles.create({ query?, catalog })` returns `{ defineBundle, getBundle, listBundles, expand, priceBundle, updateBundle, deleteBundle }`. Bundle SKUs reference real catalog SKUs (or nested bundle SKUs up to 2 levels). `expand({ bundle_sku, quantity })` returns the flattened component list with multipliers applied. `priceBundle` sums child priceMinor and applies an optional `bundle_discount_bps` override. Cycle detection refuses bundles that eventually point back to themselves. `deleteBundle` refuses while any active cart line references the bundle. Migration `0032_bundles.sql` (`bundles` + `bundle_components` tables with UNIQUE(bundle_sku, sku) + FK CASCADE). · *`variants` primitive — product option matrix with per-variant overrides* — `bShop.variants.create({ query?, catalog })` returns `{ defineAxis, generateMatrix, materializeMatrix, getVariant, variantsForProduct, findVariant, updateVariant, archiveVariant, unarchiveVariant, archiveAxisOption }`. Operators register axes (`color`, `size`, etc.) with positions and option labels. `generateMatrix` previews the cartesian product without writing — the operator validates before `materializeMatrix` commits. SKU shape: `<prefix>-<axis1-opt>-<axis2-opt>-...` lowercase ASCII slug. `findVariant({ product_id, axis_values: { color: 'red', size: 'L' } })` resolves a specific cell. `image_url` validates via `b.safeUrl` so `javascript:` / `data:` schemes are refused at write. Migration `0033_product_variants.sql` (`product_variant_axes` + `product_variant_axis_options` + `product_variants` tables). · *`inventoryLocations` primitive — multi-warehouse stock + order routing* — `bShop.inventoryLocations.create({ query?, catalog })` returns `{ defineLocation, listLocations, getLocation, updateLocation, deactivateLocation, setStock, adjustStock, transferStock, stockForSku, totalForSku, availableLocations, routeOrder }`. Each location has a type (`warehouse` / `retail` / `dropship`) + priority + active flag. `transferStock` is an atomic two-row write so no stock is created or lost. `routeOrder` strategies: `priority-fill` (default — walks active locations in priority order), `single-location-cheapest`, `nearest-by-postal-prefix`. Returns `{ allocation: [{ location_code, lines }], unfulfillable }`. `inventory_adjustments` ledger is append-only — every `adjustStock` writes a reason-tagged row for audit replay. Migration `0034_inventory_locations.sql`. · *`backorder` primitive — orderable when out-of-stock with expected-ship dates* — `bShop.backorder.create({ query?, catalog })` returns `{ markBackorderable, markNotBackorderable, getStatus, listBackorderable, recordBackorder, fulfillBackorder, cancelBackorder, availabilityFor, customerBackorders, pendingForSku, arrivalsThisWeek }`. `availabilityFor(sku)` returns `{ status: 'in_stock' | 'backorderable' | 'out_of_stock', expected_ship_date?, message? }` for the PDP. `recordBackorder` enforces the per-sku `max_backorder_quantity` cap (NULL = unlimited). `arrivalsThisWeek` returns backorders with `expected_ship_date <= now + 7d` for operator-facing dashboards. Migration `0035_backorder.sql` (`backorder_skus` config table + `backorder_lines` per-order rows with status FSM (pending / fulfilled / cancelled)). · *`paymentMethods` primitive — saved processor tokens with PAN / CVV refusal* — `bShop.paymentMethods.create({ query? })` returns `{ add, get, listForCustomer, setDefault, archive, markExpired, defaultForCustomer, byProcessorToken, audit }`. Add refuses any field that looks like a raw PAN (regex: 13-19 digits in a row, including hyphen / space-collapsed forms), refuses CVV-shaped fields, validates processor enum (stripe / paypal / square / braintree / authorize_net), validates last4 (exactly 4 digits), validates expiry not in the past. `setDefault` enforces write-side default-uniqueness — promoting clears sibling default in the same transaction. `markExpired` is operator-scheduler-callable and writes an audit row per archived method. `audit(payment_method_id)` returns the row's full state-change history. Migration `0036_payment_methods.sql` (`payment_methods` + `payment_method_audit` tables with partial UNIQUE index on `(customer_id) WHERE is_default = 1 AND archived_at IS NULL`). · *`orderNotes` primitive — threaded customer-service notes per order* — `bShop.orderNotes.create({ query?, cursorSecret? })` returns `{ add, get, listForOrder, thread, markRead, pin, unpin, customerVisibleForOrder, internalForOrder, resolve, reopen }`. Visibility enum: `internal` (operator-only) / `customer_visible` (rendered on the customer's order page + emailed). Body cap 8000 chars, refuses control bytes + zero-width chars + empty-after-trim. `thread` returns a tree-shaped reply graph. `pin` floats a note to the top of the listing; resolve / reopen runs an internal-thread workflow with a 280-char resolution summary. Migration `0037_order_notes.sql` (`order_notes` + `order_note_reads` tables with indexes on (order_id, visibility, created_at desc) and (order_id, pinned desc, created_at desc)). · *`fraudScreen` primitive — heuristic risk score + decision at checkout* — `bShop.fraudScreen.create({ query?, customers?, addresses?, paymentMethods?, emailSuppressions? })` returns `{ screen, recordOutcome, recordChargeback, flagEmail, unflagEmail, customerRiskHistory, recentScreenings, hashEmail }`. `screen({ order_draft })` returns `{ score: 0..100, decision: 'approve' | 'review' | 'step_up' | 'refuse', signals }`. Decision thresholds: 0-39 approve, 40-69 review, 70-89 step_up, 90+ refuse. 13 signals fire independently and stack with operator-tunable weights: velocity (>N orders/24h from same email hash), high-value-new-customer, address-mismatch, free-email-domain, disposable-email-domain, session-too-fast (< 15s), session-too-old (> 24h), ua-curl-class, large-line-count, mismatched-bin-country (operator-supplied BIN → country map), prior-chargeback (looks up chargebacks ledger by email hash), suppressed-email, manually-flagged. Emails always hashed via `namespaceHash('fraud-email', ...)` before write. Migration `0038_fraud_screenings.sql` (`fraud_screenings` + `fraud_chargebacks` + `fraud_email_flags` tables). · *`orderExport` primitive — CSV / NDJSON streaming export with CSV-injection refusal* — `bShop.orderExport.create({ query?, order, cursorSecret? })` returns `{ csvForRange, ndjsonForRange, summaryForRange, scheduleExport, cancelExport, getExport, listExports, markExportRunning, markExportComplete, markExportFailed }`. Built-in 24-column schema covering order id / status / customer hash / addresses / line counts / monetary totals / payment method / fulfillment status / refund / return / chargeback flags. RFC-4180 quoting with CRLF; cell content starting with `=`, `+`, `-`, `@` gets prefixed with `'` per OWASP guidance (signed numerics like `+15.00` are exempt). Email is hashed via `namespaceHash('order-export-email', ...)` — raw email never reaches the export. `scheduleExport` writes a queued row + the operator's worker walks the `pod_fulfillments`-style FSM (queued → running → complete | failed | cancelled). Migration `0039_scheduled_exports.sql`. · *`printOnDemand` primitive — supplier-agnostic POD passthrough* — `bShop.printOnDemand.create({ query?, catalog })` returns `{ bindSku, unbindSku, getBinding, listBindings, updateBinding, costForOrder, forwardOrder, markFulfillmentSubmitted, markFulfillmentShipped, markFulfillmentFailed, markFulfillmentCancelled, getFulfillment, fulfillmentsForOrder, pendingFulfillments }`. Supplier enum: `printful` / `printify` / `gelato` / `lulu` / `gooten` / `cloudprinter` / `custom`. The primitive stores the SKU → supplier-product binding (artwork URL + variant id + position config + colorway + operator's wholesale `cost_minor`) and the per-order pending-fulfillment row. The actual HTTP push to each supplier is the operator's worker (each has its own auth + endpoint). `costForOrder(order_lines)` returns `{ total_cost_minor, by_supplier, unbound_skus }` for margin reporting. Migration `0040_print_on_demand.sql` (`pod_bindings` + `pod_fulfillments` tables with FSM-driven status column). · *`saveForLater` primitive — park a cart line on the customer account* — `bShop.saveForLater.create({ query?, catalog, currency?, cursorSecret? })` returns `{ moveFromCart, moveToCart, add, remove, clear, listForCustomer, countForCustomer, staleCheck, repriceAll, expireOlderThan }`. Distinct from `wishlist` — `wishlist` is "I'm interested", `saveForLater` is "I was about to buy this but not now". Lines carry their original cart context (price-at-save, variant, quantity, notes) so the customer can re-add with the snapshot price or the current price (`moveToCart` `use_price` parameter). `staleCheck` flags `is_stale` (price changed), `is_unavailable` (SKU gone), `is_low_stock` (catalog quantity < quantity-saved) per row. Refuses re-add to cart when the SKU is now out-of-stock and not backorderable. `expireOlderThan(days)` is the operator-scheduler sweep. Migration `0041_save_for_later.sql` with UNIQUE(customer_id, sku, COALESCE(variant_id, '')). **Fixed:** *`orderNotes` — monotonic `created_at` so back-to-back inserts always sort newest-first* — `Date.now()` on Windows resolves at ~15ms — three notes inserted in the same millisecond shared a `created_at` value, and the secondary `id DESC` tiebreaker fell back to UUID v7's random suffix (unstable since the time prefix collapsed). The primitive now stamps `created_at` with a process-local monotonic clock that increments by 1ms when `Date.now()` returns a stale value, so the listing's `pinned DESC, created_at DESC, id DESC` ordering reliably places the most recent note first regardless of insert burstiness.
|
|
12
14
|
|
|
13
15
|
- v0.0.56 (2026-05-22) — **Republish of the eleven-primitive batch to npm.** 0.0.55 source landed on `main` but the npm publish workflow ran against the prior commit and didn't reach the registry. 0.0.56 republishes the same eleven primitives (`orderTracking`, `returns`, `loyalty`, `referrals`, `notifications`, `addresses`, `taxExempt`, `emailSuppressions`, `currencyDisplay`, `searchSuggestions`, `cartAbandonment`) plus migrations `0021`-`0031` so `npm install @blamejs/blamejs-shop@0.0.56` pulls the full surface. No code changes from 0.0.55 — only the version bump + this changelog entry. **Fixed:** *Republish to npm — 0.0.55 source on `main` reached operators via `git clone` but not via `npm install`* — `npm install @blamejs/blamejs-shop@0.0.55` would resolve to a missing version. 0.0.56 ships the identical surface so operators upgrading by version number get the full eleven-primitive batch + the eleven new migrations. Tag `v0.0.55` remains on the repo; operators referring to either version see equivalent code.
|
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.giftOptions
|
|
4
|
+
* @title Gift options — per-order wrap / message / recipient / hide-prices
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* Three independent concerns folded into one primitive because
|
|
8
|
+
* they all live on the same packing slip and clear together when
|
|
9
|
+
* the order is cancelled:
|
|
10
|
+
*
|
|
11
|
+
* wrap — operator pre-defines a catalog SKU as a
|
|
12
|
+
* wrap option with a wrap fee. wrap_sku is a
|
|
13
|
+
* real catalog SKU so inventory + cost flow
|
|
14
|
+
* through the normal channels.
|
|
15
|
+
* gift_message — short customer-authored prose (≤ 500 chars,
|
|
16
|
+
* control-byte + zero-width-char free)
|
|
17
|
+
* rendered on the packing slip.
|
|
18
|
+
* recipient_name — for gift-to-someone-else orders (≤ 120
|
|
19
|
+
* chars, same hygiene as gift_message).
|
|
20
|
+
* hide_prices — toggle that suppresses prices on the slip
|
|
21
|
+
* (the "gift receipt" pattern).
|
|
22
|
+
*
|
|
23
|
+
* Composes:
|
|
24
|
+
* - `b.guardUuid` — order_id is UUID-shape-validated at every
|
|
25
|
+
* entry point; bad shape throws TypeError the calling route
|
|
26
|
+
* handler translates to HTTP 400.
|
|
27
|
+
* - `b.template.escapeHtml` — `renderPackingSlipLine` returns
|
|
28
|
+
* HTML-escaped strings ready for inline insertion into the
|
|
29
|
+
* packing-slip template.
|
|
30
|
+
*
|
|
31
|
+
* Surface:
|
|
32
|
+
* - `defineWrap({ wrap_sku, title, fee_minor, image_url?,
|
|
33
|
+
* max_per_order?, active })` — register a wrap
|
|
34
|
+
* option. Refuses unless wrap_sku is a real catalog variant.
|
|
35
|
+
* - `listWraps({ active_only? })` / `getWrap(wrap_sku)` /
|
|
36
|
+
* `updateWrap(wrap_sku, patch)` / `archiveWrap(wrap_sku)`.
|
|
37
|
+
* - `setForOrder({ order_id, wrap_sku?, gift_message?,
|
|
38
|
+
* recipient_name?, hide_prices? })` — UPSERT.
|
|
39
|
+
* - `getForOrder(order_id)` / `clearForOrder(order_id)`.
|
|
40
|
+
* - `feeForOrder(order_id)` — wrap fee_minor or 0.
|
|
41
|
+
* - `renderPackingSlipLine({ order_id, locale })` — returns
|
|
42
|
+
* `{ message_lines, recipient_name, hide_prices }` with
|
|
43
|
+
* HTML-escaped strings.
|
|
44
|
+
* - `analytics({ from, to })` — count of orders with options,
|
|
45
|
+
* top wrap_skus, gift-message rate.
|
|
46
|
+
*
|
|
47
|
+
* Storage:
|
|
48
|
+
* - `gift_wraps` + `gift_options` (migration
|
|
49
|
+
* `0046_gift_options.sql`).
|
|
50
|
+
*
|
|
51
|
+
* @primitive giftOptions
|
|
52
|
+
* @related b.guardUuid, b.template.escapeHtml
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
var MAX_TITLE_LEN = 200;
|
|
56
|
+
var MAX_IMAGE_URL_LEN = 2048;
|
|
57
|
+
var MAX_MESSAGE_LEN = 500;
|
|
58
|
+
var MAX_RECIPIENT_LEN = 120;
|
|
59
|
+
|
|
60
|
+
// SKU shape mirrors catalog.js — alnum + . _ -, ≤ 128 chars,
|
|
61
|
+
// leading char must be alnum so a wrap_sku can never start with a
|
|
62
|
+
// hyphen / dot (sidesteps shell-arg-style ambiguity in downstream
|
|
63
|
+
// CSV exports + the "looks like a flag" class of operator slips).
|
|
64
|
+
var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
65
|
+
|
|
66
|
+
// Refuse C0 control bytes + DEL. The gift message + recipient name
|
|
67
|
+
// render onto a packing slip and (potentially) a printer queue;
|
|
68
|
+
// embedded control bytes have caused header-injection-class slips
|
|
69
|
+
// in adjacent ecosystems. Newlines are allowed in gift_message
|
|
70
|
+
// (people write multi-line messages); the recipient name is a
|
|
71
|
+
// single line and refuses LF / CR too.
|
|
72
|
+
var CONTROL_BYTE_MSG_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
|
|
73
|
+
var CONTROL_BYTE_NAME_RE = /[\x00-\x1f\x7f]/;
|
|
74
|
+
|
|
75
|
+
// Zero-width / direction-override family — mirrors the order-notes
|
|
76
|
+
// primitive's catalogue: ZWSP/ZWNJ/ZWJ (U+200B-200D), LRM/RLM
|
|
77
|
+
// (U+200E/U+200F), the bidi-formatting block (U+202A-U+202E), the
|
|
78
|
+
// invisible-math block (U+2060-U+2064), the LRI/RLI/FSI/PDI block
|
|
79
|
+
// (U+2066-U+2069), the BOM (U+FEFF), and the Arabic letter mark
|
|
80
|
+
// (U+061C). Spelled with \u-escapes so ESLint's
|
|
81
|
+
// no-irregular-whitespace stays happy.
|
|
82
|
+
var ZERO_WIDTH_RE = new RegExp(
|
|
83
|
+
"[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
var ALLOWED_WRAP_COLUMNS = Object.freeze([
|
|
87
|
+
"title", "fee_minor", "image_url", "max_per_order", "active",
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
var bShop;
|
|
91
|
+
function _b() {
|
|
92
|
+
if (!bShop) bShop = require("./index");
|
|
93
|
+
return bShop.framework;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---- validators ---------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
function _uuid(s, label) {
|
|
99
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
100
|
+
catch (e) { throw new TypeError("giftOptions: " + label + " — " + (e && e.message || "invalid UUID")); }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function _sku(s, label) {
|
|
104
|
+
if (typeof s !== "string" || !SKU_RE.test(s)) {
|
|
105
|
+
throw new TypeError("giftOptions: " + label + " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, ≤ 128 chars)");
|
|
106
|
+
}
|
|
107
|
+
return s;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function _title(s) {
|
|
111
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
|
|
112
|
+
throw new TypeError("giftOptions: title must be a non-empty string ≤ " + MAX_TITLE_LEN + " chars");
|
|
113
|
+
}
|
|
114
|
+
if (CONTROL_BYTE_NAME_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
|
|
115
|
+
throw new TypeError("giftOptions: title contains control / zero-width bytes");
|
|
116
|
+
}
|
|
117
|
+
return s;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function _feeMinor(n) {
|
|
121
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
122
|
+
throw new TypeError("giftOptions: fee_minor must be a non-negative integer");
|
|
123
|
+
}
|
|
124
|
+
return n;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function _imageUrl(s) {
|
|
128
|
+
if (s == null) return null;
|
|
129
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_IMAGE_URL_LEN) {
|
|
130
|
+
throw new TypeError("giftOptions: image_url must be a non-empty string ≤ " + MAX_IMAGE_URL_LEN + " chars");
|
|
131
|
+
}
|
|
132
|
+
// Restrict to https:// or // (protocol-relative) — http:// is a
|
|
133
|
+
// mixed-content liability on a storefront served over https, and
|
|
134
|
+
// javascript: / data: must never reach an <img src> on the
|
|
135
|
+
// checkout page. The browser-side CSP catches these too; this
|
|
136
|
+
// gate is defense-in-depth at the persistence layer.
|
|
137
|
+
if (!(/^https:\/\//.test(s) || /^\/\//.test(s) || /^\//.test(s))) {
|
|
138
|
+
throw new TypeError("giftOptions: image_url must be https://, // (protocol-relative), or / (absolute path)");
|
|
139
|
+
}
|
|
140
|
+
if (CONTROL_BYTE_NAME_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
|
|
141
|
+
throw new TypeError("giftOptions: image_url contains control / zero-width bytes");
|
|
142
|
+
}
|
|
143
|
+
return s;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function _maxPerOrder(n) {
|
|
147
|
+
if (n == null) return null;
|
|
148
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
149
|
+
throw new TypeError("giftOptions: max_per_order must be a positive integer or null");
|
|
150
|
+
}
|
|
151
|
+
return n;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function _bool(v, label) {
|
|
155
|
+
if (typeof v !== "boolean") {
|
|
156
|
+
throw new TypeError("giftOptions: " + label + " must be a boolean");
|
|
157
|
+
}
|
|
158
|
+
return v ? 1 : 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function _giftMessage(s) {
|
|
162
|
+
if (s == null) return null;
|
|
163
|
+
if (typeof s !== "string") {
|
|
164
|
+
throw new TypeError("giftOptions: gift_message must be a string");
|
|
165
|
+
}
|
|
166
|
+
if (s.length > MAX_MESSAGE_LEN) {
|
|
167
|
+
throw new TypeError("giftOptions: gift_message must be ≤ " + MAX_MESSAGE_LEN + " chars");
|
|
168
|
+
}
|
|
169
|
+
if (CONTROL_BYTE_MSG_RE.test(s)) {
|
|
170
|
+
throw new TypeError("giftOptions: gift_message contains control bytes");
|
|
171
|
+
}
|
|
172
|
+
if (ZERO_WIDTH_RE.test(s)) {
|
|
173
|
+
throw new TypeError("giftOptions: gift_message contains zero-width / direction-override characters");
|
|
174
|
+
}
|
|
175
|
+
return s;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function _recipientName(s) {
|
|
179
|
+
if (s == null) return null;
|
|
180
|
+
if (typeof s !== "string") {
|
|
181
|
+
throw new TypeError("giftOptions: recipient_name must be a string");
|
|
182
|
+
}
|
|
183
|
+
if (s.length > MAX_RECIPIENT_LEN) {
|
|
184
|
+
throw new TypeError("giftOptions: recipient_name must be ≤ " + MAX_RECIPIENT_LEN + " chars");
|
|
185
|
+
}
|
|
186
|
+
if (CONTROL_BYTE_NAME_RE.test(s)) {
|
|
187
|
+
throw new TypeError("giftOptions: recipient_name contains control bytes (incl. CR/LF)");
|
|
188
|
+
}
|
|
189
|
+
if (ZERO_WIDTH_RE.test(s)) {
|
|
190
|
+
throw new TypeError("giftOptions: recipient_name contains zero-width / direction-override characters");
|
|
191
|
+
}
|
|
192
|
+
return s;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function _epochMs(n, label) {
|
|
196
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
197
|
+
throw new TypeError("giftOptions: " + label + " must be a non-negative integer (epoch ms)");
|
|
198
|
+
}
|
|
199
|
+
return n;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function _now() { return Date.now(); }
|
|
203
|
+
|
|
204
|
+
// ---- factory ------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
function create(opts) {
|
|
207
|
+
opts = opts || {};
|
|
208
|
+
var query = opts.query;
|
|
209
|
+
if (!query) {
|
|
210
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
211
|
+
}
|
|
212
|
+
// The catalog handle is required so `defineWrap` can verify that
|
|
213
|
+
// wrap_sku resolves to a real variant before persisting. Operators
|
|
214
|
+
// who'd run without a catalog handle would silently get wraps that
|
|
215
|
+
// point at no inventory row — refuse at factory time.
|
|
216
|
+
if (!opts.catalog) {
|
|
217
|
+
throw new TypeError("giftOptions.create: opts.catalog required (composes catalog.variants.bySku for wrap_sku resolution)");
|
|
218
|
+
}
|
|
219
|
+
var catalog = opts.catalog;
|
|
220
|
+
|
|
221
|
+
// -- gift_wraps surface -------------------------------------------------
|
|
222
|
+
|
|
223
|
+
async function defineWrap(input) {
|
|
224
|
+
if (!input || typeof input !== "object") {
|
|
225
|
+
throw new TypeError("giftOptions.defineWrap: input object required");
|
|
226
|
+
}
|
|
227
|
+
var wrapSku = _sku(input.wrap_sku, "wrap_sku");
|
|
228
|
+
var title = _title(input.title);
|
|
229
|
+
var feeMinor = _feeMinor(input.fee_minor);
|
|
230
|
+
var imageUrl = _imageUrl(input.image_url);
|
|
231
|
+
var maxPerOrder = _maxPerOrder(input.max_per_order);
|
|
232
|
+
var active = _bool(input.active, "active");
|
|
233
|
+
|
|
234
|
+
// wrap_sku must resolve to a real catalog variant so inventory
|
|
235
|
+
// + cost can flow through normal channels. Look it up via the
|
|
236
|
+
// catalog handle the factory was given.
|
|
237
|
+
var variant = await catalog.variants.bySku(wrapSku);
|
|
238
|
+
if (!variant) {
|
|
239
|
+
throw new TypeError("giftOptions.defineWrap: wrap_sku " + JSON.stringify(wrapSku) + " is not a known catalog variant");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
var ts = _now();
|
|
243
|
+
await query(
|
|
244
|
+
"INSERT INTO gift_wraps (wrap_sku, title, fee_minor, image_url, max_per_order, active, archived_at, created_at, updated_at) " +
|
|
245
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?7)",
|
|
246
|
+
[wrapSku, title, feeMinor, imageUrl, maxPerOrder, active, ts],
|
|
247
|
+
);
|
|
248
|
+
return {
|
|
249
|
+
wrap_sku: wrapSku,
|
|
250
|
+
title: title,
|
|
251
|
+
fee_minor: feeMinor,
|
|
252
|
+
image_url: imageUrl,
|
|
253
|
+
max_per_order: maxPerOrder,
|
|
254
|
+
active: Boolean(active),
|
|
255
|
+
archived_at: null,
|
|
256
|
+
created_at: ts,
|
|
257
|
+
updated_at: ts,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function _hydrateWrapRow(r) {
|
|
262
|
+
if (!r) return null;
|
|
263
|
+
return {
|
|
264
|
+
wrap_sku: r.wrap_sku,
|
|
265
|
+
title: r.title,
|
|
266
|
+
fee_minor: Number(r.fee_minor),
|
|
267
|
+
image_url: r.image_url,
|
|
268
|
+
max_per_order: r.max_per_order == null ? null : Number(r.max_per_order),
|
|
269
|
+
active: Number(r.active) === 1,
|
|
270
|
+
archived_at: r.archived_at == null ? null : Number(r.archived_at),
|
|
271
|
+
created_at: Number(r.created_at),
|
|
272
|
+
updated_at: Number(r.updated_at),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function listWraps(listOpts) {
|
|
277
|
+
listOpts = listOpts || {};
|
|
278
|
+
var activeOnly = false;
|
|
279
|
+
if (listOpts.active_only != null) {
|
|
280
|
+
if (typeof listOpts.active_only !== "boolean") {
|
|
281
|
+
throw new TypeError("giftOptions.listWraps: active_only must be a boolean");
|
|
282
|
+
}
|
|
283
|
+
activeOnly = listOpts.active_only;
|
|
284
|
+
}
|
|
285
|
+
var sql, params;
|
|
286
|
+
if (activeOnly) {
|
|
287
|
+
sql = "SELECT * FROM gift_wraps WHERE active = 1 AND archived_at IS NULL ORDER BY created_at ASC, wrap_sku ASC";
|
|
288
|
+
params = [];
|
|
289
|
+
} else {
|
|
290
|
+
sql = "SELECT * FROM gift_wraps ORDER BY created_at ASC, wrap_sku ASC";
|
|
291
|
+
params = [];
|
|
292
|
+
}
|
|
293
|
+
var rows = (await query(sql, params)).rows;
|
|
294
|
+
return rows.map(_hydrateWrapRow);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function getWrap(wrapSku) {
|
|
298
|
+
_sku(wrapSku, "wrap_sku");
|
|
299
|
+
var r = (await query(
|
|
300
|
+
"SELECT * FROM gift_wraps WHERE wrap_sku = ?1 LIMIT 1",
|
|
301
|
+
[wrapSku],
|
|
302
|
+
)).rows[0];
|
|
303
|
+
return _hydrateWrapRow(r);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function updateWrap(wrapSku, patch) {
|
|
307
|
+
_sku(wrapSku, "wrap_sku");
|
|
308
|
+
if (!patch || typeof patch !== "object") {
|
|
309
|
+
throw new TypeError("giftOptions.updateWrap: patch object required");
|
|
310
|
+
}
|
|
311
|
+
var keys = Object.keys(patch);
|
|
312
|
+
if (!keys.length) {
|
|
313
|
+
throw new TypeError("giftOptions.updateWrap: patch must include at least one column");
|
|
314
|
+
}
|
|
315
|
+
var sets = [];
|
|
316
|
+
var params = [];
|
|
317
|
+
var idx = 1;
|
|
318
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
319
|
+
var col = keys[i];
|
|
320
|
+
// Lock the patch surface to a known column set — composing
|
|
321
|
+
// b.safeSql.assertOneOf would be the canonical path, but the
|
|
322
|
+
// primitive's column count is tiny enough that an explicit
|
|
323
|
+
// Object.freeze list is just as defensible and keeps the
|
|
324
|
+
// detector greps for unsafe column-name concatenation green.
|
|
325
|
+
if (ALLOWED_WRAP_COLUMNS.indexOf(col) === -1) {
|
|
326
|
+
throw new TypeError("giftOptions.updateWrap: unsupported column " + JSON.stringify(col));
|
|
327
|
+
}
|
|
328
|
+
var v;
|
|
329
|
+
if (col === "title") v = _title(patch[col]);
|
|
330
|
+
else if (col === "fee_minor") v = _feeMinor(patch[col]);
|
|
331
|
+
else if (col === "image_url") v = _imageUrl(patch[col]);
|
|
332
|
+
else if (col === "max_per_order") v = _maxPerOrder(patch[col]);
|
|
333
|
+
else /* active */ v = _bool(patch[col], "active");
|
|
334
|
+
sets.push(col + " = ?" + idx);
|
|
335
|
+
params.push(v);
|
|
336
|
+
idx += 1;
|
|
337
|
+
}
|
|
338
|
+
sets.push("updated_at = ?" + idx);
|
|
339
|
+
params.push(_now());
|
|
340
|
+
idx += 1;
|
|
341
|
+
params.push(wrapSku);
|
|
342
|
+
var r = await query(
|
|
343
|
+
"UPDATE gift_wraps SET " + sets.join(", ") + " WHERE wrap_sku = ?" + idx,
|
|
344
|
+
params,
|
|
345
|
+
);
|
|
346
|
+
if (Number(r.rowCount || 0) === 0) {
|
|
347
|
+
throw new TypeError("giftOptions.updateWrap: wrap_sku " + JSON.stringify(wrapSku) + " not found");
|
|
348
|
+
}
|
|
349
|
+
return await getWrap(wrapSku);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function archiveWrap(wrapSku) {
|
|
353
|
+
_sku(wrapSku, "wrap_sku");
|
|
354
|
+
var ts = _now();
|
|
355
|
+
var r = await query(
|
|
356
|
+
"UPDATE gift_wraps SET active = 0, archived_at = ?1, updated_at = ?1 WHERE wrap_sku = ?2",
|
|
357
|
+
[ts, wrapSku],
|
|
358
|
+
);
|
|
359
|
+
if (Number(r.rowCount || 0) === 0) {
|
|
360
|
+
throw new TypeError("giftOptions.archiveWrap: wrap_sku " + JSON.stringify(wrapSku) + " not found");
|
|
361
|
+
}
|
|
362
|
+
return await getWrap(wrapSku);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// -- gift_options surface ----------------------------------------------
|
|
366
|
+
|
|
367
|
+
function _hydrateOptionsRow(r) {
|
|
368
|
+
if (!r) return null;
|
|
369
|
+
return {
|
|
370
|
+
order_id: r.order_id,
|
|
371
|
+
wrap_sku: r.wrap_sku,
|
|
372
|
+
gift_message: r.gift_message,
|
|
373
|
+
recipient_name: r.recipient_name,
|
|
374
|
+
hide_prices: Number(r.hide_prices) === 1,
|
|
375
|
+
set_at: Number(r.set_at),
|
|
376
|
+
updated_at: Number(r.updated_at),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function setForOrder(input) {
|
|
381
|
+
if (!input || typeof input !== "object") {
|
|
382
|
+
throw new TypeError("giftOptions.setForOrder: input object required");
|
|
383
|
+
}
|
|
384
|
+
var orderId = _uuid(input.order_id, "order_id");
|
|
385
|
+
|
|
386
|
+
var wrapSku = null;
|
|
387
|
+
if (input.wrap_sku != null) {
|
|
388
|
+
_sku(input.wrap_sku, "wrap_sku");
|
|
389
|
+
// Refuse if the wrap_sku isn't a defined, active, non-archived
|
|
390
|
+
// wrap. Archived wraps stay reachable via getForOrder for
|
|
391
|
+
// older orders, but setForOrder can't attach a new order to a
|
|
392
|
+
// wrap the operator has retired.
|
|
393
|
+
var wrap = (await query(
|
|
394
|
+
"SELECT * FROM gift_wraps WHERE wrap_sku = ?1 LIMIT 1",
|
|
395
|
+
[input.wrap_sku],
|
|
396
|
+
)).rows[0];
|
|
397
|
+
if (!wrap) {
|
|
398
|
+
throw new TypeError("giftOptions.setForOrder: wrap_sku " + JSON.stringify(input.wrap_sku) + " is not a defined wrap");
|
|
399
|
+
}
|
|
400
|
+
if (Number(wrap.active) !== 1 || wrap.archived_at != null) {
|
|
401
|
+
throw new TypeError("giftOptions.setForOrder: wrap_sku " + JSON.stringify(input.wrap_sku) + " is archived / inactive");
|
|
402
|
+
}
|
|
403
|
+
wrapSku = input.wrap_sku;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
var giftMessage = _giftMessage(input.gift_message);
|
|
407
|
+
var recipientName = _recipientName(input.recipient_name);
|
|
408
|
+
|
|
409
|
+
var hidePrices = 0;
|
|
410
|
+
if (input.hide_prices != null) {
|
|
411
|
+
hidePrices = _bool(input.hide_prices, "hide_prices");
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
var ts = _now();
|
|
415
|
+
// UPSERT — one gift_options row per order. Re-running with
|
|
416
|
+
// different inputs replaces every column (the operator UI
|
|
417
|
+
// re-submits the full state each time the customer edits gift
|
|
418
|
+
// options, so a partial UPSERT would silently retain stale
|
|
419
|
+
// fields).
|
|
420
|
+
await query(
|
|
421
|
+
"INSERT INTO gift_options (order_id, wrap_sku, gift_message, recipient_name, hide_prices, set_at, updated_at) " +
|
|
422
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6) " +
|
|
423
|
+
"ON CONFLICT (order_id) DO UPDATE SET " +
|
|
424
|
+
" wrap_sku = excluded.wrap_sku, " +
|
|
425
|
+
" gift_message = excluded.gift_message, " +
|
|
426
|
+
" recipient_name = excluded.recipient_name, " +
|
|
427
|
+
" hide_prices = excluded.hide_prices, " +
|
|
428
|
+
" updated_at = excluded.updated_at",
|
|
429
|
+
[orderId, wrapSku, giftMessage, recipientName, hidePrices, ts],
|
|
430
|
+
);
|
|
431
|
+
return await getForOrder(orderId);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async function getForOrder(orderId) {
|
|
435
|
+
var oid = _uuid(orderId, "order_id");
|
|
436
|
+
var r = (await query(
|
|
437
|
+
"SELECT * FROM gift_options WHERE order_id = ?1 LIMIT 1",
|
|
438
|
+
[oid],
|
|
439
|
+
)).rows[0];
|
|
440
|
+
return _hydrateOptionsRow(r);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function clearForOrder(orderId) {
|
|
444
|
+
var oid = _uuid(orderId, "order_id");
|
|
445
|
+
var r = await query(
|
|
446
|
+
"DELETE FROM gift_options WHERE order_id = ?1",
|
|
447
|
+
[oid],
|
|
448
|
+
);
|
|
449
|
+
return { cleared: Number(r.rowCount || 0) > 0 };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function feeForOrder(orderId) {
|
|
453
|
+
var oid = _uuid(orderId, "order_id");
|
|
454
|
+
// Single JOIN read — pulls the wrap fee in one round-trip
|
|
455
|
+
// rather than two reads (gift_options then gift_wraps).
|
|
456
|
+
var r = (await query(
|
|
457
|
+
"SELECT gw.fee_minor AS fee_minor " +
|
|
458
|
+
"FROM gift_options go " +
|
|
459
|
+
"JOIN gift_wraps gw ON gw.wrap_sku = go.wrap_sku " +
|
|
460
|
+
"WHERE go.order_id = ?1 LIMIT 1",
|
|
461
|
+
[oid],
|
|
462
|
+
)).rows[0];
|
|
463
|
+
return r ? Number(r.fee_minor) : 0;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// -- packing-slip render ------------------------------------------------
|
|
467
|
+
|
|
468
|
+
async function renderPackingSlipLine(input) {
|
|
469
|
+
if (!input || typeof input !== "object") {
|
|
470
|
+
throw new TypeError("giftOptions.renderPackingSlipLine: input object required");
|
|
471
|
+
}
|
|
472
|
+
var oid = _uuid(input.order_id, "order_id");
|
|
473
|
+
// Locale is captured for future per-locale renderers — today
|
|
474
|
+
// the message itself is operator-authored prose and isn't
|
|
475
|
+
// translated, but the renderer surfaces the locale so a
|
|
476
|
+
// downstream slip template can pick a language-appropriate
|
|
477
|
+
// header. Refuse anything that isn't a BCP-47-shape string.
|
|
478
|
+
var locale = input.locale;
|
|
479
|
+
if (locale == null || typeof locale !== "string" || !/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(locale)) {
|
|
480
|
+
throw new TypeError("giftOptions.renderPackingSlipLine: locale must be a BCP-47-shape string (e.g. 'en-US')");
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
var row = (await query(
|
|
484
|
+
"SELECT * FROM gift_options WHERE order_id = ?1 LIMIT 1",
|
|
485
|
+
[oid],
|
|
486
|
+
)).rows[0];
|
|
487
|
+
if (!row) {
|
|
488
|
+
return {
|
|
489
|
+
message_lines: [],
|
|
490
|
+
recipient_name: null,
|
|
491
|
+
hide_prices: false,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
var escapeHtml = _b().template.escapeHtml;
|
|
496
|
+
|
|
497
|
+
// Split the message on LF (and the rare CRLF) so the packing-
|
|
498
|
+
// slip template can emit one <div> per line without parsing
|
|
499
|
+
// raw newlines downstream. Trailing empty lines are dropped to
|
|
500
|
+
// keep the slip tidy when the customer typed an extra newline
|
|
501
|
+
// at the end.
|
|
502
|
+
var messageLines = [];
|
|
503
|
+
if (row.gift_message) {
|
|
504
|
+
var raw = String(row.gift_message).replace(/\r\n/g, "\n").split("\n");
|
|
505
|
+
while (raw.length && raw[raw.length - 1] === "") raw.pop();
|
|
506
|
+
messageLines = raw.map(function (line) { return escapeHtml(line); });
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return {
|
|
510
|
+
message_lines: messageLines,
|
|
511
|
+
recipient_name: row.recipient_name ? escapeHtml(row.recipient_name) : null,
|
|
512
|
+
hide_prices: Number(row.hide_prices) === 1,
|
|
513
|
+
locale: locale,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// -- analytics ----------------------------------------------------------
|
|
518
|
+
|
|
519
|
+
async function analytics(input) {
|
|
520
|
+
if (!input || typeof input !== "object") {
|
|
521
|
+
throw new TypeError("giftOptions.analytics: input object required");
|
|
522
|
+
}
|
|
523
|
+
var from = _epochMs(input.from, "from");
|
|
524
|
+
var to = _epochMs(input.to, "to");
|
|
525
|
+
if (to < from) {
|
|
526
|
+
throw new TypeError("giftOptions.analytics: to must be >= from");
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Total orders with any gift option in the window — counts
|
|
530
|
+
// every row regardless of which field(s) are set.
|
|
531
|
+
var totalRow = (await query(
|
|
532
|
+
"SELECT COUNT(*) AS n FROM gift_options WHERE set_at >= ?1 AND set_at < ?2",
|
|
533
|
+
[from, to],
|
|
534
|
+
)).rows[0];
|
|
535
|
+
var totalOrders = Number((totalRow || {}).n || 0);
|
|
536
|
+
|
|
537
|
+
// Top wrap_skus — orders with a non-NULL wrap_sku grouped by
|
|
538
|
+
// the wrap. Capped at 10 because the operator dashboard
|
|
539
|
+
// doesn't need an unbounded list.
|
|
540
|
+
var topWraps = (await query(
|
|
541
|
+
"SELECT wrap_sku, COUNT(*) AS n FROM gift_options " +
|
|
542
|
+
"WHERE set_at >= ?1 AND set_at < ?2 AND wrap_sku IS NOT NULL " +
|
|
543
|
+
"GROUP BY wrap_sku ORDER BY n DESC, wrap_sku ASC LIMIT 10",
|
|
544
|
+
[from, to],
|
|
545
|
+
)).rows.map(function (r) {
|
|
546
|
+
return { wrap_sku: r.wrap_sku, count: Number(r.n) };
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// Gift-message rate — fraction of gift_options rows in the
|
|
550
|
+
// window that carry a non-NULL message. Returned as a float in
|
|
551
|
+
// [0, 1] so the dashboard can render as a percentage without
|
|
552
|
+
// worrying about division-by-zero.
|
|
553
|
+
var messageRow = (await query(
|
|
554
|
+
"SELECT COUNT(*) AS n FROM gift_options " +
|
|
555
|
+
"WHERE set_at >= ?1 AND set_at < ?2 AND gift_message IS NOT NULL AND gift_message != ''",
|
|
556
|
+
[from, to],
|
|
557
|
+
)).rows[0];
|
|
558
|
+
var messageCount = Number((messageRow || {}).n || 0);
|
|
559
|
+
var messageRate = totalOrders > 0 ? messageCount / totalOrders : 0;
|
|
560
|
+
|
|
561
|
+
return {
|
|
562
|
+
from: from,
|
|
563
|
+
to: to,
|
|
564
|
+
orders_with_gift: totalOrders,
|
|
565
|
+
top_wrap_skus: topWraps,
|
|
566
|
+
gift_message_count: messageCount,
|
|
567
|
+
gift_message_rate: messageRate,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return {
|
|
572
|
+
MAX_TITLE_LEN: MAX_TITLE_LEN,
|
|
573
|
+
MAX_MESSAGE_LEN: MAX_MESSAGE_LEN,
|
|
574
|
+
MAX_RECIPIENT_LEN: MAX_RECIPIENT_LEN,
|
|
575
|
+
|
|
576
|
+
defineWrap: defineWrap,
|
|
577
|
+
listWraps: listWraps,
|
|
578
|
+
getWrap: getWrap,
|
|
579
|
+
updateWrap: updateWrap,
|
|
580
|
+
archiveWrap: archiveWrap,
|
|
581
|
+
|
|
582
|
+
setForOrder: setForOrder,
|
|
583
|
+
getForOrder: getForOrder,
|
|
584
|
+
clearForOrder: clearForOrder,
|
|
585
|
+
feeForOrder: feeForOrder,
|
|
586
|
+
renderPackingSlipLine: renderPackingSlipLine,
|
|
587
|
+
analytics: analytics,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
module.exports = {
|
|
592
|
+
create: create,
|
|
593
|
+
MAX_TITLE_LEN: MAX_TITLE_LEN,
|
|
594
|
+
MAX_MESSAGE_LEN: MAX_MESSAGE_LEN,
|
|
595
|
+
MAX_RECIPIENT_LEN: MAX_RECIPIENT_LEN,
|
|
596
|
+
};
|
package/lib/index.js
CHANGED
|
@@ -79,4 +79,9 @@ module.exports = {
|
|
|
79
79
|
orderExport: require("./order-export"),
|
|
80
80
|
printOnDemand: require("./print-on-demand"),
|
|
81
81
|
saveForLater: require("./save-for-later"),
|
|
82
|
+
salesReports: require("./sales-reports"),
|
|
83
|
+
quantityDiscounts: require("./quantity-discounts"),
|
|
84
|
+
subscriptionControls: require("./subscription-controls"),
|
|
85
|
+
giftOptions: require("./gift-options"),
|
|
86
|
+
supportTickets: require("./support-tickets"),
|
|
82
87
|
};
|