@aglyn/tenant-data-admin 1.0.0-beta.218 → 1.0.0-beta.220

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/package.json +8 -8
  2. package/src/lib/server/api-http.js +5 -1
  3. package/src/lib/server/api-http.js.map +1 -1
  4. package/src/lib/server/api-v1-description.d.ts +112 -0
  5. package/src/lib/server/api-v1-description.js +126 -0
  6. package/src/lib/server/api-v1-description.js.map +1 -0
  7. package/src/lib/server/api-v1-kit.d.ts +163 -0
  8. package/src/lib/server/api-v1-kit.js +191 -0
  9. package/src/lib/server/api-v1-kit.js.map +1 -0
  10. package/src/lib/server/api-v1-resources.d.ts +143 -0
  11. package/src/lib/server/api-v1-resources.js +125 -0
  12. package/src/lib/server/api-v1-resources.js.map +1 -0
  13. package/src/lib/server/campaign-conversion-attribution.d.ts +1 -1
  14. package/src/lib/server/campaign-conversion-attribution.js +3 -3
  15. package/src/lib/server/campaign-conversion-attribution.js.map +1 -1
  16. package/src/lib/server/card-payment-velocity.d.ts +9 -0
  17. package/src/lib/server/card-payment-velocity.js +28 -2
  18. package/src/lib/server/card-payment-velocity.js.map +1 -1
  19. package/src/lib/server/consent-group-carry.js +1 -1
  20. package/src/lib/server/consent-group-carry.js.map +1 -1
  21. package/src/lib/server/consent-group-change.d.ts +2 -1
  22. package/src/lib/server/consent-group-change.js +43 -18
  23. package/src/lib/server/consent-group-change.js.map +1 -1
  24. package/src/lib/server/contact-email-index.d.ts +22 -0
  25. package/src/lib/server/contact-email-index.js +108 -0
  26. package/src/lib/server/contact-email-index.js.map +1 -1
  27. package/src/lib/server/crm-email-activity.d.ts +1 -1
  28. package/src/lib/server/crm-email-activity.js +1 -1
  29. package/src/lib/server/crm-email-activity.js.map +1 -1
  30. package/src/lib/server/crm-records.d.ts +9 -1
  31. package/src/lib/server/crm-records.js +19 -2
  32. package/src/lib/server/crm-records.js.map +1 -1
  33. package/src/lib/server/cron-auth.d.ts +44 -0
  34. package/src/lib/server/cron-auth.js +76 -0
  35. package/src/lib/server/cron-auth.js.map +1 -0
  36. package/src/lib/server/dataset-live-pages.js +6 -6
  37. package/src/lib/server/dataset-live-pages.js.map +1 -1
  38. package/src/lib/server/duplicate-resource.d.ts +26 -0
  39. package/src/lib/server/duplicate-resource.js +47 -25
  40. package/src/lib/server/duplicate-resource.js.map +1 -1
  41. package/src/lib/server/dynamic-list-materialize.js +6 -3
  42. package/src/lib/server/dynamic-list-materialize.js.map +1 -1
  43. package/src/lib/server/email-flow-gate.js +2 -2
  44. package/src/lib/server/email-flow-gate.js.map +1 -1
  45. package/src/lib/server/email-suppression.d.ts +11 -0
  46. package/src/lib/server/email-suppression.js +37 -2
  47. package/src/lib/server/email-suppression.js.map +1 -1
  48. package/src/lib/server/email-unsubscribe-link.d.ts +8 -5
  49. package/src/lib/server/email-unsubscribe-link.js +7 -7
  50. package/src/lib/server/email-unsubscribe-link.js.map +1 -1
  51. package/src/lib/server/erase.d.ts +19 -33
  52. package/src/lib/server/erase.js +37 -162
  53. package/src/lib/server/erase.js.map +1 -1
  54. package/src/lib/server/host-email-tokens.js +1 -1
  55. package/src/lib/server/host-email-tokens.js.map +1 -1
  56. package/src/lib/server/host-memberships.js +3 -3
  57. package/src/lib/server/host-memberships.js.map +1 -1
  58. package/src/lib/server/host-visitor-records.d.ts +32 -4
  59. package/src/lib/server/host-visitor-records.js +73 -14
  60. package/src/lib/server/host-visitor-records.js.map +1 -1
  61. package/src/lib/server/hosted-page-review.d.ts +27 -0
  62. package/src/lib/server/hosted-page-review.js +149 -12
  63. package/src/lib/server/hosted-page-review.js.map +1 -1
  64. package/src/lib/server/media-cdn-rate-limit.d.ts +6 -3
  65. package/src/lib/server/media-cdn-rate-limit.js +6 -3
  66. package/src/lib/server/media-cdn-rate-limit.js.map +1 -1
  67. package/src/lib/server/member-email-aliases.d.ts +10 -2
  68. package/src/lib/server/member-email-aliases.js +26 -2
  69. package/src/lib/server/member-email-aliases.js.map +1 -1
  70. package/src/lib/server/new-account-announcement.d.ts +62 -0
  71. package/src/lib/server/new-account-announcement.js +77 -0
  72. package/src/lib/server/new-account-announcement.js.map +1 -0
  73. package/src/lib/server/notifications.d.ts +22 -2
  74. package/src/lib/server/notifications.js +40 -3
  75. package/src/lib/server/notifications.js.map +1 -1
  76. package/src/lib/server/operator-alerts.d.ts +7 -1
  77. package/src/lib/server/operator-alerts.js +5 -3
  78. package/src/lib/server/operator-alerts.js.map +1 -1
  79. package/src/lib/server/org-containers.d.ts +66 -0
  80. package/src/lib/server/org-containers.js +95 -0
  81. package/src/lib/server/org-containers.js.map +1 -0
  82. package/src/lib/server/org-member-notice.js +3 -2
  83. package/src/lib/server/org-member-notice.js.map +1 -1
  84. package/src/lib/server/payment-fraud-signal.js +5 -1
  85. package/src/lib/server/payment-fraud-signal.js.map +1 -1
  86. package/src/lib/server/{connect-payout-failure.d.ts → payment-provider-stripe-connect-payouts.d.ts} +3 -0
  87. package/src/lib/server/{connect-payout-failure.js → payment-provider-stripe-connect-payouts.js} +36 -2
  88. package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -0
  89. package/src/lib/server/{connect-account-status.d.ts → payment-provider-stripe-connect-status.d.ts} +3 -0
  90. package/src/lib/server/{connect-account-status.js → payment-provider-stripe-connect-status.js} +1 -1
  91. package/src/lib/server/payment-provider-stripe-connect-status.js.map +1 -0
  92. package/src/lib/server/{stripe-account-mode.d.ts → payment-provider-stripe-connect.d.ts} +45 -70
  93. package/src/lib/server/{stripe-account-mode.js → payment-provider-stripe-connect.js} +78 -52
  94. package/src/lib/server/payment-provider-stripe-connect.js.map +1 -0
  95. package/src/lib/server/payment-provider.d.ts +173 -0
  96. package/src/lib/server/payment-provider.js +70 -0
  97. package/src/lib/server/payment-provider.js.map +1 -0
  98. package/src/lib/server/payment-risk-record.d.ts +1 -1
  99. package/src/lib/server/payment-risk-record.js +3 -1
  100. package/src/lib/server/payment-risk-record.js.map +1 -1
  101. package/src/lib/server/personal-data-export.js +1 -1
  102. package/src/lib/server/personal-data-export.js.map +1 -1
  103. package/src/lib/server/platform-marketing-consent.d.ts +1 -1
  104. package/src/lib/server/platform-marketing-consent.js +1 -1
  105. package/src/lib/server/platform-marketing-consent.js.map +1 -1
  106. package/src/lib/server/rate-limit-store.js +13 -3
  107. package/src/lib/server/rate-limit-store.js.map +1 -1
  108. package/src/lib/server/realm-plugins.js +8 -3
  109. package/src/lib/server/realm-plugins.js.map +1 -1
  110. package/src/lib/server/retained-refusals.d.ts +142 -0
  111. package/src/lib/server/retained-refusals.js +325 -0
  112. package/src/lib/server/retained-refusals.js.map +1 -0
  113. package/src/lib/server/risk-notice.d.ts +32 -1
  114. package/src/lib/server/risk-notice.js +90 -28
  115. package/src/lib/server/risk-notice.js.map +1 -1
  116. package/src/lib/server/sending-domains.d.ts +17 -0
  117. package/src/lib/server/sending-domains.js +28 -4
  118. package/src/lib/server/sending-domains.js.map +1 -1
  119. package/src/lib/server/sso-enforcement.js +7 -2
  120. package/src/lib/server/sso-enforcement.js.map +1 -1
  121. package/src/lib/server/staff-alert-email.js +4 -2
  122. package/src/lib/server/staff-alert-email.js.map +1 -1
  123. package/src/lib/server/test-firestore-queries.d.ts +7 -0
  124. package/src/lib/server/test-firestore-queries.js +70 -27
  125. package/src/lib/server/test-firestore-queries.js.map +1 -1
  126. package/src/lib/server/{email-topic-confirmation.js → topic-subscriptions.js} +15 -11
  127. package/src/lib/server/topic-subscriptions.js.map +1 -0
  128. package/src/lib/server/upsert-contact.d.ts +46 -1
  129. package/src/lib/server/upsert-contact.js +87 -20
  130. package/src/lib/server/upsert-contact.js.map +1 -1
  131. package/src/tenancy.d.ts +5 -8
  132. package/src/tenancy.js +12 -13
  133. package/src/tenancy.js.map +1 -1
  134. package/src/lib/server/connect-account-status.js.map +0 -1
  135. package/src/lib/server/connect-payout-failure.js.map +0 -1
  136. package/src/lib/server/contact-email-engagement.d.ts +0 -32
  137. package/src/lib/server/contact-email-engagement.js +0 -145
  138. package/src/lib/server/contact-email-engagement.js.map +0 -1
  139. package/src/lib/server/contact-lifecycle-floor.d.ts +0 -89
  140. package/src/lib/server/contact-lifecycle-floor.js +0 -91
  141. package/src/lib/server/contact-lifecycle-floor.js.map +0 -1
  142. package/src/lib/server/contact-merge.d.ts +0 -55
  143. package/src/lib/server/contact-merge.js +0 -288
  144. package/src/lib/server/contact-merge.js.map +0 -1
  145. package/src/lib/server/crm-booking-activity.d.ts +0 -86
  146. package/src/lib/server/crm-booking-activity.js +0 -257
  147. package/src/lib/server/crm-booking-activity.js.map +0 -1
  148. package/src/lib/server/crm-inbound-email.d.ts +0 -145
  149. package/src/lib/server/crm-inbound-email.js +0 -297
  150. package/src/lib/server/crm-inbound-email.js.map +0 -1
  151. package/src/lib/server/email-topic-confirmation.js.map +0 -1
  152. package/src/lib/server/form-dataset-binding-token.d.ts +0 -35
  153. package/src/lib/server/form-dataset-binding-token.js +0 -107
  154. package/src/lib/server/form-dataset-binding-token.js.map +0 -1
  155. package/src/lib/server/stripe-account-mode.js.map +0 -1
  156. /package/src/lib/server/{email-topic-confirmation.d.ts → topic-subscriptions.d.ts} +0 -0
