@aglyn/tenant-data-admin 1.0.0-beta.199 → 1.0.0-beta.201

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 (96) hide show
  1. package/package.json +8 -7
  2. package/src/lib/server/admin-audit-write.d.ts +28 -0
  3. package/src/lib/server/admin-audit-write.js +46 -0
  4. package/src/lib/server/admin-audit-write.js.map +1 -0
  5. package/src/lib/server/admin-audit.d.ts +4 -9
  6. package/src/lib/server/admin-audit.js +12 -31
  7. package/src/lib/server/admin-audit.js.map +1 -1
  8. package/src/lib/server/consent-group-carry.js +2 -0
  9. package/src/lib/server/consent-group-carry.js.map +1 -1
  10. package/src/lib/server/contact-company-link.js +3 -0
  11. package/src/lib/server/contact-company-link.js.map +1 -1
  12. package/src/lib/server/contact-lifecycle-floor.js +3 -0
  13. package/src/lib/server/contact-lifecycle-floor.js.map +1 -1
  14. package/src/lib/server/contact-merge.js +4 -0
  15. package/src/lib/server/contact-merge.js.map +1 -1
  16. package/src/lib/server/crm-booking-activity.js +3 -2
  17. package/src/lib/server/crm-booking-activity.js.map +1 -1
  18. package/src/lib/server/crm-records.d.ts +15 -0
  19. package/src/lib/server/crm-records.js +52 -0
  20. package/src/lib/server/crm-records.js.map +1 -1
  21. package/src/lib/server/csp-aggregate.d.ts +16 -0
  22. package/src/lib/server/csp-aggregate.js +36 -0
  23. package/src/lib/server/csp-aggregate.js.map +1 -1
  24. package/src/lib/server/dns-probe.d.ts +7 -0
  25. package/src/lib/server/dns-probe.js +52 -0
  26. package/src/lib/server/dns-probe.js.map +1 -1
  27. package/src/lib/server/duplicate-resource.js +12 -5
  28. package/src/lib/server/duplicate-resource.js.map +1 -1
  29. package/src/lib/server/email-deliverability.d.ts +150 -0
  30. package/src/lib/server/email-deliverability.js +538 -0
  31. package/src/lib/server/email-deliverability.js.map +1 -0
  32. package/src/lib/server/email-delivery-log.d.ts +33 -0
  33. package/src/lib/server/email-delivery-log.js +43 -2
  34. package/src/lib/server/email-delivery-log.js.map +1 -1
  35. package/src/lib/server/email-sender-reputation.d.ts +2 -11
  36. package/src/lib/server/email-sender-reputation.js +8 -25
  37. package/src/lib/server/email-sender-reputation.js.map +1 -1
  38. package/src/lib/server/email-suppression.d.ts +21 -15
  39. package/src/lib/server/email-suppression.js +18 -34
  40. package/src/lib/server/email-suppression.js.map +1 -1
  41. package/src/lib/server/erase-person.js +53 -6
  42. package/src/lib/server/erase-person.js.map +1 -1
  43. package/src/lib/server/erase.js +4 -3
  44. package/src/lib/server/erase.js.map +1 -1
  45. package/src/lib/server/host-memberships.d.ts +34 -0
  46. package/src/lib/server/host-memberships.js +89 -1
  47. package/src/lib/server/host-memberships.js.map +1 -1
  48. package/src/lib/server/host-visitor-records.d.ts +16 -2
  49. package/src/lib/server/host-visitor-records.js +52 -7
  50. package/src/lib/server/host-visitor-records.js.map +1 -1
  51. package/src/lib/server/hosted-page-review.d.ts +92 -0
  52. package/src/lib/server/hosted-page-review.js +342 -0
  53. package/src/lib/server/hosted-page-review.js.map +1 -0
  54. package/src/lib/server/list-members.d.ts +11 -0
  55. package/src/lib/server/list-members.js +28 -1
  56. package/src/lib/server/list-members.js.map +1 -1
  57. package/src/lib/server/list-query.d.ts +36 -0
  58. package/src/lib/server/list-query.js +49 -0
  59. package/src/lib/server/list-query.js.map +1 -0
  60. package/src/lib/server/media-tombstone.js +8 -2
  61. package/src/lib/server/media-tombstone.js.map +1 -1
  62. package/src/lib/server/org-age.d.ts +27 -0
  63. package/src/lib/server/org-age.js +44 -0
  64. package/src/lib/server/org-age.js.map +1 -0
  65. package/src/lib/server/org-member-list-fields.d.ts +31 -0
  66. package/src/lib/server/org-member-list-fields.js +38 -0
  67. package/src/lib/server/org-member-list-fields.js.map +1 -0
  68. package/src/lib/server/organizations.js +37 -7
  69. package/src/lib/server/organizations.js.map +1 -1
  70. package/src/lib/server/outbound-send-review.d.ts +222 -0
  71. package/src/lib/server/outbound-send-review.js +479 -0
  72. package/src/lib/server/outbound-send-review.js.map +1 -0
  73. package/src/lib/server/payment-fraud-signal.d.ts +156 -0
  74. package/src/lib/server/payment-fraud-signal.js +368 -0
  75. package/src/lib/server/payment-fraud-signal.js.map +1 -0
  76. package/src/lib/server/payment-risk-record.d.ts +59 -0
  77. package/src/lib/server/payment-risk-record.js +76 -0
  78. package/src/lib/server/payment-risk-record.js.map +1 -0
  79. package/src/lib/server/replay-erasures.js +2 -1
  80. package/src/lib/server/replay-erasures.js.map +1 -1
  81. package/src/lib/server/sending-domains.d.ts +6 -0
  82. package/src/lib/server/sending-domains.js +98 -6
  83. package/src/lib/server/sending-domains.js.map +1 -1
  84. package/src/lib/server/sso-enforcement.js +2 -1
  85. package/src/lib/server/sso-enforcement.js.map +1 -1
  86. package/src/lib/server/stripe-card-authentication.d.ts +35 -0
  87. package/src/lib/server/stripe-card-authentication.js +63 -0
  88. package/src/lib/server/stripe-card-authentication.js.map +1 -0
  89. package/src/lib/server/tenant-write-lockdown.d.ts +32 -2
  90. package/src/lib/server/tenant-write-lockdown.js +33 -3
  91. package/src/lib/server/tenant-write-lockdown.js.map +1 -1
  92. package/src/lib/server/upsert-contact.js +8 -4
  93. package/src/lib/server/upsert-contact.js.map +1 -1
  94. package/src/tenancy.d.ts +3 -0
  95. package/src/tenancy.js +8 -0
  96. package/src/tenancy.js.map +1 -1
