@blamejs/blamejs-shop 0.0.70 → 0.0.72
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/index.js +1 -0
- package/lib/loyalty-earn-rules.js +786 -0
- package/lib/split-shipments.js +7 -1
- 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.0.x
|
|
10
10
|
|
|
11
|
+
- v0.0.72 (2026-05-22) — **`loyaltyEarnRules` primitive — per-action point earning rules.** `loyaltyEarnRules` defines HOW points are earned across eight trigger events: `per_dollar_spent`, `per_purchase`, `per_review`, `per_referral_redeemed`, `birthday`, `signup_bonus`, `first_purchase`, `abandoned_cart_recovered`. Distinct from the existing `loyalty` (ledger) and `tierBenefits` (perks) primitives. **Added:** *`loyaltyEarnRules` primitive — per-action point earning rules with optional per-event caps* — `bShop.loyaltyEarnRules.create({ query?, loyalty? })` returns `{ defineRule, getRule, listRules, updateRule, archiveRule, evaluateForEvent, awardForEvent, metricsForRule, applyBatch }`. Triggers: `per_dollar_spent / per_purchase / per_review / per_referral_redeemed / birthday / signup_bonus / first_purchase / abandoned_cart_recovered`. `evaluateForEvent` returns the points calculation (multiply + floor + zero-floor); `awardForEvent` composes the injected `loyalty.earn` handle and rolls back the audit row on ledger failure. Per-event cap (`max_per_event`) clips runaway awards. `customer_status_in` allowlist filters which customer tiers a rule applies to. UNIQUE(`rule_slug`, `customer_id`, `trigger_event_ref`) dedups re-submitted events. `applyBatch` runs multiple awards atomically with partial-failure reporting. Migration `0163_loyalty_earn_rules.sql`.
|
|
12
|
+
|
|
13
|
+
- v0.0.71 (2026-05-22) — **`splitShipments` — monotonic `proposed_at` so back-to-back plans sort newest-first.** Single-fix release. `splitShipments.planSplit` calls landing in the same wall-clock millisecond now get strictly-increasing `proposed_at` stamps via a process-local monotonic clock. Without the fix, `splitsForOrder(order_id)` returned the rows in nondeterministic order under burst-write conditions — CI runners hit the collision more reliably than local clocks. **Fixed:** *`splitShipments` — process-local monotonic `_now` so consecutive `planSplit` calls sort deterministically* — Two `planSplit` calls inside the same millisecond shared a `proposed_at` value, and the secondary tiebreaker (UUID v7 id) was scrambled by the intra-ms random suffix. The primitive now bumps `_now()` by 1ms when `Date.now()` returns a stale value, so `splitsForOrder` reliably returns newest-first under any insert burstiness. Same fix pattern as `orderNotes` / `productBulkOps` / `cookieConsent` / `liveChat` / `sms-dispatcher` / others — applies cleanly to any primitive where `Date.now()` precision drives sort order.
|
|
14
|
+
|
|
11
15
|
- v0.0.70 (2026-05-22) — **Six new primitives: click and collect, customer surveys, email templates, knowledge base, pixel events, sitemap generator.** Six primitives ship in one release covering store pickup (`clickAndCollect`), customer feedback (`customerSurveys`), operator-editable transactional email templates (`emailTemplates`), self-serve help center (`knowledgeBase`), server-side conversion pixel tracking (`pixelEvents`), and storefront sitemap generation (`sitemapGenerator`). **Added:** *`clickAndCollect` primitive — buy-online-pickup-in-store workflow* — `bShop.clickAndCollect.create({ query?, order?, inventoryLocations?, notifications? })` returns `{ definePickupLocation, availableLocations, scheduleAtLocation, markReadyForPickup, markPickedUp, markNoShow, pickupsForLocation, customerSchedules }`. Five-state FSM: scheduled → ready → picked_up + no_show + cancelled terminals. Capacity gated on one-hour buckets; lead-time gate per location. `markReadyForPickup` enqueues a pickup-ready notification through the injected handle (drop-silent on failure so a notifications outage can't roll back the hold shelf). `markPickedUp` drives the parent order to delivered via `order.transition(id, 'mark_delivered')` (swallows fsm/illegal-transition for idempotency). Signature hashed via `namespaceHash('click-and-collect-signature', raw)`. Migration `0126_click_and_collect.sql`. · *`customerSurveys` primitive — post-purchase NPS / CSAT / CES surveys* — `bShop.customerSurveys.create({ query? })` returns `{ defineSurvey, getSurvey, archiveSurvey, issueInvitation, getInvitation, invitationsForCustomer, submitResponse, responsesForSurvey, rollup, closeInvitation, cleanupExpired }`. Kinds: nps / csat / ces / custom. Trigger events: after_delivery / after_support_close / after_refund / manual. NPS / CSAT / CES enforce primary-question shape (`max === 10 / 5 / 7` respectively). NPS rollup = `round(%promoters - %detractors)`, CSAT = top-2-box positive_pct + mean, CES = mean + agree_pct. Invitation tokens 32-byte base64url plaintext returned exactly once, stored only as SHA3-512 namespaceHash. Migration `0128_customer_surveys.sql`. · *`emailTemplates` primitive — operator-editable transactional email templates* — `bShop.emailTemplates.create({ query? })` returns `{ defineTemplate, getTemplate, listTemplates, updateTemplate, publishVersion, archiveTemplate, renderTemplate, versionsFor, validateVariables }`. Closed 11-kind enum (order_confirmation / order_shipped / order_delivered / order_refunded / password_reset / account_verification / abandoned_cart / wishlist_discount / review_request / welcome / generic). `{{var_name}}` substitution composes `b.template.escapeHtml`; `{{var_name|raw}}` slots require schema vouching at definition time. Locale fallback to `en`. Migration `0125_email_templates.sql`. · *`knowledgeBase` primitive — self-serve customer help center with search ranking* — `bShop.knowledgeBase.create({ query?, defaultLocale?, cursorSecret? })` returns `{ defineArticle, getArticle, listArticles, updateArticle, publishArticle, unpublishArticle, archiveArticle, recordView, recordVote, voteAggregateForArticle, popularArticles, searchSuggest }`. Locale fallback chain. `searchSuggest` ranks by title 3 + tag 2 + body 1 weighted score; archived + unpublished excluded. Vote dedup at the `UNIQUE(slug, session_id_hash)` index. In-process markdown subset composes `b.template.escapeHtml` + `b.safeUrl.parse` (https-only + /-rooted internal). Session ids hashed via `namespaceHash` under `kb-view-session` / `kb-vote-session`. Migration `0162_knowledge_base.sql`. · *`pixelEvents` primitive — server-side conversion-tracking pixel / event API* — `bShop.pixelEvents.create({ query? })` returns `{ registerProvider, recordEvent, dispatchTick, markDispatched, markFailed, eventsForOrder, dispatchedInPeriod, failedEvents, metricsForProvider }`. Provider enum: meta_capi / google_ec / tiktok_events / pinterest_capi / snap_capi. Event names: purchase / add_to_cart / view_content / lead / complete_registration / search. Customer email + phone SHA-256-hashed (per provider spec — NOT namespaceHash; ad platforms accept SHA-256 of the normalised value). Five-step retry back-off on transient failures. Migration `0123_pixel_events.sql`. · *`sitemapGenerator` primitive — storefront sitemap.xml + sitemap-index.xml* — `bShop.sitemapGenerator.create({ query?, catalog?, collections?, storefrontPages?, custom? })` returns `{ defineSection, sections, archiveSection, validateOriginUrl, generate, recordGeneration, lastGeneration }`. Splits at 50,000 URLs or 50 MB serialized per chunk (whichever hits first). Path percent-encoding + XML escape on every emitted `<loc>`. Per-section sources: product / collection / storefront_page / custom. `validateOriginUrl` gates via `b.safeUrl` (https-only). Migration `0130_sitemap_generator.sql`.
|
|
12
16
|
|
|
13
17
|
- v0.0.69 (2026-05-22) — **Twelve new primitives + return-labels CI smoke fix.** Twelve primitives ship in one release plus a CI smoke fix that unblocks the npm publish workflow. The smoke fix bumps a too-tight 50ms `waitUntil` in `return-labels.test.js` to 5s so it survives the GitHub Actions runner's slower scheduling. New primitives cover orders (order ratings, packing slips, print queue), customers (wishlist sharing, customer activity feed, push notifications, order escalation), inventory (allocations, drop-ship forwarding, auto-replenishment), operator tooling (operator roles, clickstream events, damage photos). **Added:** *`orderRatings` primitive — per-order rating for shipping / packaging / recommend* — `bShop.orderRatings.create({ query? })` returns `{ submitRating, getRating, ratingsForCustomer, aggregateForPeriod, flagComment, responseToCustomer, topPositiveRatings, topNegativeRatings }`. Ratings 1..5 across three dimensions. UNIQUE(order_id) so one rating per order. Comment + operator response HTML-escaped on render. flagComment moderates abuse; responseToCustomer is the operator's public reply. Migration `0151_order_ratings.sql`. · *`wishlistSharing` primitive — share-a-wishlist links + group wishlists* — `bShop.wishlistSharing.create({ query?, wishlist? })` returns `{ createShareLink, revokeShareLink, viewShared, recordView, createGroupWishlist, joinGroupWishlist, leaveGroupWishlist, groupWishlistsForCustomer, listSharesForOwner, listGroupMembers }`. Tokens are 32-byte base64url plaintext returned once, hashed at rest via `namespaceHash` under per-domain namespaces. Privacy enum: public / unlisted / friends_only. Group wishlists let multiple customers co-author a list. Migration `0150_wishlist_sharing.sql`. · *`inventoryAllocations` primitive — soft-reserve holds for in-progress carts* — `bShop.inventoryAllocations.create({ query?, inventoryLocations? })` returns `{ holdForCart, releaseHold, releaseAllForCart, commitHold, extendHold, availableForSku, holdsForCart, cleanupExpiredHolds, metricsForSku }`. Holds prevent overselling during checkout. `availableForSku` returns committed - on_hold. `commitHold` composes `inventoryLocations.adjustStock` to commit the reservation as a real stock movement. TTL-bound; `cleanupExpiredHolds` walks expired rows and frees the inventory. Migration `0152_inventory_allocations.sql`. · *`dropshipForwarding` primitive — drop-ship vendor order-forwarding flow* — `bShop.dropshipForwarding.create({ query?, vendors?, orderTracking? })` returns `{ bindSkuToVendor, forwardOrder, markVendorAccepted, markVendorShipped, markVendorDelivered, markVendorFailed, markVendorReturned, getForwarding, forwardingsForOrder, pendingForwardings, metricsForVendor }`. Six-state FSM: queued / accepted / shipped / delivered / failed / returned. Composes `b.fsm` for transition validation. `forwardOrder` writes per-vendor forwarding rows. Migration `0154_dropship_forwarding.sql`. · *`damagePhotos` primitive — image attachments for damage / quality-control events* — `bShop.damagePhotos.create({ query? })` returns `{ recordPhoto, getPhoto, photosForSubject, archivePhoto, replacePhoto, metricsForKind, findDuplicatesBySha }`. Subject kinds: writeoff / return / quality_check / damaged_receipt / customer_complaint. SHA3-512 + size + content_type per upload. content_type allowlist: image/jpeg, image/png, image/webp, image/heic. `findDuplicatesBySha` returns prior uploads with the same digest (fraud detection signal). Migration `0159_damage_photos.sql`. · *`pushNotifications` primitive — APNs / FCM / Web Push outbound* — `bShop.pushNotifications.create({ query? })` returns `{ registerProvider, registerDevice, revokeDevice, recordOptIn, recordOptOut, isOptedIn, enqueueNotification, markDelivered, markFailed, dispatchTick, notificationsForCustomer, devicesForCustomer, metricsForProvider }`. Marketing channel uses opt-IN; transactional + alert use opt-OUT. Device class / provider kind compatibility enforced (ios↔apns, android↔fcm, web↔web_push, desktop↔fcm|web_push). Retry budget 5 with [1m, 5m, 30m, 2h, 12h] back-off. Migration `0148_push_notifications.sql`. · *`autoReplenish` primitive — auto-PO when reorder thresholds fire* — `bShop.autoReplenish.create({ query?, reorderThresholds?, purchaseOrders?, vendors? })` returns `{ definePolicy, tickReplenishment, getPolicy, listPolicies, archivePolicy, updatePolicy, replenishmentHistory, markPolicyTriggered }`. `tickReplenishment` finds reorder candidates via `reorderThresholds.scanAll` + groups by vendor + submits PO via `purchaseOrders.createDraft + submitToVendor`. Per-policy gates: min/max PO value, max_concurrent_open_pos cap, vendor-archived skip. Schedule: hourly / daily / weekly. Migration `0155_auto_replenish.sql`. · *`operatorRoles` primitive — staff-side RBAC* — `bShop.operatorRoles.create({ query?, operatorAuditLog? })` returns `{ defineRole, assignRoleToOperator, revokeRoleFromOperator, rolesForOperator, operatorsWithRole, hasPermission, listRoles, updateRole, archiveRole, listPermissions, permissionUsageLog, recordPermissionUse }`. 15-permission closed allow-list (orders.* / customers.* / catalog.* / inventory.write / vendors.manage / settings.write / billing.view / reports.read / users.* / support.handle). Multi-role union; expires_at honored. Migration `0157_operator_roles.sql`. · *`clickstream` primitive — server-side storefront analytics events* — `bShop.clickstream.create({ query? })` returns `{ recordPageView, recordEvent, sessionPath, topPages, topClicks, funnelAnalysis, bouncerate, dwellByPage, cleanupOlderThan }`. Tracks page views + clicks + form submits + scroll depth + dwell without third-party scripts. Event kinds: click / form_submit / form_abandon / video_play / video_complete / scroll_depth / dwell. Query strings + URL fragments stripped at write so PII in query params never reaches the table. Drop-silent on bad input. Migration `0161_clickstream.sql`. · *`customerActivity` primitive — per-customer aggregated activity timeline* — `bShop.customerActivity.create({ query?, cursorSecret?, order?, wishlist?, loyalty?, supportTickets?, reviews? })` returns `{ forCustomer, recentActivity, summarize, lastActivityAt, inactiveCustomers, purgeStaleCache }`. Aggregates events from order / wishlist / loyalty / support / reviews into one feed for the operator's customer-detail page. Missing peers skipped silently. `summarize` returns 30/90/365-day kind counts with cache hit/miss. Migration `0153_customer_activity_cache.sql`. · *`packingSlips` primitive — warehouse packing-slip rendering + print queue* — `bShop.packingSlips.create({ query?, order, giftOptions? })` returns `{ renderHtml, renderPdfPayload, recordPrint, printsForOrder, enqueueForLocation, dequeueForLocation, bulkRenderForLocation }`. Inlined Code-128 barcode SVG renderer (no external library dependency). HTML-escapes every operator + customer field. Gift options: hide_prices strips price/total columns; gift_message + recipient_name rendered inert against hostile input. Composite PK on the queue table as idempotency guard. Migration `0149_packing_slips.sql`. · *`printQueue` primitive — warehouse print job queue scheduler* — `bShop.printQueue.create({ query? })` returns `{ enqueueJob, claimJob, markComplete, markFailed, cancelJob, getJob, jobsForStation, pendingByKind, cleanupCompleted, stationActivity, dailyMetrics }`. Job kinds: packing_slip / shipping_label / invoice / packing_label / return_label / pickup_slip. `claimJob` returns next queued job (FIFO with priority tiebreak), flips `in_progress`. station_filter restricts which stations can claim a job. Retry budget on markFailed. Migration `0158_print_queue.sql`. **Fixed:** *`return-labels.test.js` — bumped a 50ms `waitUntil` timeout to 5s for CI runner scheduling* — GitHub Actions Ubuntu / macOS runners under contention sometimes need more than 50ms between `Date.now()` ticks to clear a monotonic-clock guard. The two `waitUntil` calls in `_listQueries` now use a 5000ms budget so the CI smoke gate doesn't drop the npm publish workflow. Local runs were always fast enough; only CI saw the failure.
|
package/lib/index.js
CHANGED
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.loyaltyEarnRules
|
|
4
|
+
* @title Loyalty earn rules — per-action point-earning configuration
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* Distinct from `loyalty` (which records the running points balance
|
|
8
|
+
* and the audited transaction trail) and from `tierBenefits` (which
|
|
9
|
+
* configures perks unlocked at each tier). This primitive defines
|
|
10
|
+
* HOW points are earned — operators publish rules keyed by an event
|
|
11
|
+
* `trigger` and the application calls `awardForEvent` at the
|
|
12
|
+
* appropriate lifecycle moment.
|
|
13
|
+
*
|
|
14
|
+
* Triggers (closed enum):
|
|
15
|
+
* - per_dollar_spent — N points per $1 of order subtotal
|
|
16
|
+
* - per_purchase — flat N points per completed order
|
|
17
|
+
* - per_review — N points per review submitted
|
|
18
|
+
* - per_referral_redeemed — N points per referred friend's
|
|
19
|
+
* first order completing
|
|
20
|
+
* - birthday — N points on the customer's birthday
|
|
21
|
+
* - signup_bonus — N points on account creation
|
|
22
|
+
* - first_purchase — N points on the first completed order
|
|
23
|
+
* - abandoned_cart_recovered — N points when a recovered cart converts
|
|
24
|
+
*
|
|
25
|
+
* Composition:
|
|
26
|
+
*
|
|
27
|
+
* var rules = bShop.loyaltyEarnRules.create({
|
|
28
|
+
* query: q,
|
|
29
|
+
* loyalty: loy, // optional — awardForEvent composes loy.earn
|
|
30
|
+
* });
|
|
31
|
+
*
|
|
32
|
+
* await rules.defineRule({
|
|
33
|
+
* slug: "spend-1pt-per-dollar",
|
|
34
|
+
* trigger: "per_dollar_spent",
|
|
35
|
+
* points_per_unit: 1,
|
|
36
|
+
* max_per_event: 5000,
|
|
37
|
+
* customer_status_in: ["active", "vip"],
|
|
38
|
+
* });
|
|
39
|
+
*
|
|
40
|
+
* await rules.awardForEvent({
|
|
41
|
+
* trigger: "per_dollar_spent",
|
|
42
|
+
* customer_id: customerId,
|
|
43
|
+
* dollars_spent: 42,
|
|
44
|
+
* trigger_event_ref: "order:" + orderId,
|
|
45
|
+
* customer_status: "active",
|
|
46
|
+
* });
|
|
47
|
+
*
|
|
48
|
+
* `evaluateForEvent` is the dry-run companion to `awardForEvent`. It
|
|
49
|
+
* returns the same `{ points, reason }` shape but does NOT touch the
|
|
50
|
+
* audit log or the loyalty ledger — operators preview an award at
|
|
51
|
+
* checkout (so the customer sees "you'll earn 42 points") without
|
|
52
|
+
* committing.
|
|
53
|
+
*
|
|
54
|
+
* `applyBatch` runs multiple awards in a single call. Each (rule,
|
|
55
|
+
* event) pair flows through the same validate -> evaluate -> award
|
|
56
|
+
* path; per-pair failures are collected into a `failed[]` array
|
|
57
|
+
* rather than failing the whole batch (operators commonly run nightly
|
|
58
|
+
* sweeps that span thousands of events — a malformed row shouldn't
|
|
59
|
+
* block the rest).
|
|
60
|
+
*
|
|
61
|
+
* Per-event dedup: the (rule_slug, customer_id, trigger_event_ref)
|
|
62
|
+
* UNIQUE on `loyalty_earn_log` collapses retried inserts onto one
|
|
63
|
+
* row so a webhook retry doesn't double-award.
|
|
64
|
+
*
|
|
65
|
+
* Composes:
|
|
66
|
+
* - `b.uuid.v7` — audit-log row ids (lexicographic + monotonic)
|
|
67
|
+
* - `b.guardUuid` — strict UUID gate on every customer_id
|
|
68
|
+
* - `loyalty` — optional; when wired, `awardForEvent` composes
|
|
69
|
+
* `loyalty.earn` so the points land in the
|
|
70
|
+
* customer's balance + the loyalty audit trail
|
|
71
|
+
* in one call.
|
|
72
|
+
*
|
|
73
|
+
* Storage: `migrations-d1/0163_loyalty_earn_rules.sql` —
|
|
74
|
+
* `loyalty_earn_rules` + `loyalty_earn_log`.
|
|
75
|
+
*
|
|
76
|
+
* @primitive loyaltyEarnRules
|
|
77
|
+
* @related loyalty, tierBenefits, b.uuid.v7, b.guardUuid
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
var bShop;
|
|
81
|
+
function _b() {
|
|
82
|
+
if (!bShop) bShop = require("./index");
|
|
83
|
+
return bShop.framework;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---- constants ----------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
var TRIGGERS = Object.freeze([
|
|
89
|
+
"per_dollar_spent",
|
|
90
|
+
"per_purchase",
|
|
91
|
+
"per_review",
|
|
92
|
+
"per_referral_redeemed",
|
|
93
|
+
"birthday",
|
|
94
|
+
"signup_bonus",
|
|
95
|
+
"first_purchase",
|
|
96
|
+
"abandoned_cart_recovered",
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
// Triggers that scale points by a caller-supplied unit count.
|
|
100
|
+
// per_dollar_spent multiplies points_per_unit by the order's dollar
|
|
101
|
+
// subtotal; every other trigger awards a flat points_per_unit.
|
|
102
|
+
var UNIT_TRIGGERS = Object.freeze({
|
|
103
|
+
per_dollar_spent: "dollars_spent",
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,98}[a-z0-9])?$/;
|
|
107
|
+
var TRIGGER_REF_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
108
|
+
var STATUS_RE = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
109
|
+
|
|
110
|
+
var MAX_STATUS_LIST = 16;
|
|
111
|
+
var MAX_POINTS_PER_UNIT = 1000000; // 1M cap on a single multiplier
|
|
112
|
+
var MAX_MAX_PER_EVENT = 1000000000; // 1B cap on a per-event ceiling
|
|
113
|
+
|
|
114
|
+
var DEFAULT_LIST_LIMIT = 50;
|
|
115
|
+
var MAX_LIST_LIMIT = 500;
|
|
116
|
+
|
|
117
|
+
// ---- monotonic clock ----------------------------------------------------
|
|
118
|
+
//
|
|
119
|
+
// Awards land in `loyalty_earn_log` keyed by `occurred_at`. The metrics
|
|
120
|
+
// rollup window scans by (rule_slug, occurred_at >= from AND <= to);
|
|
121
|
+
// two awards in the same millisecond would tie on the sort key and the
|
|
122
|
+
// `applyBatch` path issues many awards in a tight loop. The strict-
|
|
123
|
+
// monotonic clock guarantees distinct timestamps per call so ordering
|
|
124
|
+
// is deterministic without a tiebreaker column.
|
|
125
|
+
|
|
126
|
+
var _lastTs = 0;
|
|
127
|
+
function _now() {
|
|
128
|
+
var t = Date.now();
|
|
129
|
+
if (t <= _lastTs) { t = _lastTs + 1; }
|
|
130
|
+
_lastTs = t;
|
|
131
|
+
return t;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---- validators ---------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
function _slug(s, label) {
|
|
137
|
+
if (typeof s !== "string" || !SLUG_RE.test(s)) {
|
|
138
|
+
throw new TypeError("loyaltyEarnRules: " + (label || "slug") +
|
|
139
|
+
" must be lowercase alnum + dash, no leading/trailing dash, 1..100 chars");
|
|
140
|
+
}
|
|
141
|
+
return s;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function _trigger(s) {
|
|
145
|
+
if (typeof s !== "string" || TRIGGERS.indexOf(s) < 0) {
|
|
146
|
+
throw new TypeError("loyaltyEarnRules: trigger must be one of " + TRIGGERS.join(", "));
|
|
147
|
+
}
|
|
148
|
+
return s;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function _pointsPerUnit(n) {
|
|
152
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n <= 0 || n > MAX_POINTS_PER_UNIT) {
|
|
153
|
+
throw new TypeError("loyaltyEarnRules: points_per_unit must be a positive integer <= " +
|
|
154
|
+
MAX_POINTS_PER_UNIT);
|
|
155
|
+
}
|
|
156
|
+
return n;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function _maxPerEvent(n) {
|
|
160
|
+
if (n == null) return null;
|
|
161
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n <= 0 || n > MAX_MAX_PER_EVENT) {
|
|
162
|
+
throw new TypeError("loyaltyEarnRules: max_per_event must be a positive integer <= " +
|
|
163
|
+
MAX_MAX_PER_EVENT + " (or omitted)");
|
|
164
|
+
}
|
|
165
|
+
return n;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function _customerStatusIn(arr) {
|
|
169
|
+
if (arr == null) return null;
|
|
170
|
+
if (!Array.isArray(arr)) {
|
|
171
|
+
throw new TypeError("loyaltyEarnRules: customer_status_in must be an array of status strings");
|
|
172
|
+
}
|
|
173
|
+
if (arr.length === 0) {
|
|
174
|
+
throw new TypeError("loyaltyEarnRules: customer_status_in must be non-empty when provided");
|
|
175
|
+
}
|
|
176
|
+
if (arr.length > MAX_STATUS_LIST) {
|
|
177
|
+
throw new TypeError("loyaltyEarnRules: customer_status_in must contain <= " +
|
|
178
|
+
MAX_STATUS_LIST + " entries");
|
|
179
|
+
}
|
|
180
|
+
var seen = Object.create(null);
|
|
181
|
+
var out = [];
|
|
182
|
+
for (var i = 0; i < arr.length; i += 1) {
|
|
183
|
+
var s = arr[i];
|
|
184
|
+
if (typeof s !== "string" || !STATUS_RE.test(s)) {
|
|
185
|
+
throw new TypeError("loyaltyEarnRules: customer_status_in[" + i +
|
|
186
|
+
"] must be lowercase alnum / underscore / dash, 1..32 chars");
|
|
187
|
+
}
|
|
188
|
+
if (seen[s]) {
|
|
189
|
+
throw new TypeError("loyaltyEarnRules: customer_status_in[" + i +
|
|
190
|
+
"] duplicates a previous entry");
|
|
191
|
+
}
|
|
192
|
+
seen[s] = true;
|
|
193
|
+
out.push(s);
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function _uuid(s, label) {
|
|
199
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
200
|
+
catch (e) { throw new TypeError("loyaltyEarnRules: " + label + " — " + (e && e.message || "invalid UUID")); }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function _triggerEventRef(s) {
|
|
204
|
+
if (typeof s !== "string" || !TRIGGER_REF_RE.test(s)) {
|
|
205
|
+
throw new TypeError("loyaltyEarnRules: trigger_event_ref must match /^[A-Za-z0-9][A-Za-z0-9._:-]*$/ (1..128 chars)");
|
|
206
|
+
}
|
|
207
|
+
return s;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function _statusOpt(s) {
|
|
211
|
+
if (s == null) return null;
|
|
212
|
+
if (typeof s !== "string" || !STATUS_RE.test(s)) {
|
|
213
|
+
throw new TypeError("loyaltyEarnRules: customer_status must be lowercase alnum / underscore / dash, 1..32 chars");
|
|
214
|
+
}
|
|
215
|
+
return s;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function _epochOpt(n, label) {
|
|
219
|
+
if (n == null) return null;
|
|
220
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
221
|
+
throw new TypeError("loyaltyEarnRules: " + label + " must be a non-negative integer (ms epoch) or null");
|
|
222
|
+
}
|
|
223
|
+
return n;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function _limit(n) {
|
|
227
|
+
if (n == null) return DEFAULT_LIST_LIMIT;
|
|
228
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
|
|
229
|
+
throw new TypeError("loyaltyEarnRules: limit must be an integer in [1, " + MAX_LIST_LIMIT + "]");
|
|
230
|
+
}
|
|
231
|
+
return n;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function _bool(v, label) {
|
|
235
|
+
if (typeof v !== "boolean") {
|
|
236
|
+
throw new TypeError("loyaltyEarnRules: " + label + " must be a boolean");
|
|
237
|
+
}
|
|
238
|
+
return v;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Pure compute — given a rule row + an event context, return the
|
|
242
|
+
// (points, reason) tuple WITHOUT touching storage. Exported via
|
|
243
|
+
// evaluateForEvent + reused inside awardForEvent so the math is
|
|
244
|
+
// single-sourced.
|
|
245
|
+
function _computePoints(rule, ctx) {
|
|
246
|
+
var unitField = UNIT_TRIGGERS[rule.trigger];
|
|
247
|
+
var units = 1;
|
|
248
|
+
if (unitField != null) {
|
|
249
|
+
var raw = ctx[unitField];
|
|
250
|
+
if (typeof raw !== "number" || !isFinite(raw) || raw < 0) {
|
|
251
|
+
return { points: 0, reason: unitField + " must be a non-negative finite number for trigger " + rule.trigger };
|
|
252
|
+
}
|
|
253
|
+
units = Math.floor(raw);
|
|
254
|
+
if (units <= 0) {
|
|
255
|
+
return { points: 0, reason: unitField + " floored to zero" };
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
var raw_points = rule.points_per_unit * units;
|
|
259
|
+
if (rule.max_per_event != null && raw_points > rule.max_per_event) {
|
|
260
|
+
return {
|
|
261
|
+
points: rule.max_per_event,
|
|
262
|
+
reason: "capped at max_per_event=" + rule.max_per_event +
|
|
263
|
+
" (uncapped would have been " + raw_points + ")",
|
|
264
|
+
capped: true,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
return { points: raw_points, reason: "trigger=" + rule.trigger + " units=" + units, capped: false };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---- factory ------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
function create(opts) {
|
|
273
|
+
opts = opts || {};
|
|
274
|
+
var query = opts.query;
|
|
275
|
+
if (!query) {
|
|
276
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
277
|
+
}
|
|
278
|
+
// Optional loyalty handle — when wired, awardForEvent calls
|
|
279
|
+
// loyalty.earn so the points land in the customer's balance + the
|
|
280
|
+
// loyalty transaction audit trail in one go. Absent, the primitive
|
|
281
|
+
// still writes the loyalty_earn_log breadcrumb but the operator is
|
|
282
|
+
// responsible for posting to the ledger separately.
|
|
283
|
+
var loyaltyHandle = opts.loyalty || null;
|
|
284
|
+
|
|
285
|
+
// ---- internal helpers -----------------------------------------------
|
|
286
|
+
|
|
287
|
+
function _decodeRule(row) {
|
|
288
|
+
if (!row) return null;
|
|
289
|
+
var statusList = null;
|
|
290
|
+
if (row.customer_status_in_json) {
|
|
291
|
+
try { statusList = JSON.parse(row.customer_status_in_json); }
|
|
292
|
+
catch (_e) { statusList = null; }
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
slug: row.slug,
|
|
296
|
+
trigger: row.trigger,
|
|
297
|
+
points_per_unit: Number(row.points_per_unit),
|
|
298
|
+
max_per_event: row.max_per_event == null ? null : Number(row.max_per_event),
|
|
299
|
+
customer_status_in: statusList,
|
|
300
|
+
active: Number(row.active) === 1,
|
|
301
|
+
archived_at: row.archived_at == null ? null : Number(row.archived_at),
|
|
302
|
+
created_at: Number(row.created_at),
|
|
303
|
+
updated_at: Number(row.updated_at),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function _ruleRow(slug) {
|
|
308
|
+
var r = await query("SELECT * FROM loyalty_earn_rules WHERE slug = ?1", [slug]);
|
|
309
|
+
return r.rows[0] || null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Status gate. If the rule restricts to a status list, the event's
|
|
313
|
+
// customer_status MUST appear in the list. NULL list means no
|
|
314
|
+
// restriction. Returns null when the event passes, or a reason
|
|
315
|
+
// string when it's filtered out.
|
|
316
|
+
function _statusFilter(rule, ctx) {
|
|
317
|
+
if (rule.customer_status_in == null) return null;
|
|
318
|
+
var status = ctx.customer_status;
|
|
319
|
+
if (status == null || rule.customer_status_in.indexOf(status) < 0) {
|
|
320
|
+
return "customer_status=" + JSON.stringify(status) +
|
|
321
|
+
" not in [" + rule.customer_status_in.join(", ") + "]";
|
|
322
|
+
}
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ---- defineRule -----------------------------------------------------
|
|
327
|
+
|
|
328
|
+
async function defineRule(input) {
|
|
329
|
+
if (!input || typeof input !== "object") {
|
|
330
|
+
throw new TypeError("loyaltyEarnRules.defineRule: input object required");
|
|
331
|
+
}
|
|
332
|
+
var slug = _slug(input.slug, "slug");
|
|
333
|
+
var trigger = _trigger(input.trigger);
|
|
334
|
+
var pointsPerUnit = _pointsPerUnit(input.points_per_unit);
|
|
335
|
+
var maxPerEvent = _maxPerEvent(input.max_per_event);
|
|
336
|
+
var customerStatusIn = _customerStatusIn(input.customer_status_in);
|
|
337
|
+
var active = input.active == null ? true : _bool(input.active, "active");
|
|
338
|
+
|
|
339
|
+
var existing = await _ruleRow(slug);
|
|
340
|
+
var ts = _now();
|
|
341
|
+
if (existing) {
|
|
342
|
+
if (existing.archived_at != null) {
|
|
343
|
+
throw new TypeError("loyaltyEarnRules.defineRule: rule " + JSON.stringify(slug) + " is archived");
|
|
344
|
+
}
|
|
345
|
+
await query(
|
|
346
|
+
"UPDATE loyalty_earn_rules " +
|
|
347
|
+
"SET trigger = ?1, points_per_unit = ?2, max_per_event = ?3, " +
|
|
348
|
+
"customer_status_in_json = ?4, active = ?5, updated_at = ?6 " +
|
|
349
|
+
"WHERE slug = ?7",
|
|
350
|
+
[trigger, pointsPerUnit, maxPerEvent,
|
|
351
|
+
customerStatusIn == null ? null : JSON.stringify(customerStatusIn),
|
|
352
|
+
active ? 1 : 0, ts, slug],
|
|
353
|
+
);
|
|
354
|
+
} else {
|
|
355
|
+
await query(
|
|
356
|
+
"INSERT INTO loyalty_earn_rules " +
|
|
357
|
+
"(slug, trigger, points_per_unit, max_per_event, customer_status_in_json, " +
|
|
358
|
+
" active, archived_at, created_at, updated_at) " +
|
|
359
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?7)",
|
|
360
|
+
[slug, trigger, pointsPerUnit, maxPerEvent,
|
|
361
|
+
customerStatusIn == null ? null : JSON.stringify(customerStatusIn),
|
|
362
|
+
active ? 1 : 0, ts],
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
return _decodeRule(await _ruleRow(slug));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ---- getRule / listRules --------------------------------------------
|
|
369
|
+
|
|
370
|
+
async function getRule(slug) {
|
|
371
|
+
_slug(slug, "slug");
|
|
372
|
+
return _decodeRule(await _ruleRow(slug));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function listRules(listOpts) {
|
|
376
|
+
listOpts = listOpts || {};
|
|
377
|
+
var activeOnly = listOpts.active_only == null ? false : _bool(listOpts.active_only, "active_only");
|
|
378
|
+
var limit = _limit(listOpts.limit);
|
|
379
|
+
var sql = "SELECT * FROM loyalty_earn_rules";
|
|
380
|
+
var params = [];
|
|
381
|
+
var idx = 1;
|
|
382
|
+
var where = [];
|
|
383
|
+
if (activeOnly) {
|
|
384
|
+
where.push("active = ?" + idx); params.push(1); idx += 1;
|
|
385
|
+
where.push("archived_at IS NULL");
|
|
386
|
+
}
|
|
387
|
+
if (listOpts.trigger != null) {
|
|
388
|
+
where.push("trigger = ?" + idx); params.push(_trigger(listOpts.trigger)); idx += 1;
|
|
389
|
+
}
|
|
390
|
+
if (where.length) sql += " WHERE " + where.join(" AND ");
|
|
391
|
+
sql += " ORDER BY slug ASC LIMIT ?" + idx;
|
|
392
|
+
params.push(limit);
|
|
393
|
+
var r = await query(sql, params);
|
|
394
|
+
var out = [];
|
|
395
|
+
for (var i = 0; i < r.rows.length; i += 1) out.push(_decodeRule(r.rows[i]));
|
|
396
|
+
return out;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ---- updateRule -----------------------------------------------------
|
|
400
|
+
|
|
401
|
+
async function updateRule(slug, patch) {
|
|
402
|
+
_slug(slug, "slug");
|
|
403
|
+
if (!patch || typeof patch !== "object") {
|
|
404
|
+
throw new TypeError("loyaltyEarnRules.updateRule: patch object required");
|
|
405
|
+
}
|
|
406
|
+
var existing = await _ruleRow(slug);
|
|
407
|
+
if (!existing) return null;
|
|
408
|
+
if (existing.archived_at != null) {
|
|
409
|
+
throw new TypeError("loyaltyEarnRules.updateRule: rule " + JSON.stringify(slug) + " is archived");
|
|
410
|
+
}
|
|
411
|
+
var decoded = _decodeRule(existing);
|
|
412
|
+
|
|
413
|
+
var nextPoints = decoded.points_per_unit;
|
|
414
|
+
if (Object.prototype.hasOwnProperty.call(patch, "points_per_unit")) {
|
|
415
|
+
nextPoints = _pointsPerUnit(patch.points_per_unit);
|
|
416
|
+
}
|
|
417
|
+
var nextMax = decoded.max_per_event;
|
|
418
|
+
if (Object.prototype.hasOwnProperty.call(patch, "max_per_event")) {
|
|
419
|
+
nextMax = _maxPerEvent(patch.max_per_event);
|
|
420
|
+
}
|
|
421
|
+
var nextStatus = decoded.customer_status_in;
|
|
422
|
+
if (Object.prototype.hasOwnProperty.call(patch, "customer_status_in")) {
|
|
423
|
+
nextStatus = _customerStatusIn(patch.customer_status_in);
|
|
424
|
+
}
|
|
425
|
+
var nextActive = decoded.active;
|
|
426
|
+
if (Object.prototype.hasOwnProperty.call(patch, "active")) {
|
|
427
|
+
nextActive = _bool(patch.active, "active");
|
|
428
|
+
}
|
|
429
|
+
// trigger is immutable on update — operators that need a different
|
|
430
|
+
// trigger archive the rule and define a new one. Otherwise the
|
|
431
|
+
// metricsForRule history straddles two semantically distinct
|
|
432
|
+
// event spaces and the rollup becomes a lie.
|
|
433
|
+
if (Object.prototype.hasOwnProperty.call(patch, "trigger") && patch.trigger !== decoded.trigger) {
|
|
434
|
+
throw new TypeError("loyaltyEarnRules.updateRule: trigger is immutable — archive + define a new rule instead");
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
var ts = _now();
|
|
438
|
+
await query(
|
|
439
|
+
"UPDATE loyalty_earn_rules SET points_per_unit = ?1, max_per_event = ?2, " +
|
|
440
|
+
"customer_status_in_json = ?3, active = ?4, updated_at = ?5 WHERE slug = ?6",
|
|
441
|
+
[nextPoints, nextMax,
|
|
442
|
+
nextStatus == null ? null : JSON.stringify(nextStatus),
|
|
443
|
+
nextActive ? 1 : 0, ts, slug],
|
|
444
|
+
);
|
|
445
|
+
return _decodeRule(await _ruleRow(slug));
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ---- archiveRule ----------------------------------------------------
|
|
449
|
+
|
|
450
|
+
async function archiveRule(slug) {
|
|
451
|
+
_slug(slug, "slug");
|
|
452
|
+
var ts = _now();
|
|
453
|
+
var r = await query(
|
|
454
|
+
"UPDATE loyalty_earn_rules SET archived_at = ?1, active = 0, updated_at = ?1 " +
|
|
455
|
+
"WHERE slug = ?2 AND archived_at IS NULL",
|
|
456
|
+
[ts, slug],
|
|
457
|
+
);
|
|
458
|
+
if (Number(r.rowCount || 0) === 0) {
|
|
459
|
+
var existing = await _ruleRow(slug);
|
|
460
|
+
if (!existing) return null;
|
|
461
|
+
return _decodeRule(existing);
|
|
462
|
+
}
|
|
463
|
+
return _decodeRule(await _ruleRow(slug));
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ---- evaluateForEvent (dry-run) -------------------------------------
|
|
467
|
+
|
|
468
|
+
async function evaluateForEvent(input) {
|
|
469
|
+
if (!input || typeof input !== "object") {
|
|
470
|
+
throw new TypeError("loyaltyEarnRules.evaluateForEvent: input object required");
|
|
471
|
+
}
|
|
472
|
+
var trigger = _trigger(input.trigger);
|
|
473
|
+
_uuid(input.customer_id, "customer_id");
|
|
474
|
+
_statusOpt(input.customer_status);
|
|
475
|
+
|
|
476
|
+
// The slug-targeted path lets operators preview a single named
|
|
477
|
+
// rule even when several rules share the same trigger. Absent a
|
|
478
|
+
// slug, every active matching-trigger rule is evaluated; the
|
|
479
|
+
// primitive returns each rule's verdict (eligible / skipped /
|
|
480
|
+
// capped) so the operator-facing UI can render a per-rule
|
|
481
|
+
// breakdown.
|
|
482
|
+
var rules;
|
|
483
|
+
if (input.slug != null) {
|
|
484
|
+
var slug = _slug(input.slug, "slug");
|
|
485
|
+
var r = await _ruleRow(slug);
|
|
486
|
+
rules = r ? [r] : [];
|
|
487
|
+
} else {
|
|
488
|
+
var r2 = await query(
|
|
489
|
+
"SELECT * FROM loyalty_earn_rules WHERE trigger = ?1 AND active = 1 AND archived_at IS NULL " +
|
|
490
|
+
"ORDER BY slug ASC",
|
|
491
|
+
[trigger],
|
|
492
|
+
);
|
|
493
|
+
rules = r2.rows;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
var verdicts = [];
|
|
497
|
+
var totalPoints = 0;
|
|
498
|
+
for (var i = 0; i < rules.length; i += 1) {
|
|
499
|
+
var rule = _decodeRule(rules[i]);
|
|
500
|
+
if (rule.trigger !== trigger) {
|
|
501
|
+
verdicts.push({ slug: rule.slug, eligible: false, points: 0,
|
|
502
|
+
reason: "rule.trigger=" + rule.trigger + " != requested " + trigger });
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
if (!rule.active || rule.archived_at != null) {
|
|
506
|
+
verdicts.push({ slug: rule.slug, eligible: false, points: 0,
|
|
507
|
+
reason: "rule is inactive or archived" });
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
var statusReason = _statusFilter(rule, input);
|
|
511
|
+
if (statusReason) {
|
|
512
|
+
verdicts.push({ slug: rule.slug, eligible: false, points: 0, reason: statusReason });
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
var calc = _computePoints(rule, input);
|
|
516
|
+
if (calc.points <= 0) {
|
|
517
|
+
verdicts.push({ slug: rule.slug, eligible: false, points: 0, reason: calc.reason });
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
verdicts.push({
|
|
521
|
+
slug: rule.slug,
|
|
522
|
+
eligible: true,
|
|
523
|
+
points: calc.points,
|
|
524
|
+
reason: calc.reason,
|
|
525
|
+
capped: !!calc.capped,
|
|
526
|
+
});
|
|
527
|
+
totalPoints += calc.points;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return {
|
|
531
|
+
trigger: trigger,
|
|
532
|
+
customer_id: input.customer_id,
|
|
533
|
+
total_points: totalPoints,
|
|
534
|
+
verdicts: verdicts,
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ---- awardForEvent --------------------------------------------------
|
|
539
|
+
|
|
540
|
+
async function awardForEvent(input) {
|
|
541
|
+
if (!input || typeof input !== "object") {
|
|
542
|
+
throw new TypeError("loyaltyEarnRules.awardForEvent: input object required");
|
|
543
|
+
}
|
|
544
|
+
var trigger = _trigger(input.trigger);
|
|
545
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
546
|
+
var triggerEventRef = _triggerEventRef(input.trigger_event_ref);
|
|
547
|
+
_statusOpt(input.customer_status);
|
|
548
|
+
|
|
549
|
+
var rules;
|
|
550
|
+
if (input.slug != null) {
|
|
551
|
+
var slug = _slug(input.slug, "slug");
|
|
552
|
+
var r = await _ruleRow(slug);
|
|
553
|
+
rules = r ? [r] : [];
|
|
554
|
+
} else {
|
|
555
|
+
var r2 = await query(
|
|
556
|
+
"SELECT * FROM loyalty_earn_rules WHERE trigger = ?1 AND active = 1 AND archived_at IS NULL " +
|
|
557
|
+
"ORDER BY slug ASC",
|
|
558
|
+
[trigger],
|
|
559
|
+
);
|
|
560
|
+
rules = r2.rows;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
var awarded = [];
|
|
564
|
+
var skipped = [];
|
|
565
|
+
var totalPts = 0;
|
|
566
|
+
for (var i = 0; i < rules.length; i += 1) {
|
|
567
|
+
var rule = _decodeRule(rules[i]);
|
|
568
|
+
if (rule.trigger !== trigger) {
|
|
569
|
+
skipped.push({ slug: rule.slug, reason: "rule.trigger != requested trigger" });
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
if (!rule.active || rule.archived_at != null) {
|
|
573
|
+
skipped.push({ slug: rule.slug, reason: "rule is inactive or archived" });
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
var statusReason = _statusFilter(rule, input);
|
|
577
|
+
if (statusReason) {
|
|
578
|
+
skipped.push({ slug: rule.slug, reason: statusReason });
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
var calc = _computePoints(rule, input);
|
|
582
|
+
if (calc.points <= 0) {
|
|
583
|
+
skipped.push({ slug: rule.slug, reason: calc.reason });
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Dedup at the storage layer: the UNIQUE (rule_slug,
|
|
588
|
+
// customer_id, trigger_event_ref) collapses a retried award
|
|
589
|
+
// onto the existing row. SQLite's INSERT OR IGNORE is the
|
|
590
|
+
// cheapest portable shape — when 0 rows change we surface the
|
|
591
|
+
// dedup as a skipped reason rather than a hard error so a
|
|
592
|
+
// webhook retry produces a consistent observable result.
|
|
593
|
+
var logId = _b().uuid.v7();
|
|
594
|
+
var ts = _now();
|
|
595
|
+
var ins = await query(
|
|
596
|
+
"INSERT OR IGNORE INTO loyalty_earn_log " +
|
|
597
|
+
"(id, rule_slug, customer_id, points_awarded, trigger_event_ref, occurred_at) " +
|
|
598
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
599
|
+
[logId, rule.slug, customerId, calc.points, triggerEventRef, ts],
|
|
600
|
+
);
|
|
601
|
+
if (Number(ins.rowCount || 0) === 0) {
|
|
602
|
+
skipped.push({
|
|
603
|
+
slug: rule.slug,
|
|
604
|
+
reason: "duplicate trigger_event_ref — already awarded for this event",
|
|
605
|
+
});
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Compose loyalty.earn when wired. Source is derived from the
|
|
610
|
+
// trigger name — loyalty's source validator demands lowercase
|
|
611
|
+
// alnum + `._-`, which the trigger enum already satisfies.
|
|
612
|
+
if (loyaltyHandle && typeof loyaltyHandle.earn === "function") {
|
|
613
|
+
try {
|
|
614
|
+
await loyaltyHandle.earn({
|
|
615
|
+
customer_id: customerId,
|
|
616
|
+
points: calc.points,
|
|
617
|
+
source: "earn-rule." + rule.slug,
|
|
618
|
+
notes: "trigger=" + trigger + " ref=" + triggerEventRef,
|
|
619
|
+
});
|
|
620
|
+
} catch (err) {
|
|
621
|
+
// Loyalty ledger failed AFTER the audit log wrote. Roll
|
|
622
|
+
// the audit row back so the next retry isn't dedup-skipped
|
|
623
|
+
// against a row that never made it to the ledger. The
|
|
624
|
+
// operator-facing failure carries the underlying loyalty
|
|
625
|
+
// error so debugging hits the root cause not the audit
|
|
626
|
+
// breadcrumb.
|
|
627
|
+
await query(
|
|
628
|
+
"DELETE FROM loyalty_earn_log WHERE id = ?1",
|
|
629
|
+
[logId],
|
|
630
|
+
);
|
|
631
|
+
throw err;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
awarded.push({
|
|
636
|
+
log_id: logId,
|
|
637
|
+
slug: rule.slug,
|
|
638
|
+
points: calc.points,
|
|
639
|
+
capped: !!calc.capped,
|
|
640
|
+
trigger_event_ref: triggerEventRef,
|
|
641
|
+
occurred_at: ts,
|
|
642
|
+
});
|
|
643
|
+
totalPts += calc.points;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
return {
|
|
647
|
+
trigger: trigger,
|
|
648
|
+
customer_id: customerId,
|
|
649
|
+
total_points: totalPts,
|
|
650
|
+
awarded: awarded,
|
|
651
|
+
skipped: skipped,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// ---- metricsForRule -------------------------------------------------
|
|
656
|
+
|
|
657
|
+
async function metricsForRule(input) {
|
|
658
|
+
if (!input || typeof input !== "object") {
|
|
659
|
+
throw new TypeError("loyaltyEarnRules.metricsForRule: input object required");
|
|
660
|
+
}
|
|
661
|
+
var slug = _slug(input.slug, "slug");
|
|
662
|
+
var from = _epochOpt(input.from, "from");
|
|
663
|
+
var to = _epochOpt(input.to, "to");
|
|
664
|
+
if (from != null && to != null && from > to) {
|
|
665
|
+
throw new TypeError("loyaltyEarnRules.metricsForRule: from must be <= to");
|
|
666
|
+
}
|
|
667
|
+
var ruleRow = await _ruleRow(slug);
|
|
668
|
+
if (!ruleRow) return null;
|
|
669
|
+
|
|
670
|
+
var sql = "SELECT COUNT(*) AS award_count, COUNT(DISTINCT customer_id) AS unique_customers, " +
|
|
671
|
+
"COALESCE(SUM(points_awarded), 0) AS total_points, " +
|
|
672
|
+
"MIN(occurred_at) AS first_award, MAX(occurred_at) AS last_award " +
|
|
673
|
+
"FROM loyalty_earn_log WHERE rule_slug = ?1";
|
|
674
|
+
var params = [slug];
|
|
675
|
+
var idx = 2;
|
|
676
|
+
if (from != null) { sql += " AND occurred_at >= ?" + idx; params.push(from); idx += 1; }
|
|
677
|
+
if (to != null) { sql += " AND occurred_at <= ?" + idx; params.push(to); idx += 1; }
|
|
678
|
+
|
|
679
|
+
var r = await query(sql, params);
|
|
680
|
+
var row = r.rows[0] || { award_count: 0, unique_customers: 0, total_points: 0,
|
|
681
|
+
first_award: null, last_award: null };
|
|
682
|
+
return {
|
|
683
|
+
slug: slug,
|
|
684
|
+
trigger: ruleRow.trigger,
|
|
685
|
+
from: from,
|
|
686
|
+
to: to,
|
|
687
|
+
award_count: Number(row.award_count || 0),
|
|
688
|
+
unique_customers: Number(row.unique_customers || 0),
|
|
689
|
+
total_points: Number(row.total_points || 0),
|
|
690
|
+
first_award: row.first_award == null ? null : Number(row.first_award),
|
|
691
|
+
last_award: row.last_award == null ? null : Number(row.last_award),
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// ---- applyBatch -----------------------------------------------------
|
|
696
|
+
|
|
697
|
+
async function applyBatch(input) {
|
|
698
|
+
if (!input || typeof input !== "object") {
|
|
699
|
+
throw new TypeError("loyaltyEarnRules.applyBatch: input object required");
|
|
700
|
+
}
|
|
701
|
+
if (!Array.isArray(input.events) || input.events.length === 0) {
|
|
702
|
+
throw new TypeError("loyaltyEarnRules.applyBatch: events must be a non-empty array");
|
|
703
|
+
}
|
|
704
|
+
if (input.events.length > 10000) {
|
|
705
|
+
throw new TypeError("loyaltyEarnRules.applyBatch: events.length must be <= 10000");
|
|
706
|
+
}
|
|
707
|
+
// `rules` is an optional advisory hint — when supplied, the batch
|
|
708
|
+
// restricts to those rule slugs by passing through the per-event
|
|
709
|
+
// `slug` field. The primary path uses the per-event `slug` (when
|
|
710
|
+
// present) or `trigger` (when absent).
|
|
711
|
+
var ruleFilter = null;
|
|
712
|
+
if (input.rules != null) {
|
|
713
|
+
if (!Array.isArray(input.rules)) {
|
|
714
|
+
throw new TypeError("loyaltyEarnRules.applyBatch: rules must be an array of slugs when provided");
|
|
715
|
+
}
|
|
716
|
+
ruleFilter = Object.create(null);
|
|
717
|
+
for (var ri = 0; ri < input.rules.length; ri += 1) {
|
|
718
|
+
ruleFilter[_slug(input.rules[ri], "rules[" + ri + "]")] = true;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
var awarded = [];
|
|
723
|
+
var skipped = [];
|
|
724
|
+
var failed = [];
|
|
725
|
+
var totalPts = 0;
|
|
726
|
+
|
|
727
|
+
for (var i = 0; i < input.events.length; i += 1) {
|
|
728
|
+
var ev = input.events[i];
|
|
729
|
+
if (!ev || typeof ev !== "object") {
|
|
730
|
+
failed.push({ index: i, reason: "event must be an object" });
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
if (ruleFilter != null && ev.slug != null && !ruleFilter[ev.slug]) {
|
|
734
|
+
skipped.push({ index: i, slug: ev.slug, reason: "slug not in rules filter" });
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
try {
|
|
738
|
+
var result = await awardForEvent(ev);
|
|
739
|
+
for (var a = 0; a < result.awarded.length; a += 1) {
|
|
740
|
+
awarded.push({ index: i, award: result.awarded[a] });
|
|
741
|
+
totalPts += result.awarded[a].points;
|
|
742
|
+
}
|
|
743
|
+
for (var s = 0; s < result.skipped.length; s += 1) {
|
|
744
|
+
skipped.push({ index: i, slug: result.skipped[s].slug, reason: result.skipped[s].reason });
|
|
745
|
+
}
|
|
746
|
+
} catch (err) {
|
|
747
|
+
failed.push({ index: i, reason: err && err.message ? err.message : String(err) });
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
return {
|
|
752
|
+
total_events: input.events.length,
|
|
753
|
+
total_points: totalPts,
|
|
754
|
+
awarded: awarded,
|
|
755
|
+
skipped: skipped,
|
|
756
|
+
failed: failed,
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
return {
|
|
761
|
+
TRIGGERS: TRIGGERS.slice(),
|
|
762
|
+
UNIT_TRIGGERS: Object.assign({}, UNIT_TRIGGERS),
|
|
763
|
+
MAX_POINTS_PER_UNIT: MAX_POINTS_PER_UNIT,
|
|
764
|
+
MAX_MAX_PER_EVENT: MAX_MAX_PER_EVENT,
|
|
765
|
+
MAX_STATUS_LIST: MAX_STATUS_LIST,
|
|
766
|
+
|
|
767
|
+
defineRule: defineRule,
|
|
768
|
+
getRule: getRule,
|
|
769
|
+
listRules: listRules,
|
|
770
|
+
updateRule: updateRule,
|
|
771
|
+
archiveRule: archiveRule,
|
|
772
|
+
evaluateForEvent: evaluateForEvent,
|
|
773
|
+
awardForEvent: awardForEvent,
|
|
774
|
+
metricsForRule: metricsForRule,
|
|
775
|
+
applyBatch: applyBatch,
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
module.exports = {
|
|
780
|
+
create: create,
|
|
781
|
+
TRIGGERS: TRIGGERS,
|
|
782
|
+
UNIT_TRIGGERS: UNIT_TRIGGERS,
|
|
783
|
+
MAX_POINTS_PER_UNIT: MAX_POINTS_PER_UNIT,
|
|
784
|
+
MAX_MAX_PER_EVENT: MAX_MAX_PER_EVENT,
|
|
785
|
+
MAX_STATUS_LIST: MAX_STATUS_LIST,
|
|
786
|
+
};
|
package/lib/split-shipments.js
CHANGED
|
@@ -143,7 +143,13 @@ function _shortText(s, label, max) {
|
|
|
143
143
|
return s;
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
|
|
146
|
+
var _lastTs = 0;
|
|
147
|
+
function _now() {
|
|
148
|
+
var t = Date.now();
|
|
149
|
+
if (t <= _lastTs) { t = _lastTs + 1; }
|
|
150
|
+
_lastTs = t;
|
|
151
|
+
return t;
|
|
152
|
+
}
|
|
147
153
|
|
|
148
154
|
// ---- factory ------------------------------------------------------------
|
|
149
155
|
|
package/package.json
CHANGED