@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.
- package/package.json +8 -7
- package/src/lib/server/admin-audit-write.d.ts +28 -0
- package/src/lib/server/admin-audit-write.js +46 -0
- package/src/lib/server/admin-audit-write.js.map +1 -0
- package/src/lib/server/admin-audit.d.ts +4 -9
- package/src/lib/server/admin-audit.js +12 -31
- package/src/lib/server/admin-audit.js.map +1 -1
- package/src/lib/server/consent-group-carry.js +2 -0
- package/src/lib/server/consent-group-carry.js.map +1 -1
- package/src/lib/server/contact-company-link.js +3 -0
- package/src/lib/server/contact-company-link.js.map +1 -1
- package/src/lib/server/contact-lifecycle-floor.js +3 -0
- package/src/lib/server/contact-lifecycle-floor.js.map +1 -1
- package/src/lib/server/contact-merge.js +4 -0
- package/src/lib/server/contact-merge.js.map +1 -1
- package/src/lib/server/crm-booking-activity.js +3 -2
- package/src/lib/server/crm-booking-activity.js.map +1 -1
- package/src/lib/server/crm-records.d.ts +15 -0
- package/src/lib/server/crm-records.js +52 -0
- package/src/lib/server/crm-records.js.map +1 -1
- package/src/lib/server/csp-aggregate.d.ts +16 -0
- package/src/lib/server/csp-aggregate.js +36 -0
- package/src/lib/server/csp-aggregate.js.map +1 -1
- package/src/lib/server/dns-probe.d.ts +7 -0
- package/src/lib/server/dns-probe.js +52 -0
- package/src/lib/server/dns-probe.js.map +1 -1
- package/src/lib/server/duplicate-resource.js +12 -5
- package/src/lib/server/duplicate-resource.js.map +1 -1
- package/src/lib/server/email-deliverability.d.ts +150 -0
- package/src/lib/server/email-deliverability.js +538 -0
- package/src/lib/server/email-deliverability.js.map +1 -0
- package/src/lib/server/email-delivery-log.d.ts +33 -0
- package/src/lib/server/email-delivery-log.js +43 -2
- package/src/lib/server/email-delivery-log.js.map +1 -1
- package/src/lib/server/email-sender-reputation.d.ts +2 -11
- package/src/lib/server/email-sender-reputation.js +8 -25
- package/src/lib/server/email-sender-reputation.js.map +1 -1
- package/src/lib/server/email-suppression.d.ts +21 -15
- package/src/lib/server/email-suppression.js +18 -34
- package/src/lib/server/email-suppression.js.map +1 -1
- package/src/lib/server/erase-person.js +53 -6
- package/src/lib/server/erase-person.js.map +1 -1
- package/src/lib/server/erase.js +4 -3
- package/src/lib/server/erase.js.map +1 -1
- package/src/lib/server/host-memberships.d.ts +34 -0
- package/src/lib/server/host-memberships.js +89 -1
- package/src/lib/server/host-memberships.js.map +1 -1
- package/src/lib/server/host-visitor-records.d.ts +16 -2
- package/src/lib/server/host-visitor-records.js +52 -7
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.d.ts +92 -0
- package/src/lib/server/hosted-page-review.js +342 -0
- package/src/lib/server/hosted-page-review.js.map +1 -0
- package/src/lib/server/list-members.d.ts +11 -0
- package/src/lib/server/list-members.js +28 -1
- package/src/lib/server/list-members.js.map +1 -1
- package/src/lib/server/list-query.d.ts +36 -0
- package/src/lib/server/list-query.js +49 -0
- package/src/lib/server/list-query.js.map +1 -0
- package/src/lib/server/media-tombstone.js +8 -2
- package/src/lib/server/media-tombstone.js.map +1 -1
- package/src/lib/server/org-age.d.ts +27 -0
- package/src/lib/server/org-age.js +44 -0
- package/src/lib/server/org-age.js.map +1 -0
- package/src/lib/server/org-member-list-fields.d.ts +31 -0
- package/src/lib/server/org-member-list-fields.js +38 -0
- package/src/lib/server/org-member-list-fields.js.map +1 -0
- package/src/lib/server/organizations.js +37 -7
- package/src/lib/server/organizations.js.map +1 -1
- package/src/lib/server/outbound-send-review.d.ts +222 -0
- package/src/lib/server/outbound-send-review.js +479 -0
- package/src/lib/server/outbound-send-review.js.map +1 -0
- package/src/lib/server/payment-fraud-signal.d.ts +156 -0
- package/src/lib/server/payment-fraud-signal.js +368 -0
- package/src/lib/server/payment-fraud-signal.js.map +1 -0
- package/src/lib/server/payment-risk-record.d.ts +59 -0
- package/src/lib/server/payment-risk-record.js +76 -0
- package/src/lib/server/payment-risk-record.js.map +1 -0
- package/src/lib/server/replay-erasures.js +2 -1
- package/src/lib/server/replay-erasures.js.map +1 -1
- package/src/lib/server/sending-domains.d.ts +6 -0
- package/src/lib/server/sending-domains.js +98 -6
- package/src/lib/server/sending-domains.js.map +1 -1
- package/src/lib/server/sso-enforcement.js +2 -1
- package/src/lib/server/sso-enforcement.js.map +1 -1
- package/src/lib/server/stripe-card-authentication.d.ts +35 -0
- package/src/lib/server/stripe-card-authentication.js +63 -0
- package/src/lib/server/stripe-card-authentication.js.map +1 -0
- package/src/lib/server/tenant-write-lockdown.d.ts +32 -2
- package/src/lib/server/tenant-write-lockdown.js +33 -3
- package/src/lib/server/tenant-write-lockdown.js.map +1 -1
- package/src/lib/server/upsert-contact.js +8 -4
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/tenancy.d.ts +3 -0
- package/src/tenancy.js +8 -0
- package/src/tenancy.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/tenant-data-admin",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.201",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,12 +25,12 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-util-fbserver": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-util-first-touch": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.201",
|
|
29
|
+
"@aglyn/shared-util-email": "1.0.0-beta.201",
|
|
30
|
+
"@aglyn/shared-util-fbserver": "1.0.0-beta.201",
|
|
31
|
+
"@aglyn/shared-util-first-touch": "1.0.0-beta.201",
|
|
32
|
+
"@aglyn/shared-util-http": "1.0.0-beta.201",
|
|
33
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.201",
|
|
34
34
|
"@msgpack/msgpack": "^3.1.3",
|
|
35
35
|
"@swc/helpers": "0.5.23",
|
|
36
36
|
"sharp": "^0.35.3",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"sideEffects": [
|
|
45
45
|
"./src/lib/server/client-error-report.*",
|
|
46
|
+
"./src/lib/server/email-deliverability.*",
|
|
46
47
|
"./src/lib/server/email-marketing-gate.*",
|
|
47
48
|
"./src/lib/server/email-send-rate.*"
|
|
48
49
|
],
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import { type AdminAuditIndexSource } from '@aglyn/aglyn/app-utils/admin-audit-index';
|
|
18
|
+
export declare const ADMIN_AUDIT_COLLECTION = "adminAudit";
|
|
19
|
+
/** A batch or a transaction: anything that sets a document. */
|
|
20
|
+
type AuditWriter = Pick<FirebaseFirestore.WriteBatch, 'set'> | Pick<FirebaseFirestore.Transaction, 'set'>;
|
|
21
|
+
/** Add one audit row, stamped. Resolves to the new document's reference. */
|
|
22
|
+
export declare function addAdminAudit<Entry extends AdminAuditIndexSource>(firestore: FirebaseFirestore.Firestore, entry: Entry): Promise<FirebaseFirestore.DocumentReference>;
|
|
23
|
+
/**
|
|
24
|
+
* Set one new audit row inside a batch or a transaction, stamped, so it
|
|
25
|
+
* commits with the change it records. Returns the new document's reference.
|
|
26
|
+
*/
|
|
27
|
+
export declare function setAdminAudit<Entry extends AdminAuditIndexSource>(writer: AuditWriter, firestore: FirebaseFirestore.Firestore, entry: Entry): FirebaseFirestore.DocumentReference;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ import { withAdminAuditIndex } from "@aglyn/aglyn/app-utils/admin-audit-index";
|
|
17
|
+
/*
|
|
18
|
+
* THE ONE DOOR INTO `adminAudit` (AGL-3321).
|
|
19
|
+
*
|
|
20
|
+
* The staff audit page queries each row by the `actionGroup` and
|
|
21
|
+
* `searchTokens` the writer stamped (`withAdminAuditIndex`). A row written
|
|
22
|
+
* without them is a row the Action group filter and the search can never
|
|
23
|
+
* find, while it still lists normally, so the gap would show only as an
|
|
24
|
+
* audit search that quietly misses one entry. Every server write therefore
|
|
25
|
+
* goes through these two functions, and
|
|
26
|
+
* `apps/console/specs/admin-audit-writes-are-stamped.spec.ts` refuses a
|
|
27
|
+
* write to the collection anywhere else.
|
|
28
|
+
*
|
|
29
|
+
* The Firestore instance is the caller's, never looked up here. A route's
|
|
30
|
+
* own handle is the one its spec replaces, and this module imports no
|
|
31
|
+
* Firebase at all, so reaching it costs a caller nothing it did not already
|
|
32
|
+
* load.
|
|
33
|
+
*/ export const ADMIN_AUDIT_COLLECTION = 'adminAudit';
|
|
34
|
+
/** Add one audit row, stamped. Resolves to the new document's reference. */ export function addAdminAudit(firestore, entry) {
|
|
35
|
+
return firestore.collection(ADMIN_AUDIT_COLLECTION).add(withAdminAuditIndex(entry));
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Set one new audit row inside a batch or a transaction, stamped, so it
|
|
39
|
+
* commits with the change it records. Returns the new document's reference.
|
|
40
|
+
*/ export function setAdminAudit(writer, firestore, entry) {
|
|
41
|
+
const ref = firestore.collection(ADMIN_AUDIT_COLLECTION).doc();
|
|
42
|
+
writer.set(ref, withAdminAuditIndex(entry));
|
|
43
|
+
return ref;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
//# sourceMappingURL=admin-audit-write.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/admin-audit-write.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 {\n type AdminAuditIndexSource,\n withAdminAuditIndex,\n} from '@aglyn/aglyn/app-utils/admin-audit-index'\n\n/*\n * THE ONE DOOR INTO `adminAudit` (AGL-3321).\n *\n * The staff audit page queries each row by the `actionGroup` and\n * `searchTokens` the writer stamped (`withAdminAuditIndex`). A row written\n * without them is a row the Action group filter and the search can never\n * find, while it still lists normally, so the gap would show only as an\n * audit search that quietly misses one entry. Every server write therefore\n * goes through these two functions, and\n * `apps/console/specs/admin-audit-writes-are-stamped.spec.ts` refuses a\n * write to the collection anywhere else.\n *\n * The Firestore instance is the caller's, never looked up here. A route's\n * own handle is the one its spec replaces, and this module imports no\n * Firebase at all, so reaching it costs a caller nothing it did not already\n * load.\n */\n\nexport const ADMIN_AUDIT_COLLECTION = 'adminAudit'\n\n/** A batch or a transaction: anything that sets a document. */\ntype AuditWriter = Pick<FirebaseFirestore.WriteBatch, 'set'> | Pick<FirebaseFirestore.Transaction, 'set'>\n\n/** Add one audit row, stamped. Resolves to the new document's reference. */\nexport function addAdminAudit<Entry extends AdminAuditIndexSource>(\n firestore: FirebaseFirestore.Firestore,\n entry: Entry,\n): Promise<FirebaseFirestore.DocumentReference> {\n return firestore.collection(ADMIN_AUDIT_COLLECTION).add(withAdminAuditIndex(entry))\n}\n\n/**\n * Set one new audit row inside a batch or a transaction, stamped, so it\n * commits with the change it records. Returns the new document's reference.\n */\nexport function setAdminAudit<Entry extends AdminAuditIndexSource>(\n writer: AuditWriter,\n firestore: FirebaseFirestore.Firestore,\n entry: Entry,\n): FirebaseFirestore.DocumentReference {\n const ref = firestore.collection(ADMIN_AUDIT_COLLECTION).doc()\n ;(writer as Pick<FirebaseFirestore.WriteBatch, 'set'>).set(ref, withAdminAuditIndex(entry))\n return ref\n}\n"],"names":["withAdminAuditIndex","ADMIN_AUDIT_COLLECTION","addAdminAudit","firestore","entry","collection","add","setAdminAudit","writer","ref","doc","set"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAEEA,mBAAmB,QACd,2CAA0C;AAEjD;;;;;;;;;;;;;;;;CAgBC,GAED,OAAO,MAAMC,yBAAyB,aAAY;AAKlD,0EAA0E,GAC1E,OAAO,SAASC,cACdC,SAAsC,EACtCC,KAAY;IAEZ,OAAOD,UAAUE,UAAU,CAACJ,wBAAwBK,GAAG,CAACN,oBAAoBI;AAC9E;AAEA;;;CAGC,GACD,OAAO,SAASG,cACdC,MAAmB,EACnBL,SAAsC,EACtCC,KAAY;IAEZ,MAAMK,MAAMN,UAAUE,UAAU,CAACJ,wBAAwBS,GAAG;IAC1DF,OAAqDG,GAAG,CAACF,KAAKT,oBAAoBI;IACpF,OAAOK;AACT"}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
+
import { ADMIN_AUDIT_COLLECTION } from './admin-audit-write';
|
|
17
18
|
/**
|
|
18
19
|
* THE STAFF AUDIT TRAIL: WHO A ROW IS ABOUT, AND HOW MANY TIMES IT HAPPENED.
|
|
19
20
|
*
|
|
@@ -32,7 +33,8 @@
|
|
|
32
33
|
* `subjectUid` is the separate fact, and it is what makes "who at Aglyn
|
|
33
34
|
* read my email" a query rather than a manual trawl.
|
|
34
35
|
*/
|
|
35
|
-
export
|
|
36
|
+
export { ADMIN_AUDIT_COLLECTION };
|
|
37
|
+
export { addAdminAudit, setAdminAudit } from './admin-audit-write';
|
|
36
38
|
/**
|
|
37
39
|
* How close together two identical acts must be to count as ONE act
|
|
38
40
|
* recorded twice.
|
|
@@ -50,14 +52,7 @@ export declare const ADMIN_AUDIT_COLLECTION = "adminAudit";
|
|
|
50
52
|
* interval at which a second look is a second decision.
|
|
51
53
|
*/
|
|
52
54
|
export declare const ADMIN_AUDIT_DEDUPE_WINDOW_MS = 10000;
|
|
53
|
-
|
|
54
|
-
export type AdminAuditKind = 'access' | 'change';
|
|
55
|
-
/**
|
|
56
|
-
* An access when the platform or a plugin declares the action a read — a
|
|
57
|
-
* plugin's staff card opening on an org or an account names its own read
|
|
58
|
-
* actions through its activity group (AGL-2939) — and a change otherwise.
|
|
59
|
-
*/
|
|
60
|
-
export declare function adminAuditKind(action: string | null | undefined): AdminAuditKind;
|
|
55
|
+
export { ADMIN_AUDIT_ACCESS_ACTIONS, type AdminAuditKind, adminAuditKind, } from '@aglyn/aglyn/app-utils/admin-audit-index';
|
|
61
56
|
export interface AdminAuditWrite {
|
|
62
57
|
/** The staff account performing the act. */
|
|
63
58
|
actorUid: string;
|
|
@@ -14,8 +14,9 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
14
14
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
15
|
* See the License for the specific language governing permissions and
|
|
16
16
|
* limitations under the License.
|
|
17
|
-
*/ import {
|
|
17
|
+
*/ import { withAdminAuditIndex } from "@aglyn/aglyn/app-utils/admin-audit-index";
|
|
18
18
|
import { attributableAccountForAddress } from "./account-addresses.js";
|
|
19
|
+
import { ADMIN_AUDIT_COLLECTION } from "./admin-audit-write.js";
|
|
19
20
|
import { emailSuppressionKey } from "./email-suppression.js";
|
|
20
21
|
import { firebaseAdmin } from "./firebase-admin.js";
|
|
21
22
|
/**
|
|
@@ -35,7 +36,8 @@ import { firebaseAdmin } from "./firebase-admin.js";
|
|
|
35
36
|
* cannot also name the person, because a message id is not a user path.
|
|
36
37
|
* `subjectUid` is the separate fact, and it is what makes "who at Aglyn
|
|
37
38
|
* read my email" a query rather than a manual trawl.
|
|
38
|
-
*/ export
|
|
39
|
+
*/ export { ADMIN_AUDIT_COLLECTION };
|
|
40
|
+
export { addAdminAudit, setAdminAudit } from "./admin-audit-write.js";
|
|
39
41
|
/**
|
|
40
42
|
* How close together two identical acts must be to count as ONE act
|
|
41
43
|
* recorded twice.
|
|
@@ -52,33 +54,10 @@ import { firebaseAdmin } from "./firebase-admin.js";
|
|
|
52
54
|
* separateness of two accesses, so it must stay far shorter than the
|
|
53
55
|
* interval at which a second look is a second decision.
|
|
54
56
|
*/ export const ADMIN_AUDIT_DEDUPE_WINDOW_MS = 10000;
|
|
55
|
-
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
|
|
59
|
-
* matters more than the membership: anything absent is a `change`. A change
|
|
60
|
-
* is the louder half of the console's audit card, so an action nobody has
|
|
61
|
-
* classified yet gets the MORE prominent treatment rather than the quieter
|
|
62
|
-
* one. The failure mode of the opposite default is an unclassified
|
|
63
|
-
* impersonation rendering as routine browsing.
|
|
64
|
-
*
|
|
65
|
-
* An export is deliberately NOT here. Data leaving the platform is a
|
|
66
|
-
* high-consequence act even though it mutates nothing, and it belongs beside
|
|
67
|
-
* the impersonations rather than beside the record views.
|
|
68
|
-
*/ const ADMIN_AUDIT_ACCESS_ACTIONS = new Set([
|
|
69
|
-
'email.message-viewed',
|
|
70
|
-
// The acquisition card (AGL-3289): where an account or a workspace came
|
|
71
|
-
// from, cross-checked against the sales workspace's people. Read only.
|
|
72
|
-
'user.acquisition-viewed',
|
|
73
|
-
'org.acquisition-viewed'
|
|
74
|
-
]);
|
|
75
|
-
/**
|
|
76
|
-
* An access when the platform or a plugin declares the action a read — a
|
|
77
|
-
* plugin's staff card opening on an org or an account names its own read
|
|
78
|
-
* actions through its activity group (AGL-2939) — and a change otherwise.
|
|
79
|
-
*/ export function adminAuditKind(action) {
|
|
80
|
-
return action && (ADMIN_AUDIT_ACCESS_ACTIONS.has(action) || isPluginStaffAuditAccess(action)) ? 'access' : 'change';
|
|
81
|
-
}
|
|
57
|
+
/*
|
|
58
|
+
* Whether an act only looked is stamped on the row (`kind`, AGL-3321), so the
|
|
59
|
+
* rule that decides it lives beside the other stamped fields.
|
|
60
|
+
*/ export { ADMIN_AUDIT_ACCESS_ACTIONS, adminAuditKind } from "@aglyn/aglyn/app-utils/admin-audit-index";
|
|
82
61
|
function toDate(value) {
|
|
83
62
|
var _value_toDate;
|
|
84
63
|
const converted = value == null ? void 0 : (_value_toDate = value.toDate) == null ? void 0 : _value_toDate.call(value);
|
|
@@ -123,7 +102,9 @@ function toDate(value) {
|
|
|
123
102
|
});
|
|
124
103
|
return;
|
|
125
104
|
}
|
|
126
|
-
|
|
105
|
+
// Stamped like every other row (`withAdminAuditIndex`): the audit
|
|
106
|
+
// page's Action group filter and search query these fields.
|
|
107
|
+
transaction.create(collection.doc(), withAdminAuditIndex(_extends({
|
|
127
108
|
actorUid: entry.actorUid,
|
|
128
109
|
action: entry.action,
|
|
129
110
|
target: entry.target
|
|
@@ -141,7 +122,7 @@ function toDate(value) {
|
|
|
141
122
|
* row that says how often somebody looked.
|
|
142
123
|
*/ lastAt: now,
|
|
143
124
|
repeatCount: 1
|
|
144
|
-
}));
|
|
125
|
+
})));
|
|
145
126
|
});
|
|
146
127
|
}
|
|
147
128
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/admin-audit.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 { isPluginStaffAuditAccess } from '@aglyn/aglyn/plugin-manager/plugin-activity-actions'\nimport { attributableAccountForAddress } from './account-addresses'\nimport { emailSuppressionKey } from './email-suppression'\nimport { firebaseAdmin } from './firebase-admin'\n\n/**\n * THE STAFF AUDIT TRAIL: WHO A ROW IS ABOUT, AND HOW MANY TIMES IT HAPPENED.\n *\n * `adminAudit` is not a convenience log. Firestore rules close\n * `emailDeliveries` to EVERYONE, staff included, and the console route is\n * allowed to read it only because it establishes a staff claim and records\n * who looked. The row this module writes is that compensating control, so\n * the two properties it exists to provide are:\n *\n * 1. **No access is lost.** Two views minutes apart are two accesses. A\n * collapse that dropped one would answer \"nobody read your mail\" about a\n * read that happened, which is the single failure this collection cannot\n * have.\n * 2. **The subject is answerable.** `target` names the THING acted on; it\n * cannot also name the person, because a message id is not a user path.\n * `subjectUid` is the separate fact, and it is what makes \"who at Aglyn\n * read my email\" a query rather than a manual trawl.\n */\n\nexport const ADMIN_AUDIT_COLLECTION = 'adminAudit'\n\n/**\n * How close together two identical acts must be to count as ONE act\n * recorded twice.\n *\n * A single click can reach this writer more than once — a re-run effect, a\n * retried request, a double submit — and each arrival is the same access.\n * Ten seconds sits an order of magnitude above that (the observed re-entry\n * was one second apart) and well below a person deliberately re-opening a\n * record (observed at fifty-three seconds). Nothing a human does twice on\n * purpose lands inside it.\n *\n * ⚠️ A collapse still RECORDS the repeat: `repeatCount` and `lastAt` carry\n * it, and the console renders both. Widening this window trades away the\n * separateness of two accesses, so it must stay far shorter than the\n * interval at which a second look is a second decision.\n */\nexport const ADMIN_AUDIT_DEDUPE_WINDOW_MS = 10_000\n\n/** Access looked at data; change altered something or acted on someone. */\nexport type AdminAuditKind = 'access' | 'change'\n\n/**\n * The actions that only LOOKED.\n *\n * An exception list, not a classification of everything, and the default\n * matters more than the membership: anything absent is a `change`. A change\n * is the louder half of the console's audit card, so an action nobody has\n * classified yet gets the MORE prominent treatment rather than the quieter\n * one. The failure mode of the opposite default is an unclassified\n * impersonation rendering as routine browsing.\n *\n * An export is deliberately NOT here. Data leaving the platform is a\n * high-consequence act even though it mutates nothing, and it belongs beside\n * the impersonations rather than beside the record views.\n */\nconst ADMIN_AUDIT_ACCESS_ACTIONS: ReadonlySet<string> = new Set([\n 'email.message-viewed',\n // The acquisition card (AGL-3289): where an account or a workspace came\n // from, cross-checked against the sales workspace's people. Read only.\n 'user.acquisition-viewed',\n 'org.acquisition-viewed',\n])\n\n/**\n * An access when the platform or a plugin declares the action a read — a\n * plugin's staff card opening on an org or an account names its own read\n * actions through its activity group (AGL-2939) — and a change otherwise.\n */\nexport function adminAuditKind(\n action: string | null | undefined,\n): AdminAuditKind {\n return action && (ADMIN_AUDIT_ACCESS_ACTIONS.has(action) || isPluginStaffAuditAccess(action))\n ? 'access'\n : 'change'\n}\n\nexport interface AdminAuditWrite {\n /** The staff account performing the act. */\n actorUid: string\n action: string\n /** The THING acted on, as a path or a stable identifier. */\n target: string\n /**\n * The person the act is ABOUT, when one can be resolved.\n *\n * Absent is a correct answer, not a failure: a recipient may be a site\n * member, a prospect or a bare address with no platform account behind it,\n * and inventing a uid for one of those would put an act on an innocent\n * person's page.\n */\n subjectUid?: string | null\n /**\n * `sha256` of the recipient address the act was about, when it had one.\n *\n * ## Why a hash and not the address\n *\n * The SAME derivation `emailSuppressionKey` uses, so this joins the\n * delivery log without a second key space. It is hashed for the reason\n * {@link maskEmailAddress} exists: `adminAudit` is readable by any staff\n * role, and a dump of it must not yield a mailing list.\n *\n * ## Why it exists at all, next to `subjectUid`\n *\n * `subjectUid` can only be written when the address resolves to exactly one\n * account, and an address is not reliably resolvable to one — a\n * provider-supplied address may be held by a second account with nothing\n * recording it. So the subject is now OMITTED whenever the answer is\n * ambiguous, and omitting it alone would make the access invisible on every\n * page, which is worse than the guess it replaced.\n *\n * This is the fact that needs no guess. The account page queries it with\n * the keys of every address that account holds, so one access appears on\n * the page of each account holding the address — which is the honest answer\n * when the mail went to a mailbox rather than to a uid.\n *\n * ## It opens no tier side-door\n *\n * `adminAudit` is `allow read, create: if isStaff()` with NO tier gate, so\n * anything written here is readable by every staff session whatever its\n * role — which makes an audit row a way around a tier restriction on the\n * surface the value came from. Checked, and it is not one here: the writer\n * is the message route, which gates on `staff` alone and nothing finer, so\n * this field is no more widely readable than its source.\n *\n * The hash adds no reach of its own either. It is the document id\n * `emailDeliveries` is already filed under, so any staff session that can\n * read this row could already derive the same value from an address it\n * guessed. A field whose source DID restrict by tier would not belong here.\n */\n subjectAddressKey?: string | null\n /** Human-readable context. ⚠️ Never a raw address — see `maskEmailAddress`. */\n note?: string | null\n}\n\nfunction toDate(value: unknown): Date | null {\n const converted = (value as { toDate?: () => Date } | null)?.toDate?.()\n if (converted instanceof Date) return converted\n return value instanceof Date ? value : null\n}\n\n/**\n * Write one audit row, collapsing an immediate repeat of the same act onto\n * the row already there.\n *\n * The candidate is the MOST RECENT row on the same target, which the live\n * `target ASC, at DESC` index already answers — so this costs one indexed\n * single-document read and needs no index of its own. Anything else landing\n * on that target in between ends the run, which is the conservative\n * direction: an interleaved act means the two views were not one action.\n *\n * The read and the write are one TRANSACTION because the duplicates this\n * exists to catch are CONCURRENT. Two requests from one click race each\n * other; a plain read-then-write would let both see \"nothing recent\" and\n * both insert, defeating the collapse exactly when it is needed.\n */\nexport async function recordAdminAudit(entry: AdminAuditWrite): Promise<void> {\n const firestore = firebaseAdmin.app().firestore()\n const collection = firestore.collection(ADMIN_AUDIT_COLLECTION)\n const now = new Date()\n const mostRecentOnTarget = collection\n .where('target', '==', entry.target)\n .orderBy('at', 'desc')\n .limit(1)\n\n await firestore.runTransaction(async (transaction) => {\n const previous = (await transaction.get(mostRecentOnTarget)).docs[0]\n const previousAt = previous\n ? (toDate(previous.get('lastAt')) ?? toDate(previous.get('at')))\n : null\n const sameAct =\n !!previous &&\n previous.get('actorUid') === entry.actorUid &&\n previous.get('action') === entry.action\n /*\n * Absolute distance, because the rows this compares against are not all\n * written with the same clock: most writers use a server timestamp and\n * this one uses the request's own instant, so a neighboring row can\n * carry a moment slightly ahead of `now`. A signed comparison would read\n * that as \"not recent\" and stop collapsing.\n */\n const withinWindow =\n !!previousAt &&\n Math.abs(now.getTime() - previousAt.getTime()) <=\n ADMIN_AUDIT_DEDUPE_WINDOW_MS\n\n if (previous && sameAct && withinWindow) {\n transaction.update(previous.ref, {\n repeatCount: (Number(previous.get('repeatCount')) || 1) + 1,\n lastAt: now,\n })\n return\n }\n\n transaction.create(collection.doc(), {\n actorUid: entry.actorUid,\n action: entry.action,\n target: entry.target,\n /*\n * OMITTED rather than null when there is nobody to name. The subject\n * query is `where('subjectUid','==',uid).orderBy('at','desc')`, which\n * matches only documents that HAVE the field — so leaving it out keeps\n * the index to the rows that can actually answer a subject question.\n */\n ...(entry.subjectUid ? { subjectUid: entry.subjectUid } : {}),\n // Omitted rather than null on the same reasoning as `subjectUid`: the\n // address query matches only documents that HAVE the field.\n ...(entry.subjectAddressKey\n ? { subjectAddressKey: entry.subjectAddressKey }\n : {}),\n note: entry.note ?? null,\n at: now,\n /*\n * `lastAt` and `repeatCount` are set on the FIRST write, not added on\n * the first repeat. A reader that has to treat a missing field as \"one,\n * at `at`\" will eventually forget to, and the row it gets wrong is the\n * row that says how often somebody looked.\n */\n lastAt: now,\n repeatCount: 1,\n })\n })\n}\n\n/**\n * An address a human can recognize without the log holding the address.\n *\n * `emailDeliveries` is keyed by `sha256(address)` precisely so we do not keep\n * a readable list of who we mail. An audit row echoing the address in the\n * clear made this collection the less careful of the two for the same data,\n * and `adminAudit` is readable by any staff role.\n *\n * The first character and the domain survive because an audit entry a human\n * cannot read is a weaker control: a staffer checking their own access, or an\n * investigator confirming which of a shortlist was opened, needs to recognize\n * the row. What does not survive is the harvestable part — a dump of this\n * collection yields no mailing list.\n */\nexport function maskEmailAddress(address: string | null | undefined): string {\n const trimmed = String(address ?? '').trim()\n const at = trimmed.lastIndexOf('@')\n if (at <= 0 || at === trimmed.length - 1) return '***'\n return `${trimmed.slice(0, 1)}***@${trimmed.slice(at + 1)}`\n}\n\n/** Every recipient, masked, in one note-sized string. */\nexport function maskEmailAddresses(\n addresses: readonly string[] | null | undefined,\n): string {\n return (addresses ?? []).map(maskEmailAddress).join(', ')\n}\n\n/**\n * The uid behind a recipient address, or null when there is not one.\n *\n * Across POOLS, not the project pool alone: an enterprise account signing in\n * through GCIP lives in a tenant, and a project-pool `getUserByEmail` would\n * report the customer most in need of this record as \"not a user\".\n *\n * Null is a real answer. Most addresses we send to belong to site members,\n * prospects and plain contacts with no platform account, and this must never\n * guess one for them.\n */\nexport async function resolveSubjectUidByEmail(\n address: string | null | undefined,\n): Promise<string | null> {\n const email = String(address ?? '').trim()\n if (!email) return null\n try {\n /*\n * AMBIGUITY RESOLVES TO NOBODY.\n *\n * This was `findUserByEmailAcrossPools(...).uid` — the first account\n * whose PRIMARY matched. That is a guess whenever a second account holds\n * the same address, which is a real shape: a federated provider's address\n * never entered the uniqueness index, so an account can hold one that is\n * also somebody else's primary with nothing recording the clash.\n *\n * `attributableAccountForAddress` returns null for \"more than one\" as\n * well as for \"nobody\". Both are the same instruction to this writer:\n * name no subject. A row naming the wrong customer is not a weaker answer\n * to \"who read my data\" — it is a false one, and it puts one customer's\n * name on another's data access.\n *\n * The access is still reachable: `subjectAddressKey` carries the hashed\n * recipient, and the account page finds it from the addresses it holds.\n */\n return await attributableAccountForAddress(email)\n } catch {\n // A lookup that could not run leaves the row without a subject, which is\n // the same answer as \"no account\" and is safe: it under-reports rather\n // than attributing an access to the wrong person.\n return null\n }\n}\n\n/**\n * The hashed recipient for {@link AdminAuditWrite.subjectAddressKey}.\n *\n * The FIRST recipient, matching `resolveSubjectUidForRecipients` — one scalar\n * cannot represent a multi-recipient message, and the masked note carries the\n * full list either way.\n */\nexport function subjectAddressKeyForRecipients(\n addresses: readonly string[] | null | undefined,\n): string | null {\n for (const address of addresses ?? []) {\n const key = emailSuppressionKey(address)\n if (key) return key\n }\n return null\n}\n\n/**\n * The first recipient with a platform account.\n *\n * A single scalar subject cannot represent a message sent to several people,\n * and multi-recipient system mail is rare enough that an array field plus its\n * own `array-contains` index would be carried by every writer to serve almost\n * no rows. The full (masked) recipient list stays on the note, so a row for a\n * multi-recipient message still shows that the others were involved.\n */\nexport async function resolveSubjectUidForRecipients(\n addresses: readonly string[] | null | undefined,\n): Promise<string | null> {\n for (const address of addresses ?? []) {\n const uid = await resolveSubjectUidByEmail(address)\n if (uid) return uid\n }\n return null\n}\n"],"names":["isPluginStaffAuditAccess","attributableAccountForAddress","emailSuppressionKey","firebaseAdmin","ADMIN_AUDIT_COLLECTION","ADMIN_AUDIT_DEDUPE_WINDOW_MS","ADMIN_AUDIT_ACCESS_ACTIONS","Set","adminAuditKind","action","has","toDate","value","converted","Date","recordAdminAudit","entry","firestore","app","collection","now","mostRecentOnTarget","where","target","orderBy","limit","runTransaction","transaction","previous","get","docs","previousAt","sameAct","actorUid","withinWindow","Math","abs","getTime","update","ref","repeatCount","Number","lastAt","create","doc","subjectUid","subjectAddressKey","note","at","maskEmailAddress","address","trimmed","String","trim","lastIndexOf","length","slice","maskEmailAddresses","addresses","map","join","resolveSubjectUidByEmail","email","subjectAddressKeyForRecipients","key","resolveSubjectUidForRecipients","uid"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/admin-audit.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 { withAdminAuditIndex } from '@aglyn/aglyn/app-utils/admin-audit-index'\nimport { attributableAccountForAddress } from './account-addresses'\nimport { ADMIN_AUDIT_COLLECTION } from './admin-audit-write'\nimport { emailSuppressionKey } from './email-suppression'\nimport { firebaseAdmin } from './firebase-admin'\n\n/**\n * THE STAFF AUDIT TRAIL: WHO A ROW IS ABOUT, AND HOW MANY TIMES IT HAPPENED.\n *\n * `adminAudit` is not a convenience log. Firestore rules close\n * `emailDeliveries` to EVERYONE, staff included, and the console route is\n * allowed to read it only because it establishes a staff claim and records\n * who looked. The row this module writes is that compensating control, so\n * the two properties it exists to provide are:\n *\n * 1. **No access is lost.** Two views minutes apart are two accesses. A\n * collapse that dropped one would answer \"nobody read your mail\" about a\n * read that happened, which is the single failure this collection cannot\n * have.\n * 2. **The subject is answerable.** `target` names the THING acted on; it\n * cannot also name the person, because a message id is not a user path.\n * `subjectUid` is the separate fact, and it is what makes \"who at Aglyn\n * read my email\" a query rather than a manual trawl.\n */\n\nexport { ADMIN_AUDIT_COLLECTION }\nexport { addAdminAudit, setAdminAudit } from './admin-audit-write'\n\n/**\n * How close together two identical acts must be to count as ONE act\n * recorded twice.\n *\n * A single click can reach this writer more than once — a re-run effect, a\n * retried request, a double submit — and each arrival is the same access.\n * Ten seconds sits an order of magnitude above that (the observed re-entry\n * was one second apart) and well below a person deliberately re-opening a\n * record (observed at fifty-three seconds). Nothing a human does twice on\n * purpose lands inside it.\n *\n * ⚠️ A collapse still RECORDS the repeat: `repeatCount` and `lastAt` carry\n * it, and the console renders both. Widening this window trades away the\n * separateness of two accesses, so it must stay far shorter than the\n * interval at which a second look is a second decision.\n */\nexport const ADMIN_AUDIT_DEDUPE_WINDOW_MS = 10_000\n\n/*\n * Whether an act only looked is stamped on the row (`kind`, AGL-3321), so the\n * rule that decides it lives beside the other stamped fields.\n */\nexport {\n ADMIN_AUDIT_ACCESS_ACTIONS,\n type AdminAuditKind,\n adminAuditKind,\n} from '@aglyn/aglyn/app-utils/admin-audit-index'\n\nexport interface AdminAuditWrite {\n /** The staff account performing the act. */\n actorUid: string\n action: string\n /** The THING acted on, as a path or a stable identifier. */\n target: string\n /**\n * The person the act is ABOUT, when one can be resolved.\n *\n * Absent is a correct answer, not a failure: a recipient may be a site\n * member, a prospect or a bare address with no platform account behind it,\n * and inventing a uid for one of those would put an act on an innocent\n * person's page.\n */\n subjectUid?: string | null\n /**\n * `sha256` of the recipient address the act was about, when it had one.\n *\n * ## Why a hash and not the address\n *\n * The SAME derivation `emailSuppressionKey` uses, so this joins the\n * delivery log without a second key space. It is hashed for the reason\n * {@link maskEmailAddress} exists: `adminAudit` is readable by any staff\n * role, and a dump of it must not yield a mailing list.\n *\n * ## Why it exists at all, next to `subjectUid`\n *\n * `subjectUid` can only be written when the address resolves to exactly one\n * account, and an address is not reliably resolvable to one — a\n * provider-supplied address may be held by a second account with nothing\n * recording it. So the subject is now OMITTED whenever the answer is\n * ambiguous, and omitting it alone would make the access invisible on every\n * page, which is worse than the guess it replaced.\n *\n * This is the fact that needs no guess. The account page queries it with\n * the keys of every address that account holds, so one access appears on\n * the page of each account holding the address — which is the honest answer\n * when the mail went to a mailbox rather than to a uid.\n *\n * ## It opens no tier side-door\n *\n * `adminAudit` is `allow read, create: if isStaff()` with NO tier gate, so\n * anything written here is readable by every staff session whatever its\n * role — which makes an audit row a way around a tier restriction on the\n * surface the value came from. Checked, and it is not one here: the writer\n * is the message route, which gates on `staff` alone and nothing finer, so\n * this field is no more widely readable than its source.\n *\n * The hash adds no reach of its own either. It is the document id\n * `emailDeliveries` is already filed under, so any staff session that can\n * read this row could already derive the same value from an address it\n * guessed. A field whose source DID restrict by tier would not belong here.\n */\n subjectAddressKey?: string | null\n /** Human-readable context. ⚠️ Never a raw address — see `maskEmailAddress`. */\n note?: string | null\n}\n\nfunction toDate(value: unknown): Date | null {\n const converted = (value as { toDate?: () => Date } | null)?.toDate?.()\n if (converted instanceof Date) return converted\n return value instanceof Date ? value : null\n}\n\n/**\n * Write one audit row, collapsing an immediate repeat of the same act onto\n * the row already there.\n *\n * The candidate is the MOST RECENT row on the same target, which the live\n * `target ASC, at DESC` index already answers — so this costs one indexed\n * single-document read and needs no index of its own. Anything else landing\n * on that target in between ends the run, which is the conservative\n * direction: an interleaved act means the two views were not one action.\n *\n * The read and the write are one TRANSACTION because the duplicates this\n * exists to catch are CONCURRENT. Two requests from one click race each\n * other; a plain read-then-write would let both see \"nothing recent\" and\n * both insert, defeating the collapse exactly when it is needed.\n */\nexport async function recordAdminAudit(entry: AdminAuditWrite): Promise<void> {\n const firestore = firebaseAdmin.app().firestore()\n const collection = firestore.collection(ADMIN_AUDIT_COLLECTION)\n const now = new Date()\n const mostRecentOnTarget = collection\n .where('target', '==', entry.target)\n .orderBy('at', 'desc')\n .limit(1)\n\n await firestore.runTransaction(async (transaction) => {\n const previous = (await transaction.get(mostRecentOnTarget)).docs[0]\n const previousAt = previous\n ? (toDate(previous.get('lastAt')) ?? toDate(previous.get('at')))\n : null\n const sameAct =\n !!previous &&\n previous.get('actorUid') === entry.actorUid &&\n previous.get('action') === entry.action\n /*\n * Absolute distance, because the rows this compares against are not all\n * written with the same clock: most writers use a server timestamp and\n * this one uses the request's own instant, so a neighboring row can\n * carry a moment slightly ahead of `now`. A signed comparison would read\n * that as \"not recent\" and stop collapsing.\n */\n const withinWindow =\n !!previousAt &&\n Math.abs(now.getTime() - previousAt.getTime()) <=\n ADMIN_AUDIT_DEDUPE_WINDOW_MS\n\n if (previous && sameAct && withinWindow) {\n transaction.update(previous.ref, {\n repeatCount: (Number(previous.get('repeatCount')) || 1) + 1,\n lastAt: now,\n })\n return\n }\n\n // Stamped like every other row (`withAdminAuditIndex`): the audit\n // page's Action group filter and search query these fields.\n transaction.create(collection.doc(), withAdminAuditIndex({\n actorUid: entry.actorUid,\n action: entry.action,\n target: entry.target,\n /*\n * OMITTED rather than null when there is nobody to name. The subject\n * query is `where('subjectUid','==',uid).orderBy('at','desc')`, which\n * matches only documents that HAVE the field — so leaving it out keeps\n * the index to the rows that can actually answer a subject question.\n */\n ...(entry.subjectUid ? { subjectUid: entry.subjectUid } : {}),\n // Omitted rather than null on the same reasoning as `subjectUid`: the\n // address query matches only documents that HAVE the field.\n ...(entry.subjectAddressKey\n ? { subjectAddressKey: entry.subjectAddressKey }\n : {}),\n note: entry.note ?? null,\n at: now,\n /*\n * `lastAt` and `repeatCount` are set on the FIRST write, not added on\n * the first repeat. A reader that has to treat a missing field as \"one,\n * at `at`\" will eventually forget to, and the row it gets wrong is the\n * row that says how often somebody looked.\n */\n lastAt: now,\n repeatCount: 1,\n }))\n })\n}\n\n/**\n * An address a human can recognize without the log holding the address.\n *\n * `emailDeliveries` is keyed by `sha256(address)` precisely so we do not keep\n * a readable list of who we mail. An audit row echoing the address in the\n * clear made this collection the less careful of the two for the same data,\n * and `adminAudit` is readable by any staff role.\n *\n * The first character and the domain survive because an audit entry a human\n * cannot read is a weaker control: a staffer checking their own access, or an\n * investigator confirming which of a shortlist was opened, needs to recognize\n * the row. What does not survive is the harvestable part — a dump of this\n * collection yields no mailing list.\n */\nexport function maskEmailAddress(address: string | null | undefined): string {\n const trimmed = String(address ?? '').trim()\n const at = trimmed.lastIndexOf('@')\n if (at <= 0 || at === trimmed.length - 1) return '***'\n return `${trimmed.slice(0, 1)}***@${trimmed.slice(at + 1)}`\n}\n\n/** Every recipient, masked, in one note-sized string. */\nexport function maskEmailAddresses(\n addresses: readonly string[] | null | undefined,\n): string {\n return (addresses ?? []).map(maskEmailAddress).join(', ')\n}\n\n/**\n * The uid behind a recipient address, or null when there is not one.\n *\n * Across POOLS, not the project pool alone: an enterprise account signing in\n * through GCIP lives in a tenant, and a project-pool `getUserByEmail` would\n * report the customer most in need of this record as \"not a user\".\n *\n * Null is a real answer. Most addresses we send to belong to site members,\n * prospects and plain contacts with no platform account, and this must never\n * guess one for them.\n */\nexport async function resolveSubjectUidByEmail(\n address: string | null | undefined,\n): Promise<string | null> {\n const email = String(address ?? '').trim()\n if (!email) return null\n try {\n /*\n * AMBIGUITY RESOLVES TO NOBODY.\n *\n * This was `findUserByEmailAcrossPools(...).uid` — the first account\n * whose PRIMARY matched. That is a guess whenever a second account holds\n * the same address, which is a real shape: a federated provider's address\n * never entered the uniqueness index, so an account can hold one that is\n * also somebody else's primary with nothing recording the clash.\n *\n * `attributableAccountForAddress` returns null for \"more than one\" as\n * well as for \"nobody\". Both are the same instruction to this writer:\n * name no subject. A row naming the wrong customer is not a weaker answer\n * to \"who read my data\" — it is a false one, and it puts one customer's\n * name on another's data access.\n *\n * The access is still reachable: `subjectAddressKey` carries the hashed\n * recipient, and the account page finds it from the addresses it holds.\n */\n return await attributableAccountForAddress(email)\n } catch {\n // A lookup that could not run leaves the row without a subject, which is\n // the same answer as \"no account\" and is safe: it under-reports rather\n // than attributing an access to the wrong person.\n return null\n }\n}\n\n/**\n * The hashed recipient for {@link AdminAuditWrite.subjectAddressKey}.\n *\n * The FIRST recipient, matching `resolveSubjectUidForRecipients` — one scalar\n * cannot represent a multi-recipient message, and the masked note carries the\n * full list either way.\n */\nexport function subjectAddressKeyForRecipients(\n addresses: readonly string[] | null | undefined,\n): string | null {\n for (const address of addresses ?? []) {\n const key = emailSuppressionKey(address)\n if (key) return key\n }\n return null\n}\n\n/**\n * The first recipient with a platform account.\n *\n * A single scalar subject cannot represent a message sent to several people,\n * and multi-recipient system mail is rare enough that an array field plus its\n * own `array-contains` index would be carried by every writer to serve almost\n * no rows. The full (masked) recipient list stays on the note, so a row for a\n * multi-recipient message still shows that the others were involved.\n */\nexport async function resolveSubjectUidForRecipients(\n addresses: readonly string[] | null | undefined,\n): Promise<string | null> {\n for (const address of addresses ?? []) {\n const uid = await resolveSubjectUidByEmail(address)\n if (uid) return uid\n }\n return null\n}\n"],"names":["withAdminAuditIndex","attributableAccountForAddress","ADMIN_AUDIT_COLLECTION","emailSuppressionKey","firebaseAdmin","addAdminAudit","setAdminAudit","ADMIN_AUDIT_DEDUPE_WINDOW_MS","ADMIN_AUDIT_ACCESS_ACTIONS","adminAuditKind","toDate","value","converted","Date","recordAdminAudit","entry","firestore","app","collection","now","mostRecentOnTarget","where","target","orderBy","limit","runTransaction","transaction","previous","get","docs","previousAt","sameAct","actorUid","action","withinWindow","Math","abs","getTime","update","ref","repeatCount","Number","lastAt","create","doc","subjectUid","subjectAddressKey","note","at","maskEmailAddress","address","trimmed","String","trim","lastIndexOf","length","slice","maskEmailAddresses","addresses","map","join","resolveSubjectUidByEmail","email","subjectAddressKeyForRecipients","key","resolveSubjectUidForRecipients","uid"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/consent-group-carry.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\n/**\n * CARRYING A SITE'S REFUSALS ONTO A SITE THAT IS ABOUT TO STOP READING THEM\n * (AGL-3320).\n *\n * Three per-site stores are read across the CURRENT consent group by every\n * send path (`email-suppression.ts`, `email-marketing-gate.ts`), so a site\n * leaving a group stops seeing its former siblings' — and they stop seeing\n * its. Before the declaration flips, each carry `(to ← from)` of the plan\n * copies `from`'s refusals onto `to`:\n *\n * - **C1, the suppression list** (`hosts/{h}/suppressions/{key}`). A row\n * `to` does not have is CREATED with `from`'s fields and provenance; a row\n * `to` has is never touched. `reason` is kept, so only an unsubscribe stays\n * liftable by the preference page's resubscribe, and a group-wide\n * resubscribe lifts a carried row like an original.\n * - **C2, the topic opt-outs** (`hosts/{h}/topicOptOuts/{key}`). Every\n * stream `from` has LEFT, and `to` has not, becomes an opt-out on `to`\n * dated when the person left `from`. `to`'s other entries — its own\n * confirmations, its own opt-outs — are kept as they are.\n * - **C3, the pace** (`hosts/{h}/emailFrequency/{key}`). The choice made\n * most recently wins, as `groupPace` reads it within a group, and a site\n * keeping a slower pace learns when `from` last mailed the person, so the\n * separation cannot loosen the pace they asked for. The send counters are\n * never touched.\n *\n * ## One page per call, and every page idempotent\n *\n * The executor calls this until it answers `done`, handing back the cursor.\n * A page reads `from`'s documents in id order and `to`'s matching documents\n * in one `getAll`, and writes only what `to` lacks — so a page re-run after a\n * crash, a catch-up pass and the sweep all write nothing that was already\n * carried. A write that raced another writer (a `lastUpdateTime` precondition\n * or a create that found the document) is re-read and decided again once;\n * failing twice throws, and the executor retries the page.\n *\n * `sinceMs` narrows a page to what changed since then — the catch-up, which\n * reads by `suppressedAt`, `updatedAt` and `cadenceSetAtMs`. A row written\n * without one of those is left to the sweep's full pass.\n */\n\nimport {\n readTopicSubscriptionState,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n type TopicSubscriptionEntry,\n} from '@aglyn/aglyn/app-utils/email-topics'\nimport type { ConsentGroupCarry } from '@aglyn/aglyn/app-utils/consent-group-change'\nimport { FieldPath, FieldValue, Timestamp } from 'firebase-admin/firestore'\nimport { EMAIL_FREQUENCY_SUBCOLLECTION } from './email-marketing-gate'\nimport { HOST_SUPPRESSIONS_SUBCOLLECTION } from './email-suppression'\n\n/** The three stores a carry copies, as the job counts them. */\nexport type ConsentGroupCarryStore = 'siteSuppressions' | 'topicOptOuts' | 'paces'\n\nexport const CONSENT_GROUP_CARRY_STORES: readonly ConsentGroupCarryStore[] = [\n 'siteSuppressions',\n 'topicOptOuts',\n 'paces',\n]\n\n/** Each store's subcollection under `hosts/{hostId}`. */\nexport const CONSENT_GROUP_CARRY_SUBCOLLECTIONS: Readonly<Record<ConsentGroupCarryStore, string>> = {\n siteSuppressions: HOST_SUPPRESSIONS_SUBCOLLECTION,\n topicOptOuts: TOPIC_OPT_OUTS_SUBCOLLECTION,\n paces: EMAIL_FREQUENCY_SUBCOLLECTION,\n}\n\n/** The field a catch-up reads each store by, and whether it is a timestamp. */\nconst CATCH_UP_FIELDS: Readonly<Record<ConsentGroupCarryStore, { field: string; timestamp: boolean }>> = {\n siteSuppressions: { field: 'suppressedAt', timestamp: true },\n topicOptOuts: { field: 'updatedAt', timestamp: true },\n paces: { field: 'cadenceSetAtMs', timestamp: false },\n}\n\n/**\n * The suppression row's fields a carry copies. Everything else a row could\n * carry is either the carry's own provenance or not a fact about the refusal.\n */\nconst SUPPRESSION_ROW_FIELDS = [\n 'reason',\n 'email',\n 'createdAt',\n 'suppressedAt',\n 'note',\n 'suppressedByUid',\n 'campaignId',\n 'topicId',\n] as const\n\n/** Documents one page reads from the source site. */\nexport const CONSENT_GROUP_CARRY_PAGE = 300\n\nexport interface ConsentGroupCarryPage {\n /** The source site has nothing after the cursor. */\n done: boolean\n /** Where the next page starts, or `null` once `done`. */\n cursor: string | null\n /** Source documents read. */\n read: number\n /** Documents written on the receiving site (counted, not written, on a dry run). */\n written: number\n}\n\nexport interface CarryConsentGroupRefusalsOptions {\n firestore: FirebaseFirestore.Firestore\n store: ConsentGroupCarryStore\n carry: ConsentGroupCarry\n changeId: string\n cursor: string | null\n /** Only documents changed at or after this instant — the catch-up. */\n sinceMs?: number | null\n pageSize?: number\n dryRun?: boolean\n}\n\ninterface Cursor {\n id: string\n /** The catch-up field's value on the last document read. */\n at?: { seconds: number; nanoseconds: number } | number\n}\n\nfunction readCursor(raw: string | null): Cursor | null {\n if (!raw) return null\n try {\n const parsed = JSON.parse(raw) as Cursor\n return typeof parsed?.id === 'string' ? parsed : null\n } catch {\n return null\n }\n}\n\n/** The error codes that mean \"somebody wrote this document since I read it\". */\nconst RACED = new Set([5, 6, 9])\nconst codeOf = (error: unknown): number | undefined => (error as { code?: number } | null)?.code\n\n/** Milliseconds from a stored number or timestamp, or `null`. */\nfunction storedMs(value: unknown): number | null {\n if (typeof value === 'number') return Number.isFinite(value) ? value : null\n const toMillis = (value as { toMillis?: () => number } | null)?.toMillis\n return typeof toMillis === 'function' ? toMillis.call(value) : null\n}\n\n/**\n * When the site last mailed the person, as the gate reads it: the stored\n * instant, or the newest entry of a window written before it existed.\n */\nfunction lastSentMs(data: Record<string, unknown> | undefined): number | null {\n if (!data) return null\n const stored = Number(data['lastSentAtMs'])\n if (Number.isFinite(stored) && stored > 0) return stored\n const window = Array.isArray(data['sentAtMs'])\n ? (data['sentAtMs'] as unknown[]).map(Number).filter(Number.isFinite)\n : []\n return window.length ? Math.max(...window) : null\n}\n\n/**\n * What `to` must become for one source document, or `null` when it already\n * honors everything `from` does. `create` says the document is new.\n */\ntype Decision = { create: boolean; data: Record<string, unknown> } | null\n\nfunction decideSuppression(\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n changeId: string,\n): Decision {\n if (target.exists) return null\n const row = source.data() ?? {}\n const data: Record<string, unknown> = {}\n for (const field of SUPPRESSION_ROW_FIELDS) {\n if (field in row) data[field] = row[field]\n }\n data['carriedFromHostId'] = carry.fromHostId\n data['carriedByChangeId'] = changeId\n data['carriedAt'] = FieldValue.serverTimestamp()\n return { create: true, data }\n}\n\nfunction decideTopics(\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n): Decision {\n const sourceTopics = (source.get('topics') ?? {}) as Record<string, TopicSubscriptionEntry>\n const targetTopics = ((target.exists ? target.get('topics') : null) ?? {}) as Record<\n string,\n TopicSubscriptionEntry & Record<string, unknown>\n >\n const next: Record<string, unknown> = { ...targetTopics }\n let changed = false\n for (const [topicId, entry] of Object.entries(sourceTopics)) {\n if (readTopicSubscriptionState(entry) !== 'opted-out') continue\n if (readTopicSubscriptionState(targetTopics[topicId]) === 'opted-out') continue\n next[topicId] = {\n ...(targetTopics[topicId] ?? {}),\n optedOutAt: entry.optedOutAt,\n resubscribedAt: null,\n carriedFromHostId: carry.fromHostId,\n }\n changed = true\n }\n if (!changed) return null\n const email = (target.exists ? target.get('email') : null) ?? source.get('email') ?? null\n return {\n create: !target.exists,\n data: {\n email,\n topics: next,\n updatedAt: FieldValue.serverTimestamp(),\n ...(target.exists ? {} : { createdAt: FieldValue.serverTimestamp() }),\n },\n }\n}\n\nfunction decidePace(\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n): Decision {\n const from = source.data() ?? {}\n const to = target.exists ? (target.data() ?? {}) : undefined\n const patch: Record<string, unknown> = {}\n const sourceChoice = from['cadence']\n const targetChoice = to?.['cadence']\n const sourceSetAt = Number(from['cadenceSetAtMs'])\n const targetSetAt = Number(to?.['cadenceSetAtMs'])\n const carried =\n sourceChoice != null &&\n (targetChoice == null ||\n (Number.isFinite(targetSetAt) ? targetSetAt : 0) <\n (Number.isFinite(sourceSetAt) ? sourceSetAt : 0))\n if (carried) {\n patch['cadence'] = sourceChoice\n if (Number.isFinite(sourceSetAt)) patch['cadenceSetAtMs'] = sourceSetAt\n patch['cadenceCarriedFromHostId'] = carry.fromHostId\n }\n // The pace in force afterwards decides whether the last send matters: `all`\n // sends as they come and never asks when the last one went.\n const choice = carried ? sourceChoice : targetChoice\n if (choice != null && choice !== 'all') {\n const sourceLast = lastSentMs(from)\n const targetLast = lastSentMs(to)\n if (sourceLast !== null && (targetLast === null || sourceLast > targetLast)) {\n patch['lastSentAtMs'] = sourceLast\n }\n }\n if (!Object.keys(patch).length) return null\n return {\n create: !target.exists,\n data: target.exists ? patch : { email: from['email'] ?? null, ...patch },\n }\n}\n\nfunction decide(\n store: ConsentGroupCarryStore,\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n changeId: string,\n): Decision {\n if (store === 'siteSuppressions') return decideSuppression(source, target, carry, changeId)\n if (store === 'topicOptOuts') return decideTopics(source, target, carry)\n return decidePace(source, target, carry)\n}\n\n/**\n * One page of one store of one carry. See the module note for what each\n * store copies and why a re-run writes nothing.\n */\nexport async function carryConsentGroupRefusals(\n options: CarryConsentGroupRefusalsOptions,\n): Promise<ConsentGroupCarryPage> {\n const { firestore, store, carry, changeId } = options\n const subcollection = CONSENT_GROUP_CARRY_SUBCOLLECTIONS[store]\n const sourceList = firestore.collection('hosts').doc(carry.fromHostId).collection(subcollection)\n const targetList = firestore.collection('hosts').doc(carry.toHostId).collection(subcollection)\n const limit = options.pageSize ?? CONSENT_GROUP_CARRY_PAGE\n const cursor = readCursor(options.cursor)\n const catchUp = CATCH_UP_FIELDS[store]\n const since = options.sinceMs ?? null\n\n let query: FirebaseFirestore.Query = sourceList\n if (since !== null) {\n query = query\n .where(catchUp.field, '>=', catchUp.timestamp ? Timestamp.fromMillis(since) : since)\n .orderBy(catchUp.field)\n .orderBy(FieldPath.documentId())\n if (cursor && cursor.at !== undefined) {\n const at =\n typeof cursor.at === 'number' ? cursor.at : new Timestamp(cursor.at.seconds, cursor.at.nanoseconds)\n query = query.startAfter(at, cursor.id)\n }\n } else {\n query = query.orderBy(FieldPath.documentId())\n if (cursor) query = query.startAfter(cursor.id)\n }\n const page = await query.limit(limit).get()\n if (page.empty) return { done: true, cursor: null, read: 0, written: 0 }\n\n const targets = await firestore.getAll(...page.docs.map((doc) => targetList.doc(doc.id)))\n const decisions = page.docs.map((source, index) => ({\n source,\n target: targets[index],\n decision: decide(store, source, targets[index], carry, changeId),\n }))\n const due = decisions.filter((entry) => entry.decision !== null)\n const written = options.dryRun\n ? due.length\n : await writeDecisions(firestore, targetList, store, carry, changeId, due)\n\n const last = page.docs[page.docs.length - 1]\n const next: Cursor = { id: last.id }\n if (since !== null) {\n const value = last.get(catchUp.field)\n next.at =\n value instanceof Timestamp\n ? { seconds: value.seconds, nanoseconds: value.nanoseconds }\n : (storedMs(value) ?? 0)\n }\n const done = page.size < limit\n return { done, cursor: done ? null : JSON.stringify(next), read: page.size, written }\n}\n\n/**\n * Writes one page's decisions through a `BulkWriter`, each guarded against\n * the document having moved since it was read, and decides the ones that\n * raced once more from a fresh read.\n */\nasync function writeDecisions(\n firestore: FirebaseFirestore.Firestore,\n targetList: FirebaseFirestore.CollectionReference,\n store: ConsentGroupCarryStore,\n carry: ConsentGroupCarry,\n changeId: string,\n due: Array<{\n source: FirebaseFirestore.DocumentSnapshot\n target: FirebaseFirestore.DocumentSnapshot\n decision: Decision\n }>,\n): Promise<number> {\n let written = 0\n for (let round = 0; round < 2 && due.length; round += 1) {\n const writer = firestore.bulkWriter()\n const outcomes = due.map(({ source, target, decision }) => {\n const ref = targetList.doc(source.id)\n const write = (decision as NonNullable<Decision>).create\n ? writer.create(ref, (decision as NonNullable<Decision>).data)\n : writer.update(ref, (decision as NonNullable<Decision>).data, {\n lastUpdateTime: target.updateTime as FirebaseFirestore.Timestamp,\n })\n return write.then(\n () => ({ source, error: null as unknown }),\n (error: unknown) => ({ source, error: error ?? new Error('The write failed') }),\n )\n })\n await writer.close()\n const settled = await Promise.all(outcomes)\n const retry: FirebaseFirestore.DocumentSnapshot[] = []\n for (const { source, error } of settled) {\n if (error === null) {\n written += 1\n continue\n }\n const code = codeOf(error)\n if (code === undefined || !RACED.has(code)) throw error\n // The row a suppression carry would create exists now: `to` has its own.\n if (store === 'siteSuppressions' && code === 6) continue\n retry.push(source)\n }\n if (!retry.length) return written\n const fresh = await firestore.getAll(...retry.map((source) => targetList.doc(source.id)))\n due = retry\n .map((source, index) => ({\n source,\n target: fresh[index],\n decision: decide(store, source, fresh[index], carry, changeId),\n }))\n .filter((entry) => entry.decision !== null)\n }\n if (due.length) {\n throw new Error(\n `[consent-group-carry] ${due.length} document(s) on ${carry.toHostId} kept changing under the carry`,\n )\n }\n return written\n}\n"],"names":["readTopicSubscriptionState","TOPIC_OPT_OUTS_SUBCOLLECTION","FieldPath","FieldValue","Timestamp","EMAIL_FREQUENCY_SUBCOLLECTION","HOST_SUPPRESSIONS_SUBCOLLECTION","CONSENT_GROUP_CARRY_STORES","CONSENT_GROUP_CARRY_SUBCOLLECTIONS","siteSuppressions","topicOptOuts","paces","CATCH_UP_FIELDS","field","timestamp","SUPPRESSION_ROW_FIELDS","CONSENT_GROUP_CARRY_PAGE","readCursor","raw","parsed","JSON","parse","id","RACED","Set","codeOf","error","code","storedMs","value","Number","isFinite","toMillis","call","lastSentMs","data","stored","window","Array","isArray","map","filter","length","Math","max","decideSuppression","source","target","carry","changeId","exists","row","fromHostId","serverTimestamp","create","decideTopics","sourceTopics","get","targetTopics","next","changed","topicId","entry","Object","entries","optedOutAt","resubscribedAt","carriedFromHostId","email","topics","updatedAt","createdAt","decidePace","from","to","undefined","patch","sourceChoice","targetChoice","sourceSetAt","targetSetAt","carried","choice","sourceLast","targetLast","keys","decide","store","carryConsentGroupRefusals","options","firestore","subcollection","sourceList","collection","doc","targetList","toHostId","limit","pageSize","cursor","catchUp","since","sinceMs","query","where","fromMillis","orderBy","documentId","at","seconds","nanoseconds","startAfter","page","empty","done","read","written","targets","getAll","docs","decisions","index","decision","due","dryRun","writeDecisions","last","size","stringify","round","writer","bulkWriter","outcomes","ref","write","update","lastUpdateTime","updateTime","then","Error","close","settled","Promise","all","retry","has","push","fresh"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAsCC,GAED,SACEA,0BAA0B,EAC1BC,4BAA4B,QAEvB,sCAAqC;AAE5C,SAASC,SAAS,EAAEC,UAAU,EAAEC,SAAS,QAAQ,2BAA0B;AAC3E,SAASC,6BAA6B,QAAQ,4BAAwB;AACtE,SAASC,+BAA+B,QAAQ,yBAAqB;AAKrE,OAAO,MAAMC,6BAAgE;IAC3E;IACA;IACA;CACD,CAAA;AAED,uDAAuD,GACvD,OAAO,MAAMC,qCAAuF;IAClGC,kBAAkBH;IAClBI,cAAcT;IACdU,OAAON;AACT,EAAC;AAED,6EAA6E,GAC7E,MAAMO,kBAAmG;IACvGH,kBAAkB;QAAEI,OAAO;QAAgBC,WAAW;IAAK;IAC3DJ,cAAc;QAAEG,OAAO;QAAaC,WAAW;IAAK;IACpDH,OAAO;QAAEE,OAAO;QAAkBC,WAAW;IAAM;AACrD;AAEA;;;CAGC,GACD,MAAMC,yBAAyB;IAC7B;IACA;IACA;IACA;IACA;IACA;IACA;IACA;CACD;AAED,mDAAmD,GACnD,OAAO,MAAMC,2BAA2B,IAAG;AA+B3C,SAASC,WAAWC,GAAkB;IACpC,IAAI,CAACA,KAAK,OAAO;IACjB,IAAI;QACF,MAAMC,SAASC,KAAKC,KAAK,CAACH;QAC1B,OAAO,QAAOC,0BAAAA,OAAQG,EAAE,MAAK,WAAWH,SAAS;IACnD,EAAE,eAAM;QACN,OAAO;IACT;AACF;AAEA,8EAA8E,GAC9E,MAAMI,QAAQ,IAAIC,IAAI;IAAC;IAAG;IAAG;CAAE;AAC/B,MAAMC,SAAS,CAACC,QAAwCA,yBAAD,AAACA,MAAoCC,IAAI;AAEhG,+DAA+D,GAC/D,SAASC,SAASC,KAAc;IAC9B,IAAI,OAAOA,UAAU,UAAU,OAAOC,OAAOC,QAAQ,CAACF,SAASA,QAAQ;IACvE,MAAMG,WAAYH,yBAAD,AAACA,MAA8CG,QAAQ;IACxE,OAAO,OAAOA,aAAa,aAAaA,SAASC,IAAI,CAACJ,SAAS;AACjE;AAEA;;;CAGC,GACD,SAASK,WAAWC,IAAyC;IAC3D,IAAI,CAACA,MAAM,OAAO;IAClB,MAAMC,SAASN,OAAOK,IAAI,CAAC,eAAe;IAC1C,IAAIL,OAAOC,QAAQ,CAACK,WAAWA,SAAS,GAAG,OAAOA;IAClD,MAAMC,SAASC,MAAMC,OAAO,CAACJ,IAAI,CAAC,WAAW,IACzC,AAACA,IAAI,CAAC,WAAW,CAAeK,GAAG,CAACV,QAAQW,MAAM,CAACX,OAAOC,QAAQ,IAClE,EAAE;IACN,OAAOM,OAAOK,MAAM,GAAGC,KAAKC,GAAG,IAAIP,UAAU;AAC/C;AAQA,SAASQ,kBACPC,MAA0C,EAC1CC,MAA0C,EAC1CC,KAAwB,EACxBC,QAAgB;QAGJH;IADZ,IAAIC,OAAOG,MAAM,EAAE,OAAO;IAC1B,MAAMC,OAAML,eAAAA,OAAOX,IAAI,cAAXW,eAAiB,CAAC;IAC9B,MAAMX,OAAgC,CAAC;IACvC,KAAK,MAAMtB,SAASE,uBAAwB;QAC1C,IAAIF,SAASsC,KAAKhB,IAAI,CAACtB,MAAM,GAAGsC,GAAG,CAACtC,MAAM;IAC5C;IACAsB,IAAI,CAAC,oBAAoB,GAAGa,MAAMI,UAAU;IAC5CjB,IAAI,CAAC,oBAAoB,GAAGc;IAC5Bd,IAAI,CAAC,YAAY,GAAGhC,WAAWkD,eAAe;IAC9C,OAAO;QAAEC,QAAQ;QAAMnB;IAAK;AAC9B;AAEA,SAASoB,aACPT,MAA0C,EAC1CC,MAA0C,EAC1CC,KAAwB;QAEFF,aACCC,MAkBT,OAACA;IAnBf,MAAMS,gBAAgBV,cAAAA,OAAOW,GAAG,CAAC,qBAAXX,cAAwB,CAAC;IAC/C,MAAMY,gBAAiBX,OAAAA,OAAOG,MAAM,GAAGH,OAAOU,GAAG,CAAC,YAAY,gBAAvCV,OAAgD,CAAC;IAIxE,MAAMY,OAAgC,aAAKD;IAC3C,IAAIE,UAAU;IACd,KAAK,MAAM,CAACC,SAASC,MAAM,IAAIC,OAAOC,OAAO,CAACR,cAAe;YAIrDE;QAHN,IAAI1D,2BAA2B8D,WAAW,aAAa;QACvD,IAAI9D,2BAA2B0D,YAAY,CAACG,QAAQ,MAAM,aAAa;QACvEF,IAAI,CAACE,QAAQ,GAAG,cACVH,wBAAAA,YAAY,CAACG,QAAQ,YAArBH,wBAAyB,CAAC;YAC9BO,YAAYH,MAAMG,UAAU;YAC5BC,gBAAgB;YAChBC,mBAAmBnB,MAAMI,UAAU;;QAErCQ,UAAU;IACZ;IACA,IAAI,CAACA,SAAS,OAAO;IACrB,MAAMQ,SAAQ,SAACrB,QAAAA,OAAOG,MAAM,GAAGH,OAAOU,GAAG,CAAC,WAAW,gBAAtCV,QAA+CD,OAAOW,GAAG,CAAC,oBAA3D,QAAuE;IACrF,OAAO;QACLH,QAAQ,CAACP,OAAOG,MAAM;QACtBf,MAAM;YACJiC;YACAC,QAAQV;YACRW,WAAWnE,WAAWkD,eAAe;WACjCN,OAAOG,MAAM,GAAG,CAAC,IAAI;YAAEqB,WAAWpE,WAAWkD,eAAe;QAAG;IAEvE;AACF;AAEA,SAASmB,WACP1B,MAA0C,EAC1CC,MAA0C,EAC1CC,KAAwB;QAEXF,cACeC,cA6Ba0B;IA9BzC,MAAMA,QAAO3B,eAAAA,OAAOX,IAAI,cAAXW,eAAiB,CAAC;IAC/B,MAAM4B,KAAK3B,OAAOG,MAAM,IAAIH,eAAAA,OAAOZ,IAAI,cAAXY,eAAiB,CAAC,IAAK4B;IACnD,MAAMC,QAAiC,CAAC;IACxC,MAAMC,eAAeJ,IAAI,CAAC,UAAU;IACpC,MAAMK,eAAeJ,sBAAAA,EAAI,CAAC,UAAU;IACpC,MAAMK,cAAcjD,OAAO2C,IAAI,CAAC,iBAAiB;IACjD,MAAMO,cAAclD,OAAO4C,sBAAAA,EAAI,CAAC,iBAAiB;IACjD,MAAMO,UACJJ,gBAAgB,QACfC,CAAAA,gBAAgB,QACf,AAAChD,CAAAA,OAAOC,QAAQ,CAACiD,eAAeA,cAAc,CAAA,IAC3ClD,CAAAA,OAAOC,QAAQ,CAACgD,eAAeA,cAAc,CAAA,CAAC;IACrD,IAAIE,SAAS;QACXL,KAAK,CAAC,UAAU,GAAGC;QACnB,IAAI/C,OAAOC,QAAQ,CAACgD,cAAcH,KAAK,CAAC,iBAAiB,GAAGG;QAC5DH,KAAK,CAAC,2BAA2B,GAAG5B,MAAMI,UAAU;IACtD;IACA,4EAA4E;IAC5E,4DAA4D;IAC5D,MAAM8B,SAASD,UAAUJ,eAAeC;IACxC,IAAII,UAAU,QAAQA,WAAW,OAAO;QACtC,MAAMC,aAAajD,WAAWuC;QAC9B,MAAMW,aAAalD,WAAWwC;QAC9B,IAAIS,eAAe,QAASC,CAAAA,eAAe,QAAQD,aAAaC,UAAS,GAAI;YAC3ER,KAAK,CAAC,eAAe,GAAGO;QAC1B;IACF;IACA,IAAI,CAACpB,OAAOsB,IAAI,CAACT,OAAOlC,MAAM,EAAE,OAAO;IACvC,OAAO;QACLY,QAAQ,CAACP,OAAOG,MAAM;QACtBf,MAAMY,OAAOG,MAAM,GAAG0B,QAAQ;YAAER,KAAK,GAAEK,cAAAA,IAAI,CAAC,QAAQ,YAAbA,cAAiB;WAASG;IACnE;AACF;AAEA,SAASU,OACPC,KAA6B,EAC7BzC,MAA0C,EAC1CC,MAA0C,EAC1CC,KAAwB,EACxBC,QAAgB;IAEhB,IAAIsC,UAAU,oBAAoB,OAAO1C,kBAAkBC,QAAQC,QAAQC,OAAOC;IAClF,IAAIsC,UAAU,gBAAgB,OAAOhC,aAAaT,QAAQC,QAAQC;IAClE,OAAOwB,WAAW1B,QAAQC,QAAQC;AACpC;AAEA;;;CAGC,GACD,OAAO,eAAewC,0BACpBC,OAAyC;QAM3BA,mBAGAA;IAPd,MAAM,EAAEC,SAAS,EAAEH,KAAK,EAAEvC,KAAK,EAAEC,QAAQ,EAAE,GAAGwC;IAC9C,MAAME,gBAAgBnF,kCAAkC,CAAC+E,MAAM;IAC/D,MAAMK,aAAaF,UAAUG,UAAU,CAAC,SAASC,GAAG,CAAC9C,MAAMI,UAAU,EAAEyC,UAAU,CAACF;IAClF,MAAMI,aAAaL,UAAUG,UAAU,CAAC,SAASC,GAAG,CAAC9C,MAAMgD,QAAQ,EAAEH,UAAU,CAACF;IAChF,MAAMM,SAAQR,oBAAAA,QAAQS,QAAQ,YAAhBT,oBAAoBzE;IAClC,MAAMmF,SAASlF,WAAWwE,QAAQU,MAAM;IACxC,MAAMC,UAAUxF,eAAe,CAAC2E,MAAM;IACtC,MAAMc,SAAQZ,mBAAAA,QAAQa,OAAO,YAAfb,mBAAmB;IAEjC,IAAIc,QAAiCX;IACrC,IAAIS,UAAU,MAAM;QAClBE,QAAQA,MACLC,KAAK,CAACJ,QAAQvF,KAAK,EAAE,MAAMuF,QAAQtF,SAAS,GAAGV,UAAUqG,UAAU,CAACJ,SAASA,OAC7EK,OAAO,CAACN,QAAQvF,KAAK,EACrB6F,OAAO,CAACxG,UAAUyG,UAAU;QAC/B,IAAIR,UAAUA,OAAOS,EAAE,KAAKjC,WAAW;YACrC,MAAMiC,KACJ,OAAOT,OAAOS,EAAE,KAAK,WAAWT,OAAOS,EAAE,GAAG,IAAIxG,UAAU+F,OAAOS,EAAE,CAACC,OAAO,EAAEV,OAAOS,EAAE,CAACE,WAAW;YACpGP,QAAQA,MAAMQ,UAAU,CAACH,IAAIT,OAAO7E,EAAE;QACxC;IACF,OAAO;QACLiF,QAAQA,MAAMG,OAAO,CAACxG,UAAUyG,UAAU;QAC1C,IAAIR,QAAQI,QAAQA,MAAMQ,UAAU,CAACZ,OAAO7E,EAAE;IAChD;IACA,MAAM0F,OAAO,MAAMT,MAAMN,KAAK,CAACA,OAAOxC,GAAG;IACzC,IAAIuD,KAAKC,KAAK,EAAE,OAAO;QAAEC,MAAM;QAAMf,QAAQ;QAAMgB,MAAM;QAAGC,SAAS;IAAE;IAEvE,MAAMC,UAAU,MAAM3B,UAAU4B,MAAM,IAAIN,KAAKO,IAAI,CAAC/E,GAAG,CAAC,CAACsD,MAAQC,WAAWD,GAAG,CAACA,IAAIxE,EAAE;IACtF,MAAMkG,YAAYR,KAAKO,IAAI,CAAC/E,GAAG,CAAC,CAACM,QAAQ2E,QAAW,CAAA;YAClD3E;YACAC,QAAQsE,OAAO,CAACI,MAAM;YACtBC,UAAUpC,OAAOC,OAAOzC,QAAQuE,OAAO,CAACI,MAAM,EAAEzE,OAAOC;QACzD,CAAA;IACA,MAAM0E,MAAMH,UAAU/E,MAAM,CAAC,CAACqB,QAAUA,MAAM4D,QAAQ,KAAK;IAC3D,MAAMN,UAAU3B,QAAQmC,MAAM,GAC1BD,IAAIjF,MAAM,GACV,MAAMmF,eAAenC,WAAWK,YAAYR,OAAOvC,OAAOC,UAAU0E;IAExE,MAAMG,OAAOd,KAAKO,IAAI,CAACP,KAAKO,IAAI,CAAC7E,MAAM,GAAG,EAAE;IAC5C,MAAMiB,OAAe;QAAErC,IAAIwG,KAAKxG,EAAE;IAAC;IACnC,IAAI+E,UAAU,MAAM;YAKXzE;QAJP,MAAMC,QAAQiG,KAAKrE,GAAG,CAAC2C,QAAQvF,KAAK;QACpC8C,KAAKiD,EAAE,GACL/E,iBAAiBzB,YACb;YAAEyG,SAAShF,MAAMgF,OAAO;YAAEC,aAAajF,MAAMiF,WAAW;QAAC,KACxDlF,YAAAA,SAASC,kBAATD,YAAmB;IAC5B;IACA,MAAMsF,OAAOF,KAAKe,IAAI,GAAG9B;IACzB,OAAO;QAAEiB;QAAMf,QAAQe,OAAO,OAAO9F,KAAK4G,SAAS,CAACrE;QAAOwD,MAAMH,KAAKe,IAAI;QAAEX;IAAQ;AACtF;AAEA;;;;CAIC,GACD,eAAeS,eACbnC,SAAsC,EACtCK,UAAiD,EACjDR,KAA6B,EAC7BvC,KAAwB,EACxBC,QAAgB,EAChB0E,GAIE;IAEF,IAAIP,UAAU;IACd,IAAK,IAAIa,QAAQ,GAAGA,QAAQ,KAAKN,IAAIjF,MAAM,EAAEuF,SAAS,EAAG;QACvD,MAAMC,SAASxC,UAAUyC,UAAU;QACnC,MAAMC,WAAWT,IAAInF,GAAG,CAAC,CAAC,EAAEM,MAAM,EAAEC,MAAM,EAAE2E,QAAQ,EAAE;YACpD,MAAMW,MAAMtC,WAAWD,GAAG,CAAChD,OAAOxB,EAAE;YACpC,MAAMgH,QAAQ,AAACZ,SAAmCpE,MAAM,GACpD4E,OAAO5E,MAAM,CAAC+E,KAAK,AAACX,SAAmCvF,IAAI,IAC3D+F,OAAOK,MAAM,CAACF,KAAK,AAACX,SAAmCvF,IAAI,EAAE;gBAC3DqG,gBAAgBzF,OAAO0F,UAAU;YACnC;YACJ,OAAOH,MAAMI,IAAI,CACf,IAAO,CAAA;oBAAE5F;oBAAQpB,OAAO;gBAAgB,CAAA,GACxC,CAACA,QAAoB,CAAA;oBAAEoB;oBAAQpB,KAAK,EAAEA,gBAAAA,QAAS,IAAIiH,MAAM;gBAAoB,CAAA;QAEjF;QACA,MAAMT,OAAOU,KAAK;QAClB,MAAMC,UAAU,MAAMC,QAAQC,GAAG,CAACX;QAClC,MAAMY,QAA8C,EAAE;QACtD,KAAK,MAAM,EAAElG,MAAM,EAAEpB,KAAK,EAAE,IAAImH,QAAS;YACvC,IAAInH,UAAU,MAAM;gBAClB0F,WAAW;gBACX;YACF;YACA,MAAMzF,OAAOF,OAAOC;YACpB,IAAIC,SAASgD,aAAa,CAACpD,MAAM0H,GAAG,CAACtH,OAAO,MAAMD;YAClD,yEAAyE;YACzE,IAAI6D,UAAU,sBAAsB5D,SAAS,GAAG;YAChDqH,MAAME,IAAI,CAACpG;QACb;QACA,IAAI,CAACkG,MAAMtG,MAAM,EAAE,OAAO0E;QAC1B,MAAM+B,QAAQ,MAAMzD,UAAU4B,MAAM,IAAI0B,MAAMxG,GAAG,CAAC,CAACM,SAAWiD,WAAWD,GAAG,CAAChD,OAAOxB,EAAE;QACtFqG,MAAMqB,MACHxG,GAAG,CAAC,CAACM,QAAQ2E,QAAW,CAAA;gBACvB3E;gBACAC,QAAQoG,KAAK,CAAC1B,MAAM;gBACpBC,UAAUpC,OAAOC,OAAOzC,QAAQqG,KAAK,CAAC1B,MAAM,EAAEzE,OAAOC;YACvD,CAAA,GACCR,MAAM,CAAC,CAACqB,QAAUA,MAAM4D,QAAQ,KAAK;IAC1C;IACA,IAAIC,IAAIjF,MAAM,EAAE;QACd,MAAM,IAAIiG,MACR,CAAC,sBAAsB,EAAEhB,IAAIjF,MAAM,CAAC,gBAAgB,EAAEM,MAAMgD,QAAQ,CAAC,8BAA8B,CAAC;IAExG;IACA,OAAOoB;AACT"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/consent-group-carry.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\n/**\n * CARRYING A SITE'S REFUSALS ONTO A SITE THAT IS ABOUT TO STOP READING THEM\n * (AGL-3320).\n *\n * Three per-site stores are read across the CURRENT consent group by every\n * send path (`email-suppression.ts`, `email-marketing-gate.ts`), so a site\n * leaving a group stops seeing its former siblings' — and they stop seeing\n * its. Before the declaration flips, each carry `(to ← from)` of the plan\n * copies `from`'s refusals onto `to`:\n *\n * - **C1, the suppression list** (`hosts/{h}/suppressions/{key}`). A row\n * `to` does not have is CREATED with `from`'s fields and provenance; a row\n * `to` has is never touched. `reason` is kept, so only an unsubscribe stays\n * liftable by the preference page's resubscribe, and a group-wide\n * resubscribe lifts a carried row like an original.\n * - **C2, the topic opt-outs** (`hosts/{h}/topicOptOuts/{key}`). Every\n * stream `from` has LEFT, and `to` has not, becomes an opt-out on `to`\n * dated when the person left `from`. `to`'s other entries — its own\n * confirmations, its own opt-outs — are kept as they are.\n * - **C3, the pace** (`hosts/{h}/emailFrequency/{key}`). The choice made\n * most recently wins, as `groupPace` reads it within a group, and a site\n * keeping a slower pace learns when `from` last mailed the person, so the\n * separation cannot loosen the pace they asked for. The send counters are\n * never touched.\n *\n * ## One page per call, and every page idempotent\n *\n * The executor calls this until it answers `done`, handing back the cursor.\n * A page reads `from`'s documents in id order and `to`'s matching documents\n * in one `getAll`, and writes only what `to` lacks — so a page re-run after a\n * crash, a catch-up pass and the sweep all write nothing that was already\n * carried. A write that raced another writer (a `lastUpdateTime` precondition\n * or a create that found the document) is re-read and decided again once;\n * failing twice throws, and the executor retries the page.\n *\n * `sinceMs` narrows a page to what changed since then — the catch-up, which\n * reads by `suppressedAt`, `updatedAt` and `cadenceSetAtMs`. A row written\n * without one of those is left to the sweep's full pass.\n */\n\nimport {\n readTopicSubscriptionState,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n type TopicSubscriptionEntry,\n} from '@aglyn/aglyn/app-utils/email-topics'\nimport type { ConsentGroupCarry } from '@aglyn/aglyn/app-utils/consent-group-change'\nimport { FieldPath, FieldValue, Timestamp } from 'firebase-admin/firestore'\nimport { EMAIL_FREQUENCY_SUBCOLLECTION } from './email-marketing-gate'\nimport { HOST_SUPPRESSIONS_SUBCOLLECTION } from './email-suppression'\n\n/** The three stores a carry copies, as the job counts them. */\nexport type ConsentGroupCarryStore = 'siteSuppressions' | 'topicOptOuts' | 'paces'\n\nexport const CONSENT_GROUP_CARRY_STORES: readonly ConsentGroupCarryStore[] = [\n 'siteSuppressions',\n 'topicOptOuts',\n 'paces',\n]\n\n/** Each store's subcollection under `hosts/{hostId}`. */\nexport const CONSENT_GROUP_CARRY_SUBCOLLECTIONS: Readonly<Record<ConsentGroupCarryStore, string>> = {\n siteSuppressions: HOST_SUPPRESSIONS_SUBCOLLECTION,\n topicOptOuts: TOPIC_OPT_OUTS_SUBCOLLECTION,\n paces: EMAIL_FREQUENCY_SUBCOLLECTION,\n}\n\n/** The field a catch-up reads each store by, and whether it is a timestamp. */\nconst CATCH_UP_FIELDS: Readonly<Record<ConsentGroupCarryStore, { field: string; timestamp: boolean }>> = {\n siteSuppressions: { field: 'suppressedAt', timestamp: true },\n topicOptOuts: { field: 'updatedAt', timestamp: true },\n paces: { field: 'cadenceSetAtMs', timestamp: false },\n}\n\n/**\n * The suppression row's fields a carry copies. Everything else a row could\n * carry is either the carry's own provenance or not a fact about the refusal.\n */\nconst SUPPRESSION_ROW_FIELDS = [\n 'reason',\n 'email',\n // The address's search tokens travel with it (AGL-3321).\n 'emailTokens',\n 'createdAt',\n 'suppressedAt',\n 'note',\n 'suppressedByUid',\n 'campaignId',\n 'topicId',\n] as const\n\n/** Documents one page reads from the source site. */\nexport const CONSENT_GROUP_CARRY_PAGE = 300\n\nexport interface ConsentGroupCarryPage {\n /** The source site has nothing after the cursor. */\n done: boolean\n /** Where the next page starts, or `null` once `done`. */\n cursor: string | null\n /** Source documents read. */\n read: number\n /** Documents written on the receiving site (counted, not written, on a dry run). */\n written: number\n}\n\nexport interface CarryConsentGroupRefusalsOptions {\n firestore: FirebaseFirestore.Firestore\n store: ConsentGroupCarryStore\n carry: ConsentGroupCarry\n changeId: string\n cursor: string | null\n /** Only documents changed at or after this instant — the catch-up. */\n sinceMs?: number | null\n pageSize?: number\n dryRun?: boolean\n}\n\ninterface Cursor {\n id: string\n /** The catch-up field's value on the last document read. */\n at?: { seconds: number; nanoseconds: number } | number\n}\n\nfunction readCursor(raw: string | null): Cursor | null {\n if (!raw) return null\n try {\n const parsed = JSON.parse(raw) as Cursor\n return typeof parsed?.id === 'string' ? parsed : null\n } catch {\n return null\n }\n}\n\n/** The error codes that mean \"somebody wrote this document since I read it\". */\nconst RACED = new Set([5, 6, 9])\nconst codeOf = (error: unknown): number | undefined => (error as { code?: number } | null)?.code\n\n/** Milliseconds from a stored number or timestamp, or `null`. */\nfunction storedMs(value: unknown): number | null {\n if (typeof value === 'number') return Number.isFinite(value) ? value : null\n const toMillis = (value as { toMillis?: () => number } | null)?.toMillis\n return typeof toMillis === 'function' ? toMillis.call(value) : null\n}\n\n/**\n * When the site last mailed the person, as the gate reads it: the stored\n * instant, or the newest entry of a window written before it existed.\n */\nfunction lastSentMs(data: Record<string, unknown> | undefined): number | null {\n if (!data) return null\n const stored = Number(data['lastSentAtMs'])\n if (Number.isFinite(stored) && stored > 0) return stored\n const window = Array.isArray(data['sentAtMs'])\n ? (data['sentAtMs'] as unknown[]).map(Number).filter(Number.isFinite)\n : []\n return window.length ? Math.max(...window) : null\n}\n\n/**\n * What `to` must become for one source document, or `null` when it already\n * honors everything `from` does. `create` says the document is new.\n */\ntype Decision = { create: boolean; data: Record<string, unknown> } | null\n\nfunction decideSuppression(\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n changeId: string,\n): Decision {\n if (target.exists) return null\n const row = source.data() ?? {}\n const data: Record<string, unknown> = {}\n for (const field of SUPPRESSION_ROW_FIELDS) {\n if (field in row) data[field] = row[field]\n }\n data['carriedFromHostId'] = carry.fromHostId\n data['carriedByChangeId'] = changeId\n data['carriedAt'] = FieldValue.serverTimestamp()\n return { create: true, data }\n}\n\nfunction decideTopics(\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n): Decision {\n const sourceTopics = (source.get('topics') ?? {}) as Record<string, TopicSubscriptionEntry>\n const targetTopics = ((target.exists ? target.get('topics') : null) ?? {}) as Record<\n string,\n TopicSubscriptionEntry & Record<string, unknown>\n >\n const next: Record<string, unknown> = { ...targetTopics }\n let changed = false\n for (const [topicId, entry] of Object.entries(sourceTopics)) {\n if (readTopicSubscriptionState(entry) !== 'opted-out') continue\n if (readTopicSubscriptionState(targetTopics[topicId]) === 'opted-out') continue\n next[topicId] = {\n ...(targetTopics[topicId] ?? {}),\n optedOutAt: entry.optedOutAt,\n resubscribedAt: null,\n carriedFromHostId: carry.fromHostId,\n }\n changed = true\n }\n if (!changed) return null\n const email = (target.exists ? target.get('email') : null) ?? source.get('email') ?? null\n return {\n create: !target.exists,\n data: {\n email,\n topics: next,\n updatedAt: FieldValue.serverTimestamp(),\n ...(target.exists ? {} : { createdAt: FieldValue.serverTimestamp() }),\n },\n }\n}\n\nfunction decidePace(\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n): Decision {\n const from = source.data() ?? {}\n const to = target.exists ? (target.data() ?? {}) : undefined\n const patch: Record<string, unknown> = {}\n const sourceChoice = from['cadence']\n const targetChoice = to?.['cadence']\n const sourceSetAt = Number(from['cadenceSetAtMs'])\n const targetSetAt = Number(to?.['cadenceSetAtMs'])\n const carried =\n sourceChoice != null &&\n (targetChoice == null ||\n (Number.isFinite(targetSetAt) ? targetSetAt : 0) <\n (Number.isFinite(sourceSetAt) ? sourceSetAt : 0))\n if (carried) {\n patch['cadence'] = sourceChoice\n if (Number.isFinite(sourceSetAt)) patch['cadenceSetAtMs'] = sourceSetAt\n patch['cadenceCarriedFromHostId'] = carry.fromHostId\n }\n // The pace in force afterwards decides whether the last send matters: `all`\n // sends as they come and never asks when the last one went.\n const choice = carried ? sourceChoice : targetChoice\n if (choice != null && choice !== 'all') {\n const sourceLast = lastSentMs(from)\n const targetLast = lastSentMs(to)\n if (sourceLast !== null && (targetLast === null || sourceLast > targetLast)) {\n patch['lastSentAtMs'] = sourceLast\n }\n }\n if (!Object.keys(patch).length) return null\n return {\n create: !target.exists,\n data: target.exists ? patch : { email: from['email'] ?? null, ...patch },\n }\n}\n\nfunction decide(\n store: ConsentGroupCarryStore,\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n changeId: string,\n): Decision {\n if (store === 'siteSuppressions') return decideSuppression(source, target, carry, changeId)\n if (store === 'topicOptOuts') return decideTopics(source, target, carry)\n return decidePace(source, target, carry)\n}\n\n/**\n * One page of one store of one carry. See the module note for what each\n * store copies and why a re-run writes nothing.\n */\nexport async function carryConsentGroupRefusals(\n options: CarryConsentGroupRefusalsOptions,\n): Promise<ConsentGroupCarryPage> {\n const { firestore, store, carry, changeId } = options\n const subcollection = CONSENT_GROUP_CARRY_SUBCOLLECTIONS[store]\n const sourceList = firestore.collection('hosts').doc(carry.fromHostId).collection(subcollection)\n const targetList = firestore.collection('hosts').doc(carry.toHostId).collection(subcollection)\n const limit = options.pageSize ?? CONSENT_GROUP_CARRY_PAGE\n const cursor = readCursor(options.cursor)\n const catchUp = CATCH_UP_FIELDS[store]\n const since = options.sinceMs ?? null\n\n let query: FirebaseFirestore.Query = sourceList\n if (since !== null) {\n query = query\n .where(catchUp.field, '>=', catchUp.timestamp ? Timestamp.fromMillis(since) : since)\n .orderBy(catchUp.field)\n .orderBy(FieldPath.documentId())\n if (cursor && cursor.at !== undefined) {\n const at =\n typeof cursor.at === 'number' ? cursor.at : new Timestamp(cursor.at.seconds, cursor.at.nanoseconds)\n query = query.startAfter(at, cursor.id)\n }\n } else {\n query = query.orderBy(FieldPath.documentId())\n if (cursor) query = query.startAfter(cursor.id)\n }\n const page = await query.limit(limit).get()\n if (page.empty) return { done: true, cursor: null, read: 0, written: 0 }\n\n const targets = await firestore.getAll(...page.docs.map((doc) => targetList.doc(doc.id)))\n const decisions = page.docs.map((source, index) => ({\n source,\n target: targets[index],\n decision: decide(store, source, targets[index], carry, changeId),\n }))\n const due = decisions.filter((entry) => entry.decision !== null)\n const written = options.dryRun\n ? due.length\n : await writeDecisions(firestore, targetList, store, carry, changeId, due)\n\n const last = page.docs[page.docs.length - 1]\n const next: Cursor = { id: last.id }\n if (since !== null) {\n const value = last.get(catchUp.field)\n next.at =\n value instanceof Timestamp\n ? { seconds: value.seconds, nanoseconds: value.nanoseconds }\n : (storedMs(value) ?? 0)\n }\n const done = page.size < limit\n return { done, cursor: done ? null : JSON.stringify(next), read: page.size, written }\n}\n\n/**\n * Writes one page's decisions through a `BulkWriter`, each guarded against\n * the document having moved since it was read, and decides the ones that\n * raced once more from a fresh read.\n */\nasync function writeDecisions(\n firestore: FirebaseFirestore.Firestore,\n targetList: FirebaseFirestore.CollectionReference,\n store: ConsentGroupCarryStore,\n carry: ConsentGroupCarry,\n changeId: string,\n due: Array<{\n source: FirebaseFirestore.DocumentSnapshot\n target: FirebaseFirestore.DocumentSnapshot\n decision: Decision\n }>,\n): Promise<number> {\n let written = 0\n for (let round = 0; round < 2 && due.length; round += 1) {\n const writer = firestore.bulkWriter()\n const outcomes = due.map(({ source, target, decision }) => {\n const ref = targetList.doc(source.id)\n const write = (decision as NonNullable<Decision>).create\n ? writer.create(ref, (decision as NonNullable<Decision>).data)\n : writer.update(ref, (decision as NonNullable<Decision>).data, {\n lastUpdateTime: target.updateTime as FirebaseFirestore.Timestamp,\n })\n return write.then(\n () => ({ source, error: null as unknown }),\n (error: unknown) => ({ source, error: error ?? new Error('The write failed') }),\n )\n })\n await writer.close()\n const settled = await Promise.all(outcomes)\n const retry: FirebaseFirestore.DocumentSnapshot[] = []\n for (const { source, error } of settled) {\n if (error === null) {\n written += 1\n continue\n }\n const code = codeOf(error)\n if (code === undefined || !RACED.has(code)) throw error\n // The row a suppression carry would create exists now: `to` has its own.\n if (store === 'siteSuppressions' && code === 6) continue\n retry.push(source)\n }\n if (!retry.length) return written\n const fresh = await firestore.getAll(...retry.map((source) => targetList.doc(source.id)))\n due = retry\n .map((source, index) => ({\n source,\n target: fresh[index],\n decision: decide(store, source, fresh[index], carry, changeId),\n }))\n .filter((entry) => entry.decision !== null)\n }\n if (due.length) {\n throw new Error(\n `[consent-group-carry] ${due.length} document(s) on ${carry.toHostId} kept changing under the carry`,\n )\n }\n return written\n}\n"],"names":["readTopicSubscriptionState","TOPIC_OPT_OUTS_SUBCOLLECTION","FieldPath","FieldValue","Timestamp","EMAIL_FREQUENCY_SUBCOLLECTION","HOST_SUPPRESSIONS_SUBCOLLECTION","CONSENT_GROUP_CARRY_STORES","CONSENT_GROUP_CARRY_SUBCOLLECTIONS","siteSuppressions","topicOptOuts","paces","CATCH_UP_FIELDS","field","timestamp","SUPPRESSION_ROW_FIELDS","CONSENT_GROUP_CARRY_PAGE","readCursor","raw","parsed","JSON","parse","id","RACED","Set","codeOf","error","code","storedMs","value","Number","isFinite","toMillis","call","lastSentMs","data","stored","window","Array","isArray","map","filter","length","Math","max","decideSuppression","source","target","carry","changeId","exists","row","fromHostId","serverTimestamp","create","decideTopics","sourceTopics","get","targetTopics","next","changed","topicId","entry","Object","entries","optedOutAt","resubscribedAt","carriedFromHostId","email","topics","updatedAt","createdAt","decidePace","from","to","undefined","patch","sourceChoice","targetChoice","sourceSetAt","targetSetAt","carried","choice","sourceLast","targetLast","keys","decide","store","carryConsentGroupRefusals","options","firestore","subcollection","sourceList","collection","doc","targetList","toHostId","limit","pageSize","cursor","catchUp","since","sinceMs","query","where","fromMillis","orderBy","documentId","at","seconds","nanoseconds","startAfter","page","empty","done","read","written","targets","getAll","docs","decisions","index","decision","due","dryRun","writeDecisions","last","size","stringify","round","writer","bulkWriter","outcomes","ref","write","update","lastUpdateTime","updateTime","then","Error","close","settled","Promise","all","retry","has","push","fresh"],"mappings":"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"}
|
|
@@ -29,6 +29,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
29
29
|
* fixture barrel, and a helper that reached the planner only through the
|
|
30
30
|
* barrel would find `undefined` there and take the door down with it.
|
|
31
31
|
*/ import { COMPANY_CONTACTS_COUNT_FIELD, CONTACT_COMPANY_IDS_FIELD, planContactCompanyLink, readContactCompanyLink } from "@aglyn/aglyn/app-utils/crm";
|
|
32
|
+
import { restampCrmListFieldsAt } from "./crm-records.js";
|
|
32
33
|
import { contactFacetPath } from "@aglyn/aglyn/app-utils/contacts";
|
|
33
34
|
import { FieldValue } from "firebase-admin/firestore";
|
|
34
35
|
/**
|
|
@@ -99,6 +100,8 @@ import { FieldValue } from "firebase-admin/firestore";
|
|
|
99
100
|
batch.update(companiesRef.doc(count.companyId), companyContactsCountFields(count.delta));
|
|
100
101
|
}
|
|
101
102
|
await batch.commit();
|
|
103
|
+
// The company is what the Contacts list filters by (AGL-3321).
|
|
104
|
+
await restampCrmListFieldsAt(contactRef, 'contacts');
|
|
102
105
|
return plan;
|
|
103
106
|
}
|
|
104
107
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/contact-company-link.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\n/**\n * The contact–company link as the Admin SDK writes it (AGL-2613).\n *\n * The console's writers apply `planContactCompanyLink` with the client\n * SDK's sentinels; the server doors — the capture that links on domain, the\n * create route, a lead's conversion, the upsert carrying a door's company —\n * apply the same plan with `FieldValue`'s. One planner, two sentinel sets,\n * and this file is the second: nothing here decides whether an id leaves the\n * mirror, it only spells the answer in Admin terms.\n *\n * Imported by module path rather than from the `@aglyn/aglyn/server` barrel,\n * for the reason `upsert-contact.ts` gives: the door's specs substitute a\n * fixture barrel, and a helper that reached the planner only through the\n * barrel would find `undefined` there and take the door down with it.\n */\n\nimport {\n COMPANY_CONTACTS_COUNT_FIELD,\n CONTACT_COMPANY_IDS_FIELD,\n type ContactCompanyLinkPlan,\n planContactCompanyLink,\n readContactCompanyLink,\n} from '@aglyn/aglyn/app-utils/crm'\nimport { contactFacetPath } from '@aglyn/aglyn/app-utils/contacts'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/**\n * The plan's mirror change as the sentinel — or array — an Admin write takes.\n * Both an `update()` and a merge-`set()` accept these at the top level of\n * the document, which is where the mirror lives.\n */\nexport function contactCompanyMirrorValue(\n plan: ContactCompanyLinkPlan,\n): unknown | undefined {\n const change = plan.mirror\n if (!change) return undefined\n if (change.op === 'union') return FieldValue.arrayUnion(change.companyId)\n if (change.op === 'remove') return FieldValue.arrayRemove(change.companyId)\n return change.companyIds\n}\n\n/**\n * The contact's patch for a plan, in `update()` shape: the holder's facet by\n * DOTTED path — a nested object would replace every other holder's facet —\n * and the mirror at the top of the document.\n */\nexport function contactCompanyLinkFields(\n plan: ContactCompanyLinkPlan,\n groupId: string,\n): Record<string, unknown> {\n const mirror = contactCompanyMirrorValue(plan)\n return {\n [contactFacetPath(groupId, 'companyId')]: plan.companyId ?? FieldValue.delete(),\n ...(mirror !== undefined ? { [CONTACT_COMPANY_IDS_FIELD]: mirror } : {}),\n }\n}\n\n/** The company's patch: its contacts count moved by `delta`. */\nexport function companyContactsCountFields(delta: number): Record<string, unknown> {\n return { [COMPANY_CONTACTS_COUNT_FIELD]: FieldValue.increment(delta) }\n}\n\n/**\n * Move each company's count the plan names, one `update()` each, without\n * failing the caller.\n *\n * Separate writes rather than a batch, for the callers that cannot batch:\n * the upsert has already written the contact by the time it knows the plan\n * held, and a capture door must not fail because a company document was\n * deleted between the link and the count. A count that could not move is\n * logged and left; the company's own page takes the live aggregate, which\n * is what corrects a stale figure.\n */\nexport async function settleCompanyContactsCounts(\n companiesRef: FirebaseFirestore.CollectionReference | null,\n plan: ContactCompanyLinkPlan | null,\n): Promise<void> {\n if (!companiesRef || !plan?.counts.length) return\n await Promise.all(\n plan.counts.map((count) =>\n companiesRef\n .doc(count.companyId)\n .update(companyContactsCountFields(count.delta))\n .catch((error: unknown) => {\n console.error(\n 'contact company count could not move',\n count.companyId,\n count.delta,\n error,\n )\n }),\n ),\n )\n}\n\nexport interface WriteContactCompanyLinkOptions {\n firestore: FirebaseFirestore.Firestore\n contactRef: FirebaseFirestore.DocumentReference\n /**\n * The stored contact, BEFORE this write — what the planner reads the\n * previous link and the mirror off. `null` for a row the caller has just\n * created with no company on it.\n */\n contact: Record<string, unknown> | null | undefined\n companiesRef: FirebaseFirestore.CollectionReference\n groupId: string\n /** The company to link for this holder, or `null` to unlink. */\n companyId: string | null\n}\n\n/**\n * Link a contact to a company FOR ONE HOLDER — or unlink, with `null` — as\n * ONE commit: the facet and the mirror on the contact, and the count on\n * each company the link moves. `null` when the document already said so.\n *\n * A batch rather than two writes because the count is a derived figure of\n * the mirror: a mirror that changed while the count did not is a company\n * list that says \"3\" over a page of four. `updatedAt` moves on the contact\n * — a link is an edit of the person's record — and not on the company,\n * whose list is ordered by its own edits and would otherwise reshuffle on\n * every capture.\n */\nexport async function writeContactCompanyLink(\n options: WriteContactCompanyLinkOptions,\n): Promise<ContactCompanyLinkPlan | null> {\n const { firestore, contactRef, contact, companiesRef, groupId, companyId } =\n options\n const plan = planContactCompanyLink(\n readContactCompanyLink(contact, groupId),\n companyId,\n )\n if (!plan) return null\n const batch = firestore.batch()\n batch.update(contactRef, {\n ...contactCompanyLinkFields(plan, groupId),\n updatedAt: FieldValue.serverTimestamp(),\n })\n for (const count of plan.counts) {\n batch.update(\n companiesRef.doc(count.companyId),\n companyContactsCountFields(count.delta),\n )\n }\n await batch.commit()\n return plan\n}\n"],"names":["COMPANY_CONTACTS_COUNT_FIELD","CONTACT_COMPANY_IDS_FIELD","planContactCompanyLink","readContactCompanyLink","contactFacetPath","FieldValue","contactCompanyMirrorValue","plan","change","mirror","undefined","op","arrayUnion","companyId","arrayRemove","companyIds","contactCompanyLinkFields","groupId","delete","companyContactsCountFields","delta","increment","settleCompanyContactsCounts","companiesRef","counts","length","Promise","all","map","count","doc","update","catch","error","console","writeContactCompanyLink","options","firestore","contactRef","contact","batch","updatedAt","serverTimestamp","commit"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;CAcC,GAED,SACEA,4BAA4B,EAC5BC,yBAAyB,EAEzBC,sBAAsB,EACtBC,sBAAsB,QACjB,6BAA4B;AACnC,SAASC,gBAAgB,QAAQ,kCAAiC;AAClE,SAASC,UAAU,QAAQ,2BAA0B;AAErD;;;;CAIC,GACD,OAAO,SAASC,0BACdC,IAA4B;IAE5B,MAAMC,SAASD,KAAKE,MAAM;IAC1B,IAAI,CAACD,QAAQ,OAAOE;IACpB,IAAIF,OAAOG,EAAE,KAAK,SAAS,OAAON,WAAWO,UAAU,CAACJ,OAAOK,SAAS;IACxE,IAAIL,OAAOG,EAAE,KAAK,UAAU,OAAON,WAAWS,WAAW,CAACN,OAAOK,SAAS;IAC1E,OAAOL,OAAOO,UAAU;AAC1B;AAEA;;;;CAIC,GACD,OAAO,SAASC,yBACdT,IAA4B,EAC5BU,OAAe;QAI6BV;IAF5C,MAAME,SAASH,0BAA0BC;IACzC,OAAO;QACL,CAACH,iBAAiBa,SAAS,aAAa,GAAEV,kBAAAA,KAAKM,SAAS,YAAdN,kBAAkBF,WAAWa,MAAM;OACzET,WAAWC,YAAY;QAAE,
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/contact-company-link.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\n/**\n * The contact–company link as the Admin SDK writes it (AGL-2613).\n *\n * The console's writers apply `planContactCompanyLink` with the client\n * SDK's sentinels; the server doors — the capture that links on domain, the\n * create route, a lead's conversion, the upsert carrying a door's company —\n * apply the same plan with `FieldValue`'s. One planner, two sentinel sets,\n * and this file is the second: nothing here decides whether an id leaves the\n * mirror, it only spells the answer in Admin terms.\n *\n * Imported by module path rather than from the `@aglyn/aglyn/server` barrel,\n * for the reason `upsert-contact.ts` gives: the door's specs substitute a\n * fixture barrel, and a helper that reached the planner only through the\n * barrel would find `undefined` there and take the door down with it.\n */\n\nimport {\n COMPANY_CONTACTS_COUNT_FIELD,\n CONTACT_COMPANY_IDS_FIELD,\n type ContactCompanyLinkPlan,\n planContactCompanyLink,\n readContactCompanyLink,\n} from '@aglyn/aglyn/app-utils/crm'\nimport { restampCrmListFieldsAt } from './crm-records'\nimport { contactFacetPath } from '@aglyn/aglyn/app-utils/contacts'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/**\n * The plan's mirror change as the sentinel — or array — an Admin write takes.\n * Both an `update()` and a merge-`set()` accept these at the top level of\n * the document, which is where the mirror lives.\n */\nexport function contactCompanyMirrorValue(\n plan: ContactCompanyLinkPlan,\n): unknown | undefined {\n const change = plan.mirror\n if (!change) return undefined\n if (change.op === 'union') return FieldValue.arrayUnion(change.companyId)\n if (change.op === 'remove') return FieldValue.arrayRemove(change.companyId)\n return change.companyIds\n}\n\n/**\n * The contact's patch for a plan, in `update()` shape: the holder's facet by\n * DOTTED path — a nested object would replace every other holder's facet —\n * and the mirror at the top of the document.\n */\nexport function contactCompanyLinkFields(\n plan: ContactCompanyLinkPlan,\n groupId: string,\n): Record<string, unknown> {\n const mirror = contactCompanyMirrorValue(plan)\n return {\n [contactFacetPath(groupId, 'companyId')]: plan.companyId ?? FieldValue.delete(),\n ...(mirror !== undefined ? { [CONTACT_COMPANY_IDS_FIELD]: mirror } : {}),\n }\n}\n\n/** The company's patch: its contacts count moved by `delta`. */\nexport function companyContactsCountFields(delta: number): Record<string, unknown> {\n return { [COMPANY_CONTACTS_COUNT_FIELD]: FieldValue.increment(delta) }\n}\n\n/**\n * Move each company's count the plan names, one `update()` each, without\n * failing the caller.\n *\n * Separate writes rather than a batch, for the callers that cannot batch:\n * the upsert has already written the contact by the time it knows the plan\n * held, and a capture door must not fail because a company document was\n * deleted between the link and the count. A count that could not move is\n * logged and left; the company's own page takes the live aggregate, which\n * is what corrects a stale figure.\n */\nexport async function settleCompanyContactsCounts(\n companiesRef: FirebaseFirestore.CollectionReference | null,\n plan: ContactCompanyLinkPlan | null,\n): Promise<void> {\n if (!companiesRef || !plan?.counts.length) return\n await Promise.all(\n plan.counts.map((count) =>\n companiesRef\n .doc(count.companyId)\n .update(companyContactsCountFields(count.delta))\n .catch((error: unknown) => {\n console.error(\n 'contact company count could not move',\n count.companyId,\n count.delta,\n error,\n )\n }),\n ),\n )\n}\n\nexport interface WriteContactCompanyLinkOptions {\n firestore: FirebaseFirestore.Firestore\n contactRef: FirebaseFirestore.DocumentReference\n /**\n * The stored contact, BEFORE this write — what the planner reads the\n * previous link and the mirror off. `null` for a row the caller has just\n * created with no company on it.\n */\n contact: Record<string, unknown> | null | undefined\n companiesRef: FirebaseFirestore.CollectionReference\n groupId: string\n /** The company to link for this holder, or `null` to unlink. */\n companyId: string | null\n}\n\n/**\n * Link a contact to a company FOR ONE HOLDER — or unlink, with `null` — as\n * ONE commit: the facet and the mirror on the contact, and the count on\n * each company the link moves. `null` when the document already said so.\n *\n * A batch rather than two writes because the count is a derived figure of\n * the mirror: a mirror that changed while the count did not is a company\n * list that says \"3\" over a page of four. `updatedAt` moves on the contact\n * — a link is an edit of the person's record — and not on the company,\n * whose list is ordered by its own edits and would otherwise reshuffle on\n * every capture.\n */\nexport async function writeContactCompanyLink(\n options: WriteContactCompanyLinkOptions,\n): Promise<ContactCompanyLinkPlan | null> {\n const { firestore, contactRef, contact, companiesRef, groupId, companyId } =\n options\n const plan = planContactCompanyLink(\n readContactCompanyLink(contact, groupId),\n companyId,\n )\n if (!plan) return null\n const batch = firestore.batch()\n batch.update(contactRef, {\n ...contactCompanyLinkFields(plan, groupId),\n updatedAt: FieldValue.serverTimestamp(),\n })\n for (const count of plan.counts) {\n batch.update(\n companiesRef.doc(count.companyId),\n companyContactsCountFields(count.delta),\n )\n }\n await batch.commit()\n // The company is what the Contacts list filters by (AGL-3321).\n await restampCrmListFieldsAt(contactRef, 'contacts')\n return plan\n}\n"],"names":["COMPANY_CONTACTS_COUNT_FIELD","CONTACT_COMPANY_IDS_FIELD","planContactCompanyLink","readContactCompanyLink","restampCrmListFieldsAt","contactFacetPath","FieldValue","contactCompanyMirrorValue","plan","change","mirror","undefined","op","arrayUnion","companyId","arrayRemove","companyIds","contactCompanyLinkFields","groupId","delete","companyContactsCountFields","delta","increment","settleCompanyContactsCounts","companiesRef","counts","length","Promise","all","map","count","doc","update","catch","error","console","writeContactCompanyLink","options","firestore","contactRef","contact","batch","updatedAt","serverTimestamp","commit"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;CAcC,GAED,SACEA,4BAA4B,EAC5BC,yBAAyB,EAEzBC,sBAAsB,EACtBC,sBAAsB,QACjB,6BAA4B;AACnC,SAASC,sBAAsB,QAAQ,mBAAe;AACtD,SAASC,gBAAgB,QAAQ,kCAAiC;AAClE,SAASC,UAAU,QAAQ,2BAA0B;AAErD;;;;CAIC,GACD,OAAO,SAASC,0BACdC,IAA4B;IAE5B,MAAMC,SAASD,KAAKE,MAAM;IAC1B,IAAI,CAACD,QAAQ,OAAOE;IACpB,IAAIF,OAAOG,EAAE,KAAK,SAAS,OAAON,WAAWO,UAAU,CAACJ,OAAOK,SAAS;IACxE,IAAIL,OAAOG,EAAE,KAAK,UAAU,OAAON,WAAWS,WAAW,CAACN,OAAOK,SAAS;IAC1E,OAAOL,OAAOO,UAAU;AAC1B;AAEA;;;;CAIC,GACD,OAAO,SAASC,yBACdT,IAA4B,EAC5BU,OAAe;QAI6BV;IAF5C,MAAME,SAASH,0BAA0BC;IACzC,OAAO;QACL,CAACH,iBAAiBa,SAAS,aAAa,GAAEV,kBAAAA,KAAKM,SAAS,YAAdN,kBAAkBF,WAAWa,MAAM;OACzET,WAAWC,YAAY;QAAE,CAACV,0BAA0B,EAAES;IAAO,IAAI,CAAC;AAE1E;AAEA,8DAA8D,GAC9D,OAAO,SAASU,2BAA2BC,KAAa;IACtD,OAAO;QAAE,CAACrB,6BAA6B,EAAEM,WAAWgB,SAAS,CAACD;IAAO;AACvE;AAEA;;;;;;;;;;CAUC,GACD,OAAO,eAAeE,4BACpBC,YAA0D,EAC1DhB,IAAmC;IAEnC,IAAI,CAACgB,gBAAgB,EAAChB,wBAAAA,KAAMiB,MAAM,CAACC,MAAM,GAAE;IAC3C,MAAMC,QAAQC,GAAG,CACfpB,KAAKiB,MAAM,CAACI,GAAG,CAAC,CAACC,QACfN,aACGO,GAAG,CAACD,MAAMhB,SAAS,EACnBkB,MAAM,CAACZ,2BAA2BU,MAAMT,KAAK,GAC7CY,KAAK,CAAC,CAACC;YACNC,QAAQD,KAAK,CACX,wCACAJ,MAAMhB,SAAS,EACfgB,MAAMT,KAAK,EACXa;QAEJ;AAGR;AAiBA;;;;;;;;;;;CAWC,GACD,OAAO,eAAeE,wBACpBC,OAAuC;IAEvC,MAAM,EAAEC,SAAS,EAAEC,UAAU,EAAEC,OAAO,EAAEhB,YAAY,EAAEN,OAAO,EAAEJ,SAAS,EAAE,GACxEuB;IACF,MAAM7B,OAAON,uBACXC,uBAAuBqC,SAAStB,UAChCJ;IAEF,IAAI,CAACN,MAAM,OAAO;IAClB,MAAMiC,QAAQH,UAAUG,KAAK;IAC7BA,MAAMT,MAAM,CAACO,YAAY,aACpBtB,yBAAyBT,MAAMU;QAClCwB,WAAWpC,WAAWqC,eAAe;;IAEvC,KAAK,MAAMb,SAAStB,KAAKiB,MAAM,CAAE;QAC/BgB,MAAMT,MAAM,CACVR,aAAaO,GAAG,CAACD,MAAMhB,SAAS,GAChCM,2BAA2BU,MAAMT,KAAK;IAE1C;IACA,MAAMoB,MAAMG,MAAM;IAClB,+DAA+D;IAC/D,MAAMxC,uBAAuBmC,YAAY;IACzC,OAAO/B;AACT"}
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
* hears one that did not happen.
|
|
42
42
|
*/ import { consentGroupForHost, advanceContactLifecycleStage, contactFacetPath, isContactLifecycleStage, readContactFacet } from "@aglyn/aglyn/server";
|
|
43
43
|
import { FieldValue } from "firebase-admin/firestore";
|
|
44
|
+
import { restampCrmListFieldsAt } from "./crm-records.js";
|
|
44
45
|
export async function floorContactLifecycleStage(options) {
|
|
45
46
|
var _snapshot_data, _options_hostId, _data_hostId, _advanceContactLifecycleStage, _data_email;
|
|
46
47
|
const { contactRef, org, floor } = options;
|
|
@@ -74,6 +75,8 @@ export async function floorContactLifecycleStage(options) {
|
|
|
74
75
|
[contactFacetPath(groupId, 'lifecycleStage')]: lifecycleStage,
|
|
75
76
|
updatedAt: FieldValue.serverTimestamp()
|
|
76
77
|
});
|
|
78
|
+
// The stage is what the Contacts list filters by (AGL-3321).
|
|
79
|
+
await restampCrmListFieldsAt(snapshot.ref, 'contacts');
|
|
77
80
|
return {
|
|
78
81
|
outcome: 'advanced',
|
|
79
82
|
contactId: snapshot.id,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/contact-lifecycle-floor.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\n/**\n * A person's lifecycle stage, FLOORED at what just happened to them\n * (AGL-2641).\n *\n * The capture doors floor a stage inside `upsertHostContact`: an order\n * names `customer` as its `initialLifecycleStage`, and the door fills an\n * empty stage or advances an earlier one and never moves anybody back. A\n * won deal is the same fact as a purchase — the business has decided this\n * person bought — but it arrives at a record that already exists, through a\n * writer that is not a capture door: the deal-stage route and the REST\n * deals resource. This is the door's rule for them, over a contact document\n * they already hold a reference to.\n *\n * The write goes into ONE holder's facet, because a stage is one business's\n * reading of a person and the row is shared by every site in the org. The\n * holder is the site the deal was made on, resolved to its consent group\n * the way every other facet writer resolves it; a deal that names no site\n * falls back to the site that captured the person, which is the one\n * holder a contact always has. A contact that names none either is left\n * alone and the caller told, rather than written into a facet nobody reads.\n *\n * The write is skipped when it would change nothing — a customer stays a\n * customer, an evangelist stays an evangelist, `other` is never overwritten\n * — so a caller can announce `contactStageChanged` exactly when `advanced`\n * says the stage moved, and an automation listening for the change never\n * hears one that did not happen.\n */\n\nimport {\n consentGroupForHost,\n advanceContactLifecycleStage,\n contactFacetPath,\n type ContactLifecycleStage,\n isContactLifecycleStage,\n readContactFacet,\n} from '@aglyn/aglyn/server'\nimport { FieldValue } from 'firebase-admin/firestore'\n\nexport type ContactLifecycleFloor =\n | {\n /** The stage was raised to the floor. */\n outcome: 'advanced'\n contactId: string\n /** The address on the row, for the event a caller announces. */\n email: string\n /** The site whose facet was written. */\n hostId: string\n groupId: string\n /** The stage before the write, or `''` for a person who had none. */\n previousStage: ContactLifecycleStage | ''\n lifecycleStage: ContactLifecycleStage\n }\n | {\n /** The person already held the floor or a later stage; nothing was written. */\n outcome: 'held'\n contactId: string\n email: string\n hostId: string\n groupId: string\n lifecycleStage: ContactLifecycleStage\n }\n | {\n /** No such contact. */\n outcome: 'missing'\n contactId: string\n }\n | {\n /** Neither the caller nor the contact names a site, so there is no facet to write. */\n outcome: 'unheld'\n contactId: string\n }\n\nexport interface ContactLifecycleFloorOptions {\n contactRef: FirebaseFirestore.DocumentReference\n /**\n * The org the contact belongs to, for the consent groups — the same\n * document every facet writer resolves the holder through.\n */\n org: Record<string, unknown> | null | undefined\n /**\n * The site the act happened on, whose facet takes the stage. `null` for a\n * record no site captured; the contact's own capturing site is used then.\n */\n hostId: string | null | undefined\n /** The EARLIEST stage that describes what happened — see `advanceContactLifecycleStage`. */\n floor: ContactLifecycleStage\n}\n\nexport async function floorContactLifecycleStage(\n options: ContactLifecycleFloorOptions,\n): Promise<ContactLifecycleFloor> {\n const { contactRef, org, floor } = options\n const snapshot = await contactRef.get()\n if (!snapshot.exists) return { outcome: 'missing', contactId: contactRef.id }\n const data = (snapshot.data() ?? {}) as Record<string, unknown>\n const hostId = String(options.hostId ?? '').trim() || String(data['hostId'] ?? '').trim()\n if (!hostId) return { outcome: 'unheld', contactId: snapshot.id }\n\n const { groupId } = consentGroupForHost(org ?? null, hostId)\n const facet = readContactFacet(data, groupId)\n const held = isContactLifecycleStage(facet.lifecycleStage)\n ? facet.lifecycleStage\n : undefined\n const lifecycleStage = advanceContactLifecycleStage(held, floor) ?? floor\n const email = String(data['email'] ?? '')\n if (lifecycleStage === held) {\n return { outcome: 'held', contactId: snapshot.id, email, hostId, groupId, lifecycleStage }\n }\n await snapshot.ref.update({\n [contactFacetPath(groupId, 'lifecycleStage')]: lifecycleStage,\n updatedAt: FieldValue.serverTimestamp(),\n })\n return {\n outcome: 'advanced',\n contactId: snapshot.id,\n email,\n hostId,\n groupId,\n previousStage: held ?? '',\n lifecycleStage,\n }\n}\n"],"names":["consentGroupForHost","advanceContactLifecycleStage","contactFacetPath","isContactLifecycleStage","readContactFacet","FieldValue","floorContactLifecycleStage","options","snapshot","data","contactRef","org","floor","get","exists","outcome","contactId","id","hostId","String","trim","groupId","facet","held","lifecycleStage","undefined","email","ref","update","updatedAt","serverTimestamp","previousStage"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GAED,SACEA,mBAAmB,EACnBC,4BAA4B,EAC5BC,gBAAgB,EAEhBC,uBAAuB,EACvBC,gBAAgB,QACX,sBAAqB;AAC5B,SAASC,UAAU,QAAQ,2BAA0B;
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/contact-lifecycle-floor.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\n/**\n * A person's lifecycle stage, FLOORED at what just happened to them\n * (AGL-2641).\n *\n * The capture doors floor a stage inside `upsertHostContact`: an order\n * names `customer` as its `initialLifecycleStage`, and the door fills an\n * empty stage or advances an earlier one and never moves anybody back. A\n * won deal is the same fact as a purchase — the business has decided this\n * person bought — but it arrives at a record that already exists, through a\n * writer that is not a capture door: the deal-stage route and the REST\n * deals resource. This is the door's rule for them, over a contact document\n * they already hold a reference to.\n *\n * The write goes into ONE holder's facet, because a stage is one business's\n * reading of a person and the row is shared by every site in the org. The\n * holder is the site the deal was made on, resolved to its consent group\n * the way every other facet writer resolves it; a deal that names no site\n * falls back to the site that captured the person, which is the one\n * holder a contact always has. A contact that names none either is left\n * alone and the caller told, rather than written into a facet nobody reads.\n *\n * The write is skipped when it would change nothing — a customer stays a\n * customer, an evangelist stays an evangelist, `other` is never overwritten\n * — so a caller can announce `contactStageChanged` exactly when `advanced`\n * says the stage moved, and an automation listening for the change never\n * hears one that did not happen.\n */\n\nimport {\n consentGroupForHost,\n advanceContactLifecycleStage,\n contactFacetPath,\n type ContactLifecycleStage,\n isContactLifecycleStage,\n readContactFacet,\n} from '@aglyn/aglyn/server'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { restampCrmListFieldsAt } from './crm-records'\n\nexport type ContactLifecycleFloor =\n | {\n /** The stage was raised to the floor. */\n outcome: 'advanced'\n contactId: string\n /** The address on the row, for the event a caller announces. */\n email: string\n /** The site whose facet was written. */\n hostId: string\n groupId: string\n /** The stage before the write, or `''` for a person who had none. */\n previousStage: ContactLifecycleStage | ''\n lifecycleStage: ContactLifecycleStage\n }\n | {\n /** The person already held the floor or a later stage; nothing was written. */\n outcome: 'held'\n contactId: string\n email: string\n hostId: string\n groupId: string\n lifecycleStage: ContactLifecycleStage\n }\n | {\n /** No such contact. */\n outcome: 'missing'\n contactId: string\n }\n | {\n /** Neither the caller nor the contact names a site, so there is no facet to write. */\n outcome: 'unheld'\n contactId: string\n }\n\nexport interface ContactLifecycleFloorOptions {\n contactRef: FirebaseFirestore.DocumentReference\n /**\n * The org the contact belongs to, for the consent groups — the same\n * document every facet writer resolves the holder through.\n */\n org: Record<string, unknown> | null | undefined\n /**\n * The site the act happened on, whose facet takes the stage. `null` for a\n * record no site captured; the contact's own capturing site is used then.\n */\n hostId: string | null | undefined\n /** The EARLIEST stage that describes what happened — see `advanceContactLifecycleStage`. */\n floor: ContactLifecycleStage\n}\n\nexport async function floorContactLifecycleStage(\n options: ContactLifecycleFloorOptions,\n): Promise<ContactLifecycleFloor> {\n const { contactRef, org, floor } = options\n const snapshot = await contactRef.get()\n if (!snapshot.exists) return { outcome: 'missing', contactId: contactRef.id }\n const data = (snapshot.data() ?? {}) as Record<string, unknown>\n const hostId = String(options.hostId ?? '').trim() || String(data['hostId'] ?? '').trim()\n if (!hostId) return { outcome: 'unheld', contactId: snapshot.id }\n\n const { groupId } = consentGroupForHost(org ?? null, hostId)\n const facet = readContactFacet(data, groupId)\n const held = isContactLifecycleStage(facet.lifecycleStage)\n ? facet.lifecycleStage\n : undefined\n const lifecycleStage = advanceContactLifecycleStage(held, floor) ?? floor\n const email = String(data['email'] ?? '')\n if (lifecycleStage === held) {\n return { outcome: 'held', contactId: snapshot.id, email, hostId, groupId, lifecycleStage }\n }\n await snapshot.ref.update({\n [contactFacetPath(groupId, 'lifecycleStage')]: lifecycleStage,\n updatedAt: FieldValue.serverTimestamp(),\n })\n // The stage is what the Contacts list filters by (AGL-3321).\n await restampCrmListFieldsAt(snapshot.ref, 'contacts')\n return {\n outcome: 'advanced',\n contactId: snapshot.id,\n email,\n hostId,\n groupId,\n previousStage: held ?? '',\n lifecycleStage,\n }\n}\n"],"names":["consentGroupForHost","advanceContactLifecycleStage","contactFacetPath","isContactLifecycleStage","readContactFacet","FieldValue","restampCrmListFieldsAt","floorContactLifecycleStage","options","snapshot","data","contactRef","org","floor","get","exists","outcome","contactId","id","hostId","String","trim","groupId","facet","held","lifecycleStage","undefined","email","ref","update","updatedAt","serverTimestamp","previousStage"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GAED,SACEA,mBAAmB,EACnBC,4BAA4B,EAC5BC,gBAAgB,EAEhBC,uBAAuB,EACvBC,gBAAgB,QACX,sBAAqB;AAC5B,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,sBAAsB,QAAQ,mBAAe;AAoDtD,OAAO,eAAeC,2BACpBC,OAAqC;QAKvBC,gBACQD,iBAAuCE,cAQtCT,+BACFS;IAbrB,MAAM,EAAEC,UAAU,EAAEC,GAAG,EAAEC,KAAK,EAAE,GAAGL;IACnC,MAAMC,WAAW,MAAME,WAAWG,GAAG;IACrC,IAAI,CAACL,SAASM,MAAM,EAAE,OAAO;QAAEC,SAAS;QAAWC,WAAWN,WAAWO,EAAE;IAAC;IAC5E,MAAMR,QAAQD,iBAAAA,SAASC,IAAI,cAAbD,iBAAmB,CAAC;IAClC,MAAMU,SAASC,QAAOZ,kBAAAA,QAAQW,MAAM,YAAdX,kBAAkB,IAAIa,IAAI,MAAMD,QAAOV,eAAAA,IAAI,CAAC,SAAS,YAAdA,eAAkB,IAAIW,IAAI;IACvF,IAAI,CAACF,QAAQ,OAAO;QAAEH,SAAS;QAAUC,WAAWR,SAASS,EAAE;IAAC;IAEhE,MAAM,EAAEI,OAAO,EAAE,GAAGtB,oBAAoBY,cAAAA,MAAO,MAAMO;IACrD,MAAMI,QAAQnB,iBAAiBM,MAAMY;IACrC,MAAME,OAAOrB,wBAAwBoB,MAAME,cAAc,IACrDF,MAAME,cAAc,GACpBC;IACJ,MAAMD,kBAAiBxB,gCAAAA,6BAA6BuB,MAAMX,kBAAnCZ,gCAA6CY;IACpE,MAAMc,QAAQP,QAAOV,cAAAA,IAAI,CAAC,QAAQ,YAAbA,cAAiB;IACtC,IAAIe,mBAAmBD,MAAM;QAC3B,OAAO;YAAER,SAAS;YAAQC,WAAWR,SAASS,EAAE;YAAES;YAAOR;YAAQG;YAASG;QAAe;IAC3F;IACA,MAAMhB,SAASmB,GAAG,CAACC,MAAM,CAAC;QACxB,CAAC3B,iBAAiBoB,SAAS,kBAAkB,EAAEG;QAC/CK,WAAWzB,WAAW0B,eAAe;IACvC;IACA,6DAA6D;IAC7D,MAAMzB,uBAAuBG,SAASmB,GAAG,EAAE;IAC3C,OAAO;QACLZ,SAAS;QACTC,WAAWR,SAASS,EAAE;QACtBS;QACAR;QACAG;QACAU,aAAa,EAAER,eAAAA,OAAQ;QACvBC;IACF;AACF"}
|