@aglyn/tenant-data-admin 1.0.0-beta.221 → 1.0.0-beta.222
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/src/lib/server/api-keys.js +3 -3
- package/src/lib/server/api-keys.js.map +1 -1
- package/src/lib/server/api-v1-resources.d.ts +44 -3
- package/src/lib/server/api-v1-resources.js +82 -5
- package/src/lib/server/api-v1-resources.js.map +1 -1
- package/src/lib/server/collection-live-pages.d.ts +2 -2
- package/src/lib/server/collection-live-pages.js +1 -1
- package/src/lib/server/collection-live-pages.js.map +1 -1
- package/src/lib/server/email-delivery-log.d.ts +17 -73
- package/src/lib/server/email-delivery-log.js +44 -222
- package/src/lib/server/email-delivery-log.js.map +1 -1
- package/src/lib/server/email-flow-gate.js +27 -38
- package/src/lib/server/email-flow-gate.js.map +1 -1
- package/src/lib/server/erase-person.d.ts +34 -54
- package/src/lib/server/erase-person.js +44 -239
- package/src/lib/server/erase-person.js.map +1 -1
- package/src/lib/server/host-visitor-records.d.ts +12 -4
- package/src/lib/server/host-visitor-records.js +20 -10
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.js +46 -33
- package/src/lib/server/hosted-page-review.js.map +1 -1
- package/src/lib/server/link-reputation-review.d.ts +6 -2
- package/src/lib/server/link-reputation-review.js +61 -20
- package/src/lib/server/link-reputation-review.js.map +1 -1
- package/src/lib/server/live-page-drops.d.ts +67 -0
- package/src/lib/server/live-page-drops.js +57 -0
- package/src/lib/server/live-page-drops.js.map +1 -0
- package/src/lib/server/media-bandwidth-cap.d.ts +78 -0
- package/src/lib/server/media-bandwidth-cap.js +180 -0
- package/src/lib/server/media-bandwidth-cap.js.map +1 -0
- package/src/lib/server/media-cdn-icon.d.ts +77 -0
- package/src/lib/server/media-cdn-icon.js +140 -0
- package/src/lib/server/media-cdn-icon.js.map +1 -0
- package/src/lib/server/media-delivery-regeneration.d.ts +132 -0
- package/src/lib/server/media-delivery-regeneration.js +216 -0
- package/src/lib/server/media-delivery-regeneration.js.map +1 -0
- package/src/lib/server/media-delivery.d.ts +2 -0
- package/src/lib/server/media-delivery.js +2 -1
- package/src/lib/server/media-delivery.js.map +1 -1
- package/src/lib/server/media-serve-count.d.ts +50 -0
- package/src/lib/server/media-serve-count.js +62 -0
- package/src/lib/server/media-serve-count.js.map +1 -0
- package/src/lib/server/media-signing.d.ts +13 -2
- package/src/lib/server/media-signing.js +12 -8
- package/src/lib/server/media-signing.js.map +1 -1
- package/src/lib/server/media-tombstone.js +7 -5
- package/src/lib/server/media-tombstone.js.map +1 -1
- package/src/lib/server/media-variants.d.ts +151 -20
- package/src/lib/server/media-variants.js +204 -27
- package/src/lib/server/media-variants.js.map +1 -1
- package/src/lib/server/organizations.d.ts +102 -2
- package/src/lib/server/organizations.js +388 -58
- package/src/lib/server/organizations.js.map +1 -1
- package/src/lib/server/outbound-send-review.js +5 -4
- package/src/lib/server/outbound-send-review.js.map +1 -1
- package/src/lib/server/paged-sweeps.d.ts +51 -0
- package/src/lib/server/paged-sweeps.js +79 -0
- package/src/lib/server/paged-sweeps.js.map +1 -0
- package/src/lib/server/platform-marketing-consent.d.ts +0 -49
- package/src/lib/server/platform-marketing-consent.js +27 -11
- package/src/lib/server/platform-marketing-consent.js.map +1 -1
- package/src/lib/server/serve-media-cdn.d.ts +69 -1
- package/src/lib/server/serve-media-cdn.js +424 -70
- package/src/lib/server/serve-media-cdn.js.map +1 -1
- package/src/lib/server/site-icon-render.d.ts +68 -0
- package/src/lib/server/site-icon-render.js +157 -0
- package/src/lib/server/site-icon-render.js.map +1 -0
- package/src/lib/server/staff-seat.d.ts +50 -0
- package/src/lib/server/staff-seat.js +118 -0
- package/src/lib/server/staff-seat.js.map +1 -0
- package/src/lib/server/transfer-export.d.ts +46 -0
- package/src/lib/server/transfer-export.js +221 -0
- package/src/lib/server/transfer-export.js.map +1 -0
- package/src/lib/server/transfer-jobs.d.ts +385 -0
- package/src/lib/server/transfer-jobs.js +2368 -0
- package/src/lib/server/transfer-jobs.js.map +1 -0
- package/src/lib/server/transfer-packages.d.ts +127 -0
- package/src/lib/server/transfer-packages.js +1036 -0
- package/src/lib/server/transfer-packages.js.map +1 -0
- package/src/lib/server/upgrade-proposal.d.ts +97 -0
- package/src/lib/server/upgrade-proposal.js +312 -0
- package/src/lib/server/upgrade-proposal.js.map +1 -0
- package/src/lib/server/upsert-contact.d.ts +17 -8
- package/src/lib/server/upsert-contact.js +82 -16
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/lib/server/web-risk.d.ts +48 -9
- package/src/lib/server/web-risk.js +281 -143
- package/src/lib/server/web-risk.js.map +1 -1
- package/src/tenancy.d.ts +3 -1
- package/src/tenancy.js +5 -1
- package/src/tenancy.js.map +1 -1
- package/src/lib/server/campaign-attribution-store.d.ts +0 -55
- package/src/lib/server/campaign-attribution-store.js +0 -95
- package/src/lib/server/campaign-attribution-store.js.map +0 -1
- package/src/lib/server/campaign-conversion-attribution.d.ts +0 -283
- package/src/lib/server/campaign-conversion-attribution.js +0 -450
- package/src/lib/server/campaign-conversion-attribution.js.map +0 -1
- package/src/lib/server/dataset-live-pages.d.ts +0 -179
- package/src/lib/server/dataset-live-pages.js +0 -421
- package/src/lib/server/dataset-live-pages.js.map +0 -1
- package/src/lib/server/email-revenue-attribution.d.ts +0 -185
- package/src/lib/server/email-revenue-attribution.js +0 -321
- 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
|
-
*
|
|
30
|
-
*
|
|
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
|
-
/**
|
|
38
|
-
|
|
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
|
|
57
|
-
*
|
|
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
|
-
*
|
|
71
|
-
*
|
|
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
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* 2. The plugins'
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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
|
-
*
|
|
108
|
-
*
|
|
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
|
-
*
|
|
112
|
-
* logged and counted as zero, and the
|
|
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 {
|
|
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
|
-
*
|
|
42
|
-
*
|
|
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
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* 2. The plugins'
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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
|
-
*
|
|
79
|
-
*
|
|
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
|
-
*
|
|
83
|
-
* logged and counted as zero, and the
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
|
|
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
|
-
|
|
139
|
-
|
|
115
|
+
dryRun: false,
|
|
116
|
+
atMs: (_options_now = options.now) != null ? _options_now : Date.now()
|
|
140
117
|
});
|
|
141
|
-
|
|
142
|
-
|
|
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"}
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* split as `visitorWriteRateLimitRefusal`, and for the same reason.
|
|
25
25
|
*/
|
|
26
26
|
import { type VisitorRecordKind } from '@aglyn/aglyn/server';
|
|
27
|
-
import { type
|
|
27
|
+
import { type PluginConversionTouch } from '@aglyn/aglyn/plugin-manager/plugin-conversion-credit';
|
|
28
28
|
import type { ConsentGroup, ScopeToken } from '@aglyn/aglyn/server';
|
|
29
29
|
/**
|
|
30
30
|
* A lead's one home: `orgs/{orgId}/leads/{personKey}` (AGL-3275).
|
|
@@ -142,7 +142,7 @@ export declare function leadForWrite(hostId: string, key: string): Promise<{
|
|
|
142
142
|
* count as shipped.
|
|
143
143
|
*
|
|
144
144
|
* Two audiences, one call, in exactly `recordAbuseCeilingTrip`'s shape
|
|
145
|
-
* (`
|
|
145
|
+
* (`libs/plugins/forms/src/lib/server/form-submit.ts`, AGL-1655):
|
|
146
146
|
*
|
|
147
147
|
* - A durable per-month refusal count at
|
|
148
148
|
* `hosts/{id}/counters/{siteMembers|leads}Refused`. Counters are excluded
|
|
@@ -219,6 +219,12 @@ export interface HostLeadInput {
|
|
|
219
219
|
* the same rule `upsertHostContact` applies.
|
|
220
220
|
*/
|
|
221
221
|
disclosedConsentGroup?: string | null;
|
|
222
|
+
/**
|
|
223
|
+
* A phone the capture learned (AGL-3493), E.164 as the door normalized
|
|
224
|
+
* it. Written only when the lead holds none: a number a rep typed on the
|
|
225
|
+
* lead, or an earlier capture brought, is not replaced by this one.
|
|
226
|
+
*/
|
|
227
|
+
phoneFill?: string;
|
|
222
228
|
}
|
|
223
229
|
/**
|
|
224
230
|
* Record one lead at `hosts/{hostId}/leads/{personKey}`, bounded by
|
|
@@ -285,7 +291,9 @@ export declare function addHostLeadOutcome(options: {
|
|
|
285
291
|
*/
|
|
286
292
|
ceiling?: number;
|
|
287
293
|
/**
|
|
288
|
-
*
|
|
294
|
+
* Where this person arrived from, already resolved by the door through the
|
|
295
|
+
* plugin that credits outcomes (`plugin-conversion-credit`), and opaque
|
|
296
|
+
* here.
|
|
289
297
|
*
|
|
290
298
|
* Resolved rather than raw, and passed rather than looked up, because one
|
|
291
299
|
* visitor action reaches several writers: a form submission that creates a
|
|
@@ -293,7 +301,7 @@ export declare function addHostLeadOutcome(options: {
|
|
|
293
301
|
* door that hands none — every order path, every import — attributes
|
|
294
302
|
* nothing, which is how a lead that no campaign caused stays uncredited.
|
|
295
303
|
*/
|
|
296
|
-
touch?:
|
|
304
|
+
touch?: PluginConversionTouch | null;
|
|
297
305
|
}): Promise<HostLeadOutcome>;
|
|
298
306
|
/** What {@link addHostLeadOutcome} did with one capture. */
|
|
299
307
|
export interface HostLeadOutcome {
|