@@ -21,11 +21,13 @@ import { normalizeContactEmail } from "@aglyn/aglyn/app-utils/contacts";
21
21
  import { CRM_COLLECTIONS } from "@aglyn/aglyn/app-utils/crm";
22
22
  import { personKey } from "@aglyn/aglyn/app-utils/person-key";
23
23
  import { runPluginPersonErasers } from "@aglyn/aglyn/plugin-manager/plugin-person-erasure";
24
+ import { runPluginEventHandlers } from "@aglyn/aglyn/plugin-manager/plugin-events";
24
25
  import { companyContactsCountFields } from "./contact-company-link.js";
25
26
  import { eraseEmailDeliveriesForAddresses } from "./email-delivery-log.js";
26
27
  import { suppressEmailForHostErasure } from "./email-suppression.js";
27
28
  import firebaseAdmin from "./firebase-admin.js";
28
29
  import { listMemberDocIds } from "./list-members.js";
30
+ import { addAdminAudit } from "./admin-audit-write.js";
29
31
  const defaultFirestore = ()=>firebaseAdmin.app().firestore();
30
32
  /** Firestore caps a batch at 500 writes; a page under it keeps a margin. */ const PAGE = 400;
31
33
  /**
@@ -168,6 +170,24 @@ const defaultFirestore = ()=>firebaseAdmin.app().firestore();
168
170
  billingAddress: FieldValue.delete(),
169
171
  customerErasedAtMs: now
170
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
+ };
171
191
  const erasedBooking = {
172
192
  email: null,
173
193
  name: FieldValue.delete(),
@@ -184,9 +204,21 @@ const defaultFirestore = ()=>firebaseAdmin.app().firestore();
184
204
  * `hosts/{hostId}/leads`, and an erasure that deleted only the org row would
185
205
  * leave that copy behind. So both are swept, and `counts.leads` is what was
186
206
  * actually destroyed rather than how many places were looked at.
187
- */ try {
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 {
188
214
  const orgLead = orgRef.collection('leads').doc(key);
189
- if ((await orgLead.get()).exists) {
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
+ });
190
222
  await orgLead.delete();
191
223
  counts.leads += 1;
192
224
  }
