@aglyn/tenant-data-admin 1.0.0-beta.221 → 1.0.0-beta.223

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.
Files changed (121) hide show
  1. package/package.json +7 -7
  2. package/src/lib/server/api-keys.js +3 -3
  3. package/src/lib/server/api-keys.js.map +1 -1
  4. package/src/lib/server/api-v1-resources.d.ts +44 -3
  5. package/src/lib/server/api-v1-resources.js +82 -5
  6. package/src/lib/server/api-v1-resources.js.map +1 -1
  7. package/src/lib/server/collection-live-pages.d.ts +2 -2
  8. package/src/lib/server/collection-live-pages.js +1 -1
  9. package/src/lib/server/collection-live-pages.js.map +1 -1
  10. package/src/lib/server/email-delivery-log.d.ts +17 -73
  11. package/src/lib/server/email-delivery-log.js +44 -222
  12. package/src/lib/server/email-delivery-log.js.map +1 -1
  13. package/src/lib/server/email-flow-gate.js +27 -38
  14. package/src/lib/server/email-flow-gate.js.map +1 -1
  15. package/src/lib/server/erase-person.d.ts +34 -54
  16. package/src/lib/server/erase-person.js +44 -239
  17. package/src/lib/server/erase-person.js.map +1 -1
  18. package/src/lib/server/ga4-measurement-protocol.js +41 -28
  19. package/src/lib/server/ga4-measurement-protocol.js.map +1 -1
  20. package/src/lib/server/host-visitor-records.d.ts +25 -4
  21. package/src/lib/server/host-visitor-records.js +39 -11
  22. package/src/lib/server/host-visitor-records.js.map +1 -1
  23. package/src/lib/server/hosted-page-review.js +52 -36
  24. package/src/lib/server/hosted-page-review.js.map +1 -1
  25. package/src/lib/server/link-reputation-review.d.ts +6 -2
  26. package/src/lib/server/link-reputation-review.js +61 -20
  27. package/src/lib/server/link-reputation-review.js.map +1 -1
  28. package/src/lib/server/live-page-drops.d.ts +67 -0
  29. package/src/lib/server/live-page-drops.js +57 -0
  30. package/src/lib/server/live-page-drops.js.map +1 -0
  31. package/src/lib/server/media-bandwidth-cap.d.ts +78 -0
  32. package/src/lib/server/media-bandwidth-cap.js +180 -0
  33. package/src/lib/server/media-bandwidth-cap.js.map +1 -0
  34. package/src/lib/server/media-cdn-icon.d.ts +77 -0
  35. package/src/lib/server/media-cdn-icon.js +140 -0
  36. package/src/lib/server/media-cdn-icon.js.map +1 -0
  37. package/src/lib/server/media-delivery-regeneration.d.ts +132 -0
  38. package/src/lib/server/media-delivery-regeneration.js +216 -0
  39. package/src/lib/server/media-delivery-regeneration.js.map +1 -0
  40. package/src/lib/server/media-delivery.d.ts +2 -0
  41. package/src/lib/server/media-delivery.js +2 -1
  42. package/src/lib/server/media-delivery.js.map +1 -1
  43. package/src/lib/server/media-serve-count.d.ts +50 -0
  44. package/src/lib/server/media-serve-count.js +62 -0
  45. package/src/lib/server/media-serve-count.js.map +1 -0
  46. package/src/lib/server/media-signing.d.ts +13 -2
  47. package/src/lib/server/media-signing.js +12 -8
  48. package/src/lib/server/media-signing.js.map +1 -1
  49. package/src/lib/server/media-tombstone.js +7 -5
  50. package/src/lib/server/media-tombstone.js.map +1 -1
  51. package/src/lib/server/media-variants.d.ts +151 -20
  52. package/src/lib/server/media-variants.js +204 -27
  53. package/src/lib/server/media-variants.js.map +1 -1
  54. package/src/lib/server/organizations.d.ts +102 -2
  55. package/src/lib/server/organizations.js +388 -58
  56. package/src/lib/server/organizations.js.map +1 -1
  57. package/src/lib/server/outbound-send-review.js +11 -7
  58. package/src/lib/server/outbound-send-review.js.map +1 -1
  59. package/src/lib/server/paged-sweeps.d.ts +51 -0
  60. package/src/lib/server/paged-sweeps.js +79 -0
  61. package/src/lib/server/paged-sweeps.js.map +1 -0
  62. package/src/lib/server/payment-fraud-signal.d.ts +49 -9
  63. package/src/lib/server/payment-fraud-signal.js +192 -48
  64. package/src/lib/server/payment-fraud-signal.js.map +1 -1
  65. package/src/lib/server/platform-marketing-consent.d.ts +0 -49
  66. package/src/lib/server/platform-marketing-consent.js +27 -11
  67. package/src/lib/server/platform-marketing-consent.js.map +1 -1
  68. package/src/lib/server/render-monitor.d.ts +166 -0
  69. package/src/lib/server/render-monitor.js +412 -0
  70. package/src/lib/server/render-monitor.js.map +1 -0
  71. package/src/lib/server/risk-notice.d.ts +7 -0
  72. package/src/lib/server/risk-notice.js +6 -0
  73. package/src/lib/server/risk-notice.js.map +1 -1
  74. package/src/lib/server/serve-media-cdn.d.ts +69 -1
  75. package/src/lib/server/serve-media-cdn.js +424 -70
  76. package/src/lib/server/serve-media-cdn.js.map +1 -1
  77. package/src/lib/server/site-icon-render.d.ts +68 -0
  78. package/src/lib/server/site-icon-render.js +157 -0
  79. package/src/lib/server/site-icon-render.js.map +1 -0
  80. package/src/lib/server/staff-seat.d.ts +50 -0
  81. package/src/lib/server/staff-seat.js +118 -0
  82. package/src/lib/server/staff-seat.js.map +1 -0
  83. package/src/lib/server/transfer-export.d.ts +46 -0
  84. package/src/lib/server/transfer-export.js +221 -0
  85. package/src/lib/server/transfer-export.js.map +1 -0
  86. package/src/lib/server/transfer-jobs.d.ts +385 -0
  87. package/src/lib/server/transfer-jobs.js +2368 -0
  88. package/src/lib/server/transfer-jobs.js.map +1 -0
  89. package/src/lib/server/transfer-packages.d.ts +127 -0
  90. package/src/lib/server/transfer-packages.js +1036 -0
  91. package/src/lib/server/transfer-packages.js.map +1 -0
  92. package/src/lib/server/upgrade-proposal.d.ts +97 -0
  93. package/src/lib/server/upgrade-proposal.js +312 -0
  94. package/src/lib/server/upgrade-proposal.js.map +1 -0
  95. package/src/lib/server/upsert-contact.d.ts +17 -8
  96. package/src/lib/server/upsert-contact.js +82 -16
  97. package/src/lib/server/upsert-contact.js.map +1 -1
  98. package/src/lib/server/web-risk.d.ts +50 -9
  99. package/src/lib/server/web-risk.js +290 -155
  100. package/src/lib/server/web-risk.js.map +1 -1
  101. package/src/tenancy.d.ts +3 -3
  102. package/src/tenancy.js +5 -3
  103. package/src/tenancy.js.map +1 -1
  104. package/src/lib/server/campaign-attribution-store.d.ts +0 -55
  105. package/src/lib/server/campaign-attribution-store.js +0 -95
  106. package/src/lib/server/campaign-attribution-store.js.map +0 -1
  107. package/src/lib/server/campaign-conversion-attribution.d.ts +0 -283
  108. package/src/lib/server/campaign-conversion-attribution.js +0 -450
  109. package/src/lib/server/campaign-conversion-attribution.js.map +0 -1
  110. package/src/lib/server/crm-email-activity.d.ts +0 -118
  111. package/src/lib/server/crm-email-activity.js +0 -148
  112. package/src/lib/server/crm-email-activity.js.map +0 -1
  113. package/src/lib/server/crm-next-activity.d.ts +0 -74
  114. package/src/lib/server/crm-next-activity.js +0 -194
  115. package/src/lib/server/crm-next-activity.js.map +0 -1
  116. package/src/lib/server/dataset-live-pages.d.ts +0 -179
  117. package/src/lib/server/dataset-live-pages.js +0 -421
  118. package/src/lib/server/dataset-live-pages.js.map +0 -1
  119. package/src/lib/server/email-revenue-attribution.d.ts +0 -185
  120. package/src/lib/server/email-revenue-attribution.js +0 -321
  121. package/src/lib/server/email-revenue-attribution.js.map +0 -1