@@ -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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"}
@@ -14,7 +14,8 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
- import { type ResolvedRiskAction, type RiskEventKind } from '@aglyn/shared-util-email/risk-notice-catalog';
17
+ import { type ResolvedRiskAction, type RiskEventKind, type RiskNoticeSeverity } from '@aglyn/shared-util-email/risk-notice-catalog';
18
+ import type { NotificationLevel } from '@aglyn/aglyn/server';
18
19
  import { type SendEmailOptions, type SendEmailResult } from '@aglyn/shared-util-email';
19
20
  import { type HeldPageSubject, type HeldPageTarget, type PageHoldNoticeKind } from '@aglyn/shared-util-email/held-page';
20
21
  import { type NotificationPayload, type NotifyUsersOptions } from './notifications';
@@ -25,6 +26,11 @@ export declare const RISK_NOTICE_COLLECTION = "riskNotices";
25
26
  export declare const RISK_NOTICE_LEDGER_COLLECTION = "riskNoticeLedger";
26
27
  /** The in-app notification type every owner notice is written as. */
27
28
  export declare const RISK_NOTICE_NOTIFICATION_TYPE: "system.riskNotice";
29
+ /**
30
+ * The level an owner notice is drawn at (AGL-3437), from its kind's
31
+ * severity: a release or a lift is `info`, not the red of the hold it ends.
32
+ */
33
+ export declare const RISK_NOTICE_LEVELS: Record<RiskNoticeSeverity, NotificationLevel>;
28
34
  /**
29
35
  * The burst allowance: how many notices one workspace's owners, and staff
30
36
  * about one workspace, receive one by one in {@link RISK_NOTICE_BURST.windowMs}
@@ -66,7 +72,20 @@ export interface RiskEventInput {
66
72
  occurredAtMs?: number;
67
73
  /** `$56.00 USD`. */