@@ -197,16 +229,31 @@ const defaultFirestore = ()=>firebaseAdmin.app().firestore();
197
229
  const hostRef = db.collection('hosts').doc(hostId);
198
230
  try {
199
231
  const lead = hostRef.collection('leads').doc(key);
200
- if ((await lead.get()).exists) {
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
+ });
201
239
  await lead.delete();
202
240
  counts.leads += 1;
203
241
  }
204
242
  } catch (error) {
205
243
  console.error(`erasePerson: legacy lead delete failed for ${hostId}`, error);
206
244
  }
207
- counts.orders += await updateWhere(db, hostRef.collection('orders'), 'customerEmail', email, erasedOrder);
245
+ counts.orders += await updateWhere(db, hostRef.collection('orders'), 'customerEmail', email, erasedOrderFor);
208
246
  counts.bookings += await updateWhere(db, hostRef.collection('bookings'), 'email', email, erasedBooking);
209
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
+ }
210
257
  try {
211
258
  const memberIds = listMemberDocIds(email);
212
259
  const lists = await orgRef.collection('lists').get();
@@ -232,7 +279,7 @@ const defaultFirestore = ()=>firebaseAdmin.app().firestore();
232
279
  } catch (error) {
233
280
  console.error('erasePerson: delivery log sweep failed', error);
234
281
  }