@@ -25,36 +25,24 @@ export interface ErasePersonOptions {
25
25
  now?: number;
26
26
  }
27
27
  /**
28
- * Counts, never identities: this is what the request document and the
29
- * audit row record, and both are read by people who must not learn the
30
- * address from them.
28
+ * Counts, never identities: this is what the request document and the audit
29
+ * row record, and both are read by people who must not learn the address
30
+ * from them.
31
31
  */
32
32
  export interface ErasePersonCounts {
33
33
  /** Sites in the workspace the sweep walked. */
34
34
  hosts: number;
35
35
  /** Suppression rows written, one per site. */
36
36
  hostsSuppressed: number;
37
- /** Contact documents deleted — the row every site shared. */
38
- contacts: number;
39
- /** Companies whose contact count moved down. */
40
- companyLinks: number;
41
- /** Deals whose `contactId` was removed. */
42
- deals: number;
43
- tasks: number;
44
- activities: number;
45
- /** `hosts/{hostId}/leads/{personKey}` rows deleted. */
46
- leads: number;
47
- /** Audience-list member rows deleted, across every list. */
48
- listMemberships: number;
49
- /** Orders with the buyer's identity removed; the record stays. */
50
- orders: number;
51
- /** Bookings with the person's identity removed; the record stays. */
52
- bookings: number;
37
+ /** The person's records the record system named, by count. */
38
+ records: number;
53
39
  /** Delivery-log messages deleted under the address. */
54
40
  emailDeliveries: number;
55
41
  /**
56
- * Each plugin's share (AGL-2981), by plugin id: its eraser's own counts,
57
- * or `null` for an eraser that failed — its data may remain.
42
+ * Each plugin's share (AGL-2981, AGL-3080), by plugin id: its eraser's own
43
+ * counts — the record system's people, a shop's orders, a calendar's
44
+ * bookings, an audience's members — or `null` for an eraser that failed:
45
+ * its data may remain.
58
46
  */
59
47
  plugins: Record<string, PluginPersonErasureReport | null>;
60
48
  }
