@blamejs/blamejs-shop 0.0.61 → 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.
@@ -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
+ };