@blamejs/blamejs-shop 0.0.72 → 0.0.75
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/lib/announcement-bar.js +753 -0
- package/lib/banner-ab-tests.js +806 -0
- package/lib/bin-locations.js +791 -0
- package/lib/blog-articles.js +1173 -0
- package/lib/carrier-accounts.js +805 -0
- package/lib/cart-recovery.js +1133 -0
- package/lib/category-navigation.js +934 -0
- package/lib/consent-ledger.js +539 -0
- package/lib/customer-impersonation.js +743 -0
- package/lib/customer-merge.js +879 -0
- package/lib/demand-forecast.js +1121 -0
- package/lib/dispute-resolution.js +886 -0
- package/lib/email-ab-tests.js +918 -0
- package/lib/email-engagement-score.js +649 -0
- package/lib/event-log.js +713 -0
- package/lib/fulfillment-sla.js +791 -0
- package/lib/index.js +41 -0
- package/lib/inventory-audits.js +852 -0
- package/lib/line-gift-wrap.js +430 -0
- package/lib/marketing-budget.js +792 -0
- package/lib/operator-activity-feed.js +977 -0
- package/lib/operator-approvals.js +942 -0
- package/lib/operator-help-center.js +1020 -0
- package/lib/operator-inbox.js +889 -0
- package/lib/operator-sessions.js +701 -0
- package/lib/order-exchanges.js +602 -0
- package/lib/product-compare.js +804 -0
- package/lib/pwa-manifest.js +1005 -0
- package/lib/referral-leaderboard.js +612 -0
- package/lib/sales-tax-filings.js +807 -0
- package/lib/search-ranking.js +859 -0
- package/lib/shipping-insurance.js +757 -0
- package/lib/shrinkage-report.js +1182 -0
- package/lib/sidebar-widgets.js +952 -0
- package/lib/smart-restocking.js +1048 -0
- package/lib/stock-receipts.js +834 -0
- package/lib/subscription-analytics.js +1032 -0
- package/lib/suggestion-box.js +921 -0
- package/lib/tax-remittance.js +625 -0
- package/lib/vendor-invoices.js +1021 -0
- package/lib/winback-campaigns.js +1350 -0
- package/lib/wishlist-digest.js +1133 -0
- package/package.json +1 -1
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.consentLedger
|
|
4
|
+
* @title Consent ledger — append-only per-customer record of every
|
|
5
|
+
* consent decision for GDPR / ePrivacy / CCPA audit.
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* Per-customer historical record of every consent grant /
|
|
9
|
+
* withdrawal across the nine categories that map to a supervisory-
|
|
10
|
+
* authority audit: cookie functional / analytics / marketing /
|
|
11
|
+
* preferences, marketing email, marketing SMS, third-party data
|
|
12
|
+
* sharing (partners + analytics), and a catch-all data_processing
|
|
13
|
+
* bucket for purposes that don't fit the eight category-specific
|
|
14
|
+
* kinds. Distinct from `cookieConsent`, which stores per-SESSION
|
|
15
|
+
* decisions keyed by a hashed session id — that primitive is the
|
|
16
|
+
* browser-side gate; this primitive is the durable audit trail.
|
|
17
|
+
*
|
|
18
|
+
* GDPR art. 7(1) requires the controller "be able to demonstrate
|
|
19
|
+
* that the data subject has consented to processing of his or her
|
|
20
|
+
* personal data." consentLedger is that demonstration: every
|
|
21
|
+
* decision a customer makes lands as a new row; the table is
|
|
22
|
+
* append-only from the primitive surface; the current effective
|
|
23
|
+
* state is the latest row by `occurred_at`. A Subject Access
|
|
24
|
+
* Request (`auditExport`) returns every row for one customer
|
|
25
|
+
* newest-first in CSV or JSON. A supervisory-authority sweep
|
|
26
|
+
* (`bulkExportByJurisdiction`) returns every row in a country
|
|
27
|
+
* over a closed time window. The compliance summary
|
|
28
|
+
* (`summarizeForCompliance`) returns aggregate granted /
|
|
29
|
+
* withdrawn counts per (consent_kind, source) across the window
|
|
30
|
+
* so the operator can answer "how many EU buyers opted into
|
|
31
|
+
* marketing email last quarter" without exposing per-row PII.
|
|
32
|
+
*
|
|
33
|
+
* Withdrawal is non-destructive. `recordConsentChange` with
|
|
34
|
+
* `state: "withdrawn"` writes a NEW row; the prior granted row
|
|
35
|
+
* survives so the timeline reads as a sequence rather than a
|
|
36
|
+
* silent revocation. Operators never DELETE / UPDATE rows in
|
|
37
|
+
* this table — the primitive surface only exposes INSERT.
|
|
38
|
+
*
|
|
39
|
+
* `consent_kind` is one of the nine listed under
|
|
40
|
+
* `CONSENT_KINDS`. `state` is `granted` or `withdrawn`. `source`
|
|
41
|
+
* identifies how the decision arrived (signup_form,
|
|
42
|
+
* preference_center, cookie_banner, customer_support,
|
|
43
|
+
* system_default, data_subject_request). `jurisdiction` is an
|
|
44
|
+
* optional ISO-3166-1 alpha-2 uppercase country code; the
|
|
45
|
+
* bulk-export and compliance-summary paths filter on it.
|
|
46
|
+
* `evidence_ref` is an operator-supplied opaque string pointing
|
|
47
|
+
* at the row that proves the decision (form-submission id,
|
|
48
|
+
* support-ticket id, audit-log row id) — the ledger doesn't
|
|
49
|
+
* resolve it.
|
|
50
|
+
*
|
|
51
|
+
* Composes:
|
|
52
|
+
* - `b.guardUuid.sanitize` — strict UUID validation on
|
|
53
|
+
* customer_id.
|
|
54
|
+
* - `b.uuid.v7` — row id, lexicographically
|
|
55
|
+
* sortable for tie-break on
|
|
56
|
+
* occurred_at.
|
|
57
|
+
* - `b.csv.stringify` — RFC 4180 CSV emission for
|
|
58
|
+
* `auditExport({ format: "csv" })`
|
|
59
|
+
* and `bulkExportByJurisdiction`.
|
|
60
|
+
*
|
|
61
|
+
* Monotonic per-process clock: two writes in the same
|
|
62
|
+
* millisecond would tie on `occurred_at` and make the "latest
|
|
63
|
+
* state per kind" read ambiguous. `_now` bumps to `prior + 1` on
|
|
64
|
+
* collision so the per-customer timeline carries a strict
|
|
65
|
+
* ordering even on a fast runner.
|
|
66
|
+
*
|
|
67
|
+
* Surface:
|
|
68
|
+
* - recordConsentChange({ customer_id, consent_kind, state,
|
|
69
|
+
* source, jurisdiction?, evidence_ref? })
|
|
70
|
+
* → the persisted row.
|
|
71
|
+
* - currentStateForCustomer(customer_id)
|
|
72
|
+
* → object mapping each consent_kind that has at least
|
|
73
|
+
* one row to its latest { state, source, jurisdiction,
|
|
74
|
+
* evidence_ref, occurred_at }. Kinds with no rows are
|
|
75
|
+
* omitted (the caller decides the default — typically
|
|
76
|
+
* "withdrawn" / "not given").
|
|
77
|
+
* - historyForCustomer(customer_id)
|
|
78
|
+
* → array of every row for the customer, newest first.
|
|
79
|
+
* - auditExport({ customer_id, format })
|
|
80
|
+
* → SAR-ready dump. `format` is `"csv"` or `"json"`.
|
|
81
|
+
* - bulkExportByJurisdiction({ jurisdiction, from, to,
|
|
82
|
+
* format? })
|
|
83
|
+
* → every row whose jurisdiction matches the requested
|
|
84
|
+
* code, occurred_at in [from, to). Default format CSV.
|
|
85
|
+
* - summarizeForCompliance({ from, to, jurisdiction? })
|
|
86
|
+
* → per-(consent_kind, source) granted / withdrawn
|
|
87
|
+
* counts across the window. Operator-facing aggregate
|
|
88
|
+
* that doesn't expose row-level customer_ids.
|
|
89
|
+
*
|
|
90
|
+
* Storage: `consent_ledger` (migration
|
|
91
|
+
* `0185_consent_ledger.sql`).
|
|
92
|
+
*
|
|
93
|
+
* @primitive consentLedger
|
|
94
|
+
* @related b.guardUuid, b.uuid.v7, b.csv, shop.cookieConsent
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
var CONSENT_KINDS = Object.freeze([
|
|
98
|
+
"cookies_functional",
|
|
99
|
+
"cookies_analytics",
|
|
100
|
+
"cookies_marketing",
|
|
101
|
+
"cookies_preferences",
|
|
102
|
+
"marketing_email",
|
|
103
|
+
"marketing_sms",
|
|
104
|
+
"data_sharing_partners",
|
|
105
|
+
"data_sharing_analytics",
|
|
106
|
+
"data_processing",
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
var STATES = Object.freeze(["granted", "withdrawn"]);
|
|
110
|
+
|
|
111
|
+
var SOURCES = Object.freeze([
|
|
112
|
+
"signup_form",
|
|
113
|
+
"preference_center",
|
|
114
|
+
"cookie_banner",
|
|
115
|
+
"customer_support",
|
|
116
|
+
"system_default",
|
|
117
|
+
"data_subject_request",
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
var EXPORT_FORMATS = Object.freeze(["csv", "json"]);
|
|
121
|
+
|
|
122
|
+
var JURISDICTION_RE = /^[A-Z]{2}$/;
|
|
123
|
+
var EVIDENCE_REF_MAX_LEN = 256;
|
|
124
|
+
var EVIDENCE_REF_RE = /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/;
|
|
125
|
+
|
|
126
|
+
var CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
|
|
127
|
+
|
|
128
|
+
var CSV_COLUMNS = Object.freeze([
|
|
129
|
+
"id",
|
|
130
|
+
"customer_id",
|
|
131
|
+
"consent_kind",
|
|
132
|
+
"state",
|
|
133
|
+
"source",
|
|
134
|
+
"jurisdiction",
|
|
135
|
+
"evidence_ref",
|
|
136
|
+
"occurred_at",
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
// Lazy framework handle — matches the rest of the shop primitives;
|
|
140
|
+
// avoids the require cycle that would arise from importing `./index`
|
|
141
|
+
// at module-eval time.
|
|
142
|
+
var bShop;
|
|
143
|
+
function _b() {
|
|
144
|
+
if (!bShop) bShop = require("./index");
|
|
145
|
+
return bShop.framework;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---- monotonic clock ----------------------------------------------------
|
|
149
|
+
//
|
|
150
|
+
// Operator-driven writes can land in the same millisecond on fast
|
|
151
|
+
// machines. Bumping by 1ms on a tie keeps the per-customer timeline
|
|
152
|
+
// strictly increasing so the "latest state per kind" read returns
|
|
153
|
+
// the row the caller actually issued last.
|
|
154
|
+
|
|
155
|
+
var _lastTs = 0;
|
|
156
|
+
function _now() {
|
|
157
|
+
var t = Date.now();
|
|
158
|
+
if (t <= _lastTs) t = _lastTs + 1;
|
|
159
|
+
_lastTs = t;
|
|
160
|
+
return t;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---- validators ---------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
function _customerId(s) {
|
|
166
|
+
try {
|
|
167
|
+
return _b().guardUuid.sanitize(s, { profile: "strict" });
|
|
168
|
+
} catch (e) {
|
|
169
|
+
throw new TypeError(
|
|
170
|
+
"consentLedger: customer_id — " + (e && e.message || "invalid UUID")
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function _consentKind(s) {
|
|
176
|
+
if (typeof s !== "string" || CONSENT_KINDS.indexOf(s) === -1) {
|
|
177
|
+
throw new TypeError(
|
|
178
|
+
"consentLedger: consent_kind must be one of " + CONSENT_KINDS.join(", ") +
|
|
179
|
+
", got " + JSON.stringify(s)
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
return s;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function _state(s) {
|
|
186
|
+
if (typeof s !== "string" || STATES.indexOf(s) === -1) {
|
|
187
|
+
throw new TypeError(
|
|
188
|
+
"consentLedger: state must be one of " + STATES.join(", ") +
|
|
189
|
+
", got " + JSON.stringify(s)
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return s;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function _source(s) {
|
|
196
|
+
if (typeof s !== "string" || SOURCES.indexOf(s) === -1) {
|
|
197
|
+
throw new TypeError(
|
|
198
|
+
"consentLedger: source must be one of " + SOURCES.join(", ") +
|
|
199
|
+
", got " + JSON.stringify(s)
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
return s;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function _optJurisdiction(s) {
|
|
206
|
+
if (s == null || s === "") return null;
|
|
207
|
+
if (typeof s !== "string") {
|
|
208
|
+
throw new TypeError("consentLedger: jurisdiction must be a string");
|
|
209
|
+
}
|
|
210
|
+
if (!JURISDICTION_RE.test(s)) {
|
|
211
|
+
throw new TypeError(
|
|
212
|
+
"consentLedger: jurisdiction must be ISO-3166-1 alpha-2 uppercase (e.g. 'DE', 'US'), " +
|
|
213
|
+
"got " + JSON.stringify(s)
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
return s;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function _reqJurisdiction(s) {
|
|
220
|
+
if (typeof s !== "string" || !s.length) {
|
|
221
|
+
throw new TypeError("consentLedger: jurisdiction required (non-empty string)");
|
|
222
|
+
}
|
|
223
|
+
if (!JURISDICTION_RE.test(s)) {
|
|
224
|
+
throw new TypeError(
|
|
225
|
+
"consentLedger: jurisdiction must be ISO-3166-1 alpha-2 uppercase (e.g. 'DE', 'US'), " +
|
|
226
|
+
"got " + JSON.stringify(s)
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
return s;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function _optEvidenceRef(s) {
|
|
233
|
+
if (s == null || s === "") return null;
|
|
234
|
+
if (typeof s !== "string") {
|
|
235
|
+
throw new TypeError("consentLedger: evidence_ref must be a string");
|
|
236
|
+
}
|
|
237
|
+
if (s.length > EVIDENCE_REF_MAX_LEN) {
|
|
238
|
+
throw new TypeError(
|
|
239
|
+
"consentLedger: evidence_ref must be <= " + EVIDENCE_REF_MAX_LEN + " characters"
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
243
|
+
throw new TypeError("consentLedger: evidence_ref must not contain control bytes");
|
|
244
|
+
}
|
|
245
|
+
if (!EVIDENCE_REF_RE.test(s)) {
|
|
246
|
+
throw new TypeError(
|
|
247
|
+
"consentLedger: evidence_ref must match /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/"
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
return s;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function _format(s) {
|
|
254
|
+
if (typeof s !== "string" || EXPORT_FORMATS.indexOf(s) === -1) {
|
|
255
|
+
throw new TypeError(
|
|
256
|
+
"consentLedger: format must be one of " + EXPORT_FORMATS.join(", ") +
|
|
257
|
+
", got " + JSON.stringify(s)
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
return s;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function _tsBound(n, label) {
|
|
264
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
265
|
+
throw new TypeError(
|
|
266
|
+
"consentLedger: " + label + " must be a non-negative integer (ms epoch)"
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
return n;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function _windowBounds(from, to, label) {
|
|
273
|
+
_tsBound(from, label + ".from");
|
|
274
|
+
_tsBound(to, label + ".to");
|
|
275
|
+
if (to <= from) {
|
|
276
|
+
throw new TypeError(
|
|
277
|
+
"consentLedger." + label + ": to must be > from"
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ---- row hydration ------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
function _rowToRecord(row) {
|
|
285
|
+
if (!row) return null;
|
|
286
|
+
return {
|
|
287
|
+
id: row.id,
|
|
288
|
+
customer_id: row.customer_id,
|
|
289
|
+
consent_kind: row.consent_kind,
|
|
290
|
+
state: row.state,
|
|
291
|
+
source: row.source,
|
|
292
|
+
jurisdiction: row.jurisdiction == null ? null : row.jurisdiction,
|
|
293
|
+
evidence_ref: row.evidence_ref == null ? null : row.evidence_ref,
|
|
294
|
+
occurred_at: Number(row.occurred_at),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ---- CSV emission -------------------------------------------------------
|
|
299
|
+
//
|
|
300
|
+
// Compose `b.csv.stringify` so the audit / bulk-export output is RFC
|
|
301
|
+
// 4180-shaped (quoting only on delimiter / quote / CR / LF). Header
|
|
302
|
+
// row is always emitted so a downstream auditor can ingest the file
|
|
303
|
+
// without external column metadata.
|
|
304
|
+
|
|
305
|
+
function _toCsv(rows) {
|
|
306
|
+
return _b().csv.stringify(rows, {
|
|
307
|
+
columns: CSV_COLUMNS.slice(),
|
|
308
|
+
header: true,
|
|
309
|
+
eol: "\n",
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ---- factory ------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
function create(opts) {
|
|
316
|
+
opts = opts || {};
|
|
317
|
+
var query = opts.query;
|
|
318
|
+
if (!query) {
|
|
319
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Append-only INSERT. The row is persisted with a fresh UUIDv7 id
|
|
323
|
+
// (lexicographically sortable, tie-breaks occurred_at ordering on
|
|
324
|
+
// the per-customer history walk). The customer_id is validated as
|
|
325
|
+
// a strict UUID — any non-UUID identifier is refused at the door
|
|
326
|
+
// so a typo can't quietly land an orphan row.
|
|
327
|
+
async function recordConsentChange(input) {
|
|
328
|
+
if (!input || typeof input !== "object") {
|
|
329
|
+
throw new TypeError("consentLedger.recordConsentChange: input object required");
|
|
330
|
+
}
|
|
331
|
+
var customerId = _customerId(input.customer_id);
|
|
332
|
+
var consentKind = _consentKind(input.consent_kind);
|
|
333
|
+
var state = _state(input.state);
|
|
334
|
+
var source = _source(input.source);
|
|
335
|
+
var jurisdiction = _optJurisdiction(input.jurisdiction);
|
|
336
|
+
var evidenceRef = _optEvidenceRef(input.evidence_ref);
|
|
337
|
+
|
|
338
|
+
var id = _b().uuid.v7();
|
|
339
|
+
var ts = _now();
|
|
340
|
+
|
|
341
|
+
await query(
|
|
342
|
+
"INSERT INTO consent_ledger " +
|
|
343
|
+
"(id, customer_id, consent_kind, state, source, jurisdiction, evidence_ref, occurred_at) " +
|
|
344
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
|
345
|
+
[id, customerId, consentKind, state, source, jurisdiction, evidenceRef, ts],
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
id: id,
|
|
350
|
+
customer_id: customerId,
|
|
351
|
+
consent_kind: consentKind,
|
|
352
|
+
state: state,
|
|
353
|
+
source: source,
|
|
354
|
+
jurisdiction: jurisdiction,
|
|
355
|
+
evidence_ref: evidenceRef,
|
|
356
|
+
occurred_at: ts,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Latest decision per consent_kind for this customer. Returns an
|
|
361
|
+
// object keyed by consent_kind; kinds with no row are omitted so
|
|
362
|
+
// the caller can decide what "no record" means (a missing
|
|
363
|
+
// marketing_email row typically reads as "not opted in").
|
|
364
|
+
async function currentStateForCustomer(customerId) {
|
|
365
|
+
customerId = _customerId(customerId);
|
|
366
|
+
var r = await query(
|
|
367
|
+
"SELECT * FROM consent_ledger " +
|
|
368
|
+
"WHERE customer_id = ?1 " +
|
|
369
|
+
"ORDER BY occurred_at ASC, id ASC",
|
|
370
|
+
[customerId],
|
|
371
|
+
);
|
|
372
|
+
var out = {};
|
|
373
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
374
|
+
var rec = _rowToRecord(r.rows[i]);
|
|
375
|
+
// Later rows overwrite earlier ones for the same kind — ASC
|
|
376
|
+
// order means the last write wins, which is exactly the
|
|
377
|
+
// "latest state" semantics.
|
|
378
|
+
out[rec.consent_kind] = {
|
|
379
|
+
state: rec.state,
|
|
380
|
+
source: rec.source,
|
|
381
|
+
jurisdiction: rec.jurisdiction,
|
|
382
|
+
evidence_ref: rec.evidence_ref,
|
|
383
|
+
occurred_at: rec.occurred_at,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
return out;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Newest-first row dump for one customer. Used by the SAR audit
|
|
390
|
+
// export path AND by an operator-facing "buyer's consent history"
|
|
391
|
+
// UI under /admin/customers/:id.
|
|
392
|
+
async function historyForCustomer(customerId) {
|
|
393
|
+
customerId = _customerId(customerId);
|
|
394
|
+
var r = await query(
|
|
395
|
+
"SELECT * FROM consent_ledger " +
|
|
396
|
+
"WHERE customer_id = ?1 " +
|
|
397
|
+
"ORDER BY occurred_at DESC, id DESC",
|
|
398
|
+
[customerId],
|
|
399
|
+
);
|
|
400
|
+
var out = [];
|
|
401
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
402
|
+
out.push(_rowToRecord(r.rows[i]));
|
|
403
|
+
}
|
|
404
|
+
return out;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// SAR export — the buyer (or their authorised representative)
|
|
408
|
+
// requests every row of consent activity. CSV is the supervisory-
|
|
409
|
+
// authority-friendly default; JSON is the structured shape an
|
|
410
|
+
// operator's portal can render in-page.
|
|
411
|
+
async function auditExport(input) {
|
|
412
|
+
if (!input || typeof input !== "object") {
|
|
413
|
+
throw new TypeError("consentLedger.auditExport: input object required");
|
|
414
|
+
}
|
|
415
|
+
var customerId = _customerId(input.customer_id);
|
|
416
|
+
var format = _format(input.format);
|
|
417
|
+
var rows = await historyForCustomer(customerId);
|
|
418
|
+
if (format === "json") {
|
|
419
|
+
return { format: "json", rows: rows };
|
|
420
|
+
}
|
|
421
|
+
return { format: "csv", body: _toCsv(rows) };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Supervisory-authority sweep. The operator's DPO receives a
|
|
425
|
+
// request from a country's data-protection authority asking for
|
|
426
|
+
// every consent decision recorded for that jurisdiction in a time
|
|
427
|
+
// window. The default output is CSV (it's what the regulator will
|
|
428
|
+
// ingest); JSON is available for downstream tooling.
|
|
429
|
+
async function bulkExportByJurisdiction(input) {
|
|
430
|
+
if (!input || typeof input !== "object") {
|
|
431
|
+
throw new TypeError("consentLedger.bulkExportByJurisdiction: input object required");
|
|
432
|
+
}
|
|
433
|
+
var jurisdiction = _reqJurisdiction(input.jurisdiction);
|
|
434
|
+
_windowBounds(input.from, input.to, "bulkExportByJurisdiction");
|
|
435
|
+
var format = input.format == null ? "csv" : _format(input.format);
|
|
436
|
+
|
|
437
|
+
var r = await query(
|
|
438
|
+
"SELECT * FROM consent_ledger " +
|
|
439
|
+
"WHERE jurisdiction = ?1 AND occurred_at >= ?2 AND occurred_at < ?3 " +
|
|
440
|
+
"ORDER BY occurred_at ASC, id ASC",
|
|
441
|
+
[jurisdiction, input.from, input.to],
|
|
442
|
+
);
|
|
443
|
+
var rows = [];
|
|
444
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
445
|
+
rows.push(_rowToRecord(r.rows[i]));
|
|
446
|
+
}
|
|
447
|
+
if (format === "json") {
|
|
448
|
+
return { format: "json", jurisdiction: jurisdiction, rows: rows };
|
|
449
|
+
}
|
|
450
|
+
return { format: "csv", jurisdiction: jurisdiction, body: _toCsv(rows) };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Operator-facing aggregate over a closed window. Returns one
|
|
454
|
+
// entry per (consent_kind, source) tuple with granted /
|
|
455
|
+
// withdrawn counts. The summary doesn't expose row-level
|
|
456
|
+
// customer_ids — it's the shape a quarterly compliance review
|
|
457
|
+
// consumes ("how many marketing_email opt-ins were recorded
|
|
458
|
+
// through preference_center last quarter"). Jurisdiction filter
|
|
459
|
+
// is optional; omitting it summarises every jurisdiction.
|
|
460
|
+
async function summarizeForCompliance(input) {
|
|
461
|
+
if (!input || typeof input !== "object") {
|
|
462
|
+
throw new TypeError("consentLedger.summarizeForCompliance: input object required");
|
|
463
|
+
}
|
|
464
|
+
_windowBounds(input.from, input.to, "summarizeForCompliance");
|
|
465
|
+
var jurisdiction = input.jurisdiction == null
|
|
466
|
+
? null
|
|
467
|
+
: _reqJurisdiction(input.jurisdiction);
|
|
468
|
+
|
|
469
|
+
var sql, params;
|
|
470
|
+
if (jurisdiction == null) {
|
|
471
|
+
sql =
|
|
472
|
+
"SELECT consent_kind, source, state, COUNT(*) AS n " +
|
|
473
|
+
"FROM consent_ledger " +
|
|
474
|
+
"WHERE occurred_at >= ?1 AND occurred_at < ?2 " +
|
|
475
|
+
"GROUP BY consent_kind, source, state " +
|
|
476
|
+
"ORDER BY consent_kind ASC, source ASC, state ASC";
|
|
477
|
+
params = [input.from, input.to];
|
|
478
|
+
} else {
|
|
479
|
+
sql =
|
|
480
|
+
"SELECT consent_kind, source, state, COUNT(*) AS n " +
|
|
481
|
+
"FROM consent_ledger " +
|
|
482
|
+
"WHERE occurred_at >= ?1 AND occurred_at < ?2 AND jurisdiction = ?3 " +
|
|
483
|
+
"GROUP BY consent_kind, source, state " +
|
|
484
|
+
"ORDER BY consent_kind ASC, source ASC, state ASC";
|
|
485
|
+
params = [input.from, input.to, jurisdiction];
|
|
486
|
+
}
|
|
487
|
+
var r = await query(sql, params);
|
|
488
|
+
|
|
489
|
+
// Collapse into a stable nested map keyed by consent_kind ->
|
|
490
|
+
// source -> { granted, withdrawn, total }. Tuples with no
|
|
491
|
+
// observations are omitted (operators reading the summary
|
|
492
|
+
// shouldn't infer "zero" from "absent" without thinking).
|
|
493
|
+
var summary = {};
|
|
494
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
495
|
+
var row = r.rows[i];
|
|
496
|
+
var kind = row.consent_kind;
|
|
497
|
+
var src = row.source;
|
|
498
|
+
var st = row.state;
|
|
499
|
+
var n = Number(row.n) || 0;
|
|
500
|
+
if (!summary[kind]) summary[kind] = {};
|
|
501
|
+
if (!summary[kind][src]) summary[kind][src] = { granted: 0, withdrawn: 0, total: 0 };
|
|
502
|
+
summary[kind][src][st] = (summary[kind][src][st] || 0) + n;
|
|
503
|
+
summary[kind][src].total += n;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return {
|
|
507
|
+
from: input.from,
|
|
508
|
+
to: input.to,
|
|
509
|
+
jurisdiction: jurisdiction,
|
|
510
|
+
summary: summary,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return {
|
|
515
|
+
CONSENT_KINDS: CONSENT_KINDS,
|
|
516
|
+
STATES: STATES,
|
|
517
|
+
SOURCES: SOURCES,
|
|
518
|
+
EXPORT_FORMATS: EXPORT_FORMATS,
|
|
519
|
+
CSV_COLUMNS: CSV_COLUMNS,
|
|
520
|
+
EVIDENCE_REF_MAX_LEN: EVIDENCE_REF_MAX_LEN,
|
|
521
|
+
|
|
522
|
+
recordConsentChange: recordConsentChange,
|
|
523
|
+
currentStateForCustomer: currentStateForCustomer,
|
|
524
|
+
historyForCustomer: historyForCustomer,
|
|
525
|
+
auditExport: auditExport,
|
|
526
|
+
bulkExportByJurisdiction: bulkExportByJurisdiction,
|
|
527
|
+
summarizeForCompliance: summarizeForCompliance,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
module.exports = {
|
|
532
|
+
create: create,
|
|
533
|
+
CONSENT_KINDS: CONSENT_KINDS,
|
|
534
|
+
STATES: STATES,
|
|
535
|
+
SOURCES: SOURCES,
|
|
536
|
+
EXPORT_FORMATS: EXPORT_FORMATS,
|
|
537
|
+
CSV_COLUMNS: CSV_COLUMNS,
|
|
538
|
+
EVIDENCE_REF_MAX_LEN: EVIDENCE_REF_MAX_LEN,
|
|
539
|
+
};
|