@@ -67,50 +55,42 @@ export type ErasePersonResult = ({
67
55
  /**
68
56
  * Remove one person from one workspace (AGL-2623).
69
57
  *
70
- * `planContactDetach` is deliberately not consulted: the CRM's delete is a
71
- * detach that leaves the row for the other holders, and this is the act
72
- * that must not. The contact document goes whole — every site's facet,
73
- * every consent entry, every attribution — and with it everything the
74
- * workspace keeps beside the person.
58
+ * The platform's part is the door and the log; everything the workspace keeps
59
+ * ABOUT the person is a plugin's, erased through `plugin-person-erasure`.
75
60
  *
76
61
  * ## Order
77
62
  *
78
- * 1. **Suppress first.** One row per site on the per-site suppression
79
- * list, before any delete: a form filled in while the sweep runs must
80
- * already find the door closed, or the sweep deletes a row that the
81
- * capture re-creates a moment later.
82
- * 2. The plugins' share: every eraser registered on
83
- * `plugin-person-erasure`, handed the address, its key and the ids of
84
- * the contacts about to go, while those documents still exist.
85
- * 3. The contact and its satellites — company counts, deals unlinked,
86
- * tasks and activities deleted — by the contact's id, then the
87
- * document itself.
88
- * 4. Leads, list memberships, orders and bookings by the address, on
89
- * every site of the workspace.
90
- * 5. The delivery log, last: it is filed under the address alone, and the
91
- * tombstone it leaves is what keeps a later import from refilling it.
63
+ * 1. **Suppress first.** One row per site on the per-site suppression list,
64
+ * before any delete: a form filled in while the sweep runs must already
65
+ * find the door closed, or the sweep deletes a row that the capture
66
+ * re-creates a moment later.
67
+ * 2. **The plugins' shares** (`runPluginPersonErasure`): the plugin that
68
+ * keeps people names the person's records; every other plugin erases
69
+ * what it keeps — keyed by those records or by the address — and the
70
+ * record system erases the records themselves last. A share the
71
+ * erasure promises is REQUIRED: a missing one refuses the erasure before
72
+ * anything is erased, and a failed one fails it after the rest ran, so
73
+ * the job retries rather than reporting a person erased who is not.
74
+ * 3. **The delivery log**, last: it is filed under the address alone, and
75
+ * the tombstone it leaves is what keeps a later import from refilling it.
92
76
  *
93
77
  * ## What is anonymized rather than deleted
94
78
  *
95
- * An order is the merchant's record of a sale and a booking of an
96
- * appointment; the amounts, the line items and the tax are theirs to keep
97
- * and the law expects them kept. The person is taken OFF those records —
98
- * name, email, phone, addresses — and a stamp says when. A deal is the
99
- * team's own pipeline record and is unlinked. Everything else that names
100
- * the person is about the person and is deleted.
79
+ * Each plugin's own business: an order is the merchant's record of a sale
80
+ * and a booking of an appointment, and the law expects them kept, so the
81
+ * person is taken OFF them; a deal is the team's pipeline record and is
82
+ * unlinked. Everything else that names the person is about the person and is
83
+ * deleted. `PERSON_ERASURE_REMOVES` / `RETAINS` say so to the admin.
101
84
  *
102
85
  * ## What is not reached, and why
103
86
  *
104
87
  * A form submission keeps the address inside `fields`, under whatever the
105
88
  * form called it — there is no key to query by and a scan of every
106
- * submission on every site is unbounded. A site member's login is their
107
- * own account. A subscription carries the address it bills, and a live one
108
- * cannot be anonymized without breaking its receipts. Each is named to the
109
- * admin by the dialog so they can finish by hand.
89
+ * submission on every site is unbounded. A site member's login is their own
90
+ * account. Each is named to the admin by the dialog so they can finish by
91
+ * hand.
110
92
  *
111
- * Every sweep is best-effort against the others: a failure in one is
112
- * logged and counted as zero, and the request's counts say what happened.
113
- * The caller decides whether zero contacts on a person it could see is a
114
- * failure to retry.
93
+ * The suppression and the log are best-effort against each other: a failure
94
+ * in one is logged and counted as zero, and the counts say what happened.
115
95
  */
116
96
  export declare function erasePerson(options: ErasePersonOptions): Promise<ErasePersonResult>;
@@ -18,71 +18,53 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
18
18
  // The leaves, not the barrel — see `email-suppression.ts` for why: a spec
19
19
  // of this sweep substitutes a store, not the pure helpers it leans on.
20
20
  import { normalizeContactEmail } from "@aglyn/aglyn/app-utils/contacts";
21
- import { CRM_COLLECTIONS } from "@aglyn/aglyn/app-utils/crm";
22
21
  import { personKey } from "@aglyn/aglyn/app-utils/person-key";
23
- import { runPluginPersonErasers } from "@aglyn/aglyn/plugin-manager/plugin-person-erasure";
24
- import { runPluginEventHandlers } from "@aglyn/aglyn/plugin-manager/plugin-events";
25
- import { companyContactsCountFields } from "./contact-company-link.js";
22
+ import { missingRequiredPersonErasersAfterRepair, runPluginPersonErasure } from "@aglyn/aglyn/plugin-manager/plugin-person-erasure";
26
23
  import { eraseEmailDeliveriesForAddresses } from "./email-delivery-log.js";
27
24
  import { suppressEmailForHostErasure } from "./email-suppression.js";
28
25
  import firebaseAdmin from "./firebase-admin.js";
29
- import { listMemberDocIds } from "./list-members.js";
30
26
  import { addAdminAudit } from "./admin-audit-write.js";
31
27
  const defaultFirestore = ()=>firebaseAdmin.app().firestore();
32
- /** Firestore caps a batch at 500 writes; a page under it keeps a margin. */ const PAGE = 400;
33
- /**
34
- * A pathological collection must not hold the erasure open indefinitely:
35
- * twenty pages of four hundred is eight thousand rows of one kind for one
36
- * person, past anything a real relationship produces.
37
- */ const MAX_PAGES = 20;
38
28
  /**
39
29
  * Remove one person from one workspace (AGL-2623).
40
30
  *
41
- * `planContactDetach` is deliberately not consulted: the CRM's delete is a
42
- * detach that leaves the row for the other holders, and this is the act
43
- * that must not. The contact document goes whole — every site's facet,
44
- * every consent entry, every attribution — and with it everything the
45
- * workspace keeps beside the person.
31
+ * The platform's part is the door and the log; everything the workspace keeps
32
+ * ABOUT the person is a plugin's, erased through `plugin-person-erasure`.
46
33
  *
47
34
  * ## Order
48
35
  *
49
- * 1. **Suppress first.** One row per site on the per-site suppression
50
- * list, before any delete: a form filled in while the sweep runs must
51
- * already find the door closed, or the sweep deletes a row that the
52
- * capture re-creates a moment later.
53
- * 2. The plugins' share: every eraser registered on
54
- * `plugin-person-erasure`, handed the address, its key and the ids of
55
- * the contacts about to go, while those documents still exist.
56
- * 3. The contact and its satellites — company counts, deals unlinked,
57
- * tasks and activities deleted — by the contact's id, then the
58
- * document itself.
59
- * 4. Leads, list memberships, orders and bookings by the address, on
60
- * every site of the workspace.
61
- * 5. The delivery log, last: it is filed under the address alone, and the
62
- * tombstone it leaves is what keeps a later import from refilling it.
36
+ * 1. **Suppress first.** One row per site on the per-site suppression list,
37
+ * before any delete: a form filled in while the sweep runs must already
38
+ * find the door closed, or the sweep deletes a row that the capture
39
+ * re-creates a moment later.
40
+ * 2. **The plugins' shares** (`runPluginPersonErasure`): the plugin that
41
+ * keeps people names the person's records; every other plugin erases
42
+ * what it keeps — keyed by those records or by the address — and the
43
+ * record system erases the records themselves last. A share the
44
+ * erasure promises is REQUIRED: a missing one refuses the erasure before
45
+ * anything is erased, and a failed one fails it after the rest ran, so
46
+ * the job retries rather than reporting a person erased who is not.
47
+ * 3. **The delivery log**, last: it is filed under the address alone, and
48
+ * the tombstone it leaves is what keeps a later import from refilling it.
63
49
  *
64
50
  * ## What is anonymized rather than deleted
65
51
  *
66
- * An order is the merchant's record of a sale and a booking of an
67
- * appointment; the amounts, the line items and the tax are theirs to keep
68
- * and the law expects them kept. The person is taken OFF those records —
69
- * name, email, phone, addresses — and a stamp says when. A deal is the
70
- * team's own pipeline record and is unlinked. Everything else that names
71
- * the person is about the person and is deleted.
52
+ * Each plugin's own business: an order is the merchant's record of a sale
53
+ * and a booking of an appointment, and the law expects them kept, so the
54
+ * person is taken OFF them; a deal is the team's pipeline record and is
55
+ * unlinked. Everything else that names the person is about the person and is
56
+ * deleted. `PERSON_ERASURE_REMOVES` / `RETAINS` say so to the admin.
72
57
  *
73
58
  * ## What is not reached, and why
74
59
  *
75
60
  * A form submission keeps the address inside `fields`, under whatever the
76
61
  * form called it — there is no key to query by and a scan of every
77
- * submission on every site is unbounded. A site member's login is their
78
- * own account. A subscription carries the address it bills, and a live one
79
- * cannot be anonymized without breaking its receipts. Each is named to the
80
- * admin by the dialog so they can finish by hand.
62
+ * submission on every site is unbounded. A site member's login is their own
63
+ * account. Each is named to the admin by the dialog so they can finish by
64
+ * hand.
81
65
  *
82
- * Every sweep is best-effort against the others: a failure in one is
83
- * logged and counted as zero, and the request's counts say what happened.
84
- * The caller decides whether zero contacts on a person it could see is a
85
- * failure to retry.
66
+ * The suppression and the log are best-effort against each other: a failure
67
+ * in one is logged and counted as zero, and the counts say what happened.
86
68
  */ export async function erasePerson(options) {
87
69
  var _options_firestore, _options_now;
88
70
  const email = normalizeContactEmail(options.email);
@@ -92,20 +74,19 @@ const defaultFirestore = ()=>firebaseAdmin.app().firestore();
92
74
  skippedReason: 'invalid-email'
93
75
  };
94
76
  const db = (_options_firestore = options.firestore) != null ? _options_firestore : defaultFirestore();
95
- const now = (_options_now = options.now) != null ? _options_now : Date.now();
96
- const orgRef = db.collection('orgs').doc(options.orgId);
77
+ /*
78
+ * A share the erasure promises that this process cannot run — even after
79
+ * its boot step has run once more — refuses the whole erasure BEFORE it
80
+ * writes anything: the request stays queued for a process whose boot
81
+ * registered it, rather than reporting a person erased who is still on file.
82
+ */ const missing = await missingRequiredPersonErasersAfterRepair();
83
+ if (missing.length) {
84
+ throw new Error(`erasePerson refused: ${missing.join(', ')} declared a required person eraser ` + 'and none is registered in this process');
85
+ }
97
86
  const counts = {
98
87
  hosts: 0,
99
88
  hostsSuppressed: 0,
100
- contacts: 0,
101
- companyLinks: 0,
102
- deals: 0,
103
- tasks: 0,
104
- activities: 0,
105
- leads: 0,
106
- listMemberships: 0,
107
- orders: 0,
108
- bookings: 0,
89
+ records: 0,
109
90
  emailDeliveries: 0,
110
91
  plugins: {}
111
92
  };
@@ -124,151 +105,18 @@ const defaultFirestore = ()=>firebaseAdmin.app().firestore();
124
105
  console.error(`erasePerson: suppression write failed for ${hostId}`, error);
125
106
  }
126
107
  }