68
74
  amount?: string | null;
75
+ /**
76
+ * What happened to the payment, finishing "the payment was …"
77
+ * (`RISK_PAYMENT_EVENTS`): a fraud report, a review, a dispute.
78
+ */
79
+ paymentEvent?: string | null;
69
80
  evidenceDueByMs?: number | null;
81
+ /**
82
+ * A new publisher's payout schedule: how many days each payout waits, and
83
+ * the workspace age it waits until.
84
+ */
85
+ payout?: {
86
+ delayDays: number;
87
+ untilWorkspaceAgeDays: number;
88
+ } | null;
70
89
  /** Staff only: the Stripe Dashboard page for the charge, review or dispute. */
71
90
  stripeUrl?: string | null;
72
91
  lock?: {
@@ -87,6 +106,11 @@ export interface RiskEventInput {
87
106
  staffEvidence?: string | null;
88
107
  /** Overrides the default dedupe key. */
89
108
  dedupeKey?: string | null;
109
+ /**
110
+ * A closing notice's opening kind. The closing goes to the people the
111
+ * opening went to: the site's managers too, when the opening told them.
112
+ */
113
+ openedAs?: RiskEventKind | null;
90
114
  /**
91
115
  * `false` to send no owner EMAIL for this event (a lock placed under a
92
116
  * legal hold). The in-app notice and the record are still written.
@@ -178,6 +202,13 @@ export interface RenderedRiskNoticeEmail {
178
202
  * platform does (holds, locks, lifts, cancellations, reviews).
179
203
  */
180
204
  export declare function riskNoticeEmailBrand(kind: RiskEventKind | null, orgId: string | null): Promise<SystemEmailBrand>;
205
+ /**
206
+ * The product name a notice's own words use (`your Aglyn account`): the one
207
+ * the email it rides in is branded with, so the sentence and the header
208
+ * never disagree — the platform's for platform mail, a white-label
209
+ * workspace's own for the notices sent in its brand.
210
+ */
211
+ export declare function riskNoticeProductName(brand: SystemEmailBrand): string;
181
212
  /**
182
213
  * Renders one notice email through the System emails seam (AGL-3367): the
183
214
  * published design for the kind's own `risk-…` key, or its built-in copy,