235
- await db.collection('adminAudit').add({
282
+ await addAdminAudit(db, {
236
283
  actorUid: 'system:erase-person',
237
284
  action: 'person.erased',
238
285
  target: `orgs/${options.orgId}/people/${key}`,
@@ -277,7 +324,7 @@ const defaultFirestore = ()=>firebaseAdmin.app().firestore();
277
324
  const page = await collection.where(field, '==', value).limit(PAGE).get();
278
325
  if (page.empty) break;
279
326
  const batch = db.batch();
280
- page.docs.forEach((doc)=>batch.update(doc.ref, patch));
327
+ page.docs.forEach((doc)=>batch.update(doc.ref, typeof patch === 'function' ? patch(doc.data()) : patch));
281
328
  await batch.commit();
282
329
  changed += page.size;
283
330
  if (page.size < PAGE) break;
@@ -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 { 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'\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 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 try {\n const orgLead = orgRef.collection('leads').doc(key)\n if ((await orgLead.get()).exists) {\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 if ((await lead.get()).exists) {\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, erasedOrder)\n counts.bookings += await updateWhere(db, hostRef.collection('bookings'), 'email', email, erasedBooking)\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 db\n .collection('adminAudit')\n .add({\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>,\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) => batch.update(doc.ref, patch))\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","companyContactsCountFields","eraseEmailDeliveriesForAddresses","suppressEmailForHostErasure","firebaseAdmin","listMemberDocIds","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","erasedBooking","name","phone","orgLead","exists","hostRef","lead","memberIds","lists","list","refs","found","getAll","member","sweep","address","removed","add","actorUid","action","target","before","after","at","catch","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 { 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":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,UAAU,QAAQ,2BAA0B;AACrD,0EAA0E;AAC1E,uEAAuE;AACvE,SAASC,qBAAqB,QAAQ,kCAAiC;AACvE,SAASC,eAAe,QAAQ,6BAA4B;AAC5D,SAASC,SAAS,QAAQ,oCAAmC;AAC7D,SACEC,sBAAsB,QAEjB,oDAAmD;AAC1D,SAASC,sBAAsB,QAAQ,4CAA2C;AAClF,SAASC,0BAA0B,QAAQ,4BAAwB;AACnE,SAASC,gCAAgC,QAAQ,0BAAsB;AACvE,SAASC,2BAA2B,QAAQ,yBAAqB;AACjE,OAAOC,mBAAmB,sBAAkB;AAC5C,SAASC,gBAAgB,QAAQ,oBAAgB;AACjD,SAASC,aAAa,QAAQ,yBAAqB;AAEnD,MAAMC,mBAAmB,IAAMH,cAAcI,GAAG,GAAGC,SAAS;AAE5D,0EAA0E,GAC1E,MAAMC,OAAO;AAEb;;;;CAIC,GACD,MAAMC,YAAY;AAmDlB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgDC,GACD,OAAO,eAAeC,YACpBC,OAA2B;QAKhBA,oBACCA;IAJZ,MAAMC,QAAQlB,sBAAsBiB,QAAQC,KAAK;IACjD,MAAMC,MAAMD,QAAQhB,UAAUgB,SAAS;IACvC,IAAI,CAACA,SAAS,CAACC,KAAK,OAAO;QAAEC,IAAI;QAAOC,eAAe;IAAgB;IACvE,MAAMC,MAAKL,qBAAAA,QAAQJ,SAAS,YAAjBI,qBAAqBN;IAChC,MAAMY,OAAMN,eAAAA,QAAQM,GAAG,YAAXN,eAAeO,KAAKD,GAAG;IACnC,MAAME,SAASH,GAAGI,UAAU,CAAC,QAAQC,GAAG,CAACV,QAAQW,KAAK;IAEtD,MAAMC,SAA4B;QAChCC,OAAO;QACPC,iBAAiB;QACjBC,UAAU;QACVC,cAAc;QACdC,OAAO;QACPC,OAAO;QACPC,YAAY;QACZC,OAAO;QACPC,iBAAiB;QACjBC,QAAQ;QACRC,UAAU;QACVC,iBAAiB;QACjBC,SAAS,CAAC;IACZ;IAEA,MAAMZ,QAAQ,MAAMR,GACjBI,UAAU,CAAC,SACXiB,KAAK,CAAC,SAAS,MAAM1B,QAAQW,KAAK,EAClCgB,GAAG;IACN,MAAMC,UAAoBf,MAAMgB,IAAI,CAACC,GAAG,CAAC,CAACpB,MAAaqB,OAAOrB,IAAIsB,EAAE;IACpEpB,OAAOC,KAAK,GAAGe,QAAQK,MAAM;IAE7B,KAAK,MAAMC,UAAUN,QAAS;QAC5B,IAAI;YACF,MAAMO,UAAU,MAAM7C,4BAA4B;gBAAE4C;gBAAQjC;gBAAOL,WAAWS;YAAG;YACjF,IAAI8B,SAASvB,OAAOE,eAAe,IAAI;QACzC,EAAE,OAAOsB,OAAO;YACdC,QAAQD,KAAK,CAAC,CAAC,0CAA0C,EAAEF,QAAQ,EAAEE;QACvE;IACF;IAEA,MAAMrB,WAAW,MAAMP,OAAOC,UAAU,CAAC,YAAYiB,KAAK,CAAC,SAAS,MAAMzB,OAAO0B,GAAG;IAEpF,yEAAyE;IACzE,uEAAuE;IACvE,sEAAsE;IACtE,sEAAsE;IACtE,wEAAwE;IACxE,qEAAqE;IACrEf,OAAOa,OAAO,GAAG,MAAMvC,uBAAuB;QAC5CyB,OAAOX,QAAQW,KAAK;QACpBV;QACAC;QACAoC,YAAYvB,SAASc,IAAI,CAACC,GAAG,CAAC,CAACS,UAAiBR,OAAOQ,QAAQP,EAAE;QACjEQ,QAAQ;IACV;IAEA,KAAK,MAAMD,WAAWxB,SAASc,IAAI,CAAE;QACnC,MAAMY,YAAYV,OAAOQ,QAAQP,EAAE;QACnC,MAAMU,aAAsBH,QAAQZ,GAAG,CAAC;QACxC,MAAMgB,SAASC,MAAMC,OAAO,CAACH,cACzBA,WAAWI,MAAM,CAAC,CAACd,KAAqB,OAAOA,OAAO,YAAY,CAAC,CAACA,MACpE,EAAE;QACN,KAAK,MAAMe,aAAa,IAAIC,IAAIL,QAAS;YACvC,IAAI;gBACF,MAAMnC,OACHC,UAAU,CAACzB,gBAAgBiE,SAAS,EACpCvC,GAAG,CAACqC,WACJG,MAAM,CAAC9D,2BAA2B,CAAC;gBACtCwB,OAAOI,YAAY,IAAI;YACzB,EAAE,OAAOoB,OAAO;gBACdC,QAAQD,KAAK,CAAC,CAAC,8CAA8C,EAAEW,WAAW,EAAEX;YAC9E;QACF;QACAxB,OAAOK,KAAK,IAAI,MAAMkC,YACpB9C,IACAG,OAAOC,UAAU,CAACzB,gBAAgBiC,KAAK,GACvC,aACAwB,WACA;YAAEA,WAAW3D,WAAWsE,MAAM;YAAIC,WAAWvE,WAAWwE,eAAe;QAAG;QAE5E1C,OAAOM,KAAK,IAAI,MAAMqC,YAAYlD,IAAIG,OAAOC,UAAU,CAACzB,gBAAgBkC,KAAK,GAAG,aAAauB;QAC7F7B,OAAOO,UAAU,IAAI,MAAMoC,YACzBlD,IACAG,OAAOC,UAAU,CAACzB,gBAAgBmC,UAAU,GAC5C,aACAsB;QAEF,IAAI;YACF,MAAMF,QAAQiB,GAAG,CAACJ,MAAM;YACxBxC,OAAOG,QAAQ,IAAI;QACrB,EAAE,OAAOqB,OAAO;YACdC,QAAQD,KAAK,CAAC,CAAC,uCAAuC,EAAEK,WAAW,EAAEL;QACvE;IACF;IAEA,MAAMqB,cAAc;QAClBC,eAAe;QACfC,cAAc;QACdC,iBAAiB9E,WAAWsE,MAAM;QAClCS,gBAAgB/E,WAAWsE,MAAM;QACjCU,oBAAoBxD;IACtB;IACA;;;;;;;;;GASC,GACD,MAAMyD,iBAAiB,CAACC;QACtB,MAAMC,gBAAgB,IAAIjB,IACxBJ,MAAMC,OAAO,CAACmB,wBAAAA,KAAME,mBAAmB,IAAIF,KAAKE,mBAAmB,GAAG,EAAE;QAE1E,OAAO,aACFT;YACHU,oBAAoB;YACpBD,qBAAqB,EAAE;WACnBtB,MAAMC,OAAO,CAACmB,wBAAAA,KAAMI,YAAY,IAChC;YACEA,cAAcJ,KAAKI,YAAY,CAACtB,MAAM,CACpC,CAACuB,QAAmB,CAACJ,cAAcK,GAAG,CAACD;QAE3C,IACA,CAAC;IAET;IACA,MAAME,gBAAgB;QACpBtE,OAAO;QACPuE,MAAM1F,WAAWsE,MAAM;QACvBqB,OAAO3F,WAAWsE,MAAM;QACxBU,oBAAoBxD;IACtB;IACA;;;;;;;;;;GAUC,GACD;;;;;GAKC,GACD,MAAMoE,cAAoE,EAAE;IAC5E,IAAI;QACF,MAAMC,UAAUnE,OAAOC,UAAU,CAAC,SAASC,GAAG,CAACR;QAC/C,MAAM0E,kBAAkB,MAAMD,QAAQhD,GAAG;QACzC,IAAIiD,gBAAgBC,MAAM,EAAE;gBACQD;YAAlCF,YAAYI,IAAI,CAAC;gBAAE9C,IAAI9B;gBAAK8D,IAAI,GAAEY,wBAAAA,gBAAgBZ,IAAI,cAApBY,wBAA0B,CAAC;YAAE;YAC/D,MAAMD,QAAQvB,MAAM;YACpBxC,OAAOQ,KAAK,IAAI;QAClB;IACF,EAAE,OAAOgB,OAAO;QACdC,QAAQD,KAAK,CAAC,uCAAuCA;IACvD;IACA,KAAK,MAAMF,UAAUN,QAAS;QAC5B,MAAMmD,UAAU1E,GAAGI,UAAU,CAAC,SAASC,GAAG,CAACwB;QAC3C,IAAI;YACF,MAAM8C,OAAOD,QAAQtE,UAAU,CAAC,SAASC,GAAG,CAACR;YAC7C,MAAM+E,eAAe,MAAMD,KAAKrD,GAAG;YACnC,IAAIsD,aAAaJ,MAAM,EAAE;oBACWI;gBAAlCP,YAAYI,IAAI,CAAC;oBAAE9C,IAAI9B;oBAAK8D,IAAI,GAAEiB,qBAAAA,aAAajB,IAAI,cAAjBiB,qBAAuB,CAAC;gBAAE;gBAC5D,MAAMD,KAAK5B,MAAM;gBACjBxC,OAAOQ,KAAK,IAAI;YAClB;QACF,EAAE,OAAOgB,OAAO;YACdC,QAAQD,KAAK,CAAC,CAAC,2CAA2C,EAAEF,QAAQ,EAAEE;QACxE;QACAxB,OAAOU,MAAM,IAAI,MAAM6B,YAAY9C,IAAI0E,QAAQtE,UAAU,CAAC,WAAW,iBAAiBR,OAAO8D;QAC7FnD,OAAOW,QAAQ,IAAI,MAAM4B,YAAY9C,IAAI0E,QAAQtE,UAAU,CAAC,aAAa,SAASR,OAAOsE;IAC3F;IAEA,IAAIG,YAAYzC,MAAM,EAAE;QACtB,oEAAoE;QACpE,MAAM9C,uBAAuB,wBAAwB;YACnDwB,OAAOX,QAAQW,KAAK;YACpBiB;YACAnB,YAAY;YACZyE,SAASR;QACX,GAAGS,KAAK,CAAC,CAAC/C,QAAUC,QAAQD,KAAK,CAAC,6CAA6CA;IACjF;IAEA,IAAI;QACF,MAAMgD,YAAY5F,iBAAiBS;QACnC,MAAMoF,QAAQ,MAAM7E,OAAOC,UAAU,CAAC,SAASkB,GAAG;QAClD,KAAK,MAAM2D,QAAQD,MAAMxD,IAAI,CAAE;YAC7B,MAAM0D,OAAOH,UAAUtD,GAAG,CAAC,CAACE,KAAOsD,KAAK9B,GAAG,CAAC/C,UAAU,CAAC,WAAWC,GAAG,CAACsB;YACtE,MAAMwD,QAAQ,MAAMnF,GAAGoF,MAAM,IAAIF;YACjC,KAAK,MAAMG,UAAUF,MAAO;gBAC1B,IAAI,CAACE,OAAOb,MAAM,EAAE;gBACpB,MAAMa,OAAOlC,GAAG,CAACJ,MAAM;gBACvBxC,OAAOS,eAAe,IAAI;YAC5B;QACF;IACF,EAAE,OAAOe,OAAO;QACdC,QAAQD,KAAK,CAAC,6CAA6CA;IAC7D;IAEA,IAAI;QACF,MAAMuD,QAAQ,MAAMtG,iCAAiC;YAAC;gBAAEuG,SAAS3F;YAAM;SAAE,EAAEI;QAC3EO,OAAOY,eAAe,GAAGmE,MAAME,OAAO;IACxC,EAAE,OAAOzD,OAAO;QACdC,QAAQD,KAAK,CAAC,0CAA0CA;IAC1D;IAEA,MAAM3C,cAAcY,IAAI;QACtByF,UAAU;QACVC,QAAQ;QACRC,QAAQ,CAAC,KAAK,EAAEhG,QAAQW,KAAK,CAAC,QAAQ,EAAET,KAAK;QAC7C+F,QAAQ;QACRC,OAAOtF;QACPuF,IAAIrH,WAAWwE,eAAe;IAChC,GACG6B,KAAK,CAAC,IAAMiB;IAEf,OAAO;QAAEjG,IAAI;OAASS;AACxB;AAEA;;;;CAIC,GACD,eAAe2C,YACblD,EAAO,EACPI,UAAe,EACf4F,KAAa,EACbC,KAAa;IAEb,IAAIT,UAAU;IACd,IAAI;QACF,IAAK,IAAIU,OAAO,GAAGA,OAAOzG,WAAWyG,QAAQ,EAAG;YAC9C,MAAMC,OAAO,MAAM/F,WAAWiB,KAAK,CAAC2E,OAAO,MAAMC,OAAOG,KAAK,CAAC5G,MAAM8B,GAAG;YACvE,IAAI6E,KAAKE,KAAK,EAAE;YAChB,MAAMC,QAAQtG,GAAGsG,KAAK;YACtBH,KAAK3E,IAAI,CAAC+E,OAAO,CAAC,CAAClG,MAAaiG,MAAMvD,MAAM,CAAC1C,IAAI8C,GAAG;YACpD,MAAMmD,MAAME,MAAM;YAClBhB,WAAWW,KAAKM,IAAI;YACpB,IAAIN,KAAKM,IAAI,GAAGjH,MAAM;QACxB;IACF,EAAE,OAAOuC,OAAO;QACdC,QAAQD,KAAK,CAAC,CAAC,oCAAoC,EAAEiE,OAAO,EAAEjE;IAChE;IACA,OAAOyD;AACT;AAEA;;;;;CAKC,GACD,eAAe1C,YACb9C,EAAO,EACPI,UAAe,EACf4F,KAAa,EACbC,KAAa,EACbS,KAAyG;IAEzG,IAAIC,UAAU;IACd,IAAI;QACF,IAAK,IAAIT,OAAO,GAAGA,OAAOzG,WAAWyG,QAAQ,EAAG;YAC9C,MAAMC,OAAO,MAAM/F,WAAWiB,KAAK,CAAC2E,OAAO,MAAMC,OAAOG,KAAK,CAAC5G,MAAM8B,GAAG;YACvE,IAAI6E,KAAKE,KAAK,EAAE;YAChB,MAAMC,QAAQtG,GAAGsG,KAAK;YACtBH,KAAK3E,IAAI,CAAC+E,OAAO,CAAC,CAAClG,MACjBiG,MAAMzD,MAAM,CAACxC,IAAI8C,GAAG,EAAE,OAAOuD,UAAU,aAAaA,MAAMrG,IAAIsD,IAAI,MAAM+C;YAE1E,MAAMJ,MAAME,MAAM;YAClBG,WAAWR,KAAKM,IAAI;YACpB,IAAIN,KAAKM,IAAI,GAAGjH,MAAM;QACxB;IACF,EAAE,OAAOuC,OAAO;QACdC,QAAQD,KAAK,CAAC,CAAC,oCAAoC,EAAEiE,OAAO,EAAEjE;IAChE;IACA,OAAO4E;AACT"}
@@ -34,6 +34,7 @@ import { consentGroupSiteHold } from "@aglyn/aglyn/app-utils/consent-groups";
34
34
  import { listPluginOrgCollections } from "@aglyn/aglyn/plugin-manager/plugin-host-collections";
35
35
  import { readOrgBilling } from "./org-billing.js";
36
36
  import { disposeHostSendingDomain, readSendingDomainTeardownByLabel } from "./sending-domain-debt.js";
37
+ import { addAdminAudit } from "./admin-audit-write.js";
37
38
  /** The reversible hold before a requested erasure is executed (AGL-485). */ export const ERASURE_HOLD_MS = 7 * 24 * 60 * 60 * 1000;
38
39
  /**
39
40
  * The admin app is initialized without a default storageBucket, so every
@@ -827,7 +828,7 @@ export async function eraseHost(hostId, options = {}) {
827
828
  * failing, and losing the original error to a bookkeeping write would be
828
829
  * worse than losing the row.
829
830
  */ async function recordErasureFailure(entry) {
830
- await firebaseAdmin.app().firestore().collection('adminAudit').add({
831
+ await addAdminAudit(firebaseAdmin.app().firestore(), {
831
832
  actorUid: entry.actorUid,
832
833
  action: 'org.erase-failed',
833
834
  target: `orgs/${entry.orgId}`,
@@ -1132,7 +1133,7 @@ export async function eraseHost(hostId, options = {}) {
1132
1133
  // The proof of erasure, and now the only record of it: actor, action,
1133
1134
  // target, the request it fulfilled, the inventory found and what each sweep
1134
1135
  // destroyed. Ids and counts — never the content (AGL-1443).
1135
- await firestore.collection('adminAudit').add({
1136
+ await addAdminAudit(firestore, {
1136
1137
  actorUid,
1137
1138
  action: 'org.erased',
1138
1139
  target: `orgs/${orgId}`,
@@ -1543,7 +1544,7 @@ export async function eraseHost(hostId, options = {}) {
1543
1544
  contested: contestedAddressCount,
1544
1545
  incomplete: addressSet.incomplete
1545
1546
  };
1546
- await firestore.collection('adminAudit').add({
1547
+ await addAdminAudit(firestore, {
1547
1548
  actorUid: 'system:erase-user',
1548
1549
  action: 'user.erased',
1549
1550
  target: `users/${uid}`,