127
- const contacts = await orgRef.collection('contacts').where('email', '==', email).get();
128
- // What the plugins keep about the person (AGL-2981): records filed under
129
- // the organization by the contact or by the address, which none of the
130
- // sweeps here can see. Every site's door is already closed above, and
131
- // the contacts are still there for an eraser that reads one. Isolated
132
- // per plugin: one that fails is recorded as `null` and the erasure goes
133
- // on. This erasure has no plan of its own, so it is never a dry run.
134
- counts.plugins = await runPluginPersonErasers({
108
+ // Every site's door is already closed above. This erasure has no plan of
109
+ // its own, so it is never a dry run. A required share that is missing or
110
+ // failed THROWS here, and the request stays queued for the next run.
111
+ const shares = await runPluginPersonErasure({
135
112
  orgId: options.orgId,
136
113
  email,
137
114
  key,
138
- contactIds: contacts.docs.map((contact)=>String(contact.id)),
139
- dryRun: false
115
+ dryRun: false,
116
+ atMs: (_options_now = options.now) != null ? _options_now : Date.now()
140
117
  });
141
- for (const contact of contacts.docs){
142
- const contactId = String(contact.id);
143
- const companyIds = contact.get('companyIds');
144
- const linked = Array.isArray(companyIds) ? companyIds.filter((id)=>typeof id === 'string' && !!id) : [];
145
- for (const companyId of new Set(linked)){
146
- try {
147
- await orgRef.collection(CRM_COLLECTIONS.companies).doc(companyId).update(companyContactsCountFields(-1));
148
- counts.companyLinks += 1;
149
- } catch (error) {
150
- console.error(`erasePerson: company count could not move for ${companyId}`, error);
151
- }
152
- }
153
- counts.deals += await updateWhere(db, orgRef.collection(CRM_COLLECTIONS.deals), 'contactId', contactId, {
154
- contactId: FieldValue.delete(),
155
- updatedAt: FieldValue.serverTimestamp()
156
- });
157
- counts.tasks += await deleteWhere(db, orgRef.collection(CRM_COLLECTIONS.tasks), 'contactId', contactId);
158
- counts.activities += await deleteWhere(db, orgRef.collection(CRM_COLLECTIONS.activities), 'contactId', contactId);
159
- try {
160
- await contact.ref.delete();
161
- counts.contacts += 1;
162
- } catch (error) {
163
- console.error(`erasePerson: contact delete failed for ${contactId}`, error);
164
- }
165
- }
166
- const erasedOrder = {
167
- customerEmail: null,
168
- customerName: null,
169
- shippingAddress: FieldValue.delete(),
170
- billingAddress: FieldValue.delete(),
171
- customerErasedAtMs: now
172
- };
173
- /*
174
- * The address also lives in what the orders list queries by (AGL-3321):
175
- * its lower-cased key, its search prefixes, and the quick search's token
176
- * array, which holds those prefixes beside the order number's and the item
177
- * names'. The first two go; the prefixes are taken back out of the third.
178
- * A prefix the number or an item name shares with the address goes with
179
- * it — a search for that prefix misses this order until the orders
180
- * backfill restamps it — and nothing left on the order answers to the
181
- * address.
182
- */ const erasedOrderFor = (data)=>{
183
- const addressTokens = new Set(Array.isArray(data == null ? void 0 : data.customerEmailTokens) ? data.customerEmailTokens : []);
184
- return _extends({}, erasedOrder, {
185
- customerEmailLower: null,
186
- customerEmailTokens: []
187
- }, Array.isArray(data == null ? void 0 : data.searchTokens) ? {
188
- searchTokens: data.searchTokens.filter((token)=>!addressTokens.has(token))
189
- } : {});
190
- };
191
- const erasedBooking = {
192
- email: null,
193
- name: FieldValue.delete(),
194
- phone: FieldValue.delete(),
195
- customerErasedAtMs: now
196
- };
197
- /*
198
- * THE ORG ROW, ONCE (AGL-3275) — and then every legacy row still standing.
199
- *
200
- * A lead is one document for the whole org now, so the delete leaves the
201
- * per-site loop. It is not the caller that may assume the migration is
202
- * finished, though: until AGL-3276 has folded every site and AGL-3277 has
203
- * removed the fallback, a person can still be held at
204
- * `hosts/{hostId}/leads`, and an erasure that deleted only the org row would
205
- * leave that copy behind. So both are swept, and `counts.leads` is what was
206
- * actually destroyed rather than how many places were looked at.
207
- */ /*
208
- * Every lead row this erasure removes, as it stood (AGL-3330). A plugin
209
- * that keeps a figure over leads — a form counts the people it filed —
210
- * is told once the rows are gone, through `host.records.removed`, so its
211
- * figure drops by the person this erasure removed and by nothing else.
212
- */ const erasedLeads = [];
213
- try {
214
- const orgLead = orgRef.collection('leads').doc(key);
215
- const orgLeadSnapshot = await orgLead.get();
216
- if (orgLeadSnapshot.exists) {
217
- var _orgLeadSnapshot_data;
218
- erasedLeads.push({
219
- id: key,
220
- data: (_orgLeadSnapshot_data = orgLeadSnapshot.data()) != null ? _orgLeadSnapshot_data : {}
221
- });
222
- await orgLead.delete();
223
- counts.leads += 1;
224
- }
225
- } catch (error) {
226
- console.error('erasePerson: org lead delete failed', error);
227
- }
228
- for (const hostId of hostIds){
229
- const hostRef = db.collection('hosts').doc(hostId);
230
- try {
231
- const lead = hostRef.collection('leads').doc(key);
232
- const leadSnapshot = await lead.get();
233
- if (leadSnapshot.exists) {
234
- var _leadSnapshot_data;
235
- erasedLeads.push({
236
- id: key,
237
- data: (_leadSnapshot_data = leadSnapshot.data()) != null ? _leadSnapshot_data : {}
238
- });
239
- await lead.delete();
240
- counts.leads += 1;
241
- }
242
- } catch (error) {
243
- console.error(`erasePerson: legacy lead delete failed for ${hostId}`, error);
244
- }
245
- counts.orders += await updateWhere(db, hostRef.collection('orders'), 'customerEmail', email, erasedOrderFor);
246
- counts.bookings += await updateWhere(db, hostRef.collection('bookings'), 'email', email, erasedBooking);
247
- }
248
- if (erasedLeads.length) {
249
- // Isolated per plugin by the seam, and never the erasure's failure.
250
- await runPluginEventHandlers('host.records.removed', {
251
- orgId: options.orgId,
252
- hostIds,
253
- collection: 'leads',
254
- records: erasedLeads
255
- }).catch((error)=>console.error('erasePerson: records-removed event failed', error));
256
- }
257
- try {
258
- const memberIds = listMemberDocIds(email);
259
- const lists = await orgRef.collection('lists').get();
260
- for (const list of lists.docs){
261
- const refs = memberIds.map((id)=>list.ref.collection('members').doc(id));
262
- const found = await db.getAll(...refs);
263
- for (const member of found){
264
- if (!member.exists) continue;
265
- await member.ref.delete();
266
- counts.listMemberships += 1;
267
- }
268
- }
269
- } catch (error) {
270
- console.error('erasePerson: list membership sweep failed', error);
271
- }
118
+ counts.records = shares.contactIds.length;
119
+ counts.plugins = shares.reports;
272
120
  try {
273
121
  const sweep = await eraseEmailDeliveriesForAddresses([
274
122
  {
@@ -291,48 +139,5 @@ const defaultFirestore = ()=>firebaseAdmin.app().firestore();
291
139
  ok: true
292
140
  }, counts);
293
141
  }
294
- /**
295
- * Delete every document in `collection` whose `field` equals `value`, a
296
- * page at a time. Returns how many went; a failure logs and stops, and the
297
- * count says how far it got.
298
- */ async function deleteWhere(db, collection, field, value) {
299
- let removed = 0;
300
- try {
301
- for(let pass = 0; pass < MAX_PAGES; pass += 1){
302
- const page = await collection.where(field, '==', value).limit(PAGE).get();
303
- if (page.empty) break;
304
- const batch = db.batch();
305
- page.docs.forEach((doc)=>batch.delete(doc.ref));
306
- await batch.commit();
307
- removed += page.size;
308
- if (page.size < PAGE) break;
309
- }
310
- } catch (error) {
311
- console.error(`erasePerson: delete sweep failed on ${field}`, error);
312
- }
313
- return removed;
314
- }
315
- /**
316
- * Apply `patch` to every document in `collection` whose `field` equals
317
- * `value`. Because the patch clears the very field that was matched on, a
318
- * second page never sees the first page's rows again, so the loop needs no
319
- * cursor; the page cap is the only bound.
320
- */ async function updateWhere(db, collection, field, value, patch) {
321
- let changed = 0;
322
- try {
323
- for(let pass = 0; pass < MAX_PAGES; pass += 1){
324
- const page = await collection.where(field, '==', value).limit(PAGE).get();
325
- if (page.empty) break;
326
- const batch = db.batch();
327
- page.docs.forEach((doc)=>batch.update(doc.ref, typeof patch === 'function' ? patch(doc.data()) : patch));
328
- await batch.commit();
329
- changed += page.size;
330
- if (page.size < PAGE) break;
331
- }
332
- } catch (error) {
333
- console.error(`erasePerson: update sweep failed on ${field}`, error);
334
- }
335
- return changed;
336
- }
337
142
 
338
143
  //# sourceMappingURL=erase-person.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/erase-person.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { FieldValue } from 'firebase-admin/firestore'\n// The leaves, not the barrel — see `email-suppression.ts` for why: a spec\n// of this sweep substitutes a store, not the pure helpers it leans on.\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport { CRM_COLLECTIONS } from '@aglyn/aglyn/app-utils/crm'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport {\n runPluginPersonErasers,\n type PluginPersonErasureReport,\n} from '@aglyn/aglyn/plugin-manager/plugin-person-erasure'\nimport { runPluginEventHandlers } from '@aglyn/aglyn/plugin-manager/plugin-events'\nimport { companyContactsCountFields } from './contact-company-link'\nimport { eraseEmailDeliveriesForAddresses } from './email-delivery-log'\nimport { suppressEmailForHostErasure } from './email-suppression'\nimport firebaseAdmin from './firebase-admin'\nimport { listMemberDocIds } from './list-members'\nimport { addAdminAudit } from './admin-audit-write'\n\nconst defaultFirestore = () => firebaseAdmin.app().firestore()\n\n/** Firestore caps a batch at 500 writes; a page under it keeps a margin. */\nconst PAGE = 400\n\n/**\n * A pathological collection must not hold the erasure open indefinitely:\n * twenty pages of four hundred is eight thousand rows of one kind for one\n * person, past anything a real relationship produces.\n */\nconst MAX_PAGES = 20\n\nexport interface ErasePersonOptions {\n orgId: string\n /** Any spelling; normalized before anything is looked up. */\n email: unknown\n /** Injectable for tests; defaults to the admin app's Firestore. */\n firestore?: any\n /** Injectable for tests; defaults to `Date.now()`. */\n now?: number\n}\n\n/**\n * Counts, never identities: this is what the request document and the\n * audit row record, and both are read by people who must not learn the\n * address from them.\n */\nexport interface ErasePersonCounts {\n /** Sites in the workspace the sweep walked. */\n hosts: number\n /** Suppression rows written, one per site. */\n hostsSuppressed: number\n /** Contact documents deleted — the row every site shared. */\n contacts: number\n /** Companies whose contact count moved down. */\n companyLinks: number\n /** Deals whose `contactId` was removed. */\n deals: number\n tasks: number\n activities: number\n /** `hosts/{hostId}/leads/{personKey}` rows deleted. */\n leads: number\n /** Audience-list member rows deleted, across every list. */\n listMemberships: number\n /** Orders with the buyer's identity removed; the record stays. */\n orders: number\n /** Bookings with the person's identity removed; the record stays. */\n bookings: number\n /** Delivery-log messages deleted under the address. */\n emailDeliveries: number\n /**\n * Each plugin's share (AGL-2981), by plugin id: its eraser's own counts,\n * or `null` for an eraser that failed — its data may remain.\n */\n plugins: Record<string, PluginPersonErasureReport | null>\n}\n\nexport type ErasePersonResult =\n | ({ ok: true } & ErasePersonCounts)\n | { ok: false; skippedReason: 'invalid-email' }\n\n/**\n * Remove one person from one workspace (AGL-2623).\n *\n * `planContactDetach` is deliberately not consulted: the CRM's delete is a\n * detach that leaves the row for the other holders, and this is the act\n * that must not. The contact document goes whole — every site's facet,\n * every consent entry, every attribution — and with it everything the\n * workspace keeps beside the person.\n *\n * ## Order\n *\n * 1. **Suppress first.** One row per site on the per-site suppression\n * list, before any delete: a form filled in while the sweep runs must\n * already find the door closed, or the sweep deletes a row that the\n * capture re-creates a moment later.\n * 2. The plugins' share: every eraser registered on\n * `plugin-person-erasure`, handed the address, its key and the ids of\n * the contacts about to go, while those documents still exist.\n * 3. The contact and its satellites — company counts, deals unlinked,\n * tasks and activities deleted — by the contact's id, then the\n * document itself.\n * 4. Leads, list memberships, orders and bookings by the address, on\n * every site of the workspace.\n * 5. The delivery log, last: it is filed under the address alone, and the\n * tombstone it leaves is what keeps a later import from refilling it.\n *\n * ## What is anonymized rather than deleted\n *\n * An order is the merchant's record of a sale and a booking of an\n * appointment; the amounts, the line items and the tax are theirs to keep\n * and the law expects them kept. The person is taken OFF those records —\n * name, email, phone, addresses — and a stamp says when. A deal is the\n * team's own pipeline record and is unlinked. Everything else that names\n * the person is about the person and is deleted.\n *\n * ## What is not reached, and why\n *\n * A form submission keeps the address inside `fields`, under whatever the\n * form called it — there is no key to query by and a scan of every\n * submission on every site is unbounded. A site member's login is their\n * own account. A subscription carries the address it bills, and a live one\n * cannot be anonymized without breaking its receipts. Each is named to the\n * admin by the dialog so they can finish by hand.\n *\n * Every sweep is best-effort against the others: a failure in one is\n * logged and counted as zero, and the request's counts say what happened.\n * The caller decides whether zero contacts on a person it could see is a\n * failure to retry.\n */\nexport async function erasePerson(\n options: ErasePersonOptions,\n): Promise<ErasePersonResult> {\n const email = normalizeContactEmail(options.email)\n const key = email ? personKey(email) : null\n if (!email || !key) return { ok: false, skippedReason: 'invalid-email' }\n const db = options.firestore ?? defaultFirestore()\n const now = options.now ?? Date.now()\n const orgRef = db.collection('orgs').doc(options.orgId)\n\n const counts: ErasePersonCounts = {\n hosts: 0,\n hostsSuppressed: 0,\n contacts: 0,\n companyLinks: 0,\n deals: 0,\n tasks: 0,\n activities: 0,\n leads: 0,\n listMemberships: 0,\n orders: 0,\n bookings: 0,\n emailDeliveries: 0,\n plugins: {},\n }\n\n const hosts = await db\n .collection('hosts')\n .where('orgId', '==', options.orgId)\n .get()\n const hostIds: string[] = hosts.docs.map((doc: any) => String(doc.id))\n counts.hosts = hostIds.length\n\n for (const hostId of hostIds) {\n try {\n const written = await suppressEmailForHostErasure({ hostId, email, firestore: db })\n if (written) counts.hostsSuppressed += 1\n } catch (error) {\n console.error(`erasePerson: suppression write failed for ${hostId}`, error)\n }\n }\n\n const contacts = await orgRef.collection('contacts').where('email', '==', email).get()\n\n // What the plugins keep about the person (AGL-2981): records filed under\n // the organization by the contact or by the address, which none of the\n // sweeps here can see. Every site's door is already closed above, and\n // the contacts are still there for an eraser that reads one. Isolated\n // per plugin: one that fails is recorded as `null` and the erasure goes\n // on. This erasure has no plan of its own, so it is never a dry run.\n counts.plugins = await runPluginPersonErasers({\n orgId: options.orgId,\n email,\n key,\n contactIds: contacts.docs.map((contact: any) => String(contact.id)),\n dryRun: false,\n })\n\n for (const contact of contacts.docs) {\n const contactId = String(contact.id)\n const companyIds: unknown = contact.get('companyIds')\n const linked = Array.isArray(companyIds)\n ? companyIds.filter((id): id is string => typeof id === 'string' && !!id)\n : []\n for (const companyId of new Set(linked)) {\n try {\n await orgRef\n .collection(CRM_COLLECTIONS.companies)\n .doc(companyId)\n .update(companyContactsCountFields(-1))\n counts.companyLinks += 1\n } catch (error) {\n console.error(`erasePerson: company count could not move for ${companyId}`, error)\n }\n }\n counts.deals += await updateWhere(\n db,\n orgRef.collection(CRM_COLLECTIONS.deals),\n 'contactId',\n contactId,\n { contactId: FieldValue.delete(), updatedAt: FieldValue.serverTimestamp() },\n )\n counts.tasks += await deleteWhere(db, orgRef.collection(CRM_COLLECTIONS.tasks), 'contactId', contactId)\n counts.activities += await deleteWhere(\n db,\n orgRef.collection(CRM_COLLECTIONS.activities),\n 'contactId',\n contactId,\n )\n try {\n await contact.ref.delete()\n counts.contacts += 1\n } catch (error) {\n console.error(`erasePerson: contact delete failed for ${contactId}`, error)\n }\n }\n\n const erasedOrder = {\n customerEmail: null,\n customerName: null,\n shippingAddress: FieldValue.delete(),\n billingAddress: FieldValue.delete(),\n customerErasedAtMs: now,\n }\n /*\n * The address also lives in what the orders list queries by (AGL-3321):\n * its lower-cased key, its search prefixes, and the quick search's token\n * array, which holds those prefixes beside the order number's and the item\n * names'. The first two go; the prefixes are taken back out of the third.\n * A prefix the number or an item name shares with the address goes with\n * it — a search for that prefix misses this order until the orders\n * backfill restamps it — and nothing left on the order answers to the\n * address.\n */\n const erasedOrderFor = (data: Record<string, unknown> | undefined) => {\n const addressTokens = new Set(\n Array.isArray(data?.customerEmailTokens) ? data.customerEmailTokens : [],\n )\n return {\n ...erasedOrder,\n customerEmailLower: null,\n customerEmailTokens: [],\n ...(Array.isArray(data?.searchTokens)\n ? {\n searchTokens: data.searchTokens.filter(\n (token: unknown) => !addressTokens.has(token),\n ),\n }\n : {}),\n }\n }\n const erasedBooking = {\n email: null,\n name: FieldValue.delete(),\n phone: FieldValue.delete(),\n customerErasedAtMs: now,\n }\n /*\n * THE ORG ROW, ONCE (AGL-3275) — and then every legacy row still standing.\n *\n * A lead is one document for the whole org now, so the delete leaves the\n * per-site loop. It is not the caller that may assume the migration is\n * finished, though: until AGL-3276 has folded every site and AGL-3277 has\n * removed the fallback, a person can still be held at\n * `hosts/{hostId}/leads`, and an erasure that deleted only the org row would\n * leave that copy behind. So both are swept, and `counts.leads` is what was\n * actually destroyed rather than how many places were looked at.\n */\n /*\n * Every lead row this erasure removes, as it stood (AGL-3330). A plugin\n * that keeps a figure over leads — a form counts the people it filed —\n * is told once the rows are gone, through `host.records.removed`, so its\n * figure drops by the person this erasure removed and by nothing else.\n */\n const erasedLeads: Array<{ id: string; data: Record<string, unknown> }> = []\n try {\n const orgLead = orgRef.collection('leads').doc(key)\n const orgLeadSnapshot = await orgLead.get()\n if (orgLeadSnapshot.exists) {\n erasedLeads.push({ id: key, data: orgLeadSnapshot.data() ?? {} })\n await orgLead.delete()\n counts.leads += 1\n }\n } catch (error) {\n console.error('erasePerson: org lead delete failed', error)\n }\n for (const hostId of hostIds) {\n const hostRef = db.collection('hosts').doc(hostId)\n try {\n const lead = hostRef.collection('leads').doc(key)\n const leadSnapshot = await lead.get()\n if (leadSnapshot.exists) {\n erasedLeads.push({ id: key, data: leadSnapshot.data() ?? {} })\n await lead.delete()\n counts.leads += 1\n }\n } catch (error) {\n console.error(`erasePerson: legacy lead delete failed for ${hostId}`, error)\n }\n counts.orders += await updateWhere(db, hostRef.collection('orders'), 'customerEmail', email, erasedOrderFor)\n counts.bookings += await updateWhere(db, hostRef.collection('bookings'), 'email', email, erasedBooking)\n }\n\n if (erasedLeads.length) {\n // Isolated per plugin by the seam, and never the erasure's failure.\n await runPluginEventHandlers('host.records.removed', {\n orgId: options.orgId,\n hostIds,\n collection: 'leads',\n records: erasedLeads,\n }).catch((error) => console.error('erasePerson: records-removed event failed', error))\n }\n\n try {\n const memberIds = listMemberDocIds(email)\n const lists = await orgRef.collection('lists').get()\n for (const list of lists.docs) {\n const refs = memberIds.map((id) => list.ref.collection('members').doc(id))\n const found = await db.getAll(...refs)\n for (const member of found) {\n if (!member.exists) continue\n await member.ref.delete()\n counts.listMemberships += 1\n }\n }\n } catch (error) {\n console.error('erasePerson: list membership sweep failed', error)\n }\n\n try {\n const sweep = await eraseEmailDeliveriesForAddresses([{ address: email }], db)\n counts.emailDeliveries = sweep.removed\n } catch (error) {\n console.error('erasePerson: delivery log sweep failed', error)\n }\n\n await addAdminAudit(db, {\n actorUid: 'system:erase-person',\n action: 'person.erased',\n target: `orgs/${options.orgId}/people/${key}`,\n before: null,\n after: counts,\n at: FieldValue.serverTimestamp(),\n })\n .catch(() => undefined)\n\n return { ok: true, ...counts }\n}\n\n/**\n * Delete every document in `collection` whose `field` equals `value`, a\n * page at a time. Returns how many went; a failure logs and stops, and the\n * count says how far it got.\n */\nasync function deleteWhere(\n db: any,\n collection: any,\n field: string,\n value: string,\n): Promise<number> {\n let removed = 0\n try {\n for (let pass = 0; pass < MAX_PAGES; pass += 1) {\n const page = await collection.where(field, '==', value).limit(PAGE).get()\n if (page.empty) break\n const batch = db.batch()\n page.docs.forEach((doc: any) => batch.delete(doc.ref))\n await batch.commit()\n removed += page.size\n if (page.size < PAGE) break\n }\n } catch (error) {\n console.error(`erasePerson: delete sweep failed on ${field}`, error)\n }\n return removed\n}\n\n/**\n * Apply `patch` to every document in `collection` whose `field` equals\n * `value`. Because the patch clears the very field that was matched on, a\n * second page never sees the first page's rows again, so the loop needs no\n * cursor; the page cap is the only bound.\n */\nasync function updateWhere(\n db: any,\n collection: any,\n field: string,\n value: string,\n patch: Record<string, unknown> | ((data: Record<string, unknown> | undefined) => Record<string, unknown>),\n): Promise<number> {\n let changed = 0\n try {\n for (let pass = 0; pass < MAX_PAGES; pass += 1) {\n const page = await collection.where(field, '==', value).limit(PAGE).get()\n if (page.empty) break\n const batch = db.batch()\n page.docs.forEach((doc: any) =>\n batch.update(doc.ref, typeof patch === 'function' ? patch(doc.data()) : patch),\n )\n await batch.commit()\n changed += page.size\n if (page.size < PAGE) break\n }\n } catch (error) {\n console.error(`erasePerson: update sweep failed on ${field}`, error)\n }\n return changed\n}\n"],"names":["FieldValue","normalizeContactEmail","CRM_COLLECTIONS","personKey","runPluginPersonErasers","runPluginEventHandlers","companyContactsCountFields","eraseEmailDeliveriesForAddresses","suppressEmailForHostErasure","firebaseAdmin","listMemberDocIds","addAdminAudit","defaultFirestore","app","firestore","PAGE","MAX_PAGES","erasePerson","options","email","key","ok","skippedReason","db","now","Date","orgRef","collection","doc","orgId","counts","hosts","hostsSuppressed","contacts","companyLinks","deals","tasks","activities","leads","listMemberships","orders","bookings","emailDeliveries","plugins","where","get","hostIds","docs","map","String","id","length","hostId","written","error","console","contactIds","contact","dryRun","contactId","companyIds","linked","Array","isArray","filter","companyId","Set","companies","update","updateWhere","delete","updatedAt","serverTimestamp","deleteWhere","ref","erasedOrder","customerEmail","customerName","shippingAddress","billingAddress","customerErasedAtMs","erasedOrderFor","data","addressTokens","customerEmailTokens","customerEmailLower","searchTokens","token","has","erasedBooking","name","phone","erasedLeads","orgLead","orgLeadSnapshot","exists","push","hostRef","lead","leadSnapshot","records","catch","memberIds","lists","list","refs","found","getAll","member","sweep","address","removed","actorUid","action","target","before","after","at","undefined","field","value","pass","page","limit","empty","batch","forEach","commit","size","patch","changed"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/erase-person.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { FieldValue } from 'firebase-admin/firestore'\n// The leaves, not the barrel — see `email-suppression.ts` for why: a spec\n// of this sweep substitutes a store, not the pure helpers it leans on.\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport {\n missingRequiredPersonErasersAfterRepair,\n runPluginPersonErasure,\n type PluginPersonErasureReport,\n} from '@aglyn/aglyn/plugin-manager/plugin-person-erasure'\nimport { eraseEmailDeliveriesForAddresses } from './email-delivery-log'\nimport { suppressEmailForHostErasure } from './email-suppression'\nimport firebaseAdmin from './firebase-admin'\nimport { addAdminAudit } from './admin-audit-write'\n\nconst defaultFirestore = () => firebaseAdmin.app().firestore()\n\nexport interface ErasePersonOptions {\n orgId: string\n /** Any spelling; normalized before anything is looked up. */\n email: unknown\n /** Injectable for tests; defaults to the admin app's Firestore. */\n firestore?: any\n /** Injectable for tests; defaults to `Date.now()`. */\n now?: number\n}\n\n/**\n * Counts, never identities: this is what the request document and the audit\n * row record, and both are read by people who must not learn the address\n * from them.\n */\nexport interface ErasePersonCounts {\n /** Sites in the workspace the sweep walked. */\n hosts: number\n /** Suppression rows written, one per site. */\n hostsSuppressed: number\n /** The person's records the record system named, by count. */\n records: number\n /** Delivery-log messages deleted under the address. */\n emailDeliveries: number\n /**\n * Each plugin's share (AGL-2981, AGL-3080), by plugin id: its eraser's own\n * counts — the record system's people, a shop's orders, a calendar's\n * bookings, an audience's members — or `null` for an eraser that failed:\n * its data may remain.\n */\n plugins: Record<string, PluginPersonErasureReport | null>\n}\n\nexport type ErasePersonResult =\n | ({ ok: true } & ErasePersonCounts)\n | { ok: false; skippedReason: 'invalid-email' }\n\n/**\n * Remove one person from one workspace (AGL-2623).\n *\n * The platform's part is the door and the log; everything the workspace keeps\n * ABOUT the person is a plugin's, erased through `plugin-person-erasure`.\n *\n * ## Order\n *\n * 1. **Suppress first.** One row per site on the per-site suppression list,\n * before any delete: a form filled in while the sweep runs must already\n * find the door closed, or the sweep deletes a row that the capture\n * re-creates a moment later.\n * 2. **The plugins' shares** (`runPluginPersonErasure`): the plugin that\n * keeps people names the person's records; every other plugin erases\n * what it keeps — keyed by those records or by the address — and the\n * record system erases the records themselves last. A share the\n * erasure promises is REQUIRED: a missing one refuses the erasure before\n * anything is erased, and a failed one fails it after the rest ran, so\n * the job retries rather than reporting a person erased who is not.\n * 3. **The delivery log**, last: it is filed under the address alone, and\n * the tombstone it leaves is what keeps a later import from refilling it.\n *\n * ## What is anonymized rather than deleted\n *\n * Each plugin's own business: an order is the merchant's record of a sale\n * and a booking of an appointment, and the law expects them kept, so the\n * person is taken OFF them; a deal is the team's pipeline record and is\n * unlinked. Everything else that names the person is about the person and is\n * deleted. `PERSON_ERASURE_REMOVES` / `RETAINS` say so to the admin.\n *\n * ## What is not reached, and why\n *\n * A form submission keeps the address inside `fields`, under whatever the\n * form called it — there is no key to query by and a scan of every\n * submission on every site is unbounded. A site member's login is their own\n * account. Each is named to the admin by the dialog so they can finish by\n * hand.\n *\n * The suppression and the log are best-effort against each other: a failure\n * in one is logged and counted as zero, and the counts say what happened.\n */\nexport async function erasePerson(options: ErasePersonOptions): Promise<ErasePersonResult> {\n const email = normalizeContactEmail(options.email)\n const key = email ? personKey(email) : null\n if (!email || !key) return { ok: false, skippedReason: 'invalid-email' }\n const db = options.firestore ?? defaultFirestore()\n /*\n * A share the erasure promises that this process cannot run — even after\n * its boot step has run once more — refuses the whole erasure BEFORE it\n * writes anything: the request stays queued for a process whose boot\n * registered it, rather than reporting a person erased who is still on file.\n */\n const missing = await missingRequiredPersonErasersAfterRepair()\n if (missing.length) {\n throw new Error(\n `erasePerson refused: ${missing.join(', ')} declared a required person eraser ` +\n 'and none is registered in this process',\n )\n }\n\n const counts: ErasePersonCounts = {\n hosts: 0,\n hostsSuppressed: 0,\n records: 0,\n emailDeliveries: 0,\n plugins: {},\n }\n\n const hosts = await db.collection('hosts').where('orgId', '==', options.orgId).get()\n const hostIds: string[] = hosts.docs.map((doc: any) => String(doc.id))\n counts.hosts = hostIds.length\n\n for (const hostId of hostIds) {\n try {\n const written = await suppressEmailForHostErasure({ hostId, email, firestore: db })\n if (written) counts.hostsSuppressed += 1\n } catch (error) {\n console.error(`erasePerson: suppression write failed for ${hostId}`, error)\n }\n }\n\n // Every site's door is already closed above. This erasure has no plan of\n // its own, so it is never a dry run. A required share that is missing or\n // failed THROWS here, and the request stays queued for the next run.\n const shares = await runPluginPersonErasure({\n orgId: options.orgId,\n email,\n key,\n dryRun: false,\n atMs: options.now ?? Date.now(),\n })\n counts.records = shares.contactIds.length\n counts.plugins = shares.reports\n\n try {\n const sweep = await eraseEmailDeliveriesForAddresses([{ address: email }], db)\n counts.emailDeliveries = sweep.removed\n } catch (error) {\n console.error('erasePerson: delivery log sweep failed', error)\n }\n\n await addAdminAudit(db, {\n actorUid: 'system:erase-person',\n action: 'person.erased',\n target: `orgs/${options.orgId}/people/${key}`,\n before: null,\n after: counts,\n at: FieldValue.serverTimestamp(),\n }).catch(() => undefined)\n\n return { ok: true, ...counts }\n}\n"],"names":["FieldValue","normalizeContactEmail","personKey","missingRequiredPersonErasersAfterRepair","runPluginPersonErasure","eraseEmailDeliveriesForAddresses","suppressEmailForHostErasure","firebaseAdmin","addAdminAudit","defaultFirestore","app","firestore","erasePerson","options","email","key","ok","skippedReason","db","missing","length","Error","join","counts","hosts","hostsSuppressed","records","emailDeliveries","plugins","collection","where","orgId","get","hostIds","docs","map","doc","String","id","hostId","written","error","console","shares","dryRun","atMs","now","Date","contactIds","reports","sweep","address","removed","actorUid","action","target","before","after","at","serverTimestamp","catch","undefined"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,UAAU,QAAQ,2BAA0B;AACrD,0EAA0E;AAC1E,uEAAuE;AACvE,SAASC,qBAAqB,QAAQ,kCAAiC;AACvE,SAASC,SAAS,QAAQ,oCAAmC;AAC7D,SACEC,uCAAuC,EACvCC,sBAAsB,QAEjB,oDAAmD;AAC1D,SAASC,gCAAgC,QAAQ,0BAAsB;AACvE,SAASC,2BAA2B,QAAQ,yBAAqB;AACjE,OAAOC,mBAAmB,sBAAkB;AAC5C,SAASC,aAAa,QAAQ,yBAAqB;AAEnD,MAAMC,mBAAmB,IAAMF,cAAcG,GAAG,GAAGC,SAAS;AAuC5D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAwCC,GACD,OAAO,eAAeC,YAAYC,OAA2B;QAIhDA,oBA4CHA;IA/CR,MAAMC,QAAQb,sBAAsBY,QAAQC,KAAK;IACjD,MAAMC,MAAMD,QAAQZ,UAAUY,SAAS;IACvC,IAAI,CAACA,SAAS,CAACC,KAAK,OAAO;QAAEC,IAAI;QAAOC,eAAe;IAAgB;IACvE,MAAMC,MAAKL,qBAAAA,QAAQF,SAAS,YAAjBE,qBAAqBJ;IAChC;;;;;GAKC,GACD,MAAMU,UAAU,MAAMhB;IACtB,IAAIgB,QAAQC,MAAM,EAAE;QAClB,MAAM,IAAIC,MACR,CAAC,qBAAqB,EAAEF,QAAQG,IAAI,CAAC,MAAM,mCAAmC,CAAC,GAC7E;IAEN;IAEA,MAAMC,SAA4B;QAChCC,OAAO;QACPC,iBAAiB;QACjBC,SAAS;QACTC,iBAAiB;QACjBC,SAAS,CAAC;IACZ;IAEA,MAAMJ,QAAQ,MAAMN,GAAGW,UAAU,CAAC,SAASC,KAAK,CAAC,SAAS,MAAMjB,QAAQkB,KAAK,EAAEC,GAAG;IAClF,MAAMC,UAAoBT,MAAMU,IAAI,CAACC,GAAG,CAAC,CAACC,MAAaC,OAAOD,IAAIE,EAAE;IACpEf,OAAOC,KAAK,GAAGS,QAAQb,MAAM;IAE7B,KAAK,MAAMmB,UAAUN,QAAS;QAC5B,IAAI;YACF,MAAMO,UAAU,MAAMlC,4BAA4B;gBAAEiC;gBAAQzB;gBAAOH,WAAWO;YAAG;YACjF,IAAIsB,SAASjB,OAAOE,eAAe,IAAI;QACzC,EAAE,OAAOgB,OAAO;YACdC,QAAQD,KAAK,CAAC,CAAC,0CAA0C,EAAEF,QAAQ,EAAEE;QACvE;IACF;IAEA,yEAAyE;IACzE,yEAAyE;IACzE,qEAAqE;IACrE,MAAME,SAAS,MAAMvC,uBAAuB;QAC1C2B,OAAOlB,QAAQkB,KAAK;QACpBjB;QACAC;QACA6B,QAAQ;QACRC,IAAI,GAAEhC,eAAAA,QAAQiC,GAAG,YAAXjC,eAAekC,KAAKD,GAAG;IAC/B;IACAvB,OAAOG,OAAO,GAAGiB,OAAOK,UAAU,CAAC5B,MAAM;IACzCG,OAAOK,OAAO,GAAGe,OAAOM,OAAO;IAE/B,IAAI;QACF,MAAMC,QAAQ,MAAM7C,iCAAiC;YAAC;gBAAE8C,SAASrC;YAAM;SAAE,EAAEI;QAC3EK,OAAOI,eAAe,GAAGuB,MAAME,OAAO;IACxC,EAAE,OAAOX,OAAO;QACdC,QAAQD,KAAK,CAAC,0CAA0CA;IAC1D;IAEA,MAAMjC,cAAcU,IAAI;QACtBmC,UAAU;QACVC,QAAQ;QACRC,QAAQ,CAAC,KAAK,EAAE1C,QAAQkB,KAAK,CAAC,QAAQ,EAAEhB,KAAK;QAC7CyC,QAAQ;QACRC,OAAOlC;QACPmC,IAAI1D,WAAW2D,eAAe;IAChC,GAAGC,KAAK,CAAC,IAAMC;IAEf,OAAO;QAAE7C,IAAI;OAASO;AACxB"}