@blamejs/blamejs-shop 0.0.62 → 0.0.64
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/compliance-export.js +614 -0
- package/lib/error-log.js +525 -0
- package/lib/index.js +5 -0
- package/lib/invoice-renderer.js +618 -0
- package/lib/live-chat.js +714 -0
- package/lib/store-credit.js +565 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.0.x
|
|
10
10
|
|
|
11
|
+
- v0.0.64 (2026-05-22) — **Two new primitives: compliance export + live chat.** `complianceExport` is the GDPR / CCPA / LGPD subject-access-request export + deletion lifecycle. `liveChat` is the real-time customer-service queue with operator assignment and per-session messages. **Added:** *`complianceExport` primitive — GDPR / CCPA / LGPD subject-access-request export + deletion lifecycle* — `bShop.complianceExport.create({ query?, customers, order?, orderNotes?, subscriptions?, addresses?, paymentMethods?, supportTickets?, loyalty? })` returns `{ requestExport, requestDeletion, getRequest, listRequests, fulfillRequest, dispatchExport, processDeletion, dismissRequest, auditForCustomer }`. Composes injected per-domain readers via a `forCustomerExport(customer_id)` / `forCustomerDeletion(customer_id, { dry_run })` contract; the primitive owns the request row, the scope/section filtering, and the dry-run-vs-wet posture. Scope enum: `full / orders_only / identity_only`. Jurisdiction enum: `gdpr / ccpa / lgpd / other`. Status FSM: `received / processing / fulfilled / delivered / dismissed`. Migration `0109_compliance_export.sql`. · *`liveChat` primitive — real-time customer-service queue* — `bShop.liveChat.create({ query? })` returns `{ openSession, enqueueSession, assignToOperator, recordMessage, closeSession, getSession, messagesForSession, operatorQueue, waitingQueue, operatorWorkload, markOperatorAvailable, markOperatorAway, cleanupAbandoned }`. Six-state FSM: queued / assigned / active / waiting / closed / abandoned. Visitor session id + email hashed via `namespaceHash` under `live-chat-visitor` / `live-chat-email` namespaces; raw values never persist. Process-local monotonic `_now` so back-to-back operator assignments and message inserts paginate stably. `cleanupAbandoned({ idle_minutes })` reaps idle sessions. Migration `0113_live_chat.sql`.
|
|
12
|
+
|
|
13
|
+
- v0.0.63 (2026-05-22) — **Three new primitives: invoice renderer, store credit, error log.** Three primitives ship in this release. `invoiceRenderer` produces operator + customer-facing formal invoices (HTML for PDF conversion + plain text + sequential invoice numbers). `storeCredit` is the per-customer account-bound credit wallet, distinct from gift cards (no code). `errorLog` is the operator-side HTTP error event log with top-404 / top-5xx / slow-render dashboards. **Added:** *`invoiceRenderer` primitive — formal accounting invoices with sequential numbers* — `bShop.invoiceRenderer.create({ query?, order })` returns `{ nextInvoiceNumber, renderHtml, recordInvoice, invoicesForOrder, getInvoiceByNumber }`. `nextInvoiceNumber({ series? })` returns sequential `INV-YYYY-NNNNNN`-shaped id with per-series counter. `renderHtml({ order_id, locale?, series?, due_days? })` returns the full HTML invoice with operator + customer addresses, line items, tax breakdown, totals, payment terms, due date. All operator + customer input fields HTML-escaped via `b.template.escapeHtml`. `recordInvoice` writes the audit row carrying `html_size` + `sha3_512` digest. Migration `0093_invoice_renderer.sql` (`invoice_renderings` + `invoice_sequence` tables). · *`storeCredit` primitive — per-customer account-bound credit wallet* — `bShop.storeCredit.create({ query? })` returns `{ credit, debit, expire, balance, history, transactionsForOrder, expiringWithin, bulkBalance, cleanupExpired }`. Distinct from `giftCardLedger` — store credit is account-bound, no code. `credit` source enum: `refund / goodwill / promotional / manual / loyalty_redemption`. `debit` refuses overdraft. `balance_after_minor` denormalized per row so current balance is O(1). `cleanupExpired({ now })` scheduler-callable: walks expired rows + adds offsetting expire entries. `expiringWithin({ customer_id, days })` lists balance set to expire so the storefront can prompt the customer. Migration `0094_store_credit.sql`. · *`errorLog` primitive — HTTP error event log with operator dashboards* — `bShop.errorLog.create({ query? })` returns `{ recordError, top404Paths, top5xxErrors, slowRenders, errorById, byStatus, metrics }`. Drop-silent on bad input — hot-path observability sink; throwing here would crash the request that triggered the log entry. Session id always hashed via `namespaceHash('error-log-session', ...)` before write. `top404Paths({ from, to, limit })` and `top5xxErrors({ from, to, limit })` power operator dead-link cleanup + upstream-health dashboards. `slowRenders({ from, to, limit, p99_threshold_ms? })` surfaces requests over the operator-configured p99 budget. `metrics` returns counts + p50 / p95 / p99 response time percentiles. Migration `0100_error_log.sql`.
|
|
14
|
+
|
|
11
15
|
- v0.0.62 (2026-05-22) — **Ten new primitives: plan changes, vendors, loyalty redemption, gift registry, geolocation, stock transfers, SMS dispatcher, email campaigns, storefront dashboards, refund policy.** Ten primitives ship in one release covering subscription proration, multi-vendor marketplace foundation, loyalty redemption catalog, wedding/baby/housewarming registries, country resolution from request hints, audited multi-step stock movements, SMS opt-in / dispatch / STOP-keyword handling, bulk email campaign scheduling, operator dashboard composition, and refund eligibility rules. **Added:** *`planChanges` primitive — subscription plan upgrade / downgrade with proration* — `bShop.planChanges.create({ query?, subscriptions, subscriptionBilling? })` returns `{ proposeChange, executeChange, cancelPendingChange, pendingChangeFor, historyForSubscription, applyScheduledChanges, prorate }`. `proposeChange({ subscription_id, new_plan_id, change_at? })` returns `{ proration_credit_minor, first_charge_minor, currency, effective_at, change_kind, from_plan_id, to_plan_id }`. `change_kind` derives off the clock: `change_at >= current_period_end` → `next_billing_cycle` with zero proration; otherwise → `immediate` with floored `(amount * remainingMs / periodMs)` math on both sides. `executeChange` writes the ledger row, flips `subscriptions.plan_id` when the effective clock is now-or-past or queues for the scheduler. Queues a net proration invoice through `subscriptionBilling.recordInvoice` when injected. Module-level `planChanges.prorate(fromAmount, toAmount, periodStart, periodEnd, effectiveAt)` is exposed for callers that want the pure math without instantiating the factory. Migration `0083_plan_changes.sql`. · *`vendors` primitive — multi-vendor marketplace foundation* — `bShop.vendors.create({ query? })` returns `{ registerVendor, getVendor, vendorBySlug, listVendors, updateVendor, pauseVendor, reinstateVendor, archiveVendor, assignSku, unassignSku, vendorForSku, skusForVendor, recordCommission, payoutsDue }`. Slug is the public PK, immutable post-registration so `vendor_skus` + `vendor_commissions` rows never orphan. `contact_email` hashed via `namespaceHash('vendor-contact-email', lowercased)` — raw email never persists. FSM: `active ↔ paused`, both → `archived` (terminal). `assignSku` enforces single-vendor SKU ownership via `vendor_skus.sku UNIQUE`. `recordCommission` is idempotent on `(vendor_slug, order_id)` so the same multi-vendor `order_id` across different vendors is legal. Commission math is `floor(gross_minor * split_bps / 10000)`. Migration `0084_vendors.sql`. · *`loyaltyRedemption` primitive — customer-facing reward redemption layer* — `bShop.loyaltyRedemption.create({ query?, loyalty, coupons? })` returns `{ defineReward, getReward, listRewards, updateReward, archiveReward, redeemForCustomer, getRedemption, redemptionsForCustomer, markConsumed, cancelRedemption }`. Reward kinds: `discount_percent / discount_amount / free_product / free_shipping`. `redeemForCustomer` debits points via `loyalty.redeem`, mints a single-use coupon via `coupons` if injected. `max_per_customer` cap enforced across active+consumed; cancelled redemptions free a slot. `cancelRedemption` refunds points only when status is `active` at cancel time — points consumed past the FSM state are operator-only via `loyalty.adjust`. Migration `0085_loyalty_redemptions.sql`. · *`giftRegistry` primitive — wedding / baby / housewarming registries* — `bShop.giftRegistry.create({ query?, catalog })` returns `{ createRegistry, addItem, removeItem, getRegistry, getBySlug, listForOwner, searchPublic, purchaseItem, progressFor, closeRegistry, update }`. Privacy enum: `private / unlisted / public`. Owner-facing purchase reads redact `buyer_customer_id` when `reveal_buyer=false`; storage still carries the id for refund/audit. `active → closed` FSM gates all mutation paths. Over-purchase past `quantity_desired` throws `GIFT_REGISTRY_OVER_PURCHASE`. `progressFor(slug)` returns per-item + overall progress percentages. Migration `0086_gift_registry.sql`. · *`geolocation` primitive — country / locale resolution from request hints* — `bShop.geolocation.create({ query? })` returns `{ resolve, defineCountry, getCountry, listCountries, updateCountry, setGeoBlock, blockedRoster }`. `resolve({ cf_country, cf_region?, accept_language?, timezone?, customer_supplied_country? })` returns `{ country, region?, currency, locale, timezone?, geo_blocked, allowed_payment_kinds, allowed_shipping_kinds }`. Pure validators (ISO 3166-1 alpha-2, ISO 4217, BCP-47, IANA timezone shape). RFC 7231 `Accept-Language` q-sorted parser picks the highest-q tag whose primary subtag matches the row default's primary subtag. Customer-supplied country wins only when a row exists; stale values fall through to `cf_country`. Migration `0092_geolocation.sql`. · *`stockTransfers` primitive — audited multi-step stock movement between locations* — `bShop.stockTransfers.create({ query?, inventoryLocations })` returns `{ openTransfer, markShipped, markInTransit, markReceived, reconcile, markException, getTransfer, listOpen, transfersForLocation, discrepanciesFor }`. Six-state FSM: `open → shipped → in_transit → received → reconciled` (happy) or `→ exception` (terminal sad). Composes `inventoryLocations.adjustStock` as the sole owner of `inventory_stock` writes — origin debited at open, destination credited at reconcile (only by the received qty, so short-ships preserve the audit trail of loss). Discrepancy flag computed at reconcile (`quantity_shipped - quantity_received`), surfaces both short-ships (positive) and over-counts (negative). Three tables: `stock_transfers` + `stock_transfer_lines` + append-only `stock_transfer_events` audit log. Migration `0089_stock_transfers.sql`. · *`smsDispatcher` primitive — outbound SMS with opt-in / opt-out / STOP-keyword auto-handling* — `bShop.smsDispatcher.create({ query? })` returns `{ registerProvider, providerForRegion, recordOptIn, recordOptOut, isOptedIn, handleInboundKeyword, enqueue, markDelivered, markFailed, dispatchTick, messagesForCustomer, messageByProviderRef, hashPhone }`. Phone hashed via `namespaceHash('sms-phone', E.164)`. STOP-keyword auto opt-out: any incoming `STOP` / `UNSUBSCRIBE` / `CANCEL` / `END` / `QUIT` / `STOPALL` registers opt-out + sends a single confirmation. Transient-failure retry on `1m / 5m / 30m / 2h / 12h` back-off; terminal after budget. Non-https endpoints refused. Body refusal for emoji-only / control bytes / zero-width / bidi-override. Migration `0088_sms_dispatcher.sql`. · *`emailCampaigns` primitive — operator-scheduled bulk email campaigns* — `bShop.emailCampaigns.create({ query, mailingAudiences, email, emailSuppressions? })` returns `{ defineCampaign, scheduleCampaign, pauseCampaign, resumeCampaign, cancelCampaign, sendNow, dispatchTick, recordEvent, getCampaign, listCampaigns, metricsForCampaign }`. FSM: draft → scheduled → sending → sent, with paused / cancelled off-ramps. Dispatcher drains the audience in pages via `audiences.resolve`, stamps a `delivered` event per successful `mailer.send`, then transitions sending → sent with `recipients_resolved_count` + `sent_count` + `sent_at` populated. Per-recipient send failures are drop-silent so one bad ESP refusal can't stall the campaign — ESP webhook backfill via `recordEvent` records `bounced` / `unsubscribed`. Migration `0087_email_campaigns.sql`. · *`storefrontDashboards` primitive — operator dashboard composition* — `bShop.storefrontDashboards.create({ query?, salesReports?, analytics?, cartAbandonment?, inventoryAlerts? })` returns `{ defineDashboard, getDashboard, listDashboards, updateDashboard, archiveDashboard, cloneDashboard, renderDashboard }`. Layout enum: `grid_2col / grid_3col / single_column / freeform`. Widget kinds: `revenue_chart / top_products / top_customers / inventory_low_stock / cart_abandonment / order_status_funnel / recent_orders / aov_trend / refund_rate`. `renderDashboard({ slug, from, to, locale? })` dispatches each widget to its injected data source; missing sources yield `data: null`; locale falls back via BCP-47 right-trim through the bundled en / es / fr catalogue. `cloneDashboard({ source_slug, new_slug, new_title?, new_owner? })` — clone starts un-archived even if source is archived. Migration `0091_storefront_dashboards.sql`. · *`refundPolicy` primitive — operator-managed refund eligibility rules* — `bShop.refundPolicy.create({ query? })` returns `{ definePolicy, evaluate, getPolicy, listPolicies, updatePolicy, archivePolicy, auditEvaluation, listAudit }`. Walks active policies in `(priority DESC, created_at ASC, slug ASC)`; the first in-scope policy whose preconditions all pass governs. Refund-kind math: `full` and `store_credit_only` cap at `order_total_minor`; `partial` floors `total*bps/10000`; `no_refund` returns `eligible:false`. Restocking fee subtracts from the cap, clamped at zero with `restocking_fee_exceeds_refund` reason. Calendar-day window computed against UTC-midnight boundaries. `auditEvaluation` records every decision for compliance export. Migration `0090_refund_policy.sql`. **Fixed:** *`smsDispatcher` — monotonic `created_at` so back-to-back enqueues paginate stably* — Same-millisecond `enqueue` calls shared a `created_at` value, and the cursor-based pagination on `messagesForCustomer` couldn't tell consecutive rows apart. The primitive now stamps `created_at` with a process-local monotonic clock that increments by 1ms when `Date.now()` returns a stale value.
|
|
12
16
|
|
|
13
17
|
- v0.0.61 (2026-05-22) — **Eleven new primitives: customer import, code minter, storefront forms, cart bulk ops, carrier rates, operator audit log, CMS blocks, gift-card ledger, discount analytics, search facets, dunning.** Eleven primitives in one release. `customerImport` bulk-loads customers from CSV / NDJSON with dedupe by email hash + on_conflict mode. `codeMinter` generates single-use discount codes in bulk over a confusion-resistant alphabet with collision retry. `storefrontForms` is the operator-defined contact / lead-capture / wholesale-application form layer with per-session throttle. `cartBulkOps` is B2B-style cart operations (bulk add, replace, price-list apply, reorder, split). `carrierRates` caches shipping rate quotes per (carrier, service, origin, dest, weight) with TTL. `operatorAuditLog` is the cryptographically-chained tamper-evident audit log for operator-side mutations. `cmsBlocks` is the slot-content CMS for headers, hero copy, footer columns, etc. with markdown render + BCP-47 locale fallback. `giftCardLedger` is the append-only credit/debit/expire ledger on top of `giftcards` with denormalized balance-after column for O(1) balance queries. `discountAnalytics` rolls up coupon impressions + redemptions for operator dashboards. `searchFacets` is the operator-registered facet engine (categorical / numeric_range / boolean) for storefront filter sidebars. `dunning` is the payment-recovery workflow for failed subscription charges. **Added:** *`customerImport` primitive — bulk customer loader with email-hash dedup* — `bShop.customerImport.create({ query?, customers })` returns `{ dryRun, importRows, importFromCsv, importFromNdjson, lastReport, cancelInflight }`. Dedup is by the same `namespaceHash('customer-email', canonical)` digest the `customers` primitive computes — two emails differing only in domain casing collide on a single hash (local-part stays RFC-5321-sensitive). on_conflict modes: `update / skip / error`. CSV-injection refusal happens before the run row opens, so a rejected upload leaves zero rows in both `customers` and `customer_imports`. `cancelInflight` flips status → cancelled and the row-processing loop checks the flag between rows; partial writes persist so the operator re-uploads to fill the gap. Migration `0061_customer_imports.sql`. · *`codeMinter` primitive — bulk single-use discount-code generator* — `bShop.codeMinter.create({ query?, coupons })` returns `{ mintBatch, getBatch, listBatches, codesForBatch, voidBatch, exportBatchCsv, DEFAULT_ALPHABET, STATUSES }`. Default alphabet `23456789ABCDEFGHJKLMNPQRSTUVWXYZ` (32 glyphs skipping confusion-resistant 0 / 1 / I / O). Custom alphabets use rejection sampling against `Math.floor(256/len)*len` to keep the draw uniform. Per-code collision retry budget of 16; bust raises `CODE_MINTER_COLLISION_BUDGET_EXHAUSTED`. The `code_batch_members.coupon_code UNIQUE` constraint is the authoritative cross-batch gate. `voidBatch` archives every member's coupon via `coupons.archive` and flips status → voided. `exportBatchCsv` yields header `coupon_code,minted_at\r\n` then one CRLF row per code, paginated 500 at a time, ordered for byte-stable re-exports. Migration `0075_code_batches.sql`. · *`storefrontForms` primitive — operator-defined contact / lead-capture forms* — `bShop.storefrontForms.create({ query?, notifications?, webhooks? })` returns `{ defineForm, getForm, listForms, updateForm, archiveForm, submit, submissionsForForm, throttleCheck, hashSession }`. Field kinds: `text / email / phone / textarea / select / checkbox / number`. submit_to kinds: `email / webhook`. Per-session throttle bucketed on the SHA3-512 namespace hash of the session id; `throttle=0` bypasses. Persist-then-dispatch flow with `dispatched_at` / `dispatch_error` stamped on the row. Email-field validation routes through `b.guardEmail`. Migration `0076_storefront_forms.sql`. · *`cartBulkOps` primitive — B2B-style bulk cart operations* — `bShop.cartBulkOps.create({ query?, cart, catalog, customers?, lineGrouper? })` returns `{ addLines, replaceLines, clearLines, priceListApply, reorder, splitCart, quoteForCart, priceLists }`. Atomicity via pre-flight validation: every SKU resolves + price-snapshots before any write; bad row throws `lines[N].sku — …` with zero side effects. `priceListApply` resolves slug from explicit input OR `price_list_assignments[customer_id]`; refuses currency mismatch / archived list / unknown slug. `reorder` skip categories surface as `{ sku, qty, reason }` so the storefront renders "couldn't add N items" affordances. `splitCart` requires an operator-supplied `lineGrouper(line, splitBy)` (no built-in vendor/category guesswork); source cart marked `abandoned` on split. Sub-module `priceLists` exposes `{ create, setMember, assign, listMembers }`. Migration `0078_price_lists.sql`. · *`carrierRates` primitive — at-checkout shipping rate cache* — `bShop.carrierRates.create({ query?, cursorSecret? })` returns `{ registerCarrier, recordRateQuote, ratesForShipment, cleanupExpiredQuotes, recentQuotes, metricsForCarrier }`. Carrier enum: `ups / fedex / usps / dhl / flat_rate`. `recordRateQuote` dedup-upserts on `UNIQUE(carrier_slug, service_code, origin_postal, dest_postal, weight_grams)`. `ratesForShipment` returns sorted-by-cost rates from the cache + a synthetic flat-rate fallback when no live rates apply; expired rows + paused carriers excluded. `rate_endpoint` gated via `b.safeUrl` (https-only). `metricsForCarrier({ slug, from, to })` aggregates by (service_code, currency). Migration `0080_carrier_rates.sql`. · *`operatorAuditLog` primitive — cryptographically-chained tamper-evident operator audit log* — `bShop.operatorAuditLog.create({ query? })` returns `{ record, listByActor, listByResource, searchAction, chainHead, verifyChain }`. Each row carries `row_hash = SHA3-512(prev_hash || canonical-json(row-without-hashes))` so the chain is replay-verifiable. `verifyChain()` recomputes hashes left-to-right and flags any tampered row. `occurred_at` is clamped to be strictly monotonic against the chain head so same-millisecond appends still chain deterministically. Actor enum: `operator / system / app`. UA-class enum: `browser / mobile_app / api_client / cli / bot / unknown`. Opaque base64url `(occurred_at, id)` keyset cursor for the list surfaces. Migration `0074_operator_audit_log.sql`. · *`cmsBlocks` primitive — slot-content CMS with BCP-47 locale fallback* — `bShop.cmsBlocks.create({ query?, translations? })` returns `{ defineBlock, setLocalized, getRendered, listBlocks, archiveBlock, update, versionsForBlock }`. Two-table layout (`cms_blocks` PK + `cms_block_localizations` FK CASCADE) so version history per (key, locale) is queryable and `defineBlock` is idempotent without disturbing localizations. `getRendered` resolution order: archived → short-circuit empty; otherwise walk BCP-47 fallback chain (right-to-left subtag strip), pick highest-version row whose publish window covers `now`; finally fall back to `default_body`. Markdown render composes `b.template.escapeHtml` + `b.safeUrl.parse` (https-only allowlist plus /-rooted absolute paths). Migration `0079_cms_blocks.sql`. · *`giftCardLedger` primitive — append-only credit/debit/expire ledger with O(1) balance* — `bShop.giftCardLedger.create({ query?, giftcards? })` returns `{ credit, debit, expire, balance, history, transactionsForOrder, bulkBalance, expiringBalance }`. `balance_after_minor` denormalized per row so current balance is O(1) (ORDER BY occurred_at DESC LIMIT 1). Replay-derivability preserved — SUM(credit) - SUM(debit) - SUM(expire) reconstructs balance and is asserted in the test. Per-card `occurred_at` made strictly monotonic via internal `_resolveOccurredAt` (bumps to `prior + 1` on tie or out-of-order operator-supplied timestamp). `expire` caps at current balance (returns `noop: true` when already drained) so scheduled sweeps degrade gracefully against interim debits. `bulkBalance` returns one row per requested id with `balance_minor: 0` for cards with no ledger rows. `expiringBalance` JOINs against `giftcards.expires_at` (canonical expiry source). Migration `0081_gift_card_ledger.sql`. · *`discountAnalytics` primitive — per-coupon + per-tier-set redemption + revenue-impact aggregates* — `bShop.discountAnalytics.create({ query?, coupons?, quantityDiscounts? })` returns `{ recordImpression, recordRedemption, topCoupons, couponPerformance, tierPerformance, revenueImpact, redemptionFunnel }`. Sessions hashed via `namespaceHash('discount-analytics-session', session_id)` at the boundary. Tier-set redemptions tagged with the `coupon_code = 'tier:<tier_set_id>'` convention so the same table backs both surfaces. Process-local monotonic `_nowMs` so back-to-back impressions in the same wall-clock millisecond chain deterministically. Migration `0073_discount_analytics.sql`. · *`searchFacets` primitive — operator-registered facets for storefront filter sidebars* — `bShop.searchFacets.create({ query?, catalog })` returns `{ defineFacet, getFacets, previewQuery, recordFacetUse, listFacets, updateFacet, archiveFacet }`. Kinds: `categorical / numeric_range / boolean`. `getFacets` computes per-facet `{ key, label, kind, options: [{ value, label, count, selected }] }` using the standard "leave focal facet unconstrained" rule so a selected vendor still shows the other vendors' counts. `previewQuery({ filters, sample? })` returns `{ total, sample }` of products matching the candidate filter set. Refuses bad key shape, bad kind, missing/illegal buckets, display_limit on non-categorical, duplicate bucket labels, inverted bucket bounds, key collisions. Session id namespace-hashed before write on `recordFacetUse`. Migration `0082_search_facets.sql`. · *`dunning` primitive — payment recovery workflow for failed subscription charges* — `bShop.dunning.create({ query, subscriptionBilling, email, notifications })` returns `{ defineDunningPolicy, enrollInvoice, tickDunning, statusForInvoice, historyForInvoice, unsetEnrollment, metricsForPolicy }`. Actions: `retry_charge / send_reminder / pause_subscription / cancel_subscription`. Enrollment FSM: `active / recovered / cancelled / abandoned`. `tickDunning({ now })` advances state with composition into email / notifications / billing dependencies — handle methods are surface-optional so a partial wiring still ticks. `metricsForPolicy` aggregates recovery rate + cancellation rate per policy. Migration `0077_dunning.sql`. **Fixed:** *`discountAnalytics` — monotonic `occurred_at` so impressions in the same millisecond sort deterministically* — `Date.now()` on Windows resolves at ~15ms; three impressions inserted in the same tick shared an `occurred_at` and UUID v7's intra-ms randomness scrambled the secondary `id` ordering. The primitive now stamps `occurred_at` with a process-local monotonic clock that increments by 1ms when `Date.now()` returns a stale value.
|
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.complianceExport
|
|
4
|
+
* @title Subject-access-request export + deletion for GDPR / CCPA /
|
|
5
|
+
* LGPD (and operator-declared "other" jurisdictions)
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* A customer (or operator acting on the customer's behalf) files
|
|
9
|
+
* a privacy request: "give me a copy of everything you hold on
|
|
10
|
+
* me" (export) or "erase everything you hold on me" (deletion).
|
|
11
|
+
* The primitive owns the request lifecycle + composes per-domain
|
|
12
|
+
* readers (customers, order, order-notes, subscriptions,
|
|
13
|
+
* addresses, payment-methods, support-tickets, loyalty) to
|
|
14
|
+
* assemble the bundle. Delivery (email / signed URL / secure
|
|
15
|
+
* download portal) is the operator worker's concern — this
|
|
16
|
+
* primitive returns the bundle as structured JSON and stamps
|
|
17
|
+
* the lifecycle row when the worker confirms dispatch.
|
|
18
|
+
*
|
|
19
|
+
* Distinct from `orderExport`. That primitive answers "operator
|
|
20
|
+
* dump of orders in date range D for accounting." This one
|
|
21
|
+
* answers "customer C invoked their right under law L."
|
|
22
|
+
*
|
|
23
|
+
* Surface:
|
|
24
|
+
*
|
|
25
|
+
* - requestExport({ customer_id, requested_by, jurisdiction,
|
|
26
|
+
* scope: 'full' | 'orders_only' | 'identity_only' })
|
|
27
|
+
* Files an export request. Returns the persisted row.
|
|
28
|
+
*
|
|
29
|
+
* - requestDeletion({ customer_id, requested_by, reason })
|
|
30
|
+
* Files a deletion request. `reason` is operator-authored
|
|
31
|
+
* prose (capped) — most jurisdictions require a stated basis.
|
|
32
|
+
*
|
|
33
|
+
* - getRequest(request_id) / listRequests({ status?, jurisdiction?, limit? })
|
|
34
|
+
*
|
|
35
|
+
* - fulfillRequest({ request_id })
|
|
36
|
+
* For an export, walks every injected reader and assembles
|
|
37
|
+
* the bundle JSON. The status flips received -> processing
|
|
38
|
+
* -> fulfilled and the row's `fulfilled_at` stamps. Returns
|
|
39
|
+
* the bundle.
|
|
40
|
+
*
|
|
41
|
+
* - dispatchExport({ request_id, delivery_method, delivery_address })
|
|
42
|
+
* Stamps fulfilled -> delivered with the channel + address.
|
|
43
|
+
* The operator worker calls this after handoff.
|
|
44
|
+
*
|
|
45
|
+
* - processDeletion({ request_id, dry_run? })
|
|
46
|
+
* For a deletion, returns the affected-row counts per table.
|
|
47
|
+
* `dry_run: true` reports the counts without executing the
|
|
48
|
+
* deletes — the operator dashboard previews the blast radius
|
|
49
|
+
* before the customer's irreversible erasure call. `dry_run:
|
|
50
|
+
* false` (the default) executes the deletes and flips
|
|
51
|
+
* received/processing -> fulfilled.
|
|
52
|
+
*
|
|
53
|
+
* - dismissRequest({ request_id, dismiss_reason })
|
|
54
|
+
* Closes a request without fulfilling (identity verification
|
|
55
|
+
* failed, jurisdiction out of scope, duplicate).
|
|
56
|
+
*
|
|
57
|
+
* - auditForCustomer(customer_id)
|
|
58
|
+
* Full history of export + deletion requests for a customer.
|
|
59
|
+
* The compliance receipt the operator presents when a
|
|
60
|
+
* supervisory authority asks "what did you do when customer C
|
|
61
|
+
* filed their SAR?"
|
|
62
|
+
*
|
|
63
|
+
* Injected-reader contract:
|
|
64
|
+
*
|
|
65
|
+
* Each injected primitive (customers / order / subscriptions /
|
|
66
|
+
* addresses / paymentMethods / supportTickets / loyalty /
|
|
67
|
+
* orderNotes) must expose either a `forCustomerExport(customer_id)`
|
|
68
|
+
* method that returns an array (or object) of redaction-clean
|
|
69
|
+
* data, or a `forCustomerDeletion(customer_id)` method that
|
|
70
|
+
* executes the per-domain deletion + returns
|
|
71
|
+
* `{ table, deleted: <integer> }`. When neither method is present
|
|
72
|
+
* on an injected primitive, the section is skipped (the bundle
|
|
73
|
+
* reports the section as absent rather than throwing) — this lets
|
|
74
|
+
* operators wire compliance-export against partial domain
|
|
75
|
+
* coverage during incremental rollout.
|
|
76
|
+
*
|
|
77
|
+
* Scope semantics on export:
|
|
78
|
+
*
|
|
79
|
+
* - `full` — every injected reader contributes.
|
|
80
|
+
* - `orders_only` — only `order` + `orderNotes` contribute;
|
|
81
|
+
* identity / loyalty / subscriptions /
|
|
82
|
+
* addresses / payment methods / support
|
|
83
|
+
* tickets are omitted.
|
|
84
|
+
* - `identity_only` — only `customers` + `addresses` contribute;
|
|
85
|
+
* everything else omitted. The "I just want
|
|
86
|
+
* to see what profile data you hold"
|
|
87
|
+
* variant.
|
|
88
|
+
*
|
|
89
|
+
* Composition:
|
|
90
|
+
* - b.uuid.v7 — request row PK
|
|
91
|
+
* - b.guardUuid — customer_id / request_id strict UUID
|
|
92
|
+
*
|
|
93
|
+
* @primitive complianceExport
|
|
94
|
+
* @related customers, order, orderNotes, subscriptions, addresses,
|
|
95
|
+
* paymentMethods, supportTickets, loyalty, orderExport
|
|
96
|
+
*/
|
|
97
|
+
|
|
98
|
+
var bShop;
|
|
99
|
+
function _b() {
|
|
100
|
+
if (!bShop) bShop = require("./index");
|
|
101
|
+
return bShop.framework;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---- constants ----------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
var REQUEST_KINDS = Object.freeze(["export", "deletion"]);
|
|
107
|
+
var JURISDICTIONS = Object.freeze(["gdpr", "ccpa", "lgpd", "other"]);
|
|
108
|
+
var SCOPES = Object.freeze(["full", "orders_only", "identity_only"]);
|
|
109
|
+
var STATUSES = Object.freeze([
|
|
110
|
+
"received", "processing", "fulfilled", "delivered", "dismissed",
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
var MAX_REASON_LEN = 4000;
|
|
114
|
+
var MAX_DISMISS_REASON_LEN = 4000;
|
|
115
|
+
var MAX_DELIVERY_METHOD_LEN = 64;
|
|
116
|
+
var MAX_DELIVERY_ADDR_LEN = 1000;
|
|
117
|
+
var MAX_REQUESTED_BY_LEN = 200;
|
|
118
|
+
var MAX_LIST_LIMIT = 200;
|
|
119
|
+
var DEFAULT_LIST_LIMIT = 50;
|
|
120
|
+
|
|
121
|
+
// Operator-authored prose lands in reason / dismiss_reason and
|
|
122
|
+
// replays into compliance review screens. Same control-byte +
|
|
123
|
+
// zero-width refusal posture the sibling primitives carry.
|
|
124
|
+
var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
|
|
125
|
+
var ZERO_WIDTH_RE = new RegExp(
|
|
126
|
+
"[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
// Scope -> which injected readers contribute on export. Keeps the
|
|
130
|
+
// fulfillRequest logic a single lookup instead of nested if-else.
|
|
131
|
+
var SCOPE_SECTIONS = Object.freeze({
|
|
132
|
+
full: Object.freeze([
|
|
133
|
+
"customers", "addresses", "order", "orderNotes",
|
|
134
|
+
"subscriptions", "paymentMethods", "supportTickets", "loyalty",
|
|
135
|
+
]),
|
|
136
|
+
orders_only: Object.freeze(["order", "orderNotes"]),
|
|
137
|
+
identity_only: Object.freeze(["customers", "addresses"]),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ---- validators ---------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
function _uuid(s, label) {
|
|
143
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
144
|
+
catch (e) {
|
|
145
|
+
throw new TypeError("complianceExport: " + label + " — " + (e && e.message || "invalid UUID"));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function _kind(s) {
|
|
150
|
+
if (typeof s !== "string" || REQUEST_KINDS.indexOf(s) === -1) {
|
|
151
|
+
throw new TypeError("complianceExport: request_kind must be one of " + REQUEST_KINDS.join(", "));
|
|
152
|
+
}
|
|
153
|
+
return s;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function _jurisdiction(s) {
|
|
157
|
+
if (typeof s !== "string" || JURISDICTIONS.indexOf(s) === -1) {
|
|
158
|
+
throw new TypeError("complianceExport: jurisdiction must be one of " + JURISDICTIONS.join(", "));
|
|
159
|
+
}
|
|
160
|
+
return s;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function _scope(s) {
|
|
164
|
+
if (typeof s !== "string" || SCOPES.indexOf(s) === -1) {
|
|
165
|
+
throw new TypeError("complianceExport: scope must be one of " + SCOPES.join(", "));
|
|
166
|
+
}
|
|
167
|
+
return s;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function _status(s, label) {
|
|
171
|
+
if (typeof s !== "string" || STATUSES.indexOf(s) === -1) {
|
|
172
|
+
throw new TypeError("complianceExport: " + label + " must be one of " + STATUSES.join(", "));
|
|
173
|
+
}
|
|
174
|
+
return s;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function _requestedBy(s) {
|
|
178
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_REQUESTED_BY_LEN) {
|
|
179
|
+
throw new TypeError("complianceExport: requested_by must be a non-empty string <= " + MAX_REQUESTED_BY_LEN + " chars");
|
|
180
|
+
}
|
|
181
|
+
if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
|
|
182
|
+
throw new TypeError("complianceExport: requested_by must not contain control bytes or zero-width characters");
|
|
183
|
+
}
|
|
184
|
+
return s;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function _prose(s, label, maxLen) {
|
|
188
|
+
if (typeof s !== "string" || !s.length || s.length > maxLen) {
|
|
189
|
+
throw new TypeError("complianceExport: " + label + " must be a non-empty string <= " + maxLen + " chars");
|
|
190
|
+
}
|
|
191
|
+
if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
|
|
192
|
+
throw new TypeError("complianceExport: " + label + " must not contain control bytes or zero-width characters");
|
|
193
|
+
}
|
|
194
|
+
return s;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function _deliveryMethod(s) {
|
|
198
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_DELIVERY_METHOD_LEN) {
|
|
199
|
+
throw new TypeError("complianceExport: delivery_method must be a non-empty string <= " + MAX_DELIVERY_METHOD_LEN + " chars");
|
|
200
|
+
}
|
|
201
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(s)) {
|
|
202
|
+
throw new TypeError("complianceExport: delivery_method must match /^[a-z0-9][a-z0-9_-]*$/");
|
|
203
|
+
}
|
|
204
|
+
return s;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function _deliveryAddress(s) {
|
|
208
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_DELIVERY_ADDR_LEN) {
|
|
209
|
+
throw new TypeError("complianceExport: delivery_address must be a non-empty string <= " + MAX_DELIVERY_ADDR_LEN + " chars");
|
|
210
|
+
}
|
|
211
|
+
if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
|
|
212
|
+
throw new TypeError("complianceExport: delivery_address must not contain control bytes or zero-width characters");
|
|
213
|
+
}
|
|
214
|
+
return s;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function _limit(n) {
|
|
218
|
+
if (n == null) return DEFAULT_LIST_LIMIT;
|
|
219
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
|
|
220
|
+
throw new TypeError("complianceExport: limit must be an integer in [1, " + MAX_LIST_LIMIT + "]");
|
|
221
|
+
}
|
|
222
|
+
return n;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function _now() { return Date.now(); }
|
|
226
|
+
|
|
227
|
+
// ---- row hydration ------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
function _hydrate(r) {
|
|
230
|
+
if (!r) return null;
|
|
231
|
+
return {
|
|
232
|
+
id: r.id,
|
|
233
|
+
customer_id: r.customer_id,
|
|
234
|
+
request_kind: r.request_kind,
|
|
235
|
+
jurisdiction: r.jurisdiction,
|
|
236
|
+
scope: r.scope == null ? null : r.scope,
|
|
237
|
+
status: r.status,
|
|
238
|
+
requested_by: r.requested_by,
|
|
239
|
+
requested_at: Number(r.requested_at),
|
|
240
|
+
fulfilled_at: r.fulfilled_at == null ? null : Number(r.fulfilled_at),
|
|
241
|
+
delivered_at: r.delivered_at == null ? null : Number(r.delivered_at),
|
|
242
|
+
dismiss_reason: r.dismiss_reason == null ? null : r.dismiss_reason,
|
|
243
|
+
delivery_method: r.delivery_method == null ? null : r.delivery_method,
|
|
244
|
+
delivery_address: r.delivery_address == null ? null : r.delivery_address,
|
|
245
|
+
reason: r.reason == null ? null : r.reason,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---- factory ------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
function create(opts) {
|
|
252
|
+
opts = opts || {};
|
|
253
|
+
var query = opts.query;
|
|
254
|
+
if (!query) {
|
|
255
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Injected readers — every one is optional. The bundle assembler
|
|
259
|
+
// skips a section whose reader isn't wired; the deletion executor
|
|
260
|
+
// skips a domain whose deletion-handler isn't wired. This lets an
|
|
261
|
+
// operator stand the primitive up against partial domain coverage
|
|
262
|
+
// during an incremental compliance rollout — the law-firm review
|
|
263
|
+
// gate is "did you read what you have access to," not "did you
|
|
264
|
+
// wire every primitive blamejs.shop ships."
|
|
265
|
+
var injectedReaders = {
|
|
266
|
+
customers: opts.customers || null,
|
|
267
|
+
order: opts.order || null,
|
|
268
|
+
orderNotes: opts.orderNotes || null,
|
|
269
|
+
subscriptions: opts.subscriptions || null,
|
|
270
|
+
addresses: opts.addresses || null,
|
|
271
|
+
paymentMethods: opts.paymentMethods || null,
|
|
272
|
+
supportTickets: opts.supportTickets || null,
|
|
273
|
+
loyalty: opts.loyalty || null,
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
// ---- requestExport -------------------------------------------------
|
|
277
|
+
|
|
278
|
+
async function requestExport(input) {
|
|
279
|
+
if (!input || typeof input !== "object") {
|
|
280
|
+
throw new TypeError("complianceExport.requestExport: input object required");
|
|
281
|
+
}
|
|
282
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
283
|
+
var requestedBy = _requestedBy(input.requested_by);
|
|
284
|
+
var jurisdiction = _jurisdiction(input.jurisdiction);
|
|
285
|
+
var scope = _scope(input.scope);
|
|
286
|
+
|
|
287
|
+
var id = _b().uuid.v7();
|
|
288
|
+
var ts = _now();
|
|
289
|
+
await query(
|
|
290
|
+
"INSERT INTO compliance_requests " +
|
|
291
|
+
"(id, customer_id, request_kind, jurisdiction, scope, status, " +
|
|
292
|
+
" requested_by, requested_at) " +
|
|
293
|
+
"VALUES (?1, ?2, 'export', ?3, ?4, 'received', ?5, ?6)",
|
|
294
|
+
[id, customerId, jurisdiction, scope, requestedBy, ts],
|
|
295
|
+
);
|
|
296
|
+
return await getRequest(id);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---- requestDeletion -----------------------------------------------
|
|
300
|
+
|
|
301
|
+
async function requestDeletion(input) {
|
|
302
|
+
if (!input || typeof input !== "object") {
|
|
303
|
+
throw new TypeError("complianceExport.requestDeletion: input object required");
|
|
304
|
+
}
|
|
305
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
306
|
+
var requestedBy = _requestedBy(input.requested_by);
|
|
307
|
+
var jurisdiction = _jurisdiction(input.jurisdiction);
|
|
308
|
+
var reason = _prose(input.reason, "reason", MAX_REASON_LEN);
|
|
309
|
+
|
|
310
|
+
var id = _b().uuid.v7();
|
|
311
|
+
var ts = _now();
|
|
312
|
+
await query(
|
|
313
|
+
"INSERT INTO compliance_requests " +
|
|
314
|
+
"(id, customer_id, request_kind, jurisdiction, scope, status, " +
|
|
315
|
+
" requested_by, requested_at, reason) " +
|
|
316
|
+
"VALUES (?1, ?2, 'deletion', ?3, NULL, 'received', ?4, ?5, ?6)",
|
|
317
|
+
[id, customerId, jurisdiction, requestedBy, ts, reason],
|
|
318
|
+
);
|
|
319
|
+
return await getRequest(id);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ---- getRequest / listRequests -------------------------------------
|
|
323
|
+
|
|
324
|
+
async function getRequest(requestId) {
|
|
325
|
+
_uuid(requestId, "request_id");
|
|
326
|
+
var r = (await query(
|
|
327
|
+
"SELECT * FROM compliance_requests WHERE id = ?1 LIMIT 1",
|
|
328
|
+
[requestId],
|
|
329
|
+
)).rows[0];
|
|
330
|
+
return _hydrate(r);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function listRequests(listOpts) {
|
|
334
|
+
listOpts = listOpts || {};
|
|
335
|
+
var status = null;
|
|
336
|
+
if (listOpts.status != null) status = _status(listOpts.status, "status filter");
|
|
337
|
+
var jurisdiction = null;
|
|
338
|
+
if (listOpts.jurisdiction != null) jurisdiction = _jurisdiction(listOpts.jurisdiction);
|
|
339
|
+
var limit = _limit(listOpts.limit);
|
|
340
|
+
|
|
341
|
+
var sql = "SELECT * FROM compliance_requests";
|
|
342
|
+
var clauses = [];
|
|
343
|
+
var params = [];
|
|
344
|
+
var i = 1;
|
|
345
|
+
if (status != null) { clauses.push("status = ?" + i); params.push(status); i += 1; }
|
|
346
|
+
if (jurisdiction != null) { clauses.push("jurisdiction = ?" + i); params.push(jurisdiction); i += 1; }
|
|
347
|
+
if (clauses.length) sql += " WHERE " + clauses.join(" AND ");
|
|
348
|
+
sql += " ORDER BY requested_at DESC, id DESC LIMIT ?" + i;
|
|
349
|
+
params.push(limit);
|
|
350
|
+
|
|
351
|
+
var rows = (await query(sql, params)).rows;
|
|
352
|
+
var out = [];
|
|
353
|
+
for (var j = 0; j < rows.length; j += 1) out.push(_hydrate(rows[j]));
|
|
354
|
+
return out;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---- fulfillRequest (export) ---------------------------------------
|
|
358
|
+
|
|
359
|
+
// Walks the scope's section list, calling each injected reader's
|
|
360
|
+
// `forCustomerExport(customer_id)` method. A reader that's not
|
|
361
|
+
// injected, or doesn't implement the method, is reported in the
|
|
362
|
+
// bundle's `sections_absent` array — the consumer can see exactly
|
|
363
|
+
// which domains were available at fulfillment time so a downstream
|
|
364
|
+
// audit knows the bundle isn't surreptitiously incomplete.
|
|
365
|
+
async function fulfillRequest(input) {
|
|
366
|
+
if (!input || typeof input !== "object") {
|
|
367
|
+
throw new TypeError("complianceExport.fulfillRequest: input object required");
|
|
368
|
+
}
|
|
369
|
+
var requestId = _uuid(input.request_id, "request_id");
|
|
370
|
+
var row = await getRequest(requestId);
|
|
371
|
+
if (!row) {
|
|
372
|
+
throw new TypeError("complianceExport.fulfillRequest: request " + JSON.stringify(requestId) + " not found");
|
|
373
|
+
}
|
|
374
|
+
if (row.request_kind !== "export") {
|
|
375
|
+
throw new TypeError("complianceExport.fulfillRequest: request " + JSON.stringify(requestId) +
|
|
376
|
+
" is " + JSON.stringify(row.request_kind) + " — use processDeletion for deletion requests");
|
|
377
|
+
}
|
|
378
|
+
if (row.status !== "received" && row.status !== "processing") {
|
|
379
|
+
throw new TypeError("complianceExport.fulfillRequest: request " + JSON.stringify(requestId) +
|
|
380
|
+
" is in status " + JSON.stringify(row.status) + " — fulfillment requires received or processing");
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Flip received -> processing first so a concurrent caller can
|
|
384
|
+
// see the fulfillment is in flight. We don't gate on a CAS here
|
|
385
|
+
// (the test in-memory adapter doesn't expose one) — the
|
|
386
|
+
// operator's external queue is the single-flight coordinator.
|
|
387
|
+
if (row.status === "received") {
|
|
388
|
+
await query(
|
|
389
|
+
"UPDATE compliance_requests SET status = 'processing' WHERE id = ?1",
|
|
390
|
+
[requestId],
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
var sections = SCOPE_SECTIONS[row.scope] || SCOPE_SECTIONS.full;
|
|
395
|
+
var bundle = {};
|
|
396
|
+
var sectionsPresent = [];
|
|
397
|
+
var sectionsAbsent = [];
|
|
398
|
+
|
|
399
|
+
for (var s = 0; s < sections.length; s += 1) {
|
|
400
|
+
var sectionName = sections[s];
|
|
401
|
+
var reader = injectedReaders[sectionName];
|
|
402
|
+
if (!reader || typeof reader.forCustomerExport !== "function") {
|
|
403
|
+
sectionsAbsent.push(sectionName);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
// The reader returns whatever shape it owns (array of rows,
|
|
407
|
+
// single object, nested structure). The bundle assembler
|
|
408
|
+
// doesn't reshape it — the per-domain primitive is the
|
|
409
|
+
// authoritative author of its own export shape.
|
|
410
|
+
var section = await reader.forCustomerExport(row.customer_id);
|
|
411
|
+
bundle[sectionName] = section == null ? null : section;
|
|
412
|
+
sectionsPresent.push(sectionName);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
var fulfilledAt = _now();
|
|
416
|
+
await query(
|
|
417
|
+
"UPDATE compliance_requests SET status = 'fulfilled', fulfilled_at = ?1 WHERE id = ?2",
|
|
418
|
+
[fulfilledAt, requestId],
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
return {
|
|
422
|
+
request_id: requestId,
|
|
423
|
+
customer_id: row.customer_id,
|
|
424
|
+
jurisdiction: row.jurisdiction,
|
|
425
|
+
scope: row.scope,
|
|
426
|
+
fulfilled_at: fulfilledAt,
|
|
427
|
+
sections_present: sectionsPresent,
|
|
428
|
+
sections_absent: sectionsAbsent,
|
|
429
|
+
data: bundle,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ---- dispatchExport ------------------------------------------------
|
|
434
|
+
|
|
435
|
+
async function dispatchExport(input) {
|
|
436
|
+
if (!input || typeof input !== "object") {
|
|
437
|
+
throw new TypeError("complianceExport.dispatchExport: input object required");
|
|
438
|
+
}
|
|
439
|
+
var requestId = _uuid(input.request_id, "request_id");
|
|
440
|
+
var deliveryMethod = _deliveryMethod(input.delivery_method);
|
|
441
|
+
var deliveryAddress = _deliveryAddress(input.delivery_address);
|
|
442
|
+
|
|
443
|
+
var row = await getRequest(requestId);
|
|
444
|
+
if (!row) {
|
|
445
|
+
throw new TypeError("complianceExport.dispatchExport: request " + JSON.stringify(requestId) + " not found");
|
|
446
|
+
}
|
|
447
|
+
if (row.request_kind !== "export") {
|
|
448
|
+
throw new TypeError("complianceExport.dispatchExport: request " + JSON.stringify(requestId) +
|
|
449
|
+
" is " + JSON.stringify(row.request_kind) + " — only export requests can be dispatched");
|
|
450
|
+
}
|
|
451
|
+
if (row.status !== "fulfilled") {
|
|
452
|
+
throw new TypeError("complianceExport.dispatchExport: request " + JSON.stringify(requestId) +
|
|
453
|
+
" is in status " + JSON.stringify(row.status) + " — dispatch requires fulfilled");
|
|
454
|
+
}
|
|
455
|
+
var ts = _now();
|
|
456
|
+
await query(
|
|
457
|
+
"UPDATE compliance_requests SET status = 'delivered', delivered_at = ?1, " +
|
|
458
|
+
"delivery_method = ?2, delivery_address = ?3 WHERE id = ?4",
|
|
459
|
+
[ts, deliveryMethod, deliveryAddress, requestId],
|
|
460
|
+
);
|
|
461
|
+
return await getRequest(requestId);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ---- processDeletion -----------------------------------------------
|
|
465
|
+
|
|
466
|
+
// Walks every injected reader that exposes `forCustomerDeletion`.
|
|
467
|
+
// Each handler returns `{ table, deleted: <integer> }` describing
|
|
468
|
+
// the per-domain effect. `dry_run: true` (operator preview) calls
|
|
469
|
+
// each reader's `forCustomerDeletionPreview(customer_id)` method
|
|
470
|
+
// if present — otherwise the handler is asked to count without
|
|
471
|
+
// deleting via a `forCustomerDeletion(customer_id, { dry_run: true })`
|
|
472
|
+
// hint. The primitive's contract: a reader that supports deletion
|
|
473
|
+
// MUST honor `dry_run` (no side effects when set) — refusing is the
|
|
474
|
+
// primitive's only safety net against an operator who clicked
|
|
475
|
+
// "preview" and got an irreversible erasure.
|
|
476
|
+
async function processDeletion(input) {
|
|
477
|
+
if (!input || typeof input !== "object") {
|
|
478
|
+
throw new TypeError("complianceExport.processDeletion: input object required");
|
|
479
|
+
}
|
|
480
|
+
var requestId = _uuid(input.request_id, "request_id");
|
|
481
|
+
var dryRun = false;
|
|
482
|
+
if (input.dry_run != null) {
|
|
483
|
+
if (typeof input.dry_run !== "boolean") {
|
|
484
|
+
throw new TypeError("complianceExport.processDeletion: dry_run must be a boolean when provided");
|
|
485
|
+
}
|
|
486
|
+
dryRun = input.dry_run;
|
|
487
|
+
}
|
|
488
|
+
var row = await getRequest(requestId);
|
|
489
|
+
if (!row) {
|
|
490
|
+
throw new TypeError("complianceExport.processDeletion: request " + JSON.stringify(requestId) + " not found");
|
|
491
|
+
}
|
|
492
|
+
if (row.request_kind !== "deletion") {
|
|
493
|
+
throw new TypeError("complianceExport.processDeletion: request " + JSON.stringify(requestId) +
|
|
494
|
+
" is " + JSON.stringify(row.request_kind) + " — use fulfillRequest for export requests");
|
|
495
|
+
}
|
|
496
|
+
if (row.status !== "received" && row.status !== "processing") {
|
|
497
|
+
throw new TypeError("complianceExport.processDeletion: request " + JSON.stringify(requestId) +
|
|
498
|
+
" is in status " + JSON.stringify(row.status) + " — deletion requires received or processing");
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Flip received -> processing for the wet-run path; dry-runs
|
|
502
|
+
// never mutate the lifecycle row (they're preview-only).
|
|
503
|
+
if (!dryRun && row.status === "received") {
|
|
504
|
+
await query(
|
|
505
|
+
"UPDATE compliance_requests SET status = 'processing' WHERE id = ?1",
|
|
506
|
+
[requestId],
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
var domainOrder = [
|
|
511
|
+
"supportTickets", "orderNotes", "order", "subscriptions",
|
|
512
|
+
"paymentMethods", "loyalty", "addresses", "customers",
|
|
513
|
+
];
|
|
514
|
+
var perDomain = [];
|
|
515
|
+
var domainsAbsent = [];
|
|
516
|
+
|
|
517
|
+
for (var i = 0; i < domainOrder.length; i += 1) {
|
|
518
|
+
var name = domainOrder[i];
|
|
519
|
+
var reader = injectedReaders[name];
|
|
520
|
+
if (!reader || typeof reader.forCustomerDeletion !== "function") {
|
|
521
|
+
domainsAbsent.push(name);
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
var effect = await reader.forCustomerDeletion(row.customer_id, { dry_run: dryRun });
|
|
525
|
+
if (!effect || typeof effect !== "object") {
|
|
526
|
+
throw new TypeError("complianceExport.processDeletion: reader " + JSON.stringify(name) +
|
|
527
|
+
".forCustomerDeletion returned non-object — must return { table, deleted }");
|
|
528
|
+
}
|
|
529
|
+
perDomain.push({
|
|
530
|
+
domain: name,
|
|
531
|
+
table: effect.table == null ? name : effect.table,
|
|
532
|
+
deleted: effect.deleted == null ? 0 : Number(effect.deleted),
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (!dryRun) {
|
|
537
|
+
var ts = _now();
|
|
538
|
+
await query(
|
|
539
|
+
"UPDATE compliance_requests SET status = 'fulfilled', fulfilled_at = ?1 WHERE id = ?2",
|
|
540
|
+
[ts, requestId],
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return {
|
|
545
|
+
request_id: requestId,
|
|
546
|
+
customer_id: row.customer_id,
|
|
547
|
+
dry_run: dryRun,
|
|
548
|
+
domains: perDomain,
|
|
549
|
+
domains_absent: domainsAbsent,
|
|
550
|
+
total_affected: perDomain.reduce(function (acc, d) { return acc + d.deleted; }, 0),
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// ---- dismissRequest ------------------------------------------------
|
|
555
|
+
|
|
556
|
+
async function dismissRequest(input) {
|
|
557
|
+
if (!input || typeof input !== "object") {
|
|
558
|
+
throw new TypeError("complianceExport.dismissRequest: input object required");
|
|
559
|
+
}
|
|
560
|
+
var requestId = _uuid(input.request_id, "request_id");
|
|
561
|
+
var dismissReason = _prose(input.dismiss_reason, "dismiss_reason", MAX_DISMISS_REASON_LEN);
|
|
562
|
+
var row = await getRequest(requestId);
|
|
563
|
+
if (!row) {
|
|
564
|
+
throw new TypeError("complianceExport.dismissRequest: request " + JSON.stringify(requestId) + " not found");
|
|
565
|
+
}
|
|
566
|
+
// Refuse dismiss of an already-delivered export — operators
|
|
567
|
+
// wanting to "retract" a delivered bundle file a separate
|
|
568
|
+
// incident; dismiss is for not-yet-fulfilled flows.
|
|
569
|
+
if (row.status === "delivered" || row.status === "dismissed") {
|
|
570
|
+
throw new TypeError("complianceExport.dismissRequest: request " + JSON.stringify(requestId) +
|
|
571
|
+
" is in terminal status " + JSON.stringify(row.status) + " — dismiss refused");
|
|
572
|
+
}
|
|
573
|
+
await query(
|
|
574
|
+
"UPDATE compliance_requests SET status = 'dismissed', dismiss_reason = ?1 WHERE id = ?2",
|
|
575
|
+
[dismissReason, requestId],
|
|
576
|
+
);
|
|
577
|
+
return await getRequest(requestId);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ---- auditForCustomer ----------------------------------------------
|
|
581
|
+
|
|
582
|
+
async function auditForCustomer(customerId) {
|
|
583
|
+
var cid = _uuid(customerId, "customer_id");
|
|
584
|
+
var rows = (await query(
|
|
585
|
+
"SELECT * FROM compliance_requests WHERE customer_id = ?1 " +
|
|
586
|
+
"ORDER BY requested_at DESC, id DESC LIMIT ?2",
|
|
587
|
+
[cid, MAX_LIST_LIMIT],
|
|
588
|
+
)).rows;
|
|
589
|
+
var out = [];
|
|
590
|
+
for (var i = 0; i < rows.length; i += 1) out.push(_hydrate(rows[i]));
|
|
591
|
+
return out;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return {
|
|
595
|
+
requestExport: requestExport,
|
|
596
|
+
requestDeletion: requestDeletion,
|
|
597
|
+
getRequest: getRequest,
|
|
598
|
+
listRequests: listRequests,
|
|
599
|
+
fulfillRequest: fulfillRequest,
|
|
600
|
+
dispatchExport: dispatchExport,
|
|
601
|
+
processDeletion: processDeletion,
|
|
602
|
+
dismissRequest: dismissRequest,
|
|
603
|
+
auditForCustomer: auditForCustomer,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
module.exports = {
|
|
608
|
+
create: create,
|
|
609
|
+
REQUEST_KINDS: REQUEST_KINDS,
|
|
610
|
+
JURISDICTIONS: JURISDICTIONS,
|
|
611
|
+
SCOPES: SCOPES,
|
|
612
|
+
STATUSES: STATUSES,
|
|
613
|
+
SCOPE_SECTIONS: SCOPE_SECTIONS,
|
|
614
|
+
};
|