@aglyn/tenant-data-admin 1.0.0-beta.218 → 1.0.0-beta.219
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/src/lib/server/api-v1-description.d.ts +112 -0
- package/src/lib/server/api-v1-description.js +126 -0
- package/src/lib/server/api-v1-description.js.map +1 -0
- package/src/lib/server/api-v1-kit.d.ts +156 -0
- package/src/lib/server/api-v1-kit.js +191 -0
- package/src/lib/server/api-v1-kit.js.map +1 -0
- package/src/lib/server/api-v1-resources.d.ts +144 -0
- package/src/lib/server/api-v1-resources.js +126 -0
- package/src/lib/server/api-v1-resources.js.map +1 -0
- package/src/lib/server/consent-group-change.d.ts +2 -1
- package/src/lib/server/consent-group-change.js +41 -16
- package/src/lib/server/consent-group-change.js.map +1 -1
- package/src/lib/server/contact-email-index.d.ts +22 -0
- package/src/lib/server/contact-email-index.js +108 -0
- package/src/lib/server/contact-email-index.js.map +1 -1
- package/src/lib/server/crm-email-activity.d.ts +1 -1
- package/src/lib/server/crm-email-activity.js +1 -1
- package/src/lib/server/crm-email-activity.js.map +1 -1
- package/src/lib/server/crm-inbound-email.d.ts +23 -0
- package/src/lib/server/crm-inbound-email.js +1 -16
- package/src/lib/server/crm-inbound-email.js.map +1 -1
- package/src/lib/server/crm-records.d.ts +9 -1
- package/src/lib/server/crm-records.js +19 -2
- package/src/lib/server/crm-records.js.map +1 -1
- package/src/lib/server/cron-auth.d.ts +44 -0
- package/src/lib/server/cron-auth.js +76 -0
- package/src/lib/server/cron-auth.js.map +1 -0
- package/src/lib/server/dataset-live-pages.js +6 -6
- package/src/lib/server/dataset-live-pages.js.map +1 -1
- package/src/lib/server/duplicate-resource.d.ts +26 -0
- package/src/lib/server/duplicate-resource.js +47 -25
- package/src/lib/server/duplicate-resource.js.map +1 -1
- package/src/lib/server/dynamic-list-materialize.js +4 -1
- package/src/lib/server/dynamic-list-materialize.js.map +1 -1
- package/src/lib/server/email-suppression.d.ts +11 -0
- package/src/lib/server/email-suppression.js +36 -1
- package/src/lib/server/email-suppression.js.map +1 -1
- package/src/lib/server/email-unsubscribe-link.d.ts +8 -5
- package/src/lib/server/email-unsubscribe-link.js +7 -7
- package/src/lib/server/email-unsubscribe-link.js.map +1 -1
- package/src/lib/server/erase.d.ts +19 -33
- package/src/lib/server/erase.js +37 -162
- package/src/lib/server/erase.js.map +1 -1
- package/src/lib/server/host-email-tokens.js +1 -1
- package/src/lib/server/host-email-tokens.js.map +1 -1
- package/src/lib/server/host-memberships.js +3 -3
- package/src/lib/server/host-memberships.js.map +1 -1
- package/src/lib/server/member-email-aliases.d.ts +1 -1
- package/src/lib/server/member-email-aliases.js +1 -1
- package/src/lib/server/member-email-aliases.js.map +1 -1
- package/src/lib/server/new-account-announcement.d.ts +62 -0
- package/src/lib/server/new-account-announcement.js +77 -0
- package/src/lib/server/new-account-announcement.js.map +1 -0
- package/src/lib/server/notifications.d.ts +8 -2
- package/src/lib/server/notifications.js +11 -3
- package/src/lib/server/notifications.js.map +1 -1
- package/src/lib/server/{connect-payout-failure.d.ts → payment-provider-stripe-connect-payouts.d.ts} +3 -0
- package/src/lib/server/{connect-payout-failure.js → payment-provider-stripe-connect-payouts.js} +1 -1
- package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -0
- package/src/lib/server/{connect-account-status.d.ts → payment-provider-stripe-connect-status.d.ts} +3 -0
- package/src/lib/server/{connect-account-status.js → payment-provider-stripe-connect-status.js} +1 -1
- package/src/lib/server/payment-provider-stripe-connect-status.js.map +1 -0
- package/src/lib/server/{stripe-account-mode.d.ts → payment-provider-stripe-connect.d.ts} +45 -70
- package/src/lib/server/{stripe-account-mode.js → payment-provider-stripe-connect.js} +78 -52
- package/src/lib/server/payment-provider-stripe-connect.js.map +1 -0
- package/src/lib/server/payment-provider.d.ts +173 -0
- package/src/lib/server/payment-provider.js +70 -0
- package/src/lib/server/payment-provider.js.map +1 -0
- package/src/lib/server/personal-data-export.js +1 -1
- package/src/lib/server/personal-data-export.js.map +1 -1
- package/src/lib/server/rate-limit-store.js +13 -3
- package/src/lib/server/rate-limit-store.js.map +1 -1
- package/src/lib/server/realm-plugins.js +8 -3
- package/src/lib/server/realm-plugins.js.map +1 -1
- package/src/lib/server/retained-refusals.d.ts +142 -0
- package/src/lib/server/retained-refusals.js +325 -0
- package/src/lib/server/retained-refusals.js.map +1 -0
- package/src/lib/server/test-firestore-queries.d.ts +7 -0
- package/src/lib/server/test-firestore-queries.js +70 -27
- package/src/lib/server/test-firestore-queries.js.map +1 -1
- package/src/lib/server/upsert-contact.d.ts +46 -1
- package/src/lib/server/upsert-contact.js +85 -18
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/tenancy.d.ts +4 -5
- package/src/tenancy.js +11 -6
- package/src/tenancy.js.map +1 -1
- package/src/lib/server/connect-account-status.js.map +0 -1
- package/src/lib/server/connect-payout-failure.js.map +0 -1
- package/src/lib/server/contact-lifecycle-floor.d.ts +0 -89
- package/src/lib/server/contact-lifecycle-floor.js +0 -91
- package/src/lib/server/contact-lifecycle-floor.js.map +0 -1
- package/src/lib/server/contact-merge.d.ts +0 -55
- package/src/lib/server/contact-merge.js +0 -288
- package/src/lib/server/contact-merge.js.map +0 -1
- package/src/lib/server/form-dataset-binding-token.d.ts +0 -35
- package/src/lib/server/form-dataset-binding-token.js +0 -107
- package/src/lib/server/form-dataset-binding-token.js.map +0 -1
- package/src/lib/server/stripe-account-mode.js.map +0 -1
|
@@ -0,0 +1,142 @@
|
|
|
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 RetainedRefusals } from '@aglyn/aglyn/app-utils/retained-refusals';
|
|
18
|
+
import { type ContactDetach } from '@aglyn/aglyn/app-utils/contacts';
|
|
19
|
+
import type { ConsentGroupCarry } from '@aglyn/aglyn/app-utils/consent-group-change';
|
|
20
|
+
import type { ConsentGroupCarryPage } from './consent-group-carry';
|
|
21
|
+
type Doc = Record<string, unknown>;
|
|
22
|
+
/** The organization document, or a collection directly under it. */
|
|
23
|
+
export type RetainedRefusalsAnchor = FirebaseFirestore.DocumentReference | FirebaseFirestore.CollectionReference;
|
|
24
|
+
/** Keys one `getAll` of the store reads. */
|
|
25
|
+
export declare const RETAINED_REFUSALS_READ_CHUNK = 100;
|
|
26
|
+
/** Documents one page of the carry reads. */
|
|
27
|
+
export declare const RETAINED_REFUSALS_CARRY_PAGE = 300;
|
|
28
|
+
/** The store under the organization `anchor` is, or is directly under. */
|
|
29
|
+
export declare function retainedRefusalsStore(anchor: RetainedRefusalsAnchor): FirebaseFirestore.CollectionReference;
|
|
30
|
+
export interface RetainRefusalsInput {
|
|
31
|
+
/** The organization document — or `contactsRef`, the collection the record was in. */
|
|
32
|
+
orgRef?: FirebaseFirestore.DocumentReference;
|
|
33
|
+
contactsRef?: FirebaseFirestore.CollectionReference;
|
|
34
|
+
/**
|
|
35
|
+
* The address the person is known by — or every one, when a merge left the
|
|
36
|
+
* record answering to more than one: each is kept under its own key.
|
|
37
|
+
*/
|
|
38
|
+
email: string | readonly string[];
|
|
39
|
+
/** The record the refusals come from, stamped on each entry. */
|
|
40
|
+
contactId: string;
|
|
41
|
+
/** What `planContactDetach`'s delete (or `refusalsOf`) named. */
|
|
42
|
+
retained: RetainedRefusals | null | undefined;
|
|
43
|
+
nowMs: number;
|
|
44
|
+
/** The delete's transaction, so the retention and the delete land together. */
|
|
45
|
+
transaction?: FirebaseFirestore.Transaction;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Keeps a record's refusals in the store, one document per address, and
|
|
49
|
+
* answers how many documents it wrote.
|
|
50
|
+
*
|
|
51
|
+
* Each site's entry is written under its own field path, replacing that
|
|
52
|
+
* site's entry and leaving every other site retained earlier as it was; the
|
|
53
|
+
* unscoped refusal is written only when there is one, so it is never
|
|
54
|
+
* cleared by a later record that had none. Nothing is written when there is
|
|
55
|
+
* nothing to keep.
|
|
56
|
+
*
|
|
57
|
+
* Inside a transaction the writes join it, and the caller must have done its
|
|
58
|
+
* reads first, as a transaction requires. Outside one they are a batch.
|
|
59
|
+
*/
|
|
60
|
+
export declare function retainRefusals(input: RetainRefusalsInput): Promise<number>;
|
|
61
|
+
/** What {@link removeContactKeepingRefusals} did to one contact. */
|
|
62
|
+
export type ContactRemoval = {
|
|
63
|
+
outcome: 'missing';
|
|
64
|
+
} | {
|
|
65
|
+
outcome: 'refused';
|
|
66
|
+
error: string;
|
|
67
|
+
} | {
|
|
68
|
+
outcome: 'detached';
|
|
69
|
+
} | {
|
|
70
|
+
outcome: 'deleted';
|
|
71
|
+
/** Retained documents written, one per address the contact answered to. */
|
|
72
|
+
retained: number;
|
|
73
|
+
};
|
|
74
|
+
export interface RemoveContactInput {
|
|
75
|
+
contactRef: FirebaseFirestore.DocumentReference;
|
|
76
|
+
/**
|
|
77
|
+
* What letting go of this contact means, decided against the document as
|
|
78
|
+
* the transaction read it: `planContactDetach` for a holder letting go,
|
|
79
|
+
* {@link deleteWholeContact} for a delete regardless of holders, or a
|
|
80
|
+
* sentence refusing it.
|
|
81
|
+
*/
|
|
82
|
+
decide: (contact: Doc) => ContactDetach | {
|
|
83
|
+
refused: string;
|
|
84
|
+
};
|
|
85
|
+
nowMs: number;
|
|
86
|
+
}
|
|
87
|
+
/** The decision `DELETE /v1/contacts/{id}` makes: the document goes, every refusal on it kept. */
|
|
88
|
+
export declare const deleteWholeContact: (contact: Doc) => ContactDetach;
|
|
89
|
+
/**
|
|
90
|
+
* Lets go of one contact in one transaction, keeping every refusal it holds.
|
|
91
|
+
*
|
|
92
|
+
* A DETACH drops the leaving holder's facet, its consent GRANTS, its scope
|
|
93
|
+
* tokens and its capture attribution; a refusal entry is not among the
|
|
94
|
+
* removals `planContactDetach` names, so it stays for the holders that
|
|
95
|
+
* remain. A DELETE copies every refusal on the document into the store under
|
|
96
|
+
* each address the contact answers to — the primary and every one a merge
|
|
97
|
+
* folded in, since the list gate may be asked about any of them — and
|
|
98
|
+
* deletes the document in the same commit.
|
|
99
|
+
*
|
|
100
|
+
* ⛔ Not the erasure path, which removes the person everywhere and closes the
|
|
101
|
+
* door with suppression rows before it deletes anything.
|
|
102
|
+
*/
|
|
103
|
+
export declare function removeContactKeepingRefusals(input: RemoveContactInput): Promise<ContactRemoval>;
|
|
104
|
+
/**
|
|
105
|
+
* The retained documents for these addresses, keyed by the normalized
|
|
106
|
+
* address; an address with nothing retained is absent.
|
|
107
|
+
*
|
|
108
|
+
* Throws when a read fails, and deliberately: every caller decides whether
|
|
109
|
+
* an operator may enroll somebody, and a read that could not see a refusal
|
|
110
|
+
* has to reach the caller's failed-read branch rather than read as none.
|
|
111
|
+
*/
|
|
112
|
+
export declare function readRetainedRefusals(anchor: RetainedRefusalsAnchor, emails: readonly string[]): Promise<Map<string, Doc>>;
|
|
113
|
+
export interface CarryRetainedRefusalsOptions {
|
|
114
|
+
firestore: FirebaseFirestore.Firestore;
|
|
115
|
+
orgId: string;
|
|
116
|
+
carry: ConsentGroupCarry;
|
|
117
|
+
changeId: string;
|
|
118
|
+
cursor: string | null;
|
|
119
|
+
/** Only documents written at or after this instant — the catch-up. */
|
|
120
|
+
sinceMs?: number | null;
|
|
121
|
+
pageSize?: number;
|
|
122
|
+
dryRun?: boolean;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* One page of the carry `to ← from` over the store: every retained refusal
|
|
126
|
+
* `from` holds is given to `to` where `to` has no entry, with where it came
|
|
127
|
+
* from and which change carried it. See `retainedRefusalCarry`.
|
|
128
|
+
*
|
|
129
|
+
* The full pass reads only the documents refusing `from`, by id. The
|
|
130
|
+
* catch-up reads everything written since `sinceMs`, by `updatedAt` then id,
|
|
131
|
+
* and decides each document in hand: an equality on a site's entry beside a
|
|
132
|
+
* range on `updatedAt` would need a composite index per site, which no
|
|
133
|
+
* index file can declare.
|
|
134
|
+
*
|
|
135
|
+
* Idempotent, like every carry: a write only fills an entry `to` lacks, so
|
|
136
|
+
* a page re-run after a crash, the catch-up and the sweep write nothing that
|
|
137
|
+
* was already carried. A write that raced another writer is decided again
|
|
138
|
+
* once from a fresh read; failing twice throws, and the executor retries the
|
|
139
|
+
* page from its cursor.
|
|
140
|
+
*/
|
|
141
|
+
export declare function carryRetainedRefusals(options: CarryRetainedRefusalsOptions): Promise<ConsentGroupCarryPage>;
|
|
142
|
+
export {};
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ /**
|
|
18
|
+
* THE RETAINED-REFUSAL STORE, ON THE ADMIN SDK (AGL-3338):
|
|
19
|
+
* `orgs/{orgId}/retainedRefusals/{personKey}`.
|
|
20
|
+
*
|
|
21
|
+
* What the store is for, and why it holds what it holds, is the pure
|
|
22
|
+
* module's note (`@aglyn/aglyn/app-utils/retained-refusals`). This is its
|
|
23
|
+
* server doors:
|
|
24
|
+
*
|
|
25
|
+
* - {@link removeContactKeepingRefusals} — the one way a contact is let go
|
|
26
|
+
* of outside an erasure: the CRM's delete and `DELETE /v1/contacts/{id}`.
|
|
27
|
+
* It decides against the document read in its own transaction, so a
|
|
28
|
+
* refusal written a moment before the delete is still the one kept;
|
|
29
|
+
* - {@link retainRefusals} — the write a record's delete makes, inside the
|
|
30
|
+
* delete's own transaction when it has one, so a record is never gone
|
|
31
|
+
* with its refusals unkept;
|
|
32
|
+
* - {@link readRetainedRefusals} — the read the list gate and the Inbox's
|
|
33
|
+
* add-to-list make beside the records, which THROWS on a failed read: a
|
|
34
|
+
* reader that cannot see whether somebody refused must not read them as
|
|
35
|
+
* somebody who did not;
|
|
36
|
+
* - {@link carryRetainedRefusals} — one page of a consent group change's
|
|
37
|
+
* carry over the store, the org-level twin of the per-site carries in
|
|
38
|
+
* `consent-group-carry.ts`.
|
|
39
|
+
*
|
|
40
|
+
* Anchored on the organization document, or on any collection under it —
|
|
41
|
+
* the caller already holds the records it is reading or deleting, and the
|
|
42
|
+
* store is their sibling.
|
|
43
|
+
*/ import { personKey } from "@aglyn/aglyn/app-utils/person-key";
|
|
44
|
+
import { MARKETING_CONSENT_BY_HOST_FIELD, MARKETING_CONSENT_FIELD } from "@aglyn/aglyn/app-utils/marketing-consent";
|
|
45
|
+
import { RETAINED_AT_FIELD, RETAINED_REFUSALS_COLLECTION, refusalsOf, retainedEntries, retainedRefusalCarry } from "@aglyn/aglyn/app-utils/retained-refusals";
|
|
46
|
+
import { contactEmails, normalizeContactEmail } from "@aglyn/aglyn/app-utils/contacts";
|
|
47
|
+
import { FieldPath, FieldValue, Timestamp } from "firebase-admin/firestore";
|
|
48
|
+
/** Keys one `getAll` of the store reads. */ export const RETAINED_REFUSALS_READ_CHUNK = 100;
|
|
49
|
+
/** Documents one page of the carry reads. */ export const RETAINED_REFUSALS_CARRY_PAGE = 300;
|
|
50
|
+
/** The organization document `anchor` is, or is directly under. */ function orgOf(anchor) {
|
|
51
|
+
const orgRef = typeof anchor.collection === 'function' ? anchor : anchor.parent;
|
|
52
|
+
if (!orgRef) throw new Error('[retained-refusals] a root collection has no organization');
|
|
53
|
+
return orgRef;
|
|
54
|
+
}
|
|
55
|
+
/** The store under the organization `anchor` is, or is directly under. */ export function retainedRefusalsStore(anchor) {
|
|
56
|
+
return orgOf(anchor).collection(RETAINED_REFUSALS_COLLECTION);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Keeps a record's refusals in the store, one document per address, and
|
|
60
|
+
* answers how many documents it wrote.
|
|
61
|
+
*
|
|
62
|
+
* Each site's entry is written under its own field path, replacing that
|
|
63
|
+
* site's entry and leaving every other site retained earlier as it was; the
|
|
64
|
+
* unscoped refusal is written only when there is one, so it is never
|
|
65
|
+
* cleared by a later record that had none. Nothing is written when there is
|
|
66
|
+
* nothing to keep.
|
|
67
|
+
*
|
|
68
|
+
* Inside a transaction the writes join it, and the caller must have done its
|
|
69
|
+
* reads first, as a transaction requires. Outside one they are a batch.
|
|
70
|
+
*/ export async function retainRefusals(input) {
|
|
71
|
+
var _ref, _input_orgRef;
|
|
72
|
+
const { retained } = input;
|
|
73
|
+
const hostIds = Object.keys((_ref = retained == null ? void 0 : retained.byHost) != null ? _ref : {});
|
|
74
|
+
if (!retained || !hostIds.length && !retained.unscoped) return 0;
|
|
75
|
+
const anchor = (_input_orgRef = input.orgRef) != null ? _input_orgRef : input.contactsRef;
|
|
76
|
+
if (!anchor) throw new Error('[retained-refusals] name the organization or its contacts');
|
|
77
|
+
const orgRef = orgOf(anchor);
|
|
78
|
+
const store = orgRef.collection(RETAINED_REFUSALS_COLLECTION);
|
|
79
|
+
const emails = typeof input.email === 'string' ? [
|
|
80
|
+
input.email
|
|
81
|
+
] : input.email;
|
|
82
|
+
const keys = [
|
|
83
|
+
...new Set(emails.map((email)=>personKey(email)).filter((key)=>Boolean(key)))
|
|
84
|
+
];
|
|
85
|
+
if (!keys.length) return 0;
|
|
86
|
+
const data = {
|
|
87
|
+
[MARKETING_CONSENT_BY_HOST_FIELD]: retainedEntries(retained, input.contactId, input.nowMs),
|
|
88
|
+
[RETAINED_AT_FIELD]: input.nowMs,
|
|
89
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
90
|
+
};
|
|
91
|
+
const fields = [
|
|
92
|
+
...hostIds.map((hostId)=>new FieldPath(MARKETING_CONSENT_BY_HOST_FIELD, hostId)),
|
|
93
|
+
RETAINED_AT_FIELD,
|
|
94
|
+
'updatedAt'
|
|
95
|
+
];
|
|
96
|
+
if (retained.unscoped) {
|
|
97
|
+
data[MARKETING_CONSENT_FIELD] = false;
|
|
98
|
+
fields.push(MARKETING_CONSENT_FIELD);
|
|
99
|
+
}
|
|
100
|
+
if (input.transaction) {
|
|
101
|
+
for (const key of keys)input.transaction.set(store.doc(key), data, {
|
|
102
|
+
mergeFields: fields
|
|
103
|
+
});
|
|
104
|
+
return keys.length;
|
|
105
|
+
}
|
|
106
|
+
const batch = orgRef.firestore.batch();
|
|
107
|
+
for (const key of keys)batch.set(store.doc(key), data, {
|
|
108
|
+
mergeFields: fields
|
|
109
|
+
});
|
|
110
|
+
await batch.commit();
|
|
111
|
+
return keys.length;
|
|
112
|
+
}
|
|
113
|
+
/** The decision `DELETE /v1/contacts/{id}` makes: the document goes, every refusal on it kept. */ export const deleteWholeContact = (contact)=>({
|
|
114
|
+
action: 'delete',
|
|
115
|
+
retained: refusalsOf(contact)
|
|
116
|
+
});
|
|
117
|
+
/**
|
|
118
|
+
* Lets go of one contact in one transaction, keeping every refusal it holds.
|
|
119
|
+
*
|
|
120
|
+
* A DETACH drops the leaving holder's facet, its consent GRANTS, its scope
|
|
121
|
+
* tokens and its capture attribution; a refusal entry is not among the
|
|
122
|
+
* removals `planContactDetach` names, so it stays for the holders that
|
|
123
|
+
* remain. A DELETE copies every refusal on the document into the store under
|
|
124
|
+
* each address the contact answers to — the primary and every one a merge
|
|
125
|
+
* folded in, since the list gate may be asked about any of them — and
|
|
126
|
+
* deletes the document in the same commit.
|
|
127
|
+
*
|
|
128
|
+
* ⛔ Not the erasure path, which removes the person everywhere and closes the
|
|
129
|
+
* door with suppression rows before it deletes anything.
|
|
130
|
+
*/ export async function removeContactKeepingRefusals(input) {
|
|
131
|
+
const { contactRef, nowMs } = input;
|
|
132
|
+
const orgRef = contactRef.parent.parent;
|
|
133
|
+
if (!orgRef) throw new Error('[retained-refusals] a contact lives under an organization');
|
|
134
|
+
return contactRef.firestore.runTransaction(async (transaction)=>{
|
|
135
|
+
var _snapshot_data;
|
|
136
|
+
const snapshot = await transaction.get(contactRef);
|
|
137
|
+
if (!snapshot.exists) return {
|
|
138
|
+
outcome: 'missing'
|
|
139
|
+
};
|
|
140
|
+
const contact = (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {};
|
|
141
|
+
const decided = input.decide(contact);
|
|
142
|
+
if ('refused' in decided) return {
|
|
143
|
+
outcome: 'refused',
|
|
144
|
+
error: decided.refused
|
|
145
|
+
};
|
|
146
|
+
if (decided.action === 'delete') {
|
|
147
|
+
const retained = await retainRefusals({
|
|
148
|
+
orgRef,
|
|
149
|
+
email: contactEmails(contact),
|
|
150
|
+
contactId: contactRef.id,
|
|
151
|
+
retained: decided.retained,
|
|
152
|
+
nowMs,
|
|
153
|
+
transaction
|
|
154
|
+
});
|
|
155
|
+
transaction.delete(contactRef);
|
|
156
|
+
return {
|
|
157
|
+
outcome: 'deleted',
|
|
158
|
+
retained
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
transaction.update(contactRef, _extends({}, Object.fromEntries(decided.remove.map((path)=>[
|
|
162
|
+
path,
|
|
163
|
+
FieldValue.delete()
|
|
164
|
+
])), {
|
|
165
|
+
visibleTo: FieldValue.arrayRemove(...decided.removeTokens),
|
|
166
|
+
capturedByHostIds: FieldValue.arrayRemove(...decided.removeHostIds),
|
|
167
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
168
|
+
}));
|
|
169
|
+
return {
|
|
170
|
+
outcome: 'detached'
|
|
171
|
+
};
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* The retained documents for these addresses, keyed by the normalized
|
|
176
|
+
* address; an address with nothing retained is absent.
|
|
177
|
+
*
|
|
178
|
+
* Throws when a read fails, and deliberately: every caller decides whether
|
|
179
|
+
* an operator may enroll somebody, and a read that could not see a refusal
|
|
180
|
+
* has to reach the caller's failed-read branch rather than read as none.
|
|
181
|
+
*/ export async function readRetainedRefusals(anchor, emails) {
|
|
182
|
+
const found = new Map();
|
|
183
|
+
const byKey = new Map();
|
|
184
|
+
for (const raw of emails){
|
|
185
|
+
const email = normalizeContactEmail(raw);
|
|
186
|
+
const key = email ? personKey(email) : null;
|
|
187
|
+
if (email && key) byKey.set(key, email);
|
|
188
|
+
}
|
|
189
|
+
if (!byKey.size) return found;
|
|
190
|
+
const orgRef = orgOf(anchor);
|
|
191
|
+
const store = orgRef.collection(RETAINED_REFUSALS_COLLECTION);
|
|
192
|
+
const keys = [
|
|
193
|
+
...byKey.keys()
|
|
194
|
+
];
|
|
195
|
+
for(let at = 0; at < keys.length; at += RETAINED_REFUSALS_READ_CHUNK){
|
|
196
|
+
const chunk = keys.slice(at, at + RETAINED_REFUSALS_READ_CHUNK);
|
|
197
|
+
const snapshots = await orgRef.firestore.getAll(...chunk.map((key)=>store.doc(key)));
|
|
198
|
+
for (const snapshot of snapshots){
|
|
199
|
+
var _snapshot_data;
|
|
200
|
+
const email = byKey.get(snapshot.id);
|
|
201
|
+
if (email && snapshot.exists) found.set(email, (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return found;
|
|
205
|
+
}
|
|
206
|
+
function readCursor(raw) {
|
|
207
|
+
if (!raw) return null;
|
|
208
|
+
try {
|
|
209
|
+
const parsed = JSON.parse(raw);
|
|
210
|
+
return typeof (parsed == null ? void 0 : parsed.id) === 'string' ? parsed : null;
|
|
211
|
+
} catch (unused) {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** The error codes that mean "somebody wrote this document since I read it". */ const RACED = new Set([
|
|
216
|
+
5,
|
|
217
|
+
9
|
|
218
|
+
]);
|
|
219
|
+
const codeOf = (error)=>error == null ? void 0 : error.code;
|
|
220
|
+
/**
|
|
221
|
+
* One page of the carry `to ← from` over the store: every retained refusal
|
|
222
|
+
* `from` holds is given to `to` where `to` has no entry, with where it came
|
|
223
|
+
* from and which change carried it. See `retainedRefusalCarry`.
|
|
224
|
+
*
|
|
225
|
+
* The full pass reads only the documents refusing `from`, by id. The
|
|
226
|
+
* catch-up reads everything written since `sinceMs`, by `updatedAt` then id,
|
|
227
|
+
* and decides each document in hand: an equality on a site's entry beside a
|
|
228
|
+
* range on `updatedAt` would need a composite index per site, which no
|
|
229
|
+
* index file can declare.
|
|
230
|
+
*
|
|
231
|
+
* Idempotent, like every carry: a write only fills an entry `to` lacks, so
|
|
232
|
+
* a page re-run after a crash, the catch-up and the sweep write nothing that
|
|
233
|
+
* was already carried. A write that raced another writer is decided again
|
|
234
|
+
* once from a fresh read; failing twice throws, and the executor retries the
|
|
235
|
+
* page from its cursor.
|
|
236
|
+
*/ export async function carryRetainedRefusals(options) {
|
|
237
|
+
var _options_pageSize, _options_sinceMs;
|
|
238
|
+
const { firestore, carry, changeId } = options;
|
|
239
|
+
const store = firestore.collection('orgs').doc(options.orgId).collection(RETAINED_REFUSALS_COLLECTION);
|
|
240
|
+
const limit = (_options_pageSize = options.pageSize) != null ? _options_pageSize : RETAINED_REFUSALS_CARRY_PAGE;
|
|
241
|
+
const cursor = readCursor(options.cursor);
|
|
242
|
+
const since = (_options_sinceMs = options.sinceMs) != null ? _options_sinceMs : null;
|
|
243
|
+
let query;
|
|
244
|
+
if (since !== null) {
|
|
245
|
+
query = store.where('updatedAt', '>=', Timestamp.fromMillis(since)).orderBy('updatedAt').orderBy(FieldPath.documentId());
|
|
246
|
+
if (cursor == null ? void 0 : cursor.at) query = query.startAfter(new Timestamp(cursor.at.seconds, cursor.at.nanoseconds), cursor.id);
|
|
247
|
+
} else {
|
|
248
|
+
query = store.where(`${MARKETING_CONSENT_BY_HOST_FIELD}.${carry.fromHostId}.${MARKETING_CONSENT_FIELD}`, '==', false).orderBy(FieldPath.documentId());
|
|
249
|
+
if (cursor) query = query.startAfter(cursor.id);
|
|
250
|
+
}
|
|
251
|
+
const page = await query.limit(limit).get();
|
|
252
|
+
if (page.empty) return {
|
|
253
|
+
done: true,
|
|
254
|
+
cursor: null,
|
|
255
|
+
read: 0,
|
|
256
|
+
written: 0
|
|
257
|
+
};
|
|
258
|
+
const decide = (snapshot)=>{
|
|
259
|
+
const patch = snapshot.exists ? retainedRefusalCarry(snapshot.data(), carry, changeId) : null;
|
|
260
|
+
return patch ? {
|
|
261
|
+
snapshot,
|
|
262
|
+
patch
|
|
263
|
+
} : null;
|
|
264
|
+
};
|
|
265
|
+
const due = page.docs.map(decide).filter((entry)=>entry !== null);
|
|
266
|
+
const written = options.dryRun ? due.length : await writeCarried(firestore, carry, due, decide);
|
|
267
|
+
const last = page.docs[page.docs.length - 1];
|
|
268
|
+
const next = {
|
|
269
|
+
id: last.id
|
|
270
|
+
};
|
|
271
|
+
if (since !== null) {
|
|
272
|
+
const value = last.get('updatedAt');
|
|
273
|
+
if (value instanceof Timestamp) next.at = {
|
|
274
|
+
seconds: value.seconds,
|
|
275
|
+
nanoseconds: value.nanoseconds
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
const done = page.size < limit;
|
|
279
|
+
return {
|
|
280
|
+
done,
|
|
281
|
+
cursor: done ? null : JSON.stringify(next),
|
|
282
|
+
read: page.size,
|
|
283
|
+
written
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Writes one page's patches through a `BulkWriter`, each guarded by the read
|
|
288
|
+
* it was decided from, and decides the ones that raced once more.
|
|
289
|
+
*/ async function writeCarried(firestore, carry, due, decide) {
|
|
290
|
+
let written = 0;
|
|
291
|
+
for(let round = 0; round < 2 && due.length; round += 1){
|
|
292
|
+
const writer = firestore.bulkWriter();
|
|
293
|
+
const outcomes = due.map(({ snapshot, patch })=>writer.update(snapshot.ref, _extends({}, patch, {
|
|
294
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
295
|
+
}), {
|
|
296
|
+
lastUpdateTime: snapshot.updateTime
|
|
297
|
+
}).then(()=>({
|
|
298
|
+
snapshot,
|
|
299
|
+
error: null
|
|
300
|
+
}), (error)=>({
|
|
301
|
+
snapshot,
|
|
302
|
+
error: error != null ? error : new Error('The write failed')
|
|
303
|
+
})));
|
|
304
|
+
await writer.close();
|
|
305
|
+
const raced = [];
|
|
306
|
+
for (const { snapshot, error } of (await Promise.all(outcomes))){
|
|
307
|
+
if (error === null) {
|
|
308
|
+
written += 1;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
const code = codeOf(error);
|
|
312
|
+
if (code === undefined || !RACED.has(code)) throw error;
|
|
313
|
+
raced.push(snapshot);
|
|
314
|
+
}
|
|
315
|
+
if (!raced.length) return written;
|
|
316
|
+
const fresh = await firestore.getAll(...raced.map((snapshot)=>snapshot.ref));
|
|
317
|
+
due = fresh.map(decide).filter((entry)=>entry !== null);
|
|
318
|
+
}
|
|
319
|
+
if (due.length) {
|
|
320
|
+
throw new Error(`[retained-refusals] ${due.length} document(s) kept changing under the carry to ${carry.toHostId}`);
|
|
321
|
+
}
|
|
322
|
+
return written;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
//# sourceMappingURL=retained-refusals.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/retained-refusals.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 RETAINED-REFUSAL STORE, ON THE ADMIN SDK (AGL-3338):\n * `orgs/{orgId}/retainedRefusals/{personKey}`.\n *\n * What the store is for, and why it holds what it holds, is the pure\n * module's note (`@aglyn/aglyn/app-utils/retained-refusals`). This is its\n * server doors:\n *\n * - {@link removeContactKeepingRefusals} — the one way a contact is let go\n * of outside an erasure: the CRM's delete and `DELETE /v1/contacts/{id}`.\n * It decides against the document read in its own transaction, so a\n * refusal written a moment before the delete is still the one kept;\n * - {@link retainRefusals} — the write a record's delete makes, inside the\n * delete's own transaction when it has one, so a record is never gone\n * with its refusals unkept;\n * - {@link readRetainedRefusals} — the read the list gate and the Inbox's\n * add-to-list make beside the records, which THROWS on a failed read: a\n * reader that cannot see whether somebody refused must not read them as\n * somebody who did not;\n * - {@link carryRetainedRefusals} — one page of a consent group change's\n * carry over the store, the org-level twin of the per-site carries in\n * `consent-group-carry.ts`.\n *\n * Anchored on the organization document, or on any collection under it —\n * the caller already holds the records it is reading or deleting, and the\n * store is their sibling.\n */\n\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport {\n MARKETING_CONSENT_BY_HOST_FIELD,\n MARKETING_CONSENT_FIELD,\n} from '@aglyn/aglyn/app-utils/marketing-consent'\nimport {\n RETAINED_AT_FIELD,\n RETAINED_REFUSALS_COLLECTION,\n type RetainedRefusals,\n refusalsOf,\n retainedEntries,\n retainedRefusalCarry,\n} from '@aglyn/aglyn/app-utils/retained-refusals'\nimport {\n type ContactDetach,\n contactEmails,\n normalizeContactEmail,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport type { ConsentGroupCarry } from '@aglyn/aglyn/app-utils/consent-group-change'\nimport { FieldPath, FieldValue, Timestamp } from 'firebase-admin/firestore'\nimport type { ConsentGroupCarryPage } from './consent-group-carry'\n\ntype Doc = Record<string, unknown>\n\n/** The organization document, or a collection directly under it. */\nexport type RetainedRefusalsAnchor =\n | FirebaseFirestore.DocumentReference\n | FirebaseFirestore.CollectionReference\n\n/** Keys one `getAll` of the store reads. */\nexport const RETAINED_REFUSALS_READ_CHUNK = 100\n\n/** Documents one page of the carry reads. */\nexport const RETAINED_REFUSALS_CARRY_PAGE = 300\n\n/** The organization document `anchor` is, or is directly under. */\nfunction orgOf(anchor: RetainedRefusalsAnchor): FirebaseFirestore.DocumentReference {\n const orgRef =\n typeof (anchor as FirebaseFirestore.DocumentReference).collection === 'function'\n ? (anchor as FirebaseFirestore.DocumentReference)\n : (anchor as FirebaseFirestore.CollectionReference).parent\n if (!orgRef) throw new Error('[retained-refusals] a root collection has no organization')\n return orgRef\n}\n\n/** The store under the organization `anchor` is, or is directly under. */\nexport function retainedRefusalsStore(anchor: RetainedRefusalsAnchor): FirebaseFirestore.CollectionReference {\n return orgOf(anchor).collection(RETAINED_REFUSALS_COLLECTION)\n}\n\nexport interface RetainRefusalsInput {\n /** The organization document — or `contactsRef`, the collection the record was in. */\n orgRef?: FirebaseFirestore.DocumentReference\n contactsRef?: FirebaseFirestore.CollectionReference\n /**\n * The address the person is known by — or every one, when a merge left the\n * record answering to more than one: each is kept under its own key.\n */\n email: string | readonly string[]\n /** The record the refusals come from, stamped on each entry. */\n contactId: string\n /** What `planContactDetach`'s delete (or `refusalsOf`) named. */\n retained: RetainedRefusals | null | undefined\n nowMs: number\n /** The delete's transaction, so the retention and the delete land together. */\n transaction?: FirebaseFirestore.Transaction\n}\n\n/**\n * Keeps a record's refusals in the store, one document per address, and\n * answers how many documents it wrote.\n *\n * Each site's entry is written under its own field path, replacing that\n * site's entry and leaving every other site retained earlier as it was; the\n * unscoped refusal is written only when there is one, so it is never\n * cleared by a later record that had none. Nothing is written when there is\n * nothing to keep.\n *\n * Inside a transaction the writes join it, and the caller must have done its\n * reads first, as a transaction requires. Outside one they are a batch.\n */\nexport async function retainRefusals(input: RetainRefusalsInput): Promise<number> {\n const { retained } = input\n const hostIds = Object.keys(retained?.byHost ?? {})\n if (!retained || (!hostIds.length && !retained.unscoped)) return 0\n const anchor = input.orgRef ?? input.contactsRef\n if (!anchor) throw new Error('[retained-refusals] name the organization or its contacts')\n const orgRef = orgOf(anchor)\n const store = orgRef.collection(RETAINED_REFUSALS_COLLECTION)\n const emails = typeof input.email === 'string' ? [input.email] : input.email\n const keys = [\n ...new Set(emails.map((email) => personKey(email)).filter((key): key is string => Boolean(key))),\n ]\n if (!keys.length) return 0\n\n const data: Doc = {\n [MARKETING_CONSENT_BY_HOST_FIELD]: retainedEntries(retained, input.contactId, input.nowMs),\n [RETAINED_AT_FIELD]: input.nowMs,\n updatedAt: FieldValue.serverTimestamp(),\n }\n const fields: Array<string | FirebaseFirestore.FieldPath> = [\n ...hostIds.map((hostId) => new FieldPath(MARKETING_CONSENT_BY_HOST_FIELD, hostId)),\n RETAINED_AT_FIELD,\n 'updatedAt',\n ]\n if (retained.unscoped) {\n data[MARKETING_CONSENT_FIELD] = false\n fields.push(MARKETING_CONSENT_FIELD)\n }\n\n if (input.transaction) {\n for (const key of keys) input.transaction.set(store.doc(key), data, { mergeFields: fields })\n return keys.length\n }\n const batch = orgRef.firestore.batch()\n for (const key of keys) batch.set(store.doc(key), data, { mergeFields: fields })\n await batch.commit()\n return keys.length\n}\n\n/** What {@link removeContactKeepingRefusals} did to one contact. */\nexport type ContactRemoval =\n | { outcome: 'missing' }\n | { outcome: 'refused'; error: string }\n | { outcome: 'detached' }\n | {\n outcome: 'deleted'\n /** Retained documents written, one per address the contact answered to. */\n retained: number\n }\n\nexport interface RemoveContactInput {\n contactRef: FirebaseFirestore.DocumentReference\n /**\n * What letting go of this contact means, decided against the document as\n * the transaction read it: `planContactDetach` for a holder letting go,\n * {@link deleteWholeContact} for a delete regardless of holders, or a\n * sentence refusing it.\n */\n decide: (contact: Doc) => ContactDetach | { refused: string }\n nowMs: number\n}\n\n/** The decision `DELETE /v1/contacts/{id}` makes: the document goes, every refusal on it kept. */\nexport const deleteWholeContact = (contact: Doc): ContactDetach => ({\n action: 'delete',\n retained: refusalsOf(contact),\n})\n\n/**\n * Lets go of one contact in one transaction, keeping every refusal it holds.\n *\n * A DETACH drops the leaving holder's facet, its consent GRANTS, its scope\n * tokens and its capture attribution; a refusal entry is not among the\n * removals `planContactDetach` names, so it stays for the holders that\n * remain. A DELETE copies every refusal on the document into the store under\n * each address the contact answers to — the primary and every one a merge\n * folded in, since the list gate may be asked about any of them — and\n * deletes the document in the same commit.\n *\n * ⛔ Not the erasure path, which removes the person everywhere and closes the\n * door with suppression rows before it deletes anything.\n */\nexport async function removeContactKeepingRefusals(\n input: RemoveContactInput,\n): Promise<ContactRemoval> {\n const { contactRef, nowMs } = input\n const orgRef = contactRef.parent.parent\n if (!orgRef) throw new Error('[retained-refusals] a contact lives under an organization')\n return contactRef.firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(contactRef)\n if (!snapshot.exists) return { outcome: 'missing' } as const\n const contact = (snapshot.data() ?? {}) as Doc\n const decided = input.decide(contact)\n if ('refused' in decided) return { outcome: 'refused', error: decided.refused } as const\n if (decided.action === 'delete') {\n const retained = await retainRefusals({\n orgRef,\n email: contactEmails(contact),\n contactId: contactRef.id,\n retained: decided.retained,\n nowMs,\n transaction,\n })\n transaction.delete(contactRef)\n return { outcome: 'deleted', retained } as const\n }\n transaction.update(contactRef, {\n ...Object.fromEntries(decided.remove.map((path) => [path, FieldValue.delete()])),\n visibleTo: FieldValue.arrayRemove(...decided.removeTokens),\n capturedByHostIds: FieldValue.arrayRemove(...decided.removeHostIds),\n updatedAt: FieldValue.serverTimestamp(),\n })\n return { outcome: 'detached' } as const\n })\n}\n\n/**\n * The retained documents for these addresses, keyed by the normalized\n * address; an address with nothing retained is absent.\n *\n * Throws when a read fails, and deliberately: every caller decides whether\n * an operator may enroll somebody, and a read that could not see a refusal\n * has to reach the caller's failed-read branch rather than read as none.\n */\nexport async function readRetainedRefusals(\n anchor: RetainedRefusalsAnchor,\n emails: readonly string[],\n): Promise<Map<string, Doc>> {\n const found = new Map<string, Doc>()\n const byKey = new Map<string, string>()\n for (const raw of emails) {\n const email = normalizeContactEmail(raw)\n const key = email ? personKey(email) : null\n if (email && key) byKey.set(key, email)\n }\n if (!byKey.size) return found\n const orgRef = orgOf(anchor)\n const store = orgRef.collection(RETAINED_REFUSALS_COLLECTION)\n const keys = [...byKey.keys()]\n for (let at = 0; at < keys.length; at += RETAINED_REFUSALS_READ_CHUNK) {\n const chunk = keys.slice(at, at + RETAINED_REFUSALS_READ_CHUNK)\n const snapshots = await orgRef.firestore.getAll(...chunk.map((key) => store.doc(key)))\n for (const snapshot of snapshots) {\n const email = byKey.get(snapshot.id)\n if (email && snapshot.exists) found.set(email, (snapshot.data() ?? {}) as Doc)\n }\n }\n return found\n}\n\nexport interface CarryRetainedRefusalsOptions {\n firestore: FirebaseFirestore.Firestore\n orgId: string\n carry: ConsentGroupCarry\n changeId: string\n cursor: string | null\n /** Only documents written 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 /** `updatedAt` on the last document a catch-up read. */\n at?: { seconds: number; nanoseconds: 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, 9])\nconst codeOf = (error: unknown): number | undefined => (error as { code?: number } | null)?.code\n\n/**\n * One page of the carry `to ← from` over the store: every retained refusal\n * `from` holds is given to `to` where `to` has no entry, with where it came\n * from and which change carried it. See `retainedRefusalCarry`.\n *\n * The full pass reads only the documents refusing `from`, by id. The\n * catch-up reads everything written since `sinceMs`, by `updatedAt` then id,\n * and decides each document in hand: an equality on a site's entry beside a\n * range on `updatedAt` would need a composite index per site, which no\n * index file can declare.\n *\n * Idempotent, like every carry: a write only fills an entry `to` lacks, so\n * a page re-run after a crash, the catch-up and the sweep write nothing that\n * was already carried. A write that raced another writer is decided again\n * once from a fresh read; failing twice throws, and the executor retries the\n * page from its cursor.\n */\nexport async function carryRetainedRefusals(\n options: CarryRetainedRefusalsOptions,\n): Promise<ConsentGroupCarryPage> {\n const { firestore, carry, changeId } = options\n const store = firestore.collection('orgs').doc(options.orgId).collection(RETAINED_REFUSALS_COLLECTION)\n const limit = options.pageSize ?? RETAINED_REFUSALS_CARRY_PAGE\n const cursor = readCursor(options.cursor)\n const since = options.sinceMs ?? null\n\n let query: FirebaseFirestore.Query\n if (since !== null) {\n query = store\n .where('updatedAt', '>=', Timestamp.fromMillis(since))\n .orderBy('updatedAt')\n .orderBy(FieldPath.documentId())\n if (cursor?.at) query = query.startAfter(new Timestamp(cursor.at.seconds, cursor.at.nanoseconds), cursor.id)\n } else {\n query = store\n .where(`${MARKETING_CONSENT_BY_HOST_FIELD}.${carry.fromHostId}.${MARKETING_CONSENT_FIELD}`, '==', false)\n .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 decide = (snapshot: FirebaseFirestore.DocumentSnapshot) => {\n const patch = snapshot.exists ? retainedRefusalCarry(snapshot.data(), carry, changeId) : null\n return patch ? { snapshot, patch } : null\n }\n const due = page.docs.map(decide).filter((entry): entry is NonNullable<typeof entry> => entry !== null)\n const written = options.dryRun ? due.length : await writeCarried(firestore, carry, due, decide)\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('updatedAt')\n if (value instanceof Timestamp) next.at = { seconds: value.seconds, nanoseconds: value.nanoseconds }\n }\n const done = page.size < limit\n return { done, cursor: done ? null : JSON.stringify(next), read: page.size, written }\n}\n\ntype Carried = { snapshot: FirebaseFirestore.DocumentSnapshot; patch: Doc }\n\n/**\n * Writes one page's patches through a `BulkWriter`, each guarded by the read\n * it was decided from, and decides the ones that raced once more.\n */\nasync function writeCarried(\n firestore: FirebaseFirestore.Firestore,\n carry: ConsentGroupCarry,\n due: Carried[],\n decide: (snapshot: FirebaseFirestore.DocumentSnapshot) => Carried | null,\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(({ snapshot, patch }) =>\n writer\n .update(\n snapshot.ref,\n { ...patch, updatedAt: FieldValue.serverTimestamp() },\n { lastUpdateTime: snapshot.updateTime as FirebaseFirestore.Timestamp },\n )\n .then(\n () => ({ snapshot, error: null as unknown }),\n (error: unknown) => ({ snapshot, error: error ?? new Error('The write failed') }),\n ),\n )\n await writer.close()\n const raced: FirebaseFirestore.DocumentSnapshot[] = []\n for (const { snapshot, error } of await Promise.all(outcomes)) {\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 raced.push(snapshot)\n }\n if (!raced.length) return written\n const fresh = await firestore.getAll(...raced.map((snapshot) => snapshot.ref))\n due = fresh.map(decide).filter((entry): entry is Carried => entry !== null)\n }\n if (due.length) {\n throw new Error(\n `[retained-refusals] ${due.length} document(s) kept changing under the carry to ${carry.toHostId}`,\n )\n }\n return written\n}\n"],"names":["personKey","MARKETING_CONSENT_BY_HOST_FIELD","MARKETING_CONSENT_FIELD","RETAINED_AT_FIELD","RETAINED_REFUSALS_COLLECTION","refusalsOf","retainedEntries","retainedRefusalCarry","contactEmails","normalizeContactEmail","FieldPath","FieldValue","Timestamp","RETAINED_REFUSALS_READ_CHUNK","RETAINED_REFUSALS_CARRY_PAGE","orgOf","anchor","orgRef","collection","parent","Error","retainedRefusalsStore","retainRefusals","input","retained","hostIds","Object","keys","byHost","length","unscoped","contactsRef","store","emails","email","Set","map","filter","key","Boolean","data","contactId","nowMs","updatedAt","serverTimestamp","fields","hostId","push","transaction","set","doc","mergeFields","batch","firestore","commit","deleteWholeContact","contact","action","removeContactKeepingRefusals","contactRef","runTransaction","snapshot","get","exists","outcome","decided","decide","error","refused","id","delete","update","fromEntries","remove","path","visibleTo","arrayRemove","removeTokens","capturedByHostIds","removeHostIds","readRetainedRefusals","found","Map","byKey","raw","size","at","chunk","slice","snapshots","getAll","readCursor","parsed","JSON","parse","RACED","codeOf","code","carryRetainedRefusals","options","carry","changeId","orgId","limit","pageSize","cursor","since","sinceMs","query","where","fromMillis","orderBy","documentId","startAfter","seconds","nanoseconds","fromHostId","page","empty","done","read","written","patch","due","docs","entry","dryRun","writeCarried","last","next","value","stringify","round","writer","bulkWriter","outcomes","ref","lastUpdateTime","updateTime","then","close","raced","Promise","all","undefined","has","fresh","toHostId"],"mappings":"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"}
|
|
@@ -31,6 +31,13 @@ export interface QueryFakeFirestore {
|
|
|
31
31
|
/** Committed writes since the last reset. */
|
|
32
32
|
writes(): number;
|
|
33
33
|
resetWrites(): void;
|
|
34
|
+
/**
|
|
35
|
+
* Read ROUND TRIPS since the last reset: one per document `get`, query
|
|
36
|
+
* `get`, `count()`, `getAll` or transaction read, however many documents
|
|
37
|
+
* it returned — the unit a request's latency is spent in.
|
|
38
|
+
*/
|
|
39
|
+
reads(): number;
|
|
40
|
+
resetReads(): void;
|
|
34
41
|
/** The clock `serverTimestamp()` and every `updateTime` read. */
|
|
35
42
|
setNow(ms: number): void;
|
|
36
43
|
/** Makes the next writes to `path` fail with `error` — a stand-in for an outage. */
|