@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.
- package/package.json +8 -8
- package/src/lib/server/api-http.js +5 -1
- package/src/lib/server/api-http.js.map +1 -1
- 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 +163 -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 +143 -0
- package/src/lib/server/api-v1-resources.js +125 -0
- package/src/lib/server/api-v1-resources.js.map +1 -0
- package/src/lib/server/campaign-conversion-attribution.d.ts +1 -1
- package/src/lib/server/campaign-conversion-attribution.js +3 -3
- package/src/lib/server/campaign-conversion-attribution.js.map +1 -1
- package/src/lib/server/card-payment-velocity.d.ts +9 -0
- package/src/lib/server/card-payment-velocity.js +28 -2
- package/src/lib/server/card-payment-velocity.js.map +1 -1
- package/src/lib/server/consent-group-carry.js +1 -1
- package/src/lib/server/consent-group-carry.js.map +1 -1
- package/src/lib/server/consent-group-change.d.ts +2 -1
- package/src/lib/server/consent-group-change.js +43 -18
- 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-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 +6 -3
- package/src/lib/server/dynamic-list-materialize.js.map +1 -1
- package/src/lib/server/email-flow-gate.js +2 -2
- package/src/lib/server/email-flow-gate.js.map +1 -1
- package/src/lib/server/email-suppression.d.ts +11 -0
- package/src/lib/server/email-suppression.js +37 -2
- 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/host-visitor-records.d.ts +32 -4
- package/src/lib/server/host-visitor-records.js +73 -14
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.d.ts +27 -0
- package/src/lib/server/hosted-page-review.js +149 -12
- package/src/lib/server/hosted-page-review.js.map +1 -1
- package/src/lib/server/media-cdn-rate-limit.d.ts +6 -3
- package/src/lib/server/media-cdn-rate-limit.js +6 -3
- package/src/lib/server/media-cdn-rate-limit.js.map +1 -1
- package/src/lib/server/member-email-aliases.d.ts +10 -2
- package/src/lib/server/member-email-aliases.js +26 -2
- 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 +22 -2
- package/src/lib/server/notifications.js +40 -3
- package/src/lib/server/notifications.js.map +1 -1
- package/src/lib/server/operator-alerts.d.ts +7 -1
- package/src/lib/server/operator-alerts.js +5 -3
- package/src/lib/server/operator-alerts.js.map +1 -1
- package/src/lib/server/org-containers.d.ts +66 -0
- package/src/lib/server/org-containers.js +95 -0
- package/src/lib/server/org-containers.js.map +1 -0
- package/src/lib/server/org-member-notice.js +3 -2
- package/src/lib/server/org-member-notice.js.map +1 -1
- package/src/lib/server/payment-fraud-signal.js +5 -1
- package/src/lib/server/payment-fraud-signal.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} +36 -2
- 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/payment-risk-record.d.ts +1 -1
- package/src/lib/server/payment-risk-record.js +3 -1
- package/src/lib/server/payment-risk-record.js.map +1 -1
- 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/platform-marketing-consent.d.ts +1 -1
- package/src/lib/server/platform-marketing-consent.js +1 -1
- package/src/lib/server/platform-marketing-consent.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/risk-notice.d.ts +32 -1
- package/src/lib/server/risk-notice.js +90 -28
- package/src/lib/server/risk-notice.js.map +1 -1
- package/src/lib/server/sending-domains.d.ts +17 -0
- package/src/lib/server/sending-domains.js +28 -4
- package/src/lib/server/sending-domains.js.map +1 -1
- package/src/lib/server/sso-enforcement.js +7 -2
- package/src/lib/server/sso-enforcement.js.map +1 -1
- package/src/lib/server/staff-alert-email.js +4 -2
- package/src/lib/server/staff-alert-email.js.map +1 -1
- 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/{email-topic-confirmation.js → topic-subscriptions.js} +15 -11
- package/src/lib/server/topic-subscriptions.js.map +1 -0
- package/src/lib/server/upsert-contact.d.ts +46 -1
- package/src/lib/server/upsert-contact.js +87 -20
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/tenancy.d.ts +5 -8
- package/src/tenancy.js +12 -13
- 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-email-engagement.d.ts +0 -32
- package/src/lib/server/contact-email-engagement.js +0 -145
- package/src/lib/server/contact-email-engagement.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/crm-booking-activity.d.ts +0 -86
- package/src/lib/server/crm-booking-activity.js +0 -257
- package/src/lib/server/crm-booking-activity.js.map +0 -1
- package/src/lib/server/crm-inbound-email.d.ts +0 -145
- package/src/lib/server/crm-inbound-email.js +0 -297
- package/src/lib/server/crm-inbound-email.js.map +0 -1
- package/src/lib/server/email-topic-confirmation.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
- /package/src/lib/server/{email-topic-confirmation.d.ts → topic-subscriptions.d.ts} +0 -0
|
@@ -154,5 +154,113 @@ import { FieldValue } from "firebase-admin/firestore";
|
|
|
154
154
|
}
|
|
155
155
|
return hit;
|
|
156
156
|
}
|
|
157
|
+
/** The most values one Firestore `in` filter may carry. */ const IN_FILTER_LIMIT = 30;
|
|
158
|
+
/** The most references handed to one `getAll`, and the most writes in one batch. */ const PAGE_LIMIT = 500;
|
|
159
|
+
function pagesOf(items, size) {
|
|
160
|
+
const out = [];
|
|
161
|
+
for(let at = 0; at < items.length; at += size)out.push(items.slice(at, at + size));
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
async function getAllPaged(firestore, refs) {
|
|
165
|
+
if (!refs.length) return [];
|
|
166
|
+
const pages = await Promise.all(pagesOf(refs, PAGE_LIMIT).map((page)=>firestore.getAll(...page)));
|
|
167
|
+
return pages.flat();
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* {@link findContactByEmail} for a whole file of addresses (AGL-3423).
|
|
171
|
+
*
|
|
172
|
+
* The same answer per address — the index first, the contact it names only
|
|
173
|
+
* if that still exists, the `email` query when the index has nothing — paid
|
|
174
|
+
* in a number of round trips that does not grow with the file: one `getAll`
|
|
175
|
+
* of the index entries, one `getAll` of the contacts they name, and one
|
|
176
|
+
* `in` query per thirty addresses the index missed, every page at once. A
|
|
177
|
+
* row found by the query gets its index entry the way a single lookup
|
|
178
|
+
* writes one, in one batch for the whole file.
|
|
179
|
+
*
|
|
180
|
+
* Unscoped, like the capture door's own lookup: one human who touched two
|
|
181
|
+
* sites is one person, and the door decides visibility, not the lookup.
|
|
182
|
+
*
|
|
183
|
+
* The map is keyed by normalized address. An address ABSENT from it is one
|
|
184
|
+
* this call could not answer — its page of the query failed — and a caller
|
|
185
|
+
* asks {@link findContactByEmail} for it rather than taking it as new;
|
|
186
|
+
* `null` is an address that resolves to nobody. When the query returns two
|
|
187
|
+
* documents for one address, the first in document order is the answer, as
|
|
188
|
+
* `limit(1)` gives the single lookup.
|
|
189
|
+
*/ export async function findContactsByEmail(contactsRef, emails) {
|
|
190
|
+
const found = new Map();
|
|
191
|
+
const wanted = [
|
|
192
|
+
...new Set(emails.map((email)=>normalizeContactEmail(email)).filter((email)=>!!email))
|
|
193
|
+
];
|
|
194
|
+
if (!wanted.length) return found;
|
|
195
|
+
const firestore = contactsRef.firestore;
|
|
196
|
+
const index = emailIndexBeside(contactsRef);
|
|
197
|
+
if (index) {
|
|
198
|
+
try {
|
|
199
|
+
const keyed = wanted.map((email)=>({
|
|
200
|
+
email,
|
|
201
|
+
key: personKey(email)
|
|
202
|
+
})).filter((entry)=>!!entry.key);
|
|
203
|
+
const entries = await getAllPaged(firestore, keyed.map((entry)=>index.doc(entry.key)));
|
|
204
|
+
const pointers = keyed.map((entry, at)=>{
|
|
205
|
+
var _entries_at_get;
|
|
206
|
+
var _entries_at;
|
|
207
|
+
return {
|
|
208
|
+
email: entry.email,
|
|
209
|
+
contactId: ((_entries_at = entries[at]) == null ? void 0 : _entries_at.exists) ? String((_entries_at_get = entries[at].get('contactId')) != null ? _entries_at_get : '') : ''
|
|
210
|
+
};
|
|
211
|
+
}).filter((pointer)=>pointer.contactId);
|
|
212
|
+
const contacts = await getAllPaged(firestore, pointers.map((pointer)=>contactsRef.doc(pointer.contactId)));
|
|
213
|
+
pointers.forEach((pointer, at)=>{
|
|
214
|
+
var _contacts_at;
|
|
215
|
+
if ((_contacts_at = contacts[at]) == null ? void 0 : _contacts_at.exists) found.set(pointer.email, contacts[at]);
|
|
216
|
+
});
|
|
217
|
+
} catch (error) {
|
|
218
|
+
console.error('[contact-email-index] bulk lookup failed', error);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const missed = wanted.filter((email)=>!found.has(email));
|
|
222
|
+
const repaired = [];
|
|
223
|
+
await Promise.all(pagesOf(missed, IN_FILTER_LIMIT).map(async (page)=>{
|
|
224
|
+
try {
|
|
225
|
+
const snapshot = await contactsRef.where('email', 'in', page).get();
|
|
226
|
+
for (const doc of snapshot.docs){
|
|
227
|
+
var _doc_get;
|
|
228
|
+
const email = String((_doc_get = doc.get('email')) != null ? _doc_get : '');
|
|
229
|
+
if (!page.includes(email) || found.get(email)) continue;
|
|
230
|
+
found.set(email, doc);
|
|
231
|
+
repaired.push({
|
|
232
|
+
email,
|
|
233
|
+
contactId: doc.id
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
for (const email of page)if (!found.has(email)) found.set(email, null);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
console.error('[contact-email-index] bulk query failed', error);
|
|
239
|
+
}
|
|
240
|
+
}));
|
|
241
|
+
if (index && repaired.length) {
|
|
242
|
+
try {
|
|
243
|
+
for (const page of pagesOf(repaired, PAGE_LIMIT)){
|
|
244
|
+
const batch = firestore.batch();
|
|
245
|
+
for (const { email, contactId } of page){
|
|
246
|
+
const key = personKey(email);
|
|
247
|
+
if (!key) continue;
|
|
248
|
+
const entry = {
|
|
249
|
+
email,
|
|
250
|
+
contactId,
|
|
251
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
252
|
+
};
|
|
253
|
+
batch.set(index.doc(key), entry, {
|
|
254
|
+
merge: true
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
await batch.commit();
|
|
258
|
+
}
|
|
259
|
+
} catch (error) {
|
|
260
|
+
console.error('[contact-email-index] bulk write failed', error);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return found;
|
|
264
|
+
}
|
|
157
265
|
|
|
158
266
|
//# sourceMappingURL=contact-email-index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/contact-email-index.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 ADDRESS INDEX (AGL-2625): `orgs/{orgId}/emailIndex/{personKey}`.\n *\n * `upsertHostContact` finds a person by `where('email', '==', …)` on the\n * contacts collection, and that query answers one document per address by\n * construction — which is exactly what stops a merged record's address from\n * ever finding the survivor. The survivor's `email` is the identity that\n * stayed; the merged address is in its `alternateEmails`, and Firestore\n * cannot answer \"the document whose primary OR alternate address is X\" as\n * one indexed query. So the address is indexed on its own: one document per\n * address, naming the contact it belongs to.\n *\n * ## Consulted first, never trusted alone\n *\n * The lookup reads the index entry and then the contact it names, and\n * takes the contact only if it still exists. A stale entry — a contact\n * detached and deleted after its entry was written — falls through to the\n * query, which is the truth the index only summarizes.\n *\n * ## Written lazily, never backfilled\n *\n * An entry is written when a capture CREATES a contact, and when a lookup\n * finds one through the query rather than the index — so every existing\n * contact gains an entry on its next write, and no job has to walk the\n * collection. A merge writes one entry per address the survivor answers to.\n *\n * ## Never in the way of a capture\n *\n * Every read and write here is wrapped: a form submission or an order must\n * succeed whatever the index did, exactly as the capture itself swallows\n * its own failures. An index that cannot be reached is a miss, and a miss\n * is the query.\n *\n * ## One lookup for every door (AGL-2633)\n *\n * The capture door was the first reader; every other place that finds a\n * person by address — an automation step, the flow-email consent gate, the\n * delivery webhook's engagement stamp, the Inbox add-to-list check, a\n * refund, a campaign's proof and consent reads, the REST create and\n * `?email=` list — resolves through this same function, so a merged\n * record's alternate address answers the same person everywhere. Two of\n * those doors read differently from the capture, and the options carry it:\n *\n * - **`hostId`** narrows the answer to what ONE site may see. The index is\n * org-wide, so the lookup is too; the scope check is applied to the\n * document it names, in memory, which answers exactly what the scoped\n * `email ==` query used to — one document per address, so a contact the\n * site cannot see is `null` rather than a reason to look further.\n * - **`transaction`** routes every read through the caller's transaction,\n * for a door that updates the contact it finds inside one. The lazy index\n * write stays OUTSIDE the transaction, deliberately: a queued write would\n * forbid the caller any later read, and the entry is a cache fill that\n * never has to be atomic with anything. It is idempotent, so a retried\n * transaction body writing it twice is harmless.\n *\n * Imported by module path rather than through the `@aglyn/aglyn/server`\n * barrel, for the reason `upsert-contact.ts` gives: the door's specs\n * substitute a fixture barrel, and a helper that reached the normalizer only\n * through it would find `undefined` there. Callers outside this library\n * import THIS module by its leaf path for the mirror-image reason: most of\n * their specs substitute the `@aglyn/tenant-data-admin` barrel wholesale.\n */\n\nimport {\n CONTACT_EMAIL_INDEX_COLLECTION,\n normalizeContactEmail,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport { visibleToHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/** One entry: the address as stored, and the contact it resolves to. */\nexport interface ContactEmailIndexEntry {\n email: string\n contactId: string\n}\n\n/**\n * The org's index collection, beside its contacts one — or `null` when the\n * handle has no parent, which is a fixture's contacts reference and never\n * a real org subcollection. `null` means \"no index\": the caller falls back\n * to the query and writes nothing.\n */\nexport function emailIndexBeside(\n contactsRef: FirebaseFirestore.CollectionReference,\n): FirebaseFirestore.CollectionReference | null {\n try {\n const parent = contactsRef.parent\n return parent ? parent.collection(CONTACT_EMAIL_INDEX_COLLECTION) : null\n } catch {\n return null\n }\n}\n\n/**\n * Point every address in `emails` at `contactId`. Never rejects: an entry\n * that could not be written is one the next lookup writes again.\n */\nexport async function writeContactEmailIndex(\n index: FirebaseFirestore.CollectionReference | null,\n contactId: string,\n emails: readonly string[],\n): Promise<void> {\n if (!index || !contactId) return\n await Promise.all(\n emails.map(async (raw) => {\n const email = normalizeContactEmail(raw)\n const key = email ? personKey(email) : null\n if (!email || !key) return\n const entry: ContactEmailIndexEntry & { updatedAt: unknown } = {\n email,\n contactId,\n updatedAt: FieldValue.serverTimestamp(),\n }\n await index.doc(key).set(entry, { merge: true })\n }),\n ).catch((error: unknown) => {\n console.error('[contact-email-index] write failed', contactId, error)\n })\n}\n\n/** How one door reads differently from the capture; see the module notes. */\nexport interface FindContactByEmailOptions {\n /**\n * Answer only a contact THIS site may see. A document the address names\n * but the site cannot read is `null` — the same answer the scoped\n * `email ==` query gave, since one address names one document.\n */\n hostId?: string\n /**\n * Read through this transaction. Every read — the index entry, the\n * contact it names, the fallback query — goes through it; the lazy index\n * write does not, so the caller may still read after this returns.\n */\n transaction?: FirebaseFirestore.Transaction\n}\n\n/**\n * The contact an address belongs to, as a document snapshot — or `null`.\n *\n * The index first, then the per-document query it summarizes. A query hit\n * writes the entry the index lacked, so the read that paid for the miss is\n * the last one that has to.\n */\nexport async function findContactByEmail(\n contactsRef: FirebaseFirestore.CollectionReference,\n email: unknown,\n options: FindContactByEmailOptions = {},\n): Promise<FirebaseFirestore.DocumentSnapshot | null> {\n const normalized = normalizeContactEmail(email)\n if (!normalized) return null\n const { hostId, transaction } = options\n const readDoc = (ref: FirebaseFirestore.DocumentReference) =>\n transaction ? transaction.get(ref) : ref.get()\n const readQuery = (query: FirebaseFirestore.Query) =>\n transaction ? transaction.get(query) : query.get()\n\n const index = emailIndexBeside(contactsRef)\n const key = index ? personKey(normalized) : null\n let hit: FirebaseFirestore.DocumentSnapshot | null = null\n if (index && key) {\n try {\n const entry = await readDoc(index.doc(key))\n const contactId = entry.exists ? String(entry.get('contactId') ?? '') : ''\n if (contactId) {\n const contact = await readDoc(contactsRef.doc(contactId))\n if (contact.exists) hit = contact\n }\n } catch (error) {\n console.error('[contact-email-index] lookup failed', error)\n }\n }\n if (!hit) {\n const found = await readQuery(\n contactsRef.where('email', '==', normalized).limit(1),\n )\n hit = found.docs[0] ?? null\n if (!hit) return null\n await writeContactEmailIndex(index, hit.id, [normalized])\n }\n if (hostId && !visibleToHost(hit.get('visibleTo') as string[] | undefined, hostId)) {\n return null\n }\n return hit\n}\n"],"names":["CONTACT_EMAIL_INDEX_COLLECTION","normalizeContactEmail","personKey","visibleToHost","FieldValue","emailIndexBeside","contactsRef","parent","collection","writeContactEmailIndex","index","contactId","emails","Promise","all","map","raw","email","key","entry","updatedAt","serverTimestamp","doc","set","merge","catch","error","console","findContactByEmail","options","normalized","hostId","transaction","readDoc","ref","get","readQuery","query","hit","exists","String","contact","found","where","limit","docs","id"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA6DC,GAED,SACEA,8BAA8B,EAC9BC,qBAAqB,QAChB,kCAAiC;AACxC,SAASC,SAAS,QAAQ,oCAAmC;AAC7D,SAASC,aAAa,QAAQ,sCAAqC;AACnE,SAASC,UAAU,QAAQ,2BAA0B;AAQrD;;;;;CAKC,GACD,OAAO,SAASC,iBACdC,WAAkD;IAElD,IAAI;QACF,MAAMC,SAASD,YAAYC,MAAM;QACjC,OAAOA,SAASA,OAAOC,UAAU,CAACR,kCAAkC;IACtE,EAAE,eAAM;QACN,OAAO;IACT;AACF;AAEA;;;CAGC,GACD,OAAO,eAAeS,uBACpBC,KAAmD,EACnDC,SAAiB,EACjBC,MAAyB;IAEzB,IAAI,CAACF,SAAS,CAACC,WAAW;IAC1B,MAAME,QAAQC,GAAG,CACfF,OAAOG,GAAG,CAAC,OAAOC;QAChB,MAAMC,QAAQhB,sBAAsBe;QACpC,MAAME,MAAMD,QAAQf,UAAUe,SAAS;QACvC,IAAI,CAACA,SAAS,CAACC,KAAK;QACpB,MAAMC,QAAyD;YAC7DF;YACAN;YACAS,WAAWhB,WAAWiB,eAAe;QACvC;QACA,MAAMX,MAAMY,GAAG,CAACJ,KAAKK,GAAG,CAACJ,OAAO;YAAEK,OAAO;QAAK;IAChD,IACAC,KAAK,CAAC,CAACC;QACPC,QAAQD,KAAK,CAAC,sCAAsCf,WAAWe;IACjE;AACF;AAkBA;;;;;;CAMC,GACD,OAAO,eAAeE,mBACpBtB,WAAkD,EAClDW,KAAc,EACdY,UAAqC,CAAC,CAAC;IAEvC,MAAMC,aAAa7B,sBAAsBgB;IACzC,IAAI,CAACa,YAAY,OAAO;IACxB,MAAM,EAAEC,MAAM,EAAEC,WAAW,EAAE,GAAGH;IAChC,MAAMI,UAAU,CAACC,MACfF,cAAcA,YAAYG,GAAG,CAACD,OAAOA,IAAIC,GAAG;IAC9C,MAAMC,YAAY,CAACC,QACjBL,cAAcA,YAAYG,GAAG,CAACE,SAASA,MAAMF,GAAG;IAElD,MAAMzB,QAAQL,iBAAiBC;IAC/B,MAAMY,MAAMR,QAAQR,UAAU4B,cAAc;IAC5C,IAAIQ,MAAiD;IACrD,IAAI5B,SAASQ,KAAK;QAChB,IAAI;gBAEsCC;YADxC,MAAMA,QAAQ,MAAMc,QAAQvB,MAAMY,GAAG,CAACJ;YACtC,MAAMP,YAAYQ,MAAMoB,MAAM,GAAGC,QAAOrB,aAAAA,MAAMgB,GAAG,CAAC,wBAAVhB,aAA0B,MAAM;YACxE,IAAIR,WAAW;gBACb,MAAM8B,UAAU,MAAMR,QAAQ3B,YAAYgB,GAAG,CAACX;gBAC9C,IAAI8B,QAAQF,MAAM,EAAED,MAAMG;YAC5B;QACF,EAAE,OAAOf,OAAO;YACdC,QAAQD,KAAK,CAAC,uCAAuCA;QACvD;IACF;IACA,IAAI,CAACY,KAAK;YAIFI;QAHN,MAAMA,QAAQ,MAAMN,UAClB9B,YAAYqC,KAAK,CAAC,SAAS,MAAMb,YAAYc,KAAK,CAAC;QAErDN,OAAMI,eAAAA,MAAMG,IAAI,CAAC,EAAE,YAAbH,eAAiB;QACvB,IAAI,CAACJ,KAAK,OAAO;QACjB,MAAM7B,uBAAuBC,OAAO4B,IAAIQ,EAAE,EAAE;YAAChB;SAAW;IAC1D;IACA,IAAIC,UAAU,CAAC5B,cAAcmC,IAAIH,GAAG,CAAC,cAAsCJ,SAAS;QAClF,OAAO;IACT;IACA,OAAOO;AACT"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/contact-email-index.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 ADDRESS INDEX (AGL-2625): `orgs/{orgId}/emailIndex/{personKey}`.\n *\n * `upsertHostContact` finds a person by `where('email', '==', …)` on the\n * contacts collection, and that query answers one document per address by\n * construction — which is exactly what stops a merged record's address from\n * ever finding the survivor. The survivor's `email` is the identity that\n * stayed; the merged address is in its `alternateEmails`, and Firestore\n * cannot answer \"the document whose primary OR alternate address is X\" as\n * one indexed query. So the address is indexed on its own: one document per\n * address, naming the contact it belongs to.\n *\n * ## Consulted first, never trusted alone\n *\n * The lookup reads the index entry and then the contact it names, and\n * takes the contact only if it still exists. A stale entry — a contact\n * detached and deleted after its entry was written — falls through to the\n * query, which is the truth the index only summarizes.\n *\n * ## Written lazily, never backfilled\n *\n * An entry is written when a capture CREATES a contact, and when a lookup\n * finds one through the query rather than the index — so every existing\n * contact gains an entry on its next write, and no job has to walk the\n * collection. A merge writes one entry per address the survivor answers to.\n *\n * ## Never in the way of a capture\n *\n * Every read and write here is wrapped: a form submission or an order must\n * succeed whatever the index did, exactly as the capture itself swallows\n * its own failures. An index that cannot be reached is a miss, and a miss\n * is the query.\n *\n * ## One lookup for every door (AGL-2633)\n *\n * The capture door was the first reader; every other place that finds a\n * person by address — an automation step, the flow-email consent gate, the\n * delivery webhook's engagement stamp, the Inbox add-to-list check, a\n * refund, a campaign's proof and consent reads, the REST create and\n * `?email=` list — resolves through this same function, so a merged\n * record's alternate address answers the same person everywhere. Two of\n * those doors read differently from the capture, and the options carry it:\n *\n * - **`hostId`** narrows the answer to what ONE site may see. The index is\n * org-wide, so the lookup is too; the scope check is applied to the\n * document it names, in memory, which answers exactly what the scoped\n * `email ==` query used to — one document per address, so a contact the\n * site cannot see is `null` rather than a reason to look further.\n * - **`transaction`** routes every read through the caller's transaction,\n * for a door that updates the contact it finds inside one. The lazy index\n * write stays OUTSIDE the transaction, deliberately: a queued write would\n * forbid the caller any later read, and the entry is a cache fill that\n * never has to be atomic with anything. It is idempotent, so a retried\n * transaction body writing it twice is harmless.\n *\n * Imported by module path rather than through the `@aglyn/aglyn/server`\n * barrel, for the reason `upsert-contact.ts` gives: the door's specs\n * substitute a fixture barrel, and a helper that reached the normalizer only\n * through it would find `undefined` there. Callers outside this library\n * import THIS module by its leaf path for the mirror-image reason: most of\n * their specs substitute the `@aglyn/tenant-data-admin` barrel wholesale.\n */\n\nimport {\n CONTACT_EMAIL_INDEX_COLLECTION,\n normalizeContactEmail,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport { visibleToHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/** One entry: the address as stored, and the contact it resolves to. */\nexport interface ContactEmailIndexEntry {\n email: string\n contactId: string\n}\n\n/**\n * The org's index collection, beside its contacts one — or `null` when the\n * handle has no parent, which is a fixture's contacts reference and never\n * a real org subcollection. `null` means \"no index\": the caller falls back\n * to the query and writes nothing.\n */\nexport function emailIndexBeside(\n contactsRef: FirebaseFirestore.CollectionReference,\n): FirebaseFirestore.CollectionReference | null {\n try {\n const parent = contactsRef.parent\n return parent ? parent.collection(CONTACT_EMAIL_INDEX_COLLECTION) : null\n } catch {\n return null\n }\n}\n\n/**\n * Point every address in `emails` at `contactId`. Never rejects: an entry\n * that could not be written is one the next lookup writes again.\n */\nexport async function writeContactEmailIndex(\n index: FirebaseFirestore.CollectionReference | null,\n contactId: string,\n emails: readonly string[],\n): Promise<void> {\n if (!index || !contactId) return\n await Promise.all(\n emails.map(async (raw) => {\n const email = normalizeContactEmail(raw)\n const key = email ? personKey(email) : null\n if (!email || !key) return\n const entry: ContactEmailIndexEntry & { updatedAt: unknown } = {\n email,\n contactId,\n updatedAt: FieldValue.serverTimestamp(),\n }\n await index.doc(key).set(entry, { merge: true })\n }),\n ).catch((error: unknown) => {\n console.error('[contact-email-index] write failed', contactId, error)\n })\n}\n\n/** How one door reads differently from the capture; see the module notes. */\nexport interface FindContactByEmailOptions {\n /**\n * Answer only a contact THIS site may see. A document the address names\n * but the site cannot read is `null` — the same answer the scoped\n * `email ==` query gave, since one address names one document.\n */\n hostId?: string\n /**\n * Read through this transaction. Every read — the index entry, the\n * contact it names, the fallback query — goes through it; the lazy index\n * write does not, so the caller may still read after this returns.\n */\n transaction?: FirebaseFirestore.Transaction\n}\n\n/**\n * The contact an address belongs to, as a document snapshot — or `null`.\n *\n * The index first, then the per-document query it summarizes. A query hit\n * writes the entry the index lacked, so the read that paid for the miss is\n * the last one that has to.\n */\nexport async function findContactByEmail(\n contactsRef: FirebaseFirestore.CollectionReference,\n email: unknown,\n options: FindContactByEmailOptions = {},\n): Promise<FirebaseFirestore.DocumentSnapshot | null> {\n const normalized = normalizeContactEmail(email)\n if (!normalized) return null\n const { hostId, transaction } = options\n const readDoc = (ref: FirebaseFirestore.DocumentReference) =>\n transaction ? transaction.get(ref) : ref.get()\n const readQuery = (query: FirebaseFirestore.Query) =>\n transaction ? transaction.get(query) : query.get()\n\n const index = emailIndexBeside(contactsRef)\n const key = index ? personKey(normalized) : null\n let hit: FirebaseFirestore.DocumentSnapshot | null = null\n if (index && key) {\n try {\n const entry = await readDoc(index.doc(key))\n const contactId = entry.exists ? String(entry.get('contactId') ?? '') : ''\n if (contactId) {\n const contact = await readDoc(contactsRef.doc(contactId))\n if (contact.exists) hit = contact\n }\n } catch (error) {\n console.error('[contact-email-index] lookup failed', error)\n }\n }\n if (!hit) {\n const found = await readQuery(\n contactsRef.where('email', '==', normalized).limit(1),\n )\n hit = found.docs[0] ?? null\n if (!hit) return null\n await writeContactEmailIndex(index, hit.id, [normalized])\n }\n if (hostId && !visibleToHost(hit.get('visibleTo') as string[] | undefined, hostId)) {\n return null\n }\n return hit\n}\n\n/** The most values one Firestore `in` filter may carry. */\nconst IN_FILTER_LIMIT = 30\n\n/** The most references handed to one `getAll`, and the most writes in one batch. */\nconst PAGE_LIMIT = 500\n\nfunction pagesOf<T>(items: readonly T[], size: number): T[][] {\n const out: T[][] = []\n for (let at = 0; at < items.length; at += size) out.push(items.slice(at, at + size))\n return out\n}\n\nasync function getAllPaged(\n firestore: FirebaseFirestore.Firestore,\n refs: readonly FirebaseFirestore.DocumentReference[],\n): Promise<FirebaseFirestore.DocumentSnapshot[]> {\n if (!refs.length) return []\n const pages = await Promise.all(\n pagesOf(refs, PAGE_LIMIT).map((page) => firestore.getAll(...page)),\n )\n return pages.flat()\n}\n\n/**\n * {@link findContactByEmail} for a whole file of addresses (AGL-3423).\n *\n * The same answer per address — the index first, the contact it names only\n * if that still exists, the `email` query when the index has nothing — paid\n * in a number of round trips that does not grow with the file: one `getAll`\n * of the index entries, one `getAll` of the contacts they name, and one\n * `in` query per thirty addresses the index missed, every page at once. A\n * row found by the query gets its index entry the way a single lookup\n * writes one, in one batch for the whole file.\n *\n * Unscoped, like the capture door's own lookup: one human who touched two\n * sites is one person, and the door decides visibility, not the lookup.\n *\n * The map is keyed by normalized address. An address ABSENT from it is one\n * this call could not answer — its page of the query failed — and a caller\n * asks {@link findContactByEmail} for it rather than taking it as new;\n * `null` is an address that resolves to nobody. When the query returns two\n * documents for one address, the first in document order is the answer, as\n * `limit(1)` gives the single lookup.\n */\nexport async function findContactsByEmail(\n contactsRef: FirebaseFirestore.CollectionReference,\n emails: readonly unknown[],\n): Promise<Map<string, FirebaseFirestore.DocumentSnapshot | null>> {\n const found = new Map<string, FirebaseFirestore.DocumentSnapshot | null>()\n const wanted = [\n ...new Set(\n emails\n .map((email) => normalizeContactEmail(email))\n .filter((email): email is string => !!email),\n ),\n ]\n if (!wanted.length) return found\n const firestore = contactsRef.firestore\n const index = emailIndexBeside(contactsRef)\n\n if (index) {\n try {\n const keyed = wanted\n .map((email) => ({ email, key: personKey(email) }))\n .filter((entry): entry is { email: string; key: string } => !!entry.key)\n const entries = await getAllPaged(\n firestore,\n keyed.map((entry) => index.doc(entry.key)),\n )\n const pointers = keyed\n .map((entry, at) => ({\n email: entry.email,\n contactId: entries[at]?.exists ? String(entries[at].get('contactId') ?? '') : '',\n }))\n .filter((pointer) => pointer.contactId)\n const contacts = await getAllPaged(\n firestore,\n pointers.map((pointer) => contactsRef.doc(pointer.contactId)),\n )\n pointers.forEach((pointer, at) => {\n if (contacts[at]?.exists) found.set(pointer.email, contacts[at])\n })\n } catch (error) {\n console.error('[contact-email-index] bulk lookup failed', error)\n }\n }\n\n const missed = wanted.filter((email) => !found.has(email))\n const repaired: { email: string; contactId: string }[] = []\n await Promise.all(\n pagesOf(missed, IN_FILTER_LIMIT).map(async (page) => {\n try {\n const snapshot = await contactsRef.where('email', 'in', page).get()\n for (const doc of snapshot.docs) {\n const email = String(doc.get('email') ?? '')\n if (!page.includes(email) || found.get(email)) continue\n found.set(email, doc)\n repaired.push({ email, contactId: doc.id })\n }\n for (const email of page) if (!found.has(email)) found.set(email, null)\n } catch (error) {\n console.error('[contact-email-index] bulk query failed', error)\n }\n }),\n )\n\n if (index && repaired.length) {\n try {\n for (const page of pagesOf(repaired, PAGE_LIMIT)) {\n const batch = firestore.batch()\n for (const { email, contactId } of page) {\n const key = personKey(email)\n if (!key) continue\n const entry: ContactEmailIndexEntry & { updatedAt: unknown } = {\n email,\n contactId,\n updatedAt: FieldValue.serverTimestamp(),\n }\n batch.set(index.doc(key), entry, { merge: true })\n }\n await batch.commit()\n }\n } catch (error) {\n console.error('[contact-email-index] bulk write failed', error)\n }\n }\n return found\n}\n"],"names":["CONTACT_EMAIL_INDEX_COLLECTION","normalizeContactEmail","personKey","visibleToHost","FieldValue","emailIndexBeside","contactsRef","parent","collection","writeContactEmailIndex","index","contactId","emails","Promise","all","map","raw","email","key","entry","updatedAt","serverTimestamp","doc","set","merge","catch","error","console","findContactByEmail","options","normalized","hostId","transaction","readDoc","ref","get","readQuery","query","hit","exists","String","contact","found","where","limit","docs","id","IN_FILTER_LIMIT","PAGE_LIMIT","pagesOf","items","size","out","at","length","push","slice","getAllPaged","firestore","refs","pages","page","getAll","flat","findContactsByEmail","Map","wanted","Set","filter","keyed","entries","pointers","pointer","contacts","forEach","missed","has","repaired","snapshot","includes","batch","commit"],"mappings":"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"}
|
|
@@ -53,7 +53,7 @@ export declare function writeCrmEmailActivity(ref: FirebaseFirestore.DocumentRef
|
|
|
53
53
|
* `null` for an event that moves nothing.
|
|
54
54
|
*
|
|
55
55
|
* Takes the log's own vocabulary (`EmailDeliveryEventType`) rather than the
|
|
56
|
-
* provider's wire strings: `
|
|
56
|
+
* provider's wire strings: the mail provider's `deliveryEvents` is the one place
|
|
57
57
|
* that reads those, and this module must not become a second. The five
|
|
58
58
|
* states the timeline shows share the log's names exactly, so the mapping
|
|
59
59
|
* is membership; `delayed` and `failed` are the log's own and the row keeps
|
|
@@ -60,7 +60,7 @@ import { FieldValue } from "firebase-admin/firestore";
|
|
|
60
60
|
* `null` for an event that moves nothing.
|
|
61
61
|
*
|
|
62
62
|
* Takes the log's own vocabulary (`EmailDeliveryEventType`) rather than the
|
|
63
|
-
* provider's wire strings: `
|
|
63
|
+
* provider's wire strings: the mail provider's `deliveryEvents` is the one place
|
|
64
64
|
* that reads those, and this module must not become a second. The five
|
|
65
65
|
* states the timeline shows share the log's names exactly, so the mapping
|
|
66
66
|
* is membership; `delayed` and `failed` are the log's own and the row keeps
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/crm-email-activity.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n CRM_COLLECTIONS,\n type CrmActivity,\n type CrmEmailDeliveryState,\n isCrmEmailDeliveryState,\n nextCrmEmailDeliveryState,\n} from '@aglyn/aglyn/server'\nimport { createHash } from 'crypto'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/**\n * The activity row a one-to-one email is logged as, on the server side\n * (AGL-2615): where it lives, how it is written, and how the delivery\n * webhook moves its state.\n *\n * Two writers log a sent email — the console's `crm/email-send` route and\n * the `sendEmail` automation step — and one reader advances it, the Resend\n * event webhook. The reader has to find the row from nothing but the tags\n * on the message, so the id is minted BEFORE the send and stamped on it,\n * and the row is written only after the provider accepted the message. A\n * row written first would be an \"email\" on the timeline for a message that\n * never left.\n *\n * Firestore is a parameter throughout, as in `crm-records.ts`: the callers\n * hold one, and a module that reached for the Admin app itself would drag\n * it into every spec that only wants the arithmetic.\n */\n\n/** `orgs/{orgId}/crmActivities/{activityId}`. */\nexport function crmActivityRef(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n activityId: string,\n): FirebaseFirestore.DocumentReference {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.activities)\n .doc(activityId)\n}\n\n/**\n * A fresh reference for the row a send is about to earn — `doc()` with no\n * id mints one locally and reads nothing, so the id can ride the message\n * as a tag before anything is written.\n */\nexport function newCrmActivityRef(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n): FirebaseFirestore.DocumentReference {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.activities)\n .doc()\n}\n\n/**\n * Writes the row a sent email is logged as, stamped with the server clock.\n *\n * `set` rather than `add`, because the id was minted ahead of the send and\n * is already on the message; a second id here would be the one the webhook\n * could not find.\n */\nexport async function writeCrmEmailActivity(\n ref: FirebaseFirestore.DocumentReference,\n activity: CrmActivity,\n): Promise<void> {\n await ref.set({\n ...activity,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n}\n\n/**\n * Which delivery state one normalized delivery event moves a row to, or\n * `null` for an event that moves nothing.\n *\n * Takes the log's own vocabulary (`EmailDeliveryEventType`) rather than the\n * provider's wire strings: `normalizeResendDeliveryEvents` is the one place\n * that reads those, and this module must not become a second. The five\n * states the timeline shows share the log's names exactly, so the mapping\n * is membership; `delayed` and `failed` are the log's own and the row keeps\n * whatever it held.\n */\nexport function crmEmailDeliveryStateForEvent(\n type: string | null | undefined,\n): CrmEmailDeliveryState | null {\n return isCrmEmailDeliveryState(type) ? type : null\n}\n\nexport type CrmEmailDeliveryOutcome =\n /** The row moved to the incoming state. */\n | 'advanced'\n /** The row already held this state or a later one; nothing written. */\n | 'unchanged'\n /** No email activity by that id in that org; nothing written. */\n | 'missing'\n /** The read or the write threw; logged, and nothing is promised. */\n | 'failed'\n\n/**\n * One delivery event, onto the activity row it names (AGL-2615).\n *\n * A TRANSACTION, because the rule is \"keep the later state\" and two events\n * for one message routinely arrive together — `delivered` and `opened`\n * within the same second from a mail client that fetches images on\n * receipt. Two plain read-then-writes interleaved would let the earlier\n * event land last and print \"Delivered\" over an open that already\n * happened; inside a transaction the second read sees the first write and\n * `nextCrmEmailDeliveryState` answers \"unchanged\".\n *\n * A row that is not an email activity is left alone: the tag named it, but\n * a delivery state on a call log would be a chip the timeline has no way to\n * explain. **Never throws** — the webhook answers the provider 200 whatever\n * happens here, so a failure is logged and reported rather than raised.\n */\nexport async function recordCrmEmailDelivery(\n firestore: FirebaseFirestore.Firestore,\n input: {\n orgId: string\n activityId: string\n state: CrmEmailDeliveryState\n /** When the provider says it happened, epoch ms. */\n atMs: number\n /**\n * What the receiving server said (AGL-3245), for a failure: the bounce's\n * diagnostic, scrubbed of the address. Kept beside the state as\n * `deliveryDetail`, so the timeline can say why the message did not land.\n */\n detail?: string | null\n },\n): Promise<CrmEmailDeliveryOutcome> {\n const { orgId, activityId, state } = input\n if (!orgId || !activityId) return 'missing'\n const ref = crmActivityRef(firestore, orgId, activityId)\n const detail = String(input.detail ?? '').replace(/\\s+/g, ' ').trim().slice(0, 500)\n try {\n return await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n if (!snapshot.exists || snapshot.get('kind') !== 'email') return 'missing'\n const current = snapshot.get('deliveryState')\n const next = nextCrmEmailDeliveryState(current, state)\n if (next === current) return 'unchanged'\n transaction.update(ref, {\n deliveryState: next,\n deliveryAtMs: Number.isFinite(input.atMs) ? input.atMs : Date.now(),\n ...(detail ? { deliveryDetail: detail } : {}),\n updatedAt: FieldValue.serverTimestamp(),\n })\n return 'advanced'\n })\n } catch (error) {\n console.error('[crm] email delivery state write failed', orgId, activityId, error)\n return 'failed'\n }\n}\n\n/**\n * `orgs/{orgId}/crmActivities/{id}` for a CAPTURED message (AGL-2657), with\n * the id derived from the message's key — its `Message-ID`, else the\n * provider's id — so a second delivery of one message addresses the row\n * the first delivery wrote. A hash rather than the key itself, because a\n * `Message-ID` carries `<`, `@` and `/`, which a document id may not.\n */\nexport function crmCapturedEmailActivityRef(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n key: string,\n): FirebaseFirestore.DocumentReference {\n const digest = createHash('sha256').update(key).digest('hex').slice(0, 28)\n return crmActivityRef(firestore, orgId, `cap_${digest}`)\n}\n\n/** What a create answered: the row is new, or it was already there. */\nexport type CrmEmailActivityCreateOutcome = 'created' | 'duplicate'\n\nconst isAlreadyExists = (error: unknown): boolean => {\n const failure = error as { code?: unknown; message?: unknown } | null\n return (\n failure?.code === 6 ||\n failure?.code === 'already-exists' ||\n /ALREADY_EXISTS/i.test(String(failure?.message ?? ''))\n )\n}\n\n/**\n * Writes a captured message's row ONCE: `create()`, which refuses a\n * document that already exists, is what makes two deliveries of one\n * message one row even when they arrive together — the second create is\n * the one Firestore refuses, and this answers `duplicate` for it. Any\n * other failure is the caller's.\n */\nexport async function createCrmEmailActivity(\n ref: FirebaseFirestore.DocumentReference,\n activity: CrmActivity,\n): Promise<CrmEmailActivityCreateOutcome> {\n try {\n await ref.create({\n ...activity,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n return 'created'\n } catch (error) {\n if (isAlreadyExists(error)) return 'duplicate'\n throw error\n }\n}\n"],"names":["CRM_COLLECTIONS","isCrmEmailDeliveryState","nextCrmEmailDeliveryState","createHash","FieldValue","crmActivityRef","firestore","orgId","activityId","collection","doc","activities","newCrmActivityRef","writeCrmEmailActivity","ref","activity","set","createdAt","serverTimestamp","updatedAt","crmEmailDeliveryStateForEvent","type","recordCrmEmailDelivery","input","state","detail","String","replace","trim","slice","runTransaction","transaction","snapshot","get","exists","current","next","update","deliveryState","deliveryAtMs","Number","isFinite","atMs","Date","now","deliveryDetail","error","console","crmCapturedEmailActivityRef","key","digest","isAlreadyExists","failure","code","test","message","createCrmEmailActivity","create"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/crm-email-activity.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n CRM_COLLECTIONS,\n type CrmActivity,\n type CrmEmailDeliveryState,\n isCrmEmailDeliveryState,\n nextCrmEmailDeliveryState,\n} from '@aglyn/aglyn/server'\nimport { createHash } from 'crypto'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/**\n * The activity row a one-to-one email is logged as, on the server side\n * (AGL-2615): where it lives, how it is written, and how the delivery\n * webhook moves its state.\n *\n * Two writers log a sent email — the console's `crm/email-send` route and\n * the `sendEmail` automation step — and one reader advances it, the Resend\n * event webhook. The reader has to find the row from nothing but the tags\n * on the message, so the id is minted BEFORE the send and stamped on it,\n * and the row is written only after the provider accepted the message. A\n * row written first would be an \"email\" on the timeline for a message that\n * never left.\n *\n * Firestore is a parameter throughout, as in `crm-records.ts`: the callers\n * hold one, and a module that reached for the Admin app itself would drag\n * it into every spec that only wants the arithmetic.\n */\n\n/** `orgs/{orgId}/crmActivities/{activityId}`. */\nexport function crmActivityRef(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n activityId: string,\n): FirebaseFirestore.DocumentReference {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.activities)\n .doc(activityId)\n}\n\n/**\n * A fresh reference for the row a send is about to earn — `doc()` with no\n * id mints one locally and reads nothing, so the id can ride the message\n * as a tag before anything is written.\n */\nexport function newCrmActivityRef(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n): FirebaseFirestore.DocumentReference {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.activities)\n .doc()\n}\n\n/**\n * Writes the row a sent email is logged as, stamped with the server clock.\n *\n * `set` rather than `add`, because the id was minted ahead of the send and\n * is already on the message; a second id here would be the one the webhook\n * could not find.\n */\nexport async function writeCrmEmailActivity(\n ref: FirebaseFirestore.DocumentReference,\n activity: CrmActivity,\n): Promise<void> {\n await ref.set({\n ...activity,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n}\n\n/**\n * Which delivery state one normalized delivery event moves a row to, or\n * `null` for an event that moves nothing.\n *\n * Takes the log's own vocabulary (`EmailDeliveryEventType`) rather than the\n * provider's wire strings: the mail provider's `deliveryEvents` is the one place\n * that reads those, and this module must not become a second. The five\n * states the timeline shows share the log's names exactly, so the mapping\n * is membership; `delayed` and `failed` are the log's own and the row keeps\n * whatever it held.\n */\nexport function crmEmailDeliveryStateForEvent(\n type: string | null | undefined,\n): CrmEmailDeliveryState | null {\n return isCrmEmailDeliveryState(type) ? type : null\n}\n\nexport type CrmEmailDeliveryOutcome =\n /** The row moved to the incoming state. */\n | 'advanced'\n /** The row already held this state or a later one; nothing written. */\n | 'unchanged'\n /** No email activity by that id in that org; nothing written. */\n | 'missing'\n /** The read or the write threw; logged, and nothing is promised. */\n | 'failed'\n\n/**\n * One delivery event, onto the activity row it names (AGL-2615).\n *\n * A TRANSACTION, because the rule is \"keep the later state\" and two events\n * for one message routinely arrive together — `delivered` and `opened`\n * within the same second from a mail client that fetches images on\n * receipt. Two plain read-then-writes interleaved would let the earlier\n * event land last and print \"Delivered\" over an open that already\n * happened; inside a transaction the second read sees the first write and\n * `nextCrmEmailDeliveryState` answers \"unchanged\".\n *\n * A row that is not an email activity is left alone: the tag named it, but\n * a delivery state on a call log would be a chip the timeline has no way to\n * explain. **Never throws** — the webhook answers the provider 200 whatever\n * happens here, so a failure is logged and reported rather than raised.\n */\nexport async function recordCrmEmailDelivery(\n firestore: FirebaseFirestore.Firestore,\n input: {\n orgId: string\n activityId: string\n state: CrmEmailDeliveryState\n /** When the provider says it happened, epoch ms. */\n atMs: number\n /**\n * What the receiving server said (AGL-3245), for a failure: the bounce's\n * diagnostic, scrubbed of the address. Kept beside the state as\n * `deliveryDetail`, so the timeline can say why the message did not land.\n */\n detail?: string | null\n },\n): Promise<CrmEmailDeliveryOutcome> {\n const { orgId, activityId, state } = input\n if (!orgId || !activityId) return 'missing'\n const ref = crmActivityRef(firestore, orgId, activityId)\n const detail = String(input.detail ?? '').replace(/\\s+/g, ' ').trim().slice(0, 500)\n try {\n return await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n if (!snapshot.exists || snapshot.get('kind') !== 'email') return 'missing'\n const current = snapshot.get('deliveryState')\n const next = nextCrmEmailDeliveryState(current, state)\n if (next === current) return 'unchanged'\n transaction.update(ref, {\n deliveryState: next,\n deliveryAtMs: Number.isFinite(input.atMs) ? input.atMs : Date.now(),\n ...(detail ? { deliveryDetail: detail } : {}),\n updatedAt: FieldValue.serverTimestamp(),\n })\n return 'advanced'\n })\n } catch (error) {\n console.error('[crm] email delivery state write failed', orgId, activityId, error)\n return 'failed'\n }\n}\n\n/**\n * `orgs/{orgId}/crmActivities/{id}` for a CAPTURED message (AGL-2657), with\n * the id derived from the message's key — its `Message-ID`, else the\n * provider's id — so a second delivery of one message addresses the row\n * the first delivery wrote. A hash rather than the key itself, because a\n * `Message-ID` carries `<`, `@` and `/`, which a document id may not.\n */\nexport function crmCapturedEmailActivityRef(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n key: string,\n): FirebaseFirestore.DocumentReference {\n const digest = createHash('sha256').update(key).digest('hex').slice(0, 28)\n return crmActivityRef(firestore, orgId, `cap_${digest}`)\n}\n\n/** What a create answered: the row is new, or it was already there. */\nexport type CrmEmailActivityCreateOutcome = 'created' | 'duplicate'\n\nconst isAlreadyExists = (error: unknown): boolean => {\n const failure = error as { code?: unknown; message?: unknown } | null\n return (\n failure?.code === 6 ||\n failure?.code === 'already-exists' ||\n /ALREADY_EXISTS/i.test(String(failure?.message ?? ''))\n )\n}\n\n/**\n * Writes a captured message's row ONCE: `create()`, which refuses a\n * document that already exists, is what makes two deliveries of one\n * message one row even when they arrive together — the second create is\n * the one Firestore refuses, and this answers `duplicate` for it. Any\n * other failure is the caller's.\n */\nexport async function createCrmEmailActivity(\n ref: FirebaseFirestore.DocumentReference,\n activity: CrmActivity,\n): Promise<CrmEmailActivityCreateOutcome> {\n try {\n await ref.create({\n ...activity,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n return 'created'\n } catch (error) {\n if (isAlreadyExists(error)) return 'duplicate'\n throw error\n }\n}\n"],"names":["CRM_COLLECTIONS","isCrmEmailDeliveryState","nextCrmEmailDeliveryState","createHash","FieldValue","crmActivityRef","firestore","orgId","activityId","collection","doc","activities","newCrmActivityRef","writeCrmEmailActivity","ref","activity","set","createdAt","serverTimestamp","updatedAt","crmEmailDeliveryStateForEvent","type","recordCrmEmailDelivery","input","state","detail","String","replace","trim","slice","runTransaction","transaction","snapshot","get","exists","current","next","update","deliveryState","deliveryAtMs","Number","isFinite","atMs","Date","now","deliveryDetail","error","console","crmCapturedEmailActivityRef","key","digest","isAlreadyExists","failure","code","test","message","createCrmEmailActivity","create"],"mappings":"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"}
|
|
@@ -151,7 +151,15 @@ export interface CrmListFieldsRestamp {
|
|
|
151
151
|
/** Records that no longer exist. */
|
|
152
152
|
missing: number;
|
|
153
153
|
}
|
|
154
|
-
/**
|
|
154
|
+
/**
|
|
155
|
+
* Restamp one record's list fields from the record as stored.
|
|
156
|
+
*
|
|
157
|
+
* Then tell the plugins a record was written (AGL-3336): every server writer
|
|
158
|
+
* of an org record passes here after its write, so this is the one place a
|
|
159
|
+
* plugin that acts on the record AFTER any write — the CRM's sharing rules —
|
|
160
|
+
* is told, without an edit to each writer. Not for a record that no longer
|
|
161
|
+
* exists, and after the restamp's own transaction, never inside it.
|
|
162
|
+
*/
|
|
155
163
|
export declare function restampCrmListFieldsAt(ref: FirebaseFirestore.DocumentReference, collection: CrmListCollection): Promise<'restamped' | 'current' | 'missing'>;
|
|
156
164
|
/** Restamp several records of one collection, a few at a time. */
|
|
157
165
|
export declare function restampCrmListFieldsOf(refs: readonly FirebaseFirestore.DocumentReference[], collection: CrmListCollection): Promise<CrmListFieldsRestamp>;
|
|
@@ -21,6 +21,7 @@ import { checkCrmEmailQuota, checkCrmRecordsQuota, CRM_COLLECTIONS, CRM_EMAIL_US
|
|
|
21
21
|
* the door.
|
|
22
22
|
*/ import { crmListFieldsPatch } from "@aglyn/aglyn/app-utils/crm";
|
|
23
23
|
import { rampedDailyAllowance } from "@aglyn/shared-util-email";
|
|
24
|
+
import { notifyPluginRecordWritten } from "@aglyn/aglyn/plugin-manager/plugin-record-written";
|
|
24
25
|
import { FieldValue } from "firebase-admin/firestore";
|
|
25
26
|
/**
|
|
26
27
|
* Count the records band for one org: three `count()` aggregates, in
|
|
@@ -219,9 +220,18 @@ import { FieldValue } from "firebase-admin/firestore";
|
|
|
219
220
|
console.error('crm email reservation release failed', error);
|
|
220
221
|
}
|
|
221
222
|
}
|
|
222
|
-
/**
|
|
223
|
+
/**
|
|
224
|
+
* Restamp one record's list fields from the record as stored.
|
|
225
|
+
*
|
|
226
|
+
* Then tell the plugins a record was written (AGL-3336): every server writer
|
|
227
|
+
* of an org record passes here after its write, so this is the one place a
|
|
228
|
+
* plugin that acts on the record AFTER any write — the CRM's sharing rules —
|
|
229
|
+
* is told, without an edit to each writer. Not for a record that no longer
|
|
230
|
+
* exists, and after the restamp's own transaction, never inside it.
|
|
231
|
+
*/ export async function restampCrmListFieldsAt(ref, collection) {
|
|
232
|
+
let outcome;
|
|
223
233
|
try {
|
|
224
|
-
|
|
234
|
+
outcome = await ref.firestore.runTransaction(async (tx)=>{
|
|
225
235
|
var _snapshot_data;
|
|
226
236
|
const snapshot = await tx.get(ref);
|
|
227
237
|
if (!snapshot.exists) return 'missing';
|
|
@@ -234,6 +244,13 @@ import { FieldValue } from "firebase-admin/firestore";
|
|
|
234
244
|
console.error(`[crm] list fields restamp failed for ${ref.path}`, error);
|
|
235
245
|
return 'current';
|
|
236
246
|
}
|
|
247
|
+
if (outcome !== 'missing') {
|
|
248
|
+
await notifyPluginRecordWritten({
|
|
249
|
+
path: ref.path,
|
|
250
|
+
collection
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
return outcome;
|
|
237
254
|
}
|
|
238
255
|
/** Restamp several records of one collection, a few at a time. */ export async function restampCrmListFieldsOf(refs, collection) {
|
|
239
256
|
const result = {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/crm-records.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { AglynOrgBilling } from '@aglyn/aglyn/foundation'\nimport {\n checkCrmEmailQuota,\n checkCrmRecordsQuota,\n CRM_COLLECTIONS,\n CRM_EMAIL_USAGE_COLLECTION,\n type CrmActivityLink,\n crmActivityCeilingLink,\n type CrmEmailQuotaResult,\n crmEmailUsageDayKey,\n type CrmRecordsQuotaResult,\n} from '@aglyn/aglyn/server'\n/*\n * By module path, not the barrel: the capture door's specs substitute a\n * fixture barrel (see `upsert-contact.ts`), and the restamp is reached from\n * the door.\n */\nimport { type CrmListCollection, crmListFieldsPatch } from '@aglyn/aglyn/app-utils/crm'\nimport {\n type EmailRampVerdict,\n rampedDailyAllowance,\n} from '@aglyn/shared-util-email'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/**\n * The CRM RECORDS band's measurement (AGL-2611): the three collections it\n * counts, and their sum.\n *\n * One shape for every server that asks, because the band is enforced at six\n * doors — contact capture, the console's company and deal drawers (through\n * the client twin of this read), the CSV import, the lead conversion and the\n * two REST creates — and the monthly rollup bills from the same three\n * aggregates. A door that counted contacts alone would admit a company on a\n * band the rollup had already found full.\n */\nexport interface CrmRecordsCount {\n contactsCount: number\n companiesCount: number\n dealsCount: number\n /** The band's own figure: the three above, summed. */\n crmRecordsCount: number\n}\n\n/**\n * Count the records band for one org: three `count()` aggregates, in\n * parallel, no document reads.\n *\n * `contacts` may be handed in by a caller that already holds the collection\n * reference — `upsertHostContact` resolves it through `orgDataCollection`\n * before it decides anything — so the read is the same one whichever door\n * it came through, and the org reference is needed only for the two\n * collections the hub added.\n */\nexport async function countCrmRecords(\n orgRef: FirebaseFirestore.DocumentReference,\n contacts: FirebaseFirestore.CollectionReference = orgRef.collection('contacts'),\n): Promise<CrmRecordsCount> {\n const [contactsSnap, companiesSnap, dealsSnap] = await Promise.all([\n contacts.count().get(),\n orgRef.collection(CRM_COLLECTIONS.companies).count().get(),\n orgRef.collection(CRM_COLLECTIONS.deals).count().get(),\n ])\n const contactsCount = aggregateCount(contactsSnap)\n const companiesCount = aggregateCount(companiesSnap)\n const dealsCount = aggregateCount(dealsSnap)\n return {\n contactsCount,\n companiesCount,\n dealsCount,\n crmRecordsCount: contactsCount + companiesCount + dealsCount,\n }\n}\n\n/**\n * The band's verdict for one org, measured now — the read every create\n * door takes before it writes, so the six doors cannot disagree about\n * whether there is room. `checkCrmRecordsQuota` answers from the org doc\n * alone; this pairs it with the measurement.\n */\nexport async function crmRecordsQuotaForOrg(\n org: Partial<AglynOrgBilling> | null | undefined,\n orgRef: FirebaseFirestore.DocumentReference,\n contacts?: FirebaseFirestore.CollectionReference,\n): Promise<CrmRecordsQuotaResult & CrmRecordsCount> {\n const counts = await countCrmRecords(orgRef, contacts)\n return { ...checkCrmRecordsQuota(org, counts.crmRecordsCount), ...counts }\n}\n\n/** `count()` answers through `.data().count`; a missing figure is zero, never NaN. */\nfunction aggregateCount(snapshot: { data(): { count?: unknown } }): number {\n const value = Number(snapshot.data()?.count ?? 0)\n return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0\n}\n\n/**\n * How many activities one record already carries — one aggregate on a\n * single-field equality, which Firestore indexes on its own, so this needs\n * no composite index and cannot go FAILED_PRECONDITION in production.\n */\nexport async function countCrmActivitiesForRecord(\n orgRef: FirebaseFirestore.DocumentReference,\n link: CrmActivityLink,\n): Promise<number> {\n const lead = crmActivityCeilingLink(link)\n if (!lead) return 0\n const snapshot = await orgRef\n .collection(CRM_COLLECTIONS.activities)\n .where(lead.field, '==', lead.id)\n .count()\n .get()\n return aggregateCount(snapshot)\n}\n\n/**\n * `orgs/{orgId}/crmEmailUsage/{day}` — one UTC day's one-to-one email\n * counter, the document `checkCrmEmailQuota` is enforced against and the\n * billing page reads.\n */\nfunction crmEmailUsageRefForDay(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n day: string,\n): FirebaseFirestore.DocumentReference {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_EMAIL_USAGE_COLLECTION)\n .doc(day)\n}\n\n/** Today's counter document — {@link crmEmailUsageRefForDay} keyed by `now`. */\nexport function crmEmailUsageRef(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n now: Date = new Date(),\n): FirebaseFirestore.DocumentReference {\n return crmEmailUsageRefForDay(firestore, orgId, crmEmailUsageDayKey(now))\n}\n\n/**\n * The counter a day-document holds, clamped.\n *\n * A malformed or absent counter reads as ZERO SENT, which is the permissive\n * direction — deliberately, and the same clamp `checkApiRequestQuota`'s\n * reader applies: a counter that cannot be read must not refuse a rep who\n * has sent nothing, and the cap is a pace rather than a cost.\n */\nfunction crmEmailCount(snapshot: { get(field: string): unknown }): number {\n const value = Number(snapshot.get('count') ?? 0)\n return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0\n}\n\n/**\n * How many one-to-one emails the org has sent today, for a meter — never\n * for the cap, which {@link reserveCrmEmailSend} judges inside the\n * transaction that also takes the slot.\n */\nexport async function crmEmailsSentToday(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n now: Date = new Date(),\n): Promise<number> {\n return crmEmailCount(await crmEmailUsageRef(firestore, orgId, now).get())\n}\n\n/** One slot on one day's counter, held from the reservation to the send. */\nexport interface CrmEmailSendReservation {\n orgId: string\n /**\n * The UTC day the slot was taken on, so a release after midnight gives\n * the slot back to the day that lent it rather than to the new day.\n */\n day: string\n}\n\nexport type ReserveCrmEmailSendResult =\n | { ok: true; reservation: CrmEmailSendReservation; quota: CrmEmailQuotaResult }\n | { ok: false; quota: CrmEmailQuotaResult }\n\n/**\n * The plan's day, narrowed to the share of it a young workspace has earned.\n *\n * The new-sender ramp reaches the one-to-one path here and only here, so the\n * ceiling the transaction below judges against is one value with one origin.\n * The alternative — a full-allowance reservation with a ramp consulted beside\n * it — is two ceilings for one decision, and under two concurrent senders it\n * admits against whichever of them was read first.\n *\n * `included` is the only field the ramp moves; `resetsAt` is the day's, which\n * a share does not change, and `used` is what the transaction read.\n */\nfunction rampedCrmEmailQuota(\n plan: CrmEmailQuotaResult,\n ramp: EmailRampVerdict | null | undefined,\n): CrmEmailQuotaResult {\n const included = rampedDailyAllowance(plan.included, ramp)\n if (included >= plan.included) return plan\n return {\n ...plan,\n included,\n allowed: plan.used < included,\n remaining: Math.max(0, included - plan.used),\n }\n}\n\n/**\n * Claims ONE one-to-one send against today's cap, ATOMICALLY (AGL-2645).\n *\n * The counter used to be read before the send and incremented after\n * delivery, so two reps sending in the same window both passed the same\n * reading and both sent — `CRM_EMAIL_SENDS_PER_MINUTE` past the cap per\n * sender, in exactly the conditions the cap exists for. **A read-then-write\n * cap is not a cap**, the lesson `reserveCampaignEmailSends` learned for\n * campaigns; this is the same shape on the day counter.\n *\n * The transaction reads the counter, judges it with `checkCrmEmailQuota` —\n * the real plan table, `UNLIMITED` on Enterprise, zero on a tier with no\n * suite — and writes an ABSOLUTE value derived from that read, deliberately\n * not `FieldValue.increment`. Firestore aborts and re-runs the callback\n * when a document the transaction read has moved, so a second sender that\n * starts inside the first one's window re-reads the raised figure and is\n * refused. An increment would be atomic on the number and useless for the\n * decision, because the decision is made from a value the write never\n * proves it still held.\n *\n * A refused reservation writes NOTHING. A taken one is the count the cap\n * enforces and the billing page shows, from the moment it is taken: a send\n * the provider then refuses hands the slot back through\n * {@link releaseCrmEmailSend}, and a process that dies between the two\n * leaves the org one send short for the rest of the UTC day — conservative\n * in the direction a cap cares about, and healed at midnight, because each\n * day is its own document.\n *\n * ## The new-sender ramp rides the same read\n *\n * `ramp` narrows the plan's day to the share of it a young workspace has\n * earned, INSIDE the transaction and before the counter is judged — see\n * {@link rampedCrmEmailQuota}. Applying it here rather than in the caller is\n * the whole of what makes it safe: the ceiling and the count it is compared\n * against come out of one read, so the retry Firestore forces on a losing\n * transaction re-derives both. A ramp checked outside would be a second\n * ceiling, decided from a snapshot the write never proves it still held —\n * the defect this reservation was built to remove, one field over.\n *\n * The ramp is optional and an absent one is not a throttle: a caller that\n * did not resolve one gets the plan's day, which is what every surface but\n * the send path wants.\n */\nexport async function reserveCrmEmailSend(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n org: Partial<AglynOrgBilling> | null | undefined,\n now: Date = new Date(),\n ramp?: EmailRampVerdict | null,\n): Promise<ReserveCrmEmailSendResult> {\n const day = crmEmailUsageDayKey(now)\n const ref = crmEmailUsageRefForDay(firestore, orgId, day)\n return firestore.runTransaction(async (tx) => {\n const quota = rampedCrmEmailQuota(\n checkCrmEmailQuota(org, crmEmailCount(await tx.get(ref)), now),\n ramp,\n )\n if (!quota.allowed) return { ok: false, quota }\n tx.set(\n ref,\n { count: quota.used + 1, day, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n return { ok: true, reservation: { orgId, day }, quota }\n })\n}\n\n/**\n * Gives back a slot the provider did not use.\n *\n * Also a transaction, and also an absolute write from its own read: a\n * decrement computed from a stale figure would undo a slot another rep took\n * in the meantime, which is the same defect one direction over. Never\n * drives the counter below zero.\n *\n * **Never throws.** This runs on the failure path of a send that was\n * already refused, and the answer the rep gets is the provider's refusal; a\n * bookkeeping failure on top of it is logged, and its cost is one slot the\n * org does not get back until midnight — the safe direction.\n */\nexport async function releaseCrmEmailSend(\n firestore: FirebaseFirestore.Firestore,\n reservation: CrmEmailSendReservation | null | undefined,\n): Promise<void> {\n if (!reservation?.orgId || !reservation.day) return\n const ref = crmEmailUsageRefForDay(firestore, reservation.orgId, reservation.day)\n try {\n await firestore.runTransaction(async (tx) => {\n const used = crmEmailCount(await tx.get(ref))\n tx.set(\n ref,\n {\n count: Math.max(0, used - 1),\n day: reservation.day,\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n })\n } catch (error) {\n console.error('crm email reservation release failed', error)\n }\n}\n\n/*==========================================\n * THE LIST FIELDS, RESTAMPED FROM WHAT WAS STORED (AGL-3321).\n *\n * A CRM list queries fields derived from the record — its search tokens, a\n * lead's status and verdict keys, a contact's facet keys (`crmListFields`\n * in `@aglyn/aglyn`). A writer that holds the whole document spreads them;\n * a writer that PATCHED an input — a dotted facet field, an `arrayUnion` of\n * tags, a merged capture — cannot know the result without reading it, so\n * it restamps here after its own write: the record is read back in a\n * transaction and only the list fields it now carries wrongly are written.\n *\n * Never throws. A restamp is a consequence of a write that already landed,\n * and failing the caller would report the write as failed; a record left\n * stale is what the one-time backfill (`backfill-crm-list-fields.mjs`)\n * brings level, and the next write to it restamps it again.\n *=========================================*/\n\nexport interface CrmListFieldsRestamp {\n /** Records whose list fields were written. */\n restamped: number\n /** Records already current. */\n current: number\n /** Records that no longer exist. */\n missing: number\n}\n\n/** Restamp one record's list fields from the record as stored. */\nexport async function restampCrmListFieldsAt(\n ref: FirebaseFirestore.DocumentReference,\n collection: CrmListCollection,\n): Promise<'restamped' | 'current' | 'missing'> {\n try {\n return await ref.firestore.runTransaction(async (tx) => {\n const snapshot = await tx.get(ref)\n if (!snapshot.exists) return 'missing' as const\n const patch = crmListFieldsPatch(collection, snapshot.data() ?? {})\n if (!Object.keys(patch).length) return 'current' as const\n tx.update(ref, patch)\n return 'restamped' as const\n })\n } catch (error) {\n console.error(`[crm] list fields restamp failed for ${ref.path}`, error)\n return 'current'\n }\n}\n\n/** Restamp several records of one collection, a few at a time. */\nexport async function restampCrmListFieldsOf(\n refs: readonly FirebaseFirestore.DocumentReference[],\n collection: CrmListCollection,\n): Promise<CrmListFieldsRestamp> {\n const result: CrmListFieldsRestamp = { restamped: 0, current: 0, missing: 0 }\n const queue = [...new Map(refs.map((ref) => [ref.path, ref])).values()]\n // Each is its own small transaction on its own record.\n const worker = async () => {\n for (let ref = queue.shift(); ref !== undefined; ref = queue.shift()) {\n const outcome = await restampCrmListFieldsAt(ref, collection)\n result[outcome] += 1\n }\n }\n await Promise.all(Array.from({ length: Math.min(RESTAMP_CONCURRENCY, queue.length) }, worker))\n return result\n}\n\n/** Restamp several records of one collection under `orgs/{orgId}/`, by id. */\nexport async function restampCrmListFields(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n collection: CrmListCollection,\n ids: readonly string[],\n): Promise<CrmListFieldsRestamp> {\n const records = firestore.collection('orgs').doc(orgId).collection(collection)\n return restampCrmListFieldsOf(\n [...new Set(ids.filter(Boolean))].map((id) => records.doc(id)),\n collection,\n )\n}\n\n/** Records restamped at once by {@link restampCrmListFields}. */\nconst RESTAMP_CONCURRENCY = 8\n"],"names":["checkCrmEmailQuota","checkCrmRecordsQuota","CRM_COLLECTIONS","CRM_EMAIL_USAGE_COLLECTION","crmActivityCeilingLink","crmEmailUsageDayKey","crmListFieldsPatch","rampedDailyAllowance","FieldValue","countCrmRecords","orgRef","contacts","collection","contactsSnap","companiesSnap","dealsSnap","Promise","all","count","get","companies","deals","contactsCount","aggregateCount","companiesCount","dealsCount","crmRecordsCount","crmRecordsQuotaForOrg","org","counts","snapshot","value","Number","data","isFinite","Math","floor","countCrmActivitiesForRecord","link","lead","activities","where","field","id","crmEmailUsageRefForDay","firestore","orgId","day","doc","crmEmailUsageRef","now","Date","crmEmailCount","crmEmailsSentToday","rampedCrmEmailQuota","plan","ramp","included","allowed","used","remaining","max","reserveCrmEmailSend","ref","runTransaction","tx","quota","ok","set","updatedAt","serverTimestamp","merge","reservation","releaseCrmEmailSend","error","console","restampCrmListFieldsAt","exists","patch","Object","keys","length","update","path","restampCrmListFieldsOf","refs","result","restamped","current","missing","queue","Map","map","values","worker","shift","undefined","outcome","Array","from","min","RESTAMP_CONCURRENCY","restampCrmListFields","ids","records","Set","filter","Boolean"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/crm-records.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { AglynOrgBilling } from '@aglyn/aglyn/foundation'\nimport {\n checkCrmEmailQuota,\n checkCrmRecordsQuota,\n CRM_COLLECTIONS,\n CRM_EMAIL_USAGE_COLLECTION,\n type CrmActivityLink,\n crmActivityCeilingLink,\n type CrmEmailQuotaResult,\n crmEmailUsageDayKey,\n type CrmRecordsQuotaResult,\n} from '@aglyn/aglyn/server'\n/*\n * By module path, not the barrel: the capture door's specs substitute a\n * fixture barrel (see `upsert-contact.ts`), and the restamp is reached from\n * the door.\n */\nimport { type CrmListCollection, crmListFieldsPatch } from '@aglyn/aglyn/app-utils/crm'\nimport {\n type EmailRampVerdict,\n rampedDailyAllowance,\n} from '@aglyn/shared-util-email'\nimport { notifyPluginRecordWritten } from '@aglyn/aglyn/plugin-manager/plugin-record-written'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/**\n * The CRM RECORDS band's measurement (AGL-2611): the three collections it\n * counts, and their sum.\n *\n * One shape for every server that asks, because the band is enforced at six\n * doors — contact capture, the console's company and deal drawers (through\n * the client twin of this read), the CSV import, the lead conversion and the\n * two REST creates — and the monthly rollup bills from the same three\n * aggregates. A door that counted contacts alone would admit a company on a\n * band the rollup had already found full.\n */\nexport interface CrmRecordsCount {\n contactsCount: number\n companiesCount: number\n dealsCount: number\n /** The band's own figure: the three above, summed. */\n crmRecordsCount: number\n}\n\n/**\n * Count the records band for one org: three `count()` aggregates, in\n * parallel, no document reads.\n *\n * `contacts` may be handed in by a caller that already holds the collection\n * reference — `upsertHostContact` resolves it through `orgDataCollection`\n * before it decides anything — so the read is the same one whichever door\n * it came through, and the org reference is needed only for the two\n * collections the hub added.\n */\nexport async function countCrmRecords(\n orgRef: FirebaseFirestore.DocumentReference,\n contacts: FirebaseFirestore.CollectionReference = orgRef.collection('contacts'),\n): Promise<CrmRecordsCount> {\n const [contactsSnap, companiesSnap, dealsSnap] = await Promise.all([\n contacts.count().get(),\n orgRef.collection(CRM_COLLECTIONS.companies).count().get(),\n orgRef.collection(CRM_COLLECTIONS.deals).count().get(),\n ])\n const contactsCount = aggregateCount(contactsSnap)\n const companiesCount = aggregateCount(companiesSnap)\n const dealsCount = aggregateCount(dealsSnap)\n return {\n contactsCount,\n companiesCount,\n dealsCount,\n crmRecordsCount: contactsCount + companiesCount + dealsCount,\n }\n}\n\n/**\n * The band's verdict for one org, measured now — the read every create\n * door takes before it writes, so the six doors cannot disagree about\n * whether there is room. `checkCrmRecordsQuota` answers from the org doc\n * alone; this pairs it with the measurement.\n */\nexport async function crmRecordsQuotaForOrg(\n org: Partial<AglynOrgBilling> | null | undefined,\n orgRef: FirebaseFirestore.DocumentReference,\n contacts?: FirebaseFirestore.CollectionReference,\n): Promise<CrmRecordsQuotaResult & CrmRecordsCount> {\n const counts = await countCrmRecords(orgRef, contacts)\n return { ...checkCrmRecordsQuota(org, counts.crmRecordsCount), ...counts }\n}\n\n/** `count()` answers through `.data().count`; a missing figure is zero, never NaN. */\nfunction aggregateCount(snapshot: { data(): { count?: unknown } }): number {\n const value = Number(snapshot.data()?.count ?? 0)\n return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0\n}\n\n/**\n * How many activities one record already carries — one aggregate on a\n * single-field equality, which Firestore indexes on its own, so this needs\n * no composite index and cannot go FAILED_PRECONDITION in production.\n */\nexport async function countCrmActivitiesForRecord(\n orgRef: FirebaseFirestore.DocumentReference,\n link: CrmActivityLink,\n): Promise<number> {\n const lead = crmActivityCeilingLink(link)\n if (!lead) return 0\n const snapshot = await orgRef\n .collection(CRM_COLLECTIONS.activities)\n .where(lead.field, '==', lead.id)\n .count()\n .get()\n return aggregateCount(snapshot)\n}\n\n/**\n * `orgs/{orgId}/crmEmailUsage/{day}` — one UTC day's one-to-one email\n * counter, the document `checkCrmEmailQuota` is enforced against and the\n * billing page reads.\n */\nfunction crmEmailUsageRefForDay(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n day: string,\n): FirebaseFirestore.DocumentReference {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_EMAIL_USAGE_COLLECTION)\n .doc(day)\n}\n\n/** Today's counter document — {@link crmEmailUsageRefForDay} keyed by `now`. */\nexport function crmEmailUsageRef(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n now: Date = new Date(),\n): FirebaseFirestore.DocumentReference {\n return crmEmailUsageRefForDay(firestore, orgId, crmEmailUsageDayKey(now))\n}\n\n/**\n * The counter a day-document holds, clamped.\n *\n * A malformed or absent counter reads as ZERO SENT, which is the permissive\n * direction — deliberately, and the same clamp `checkApiRequestQuota`'s\n * reader applies: a counter that cannot be read must not refuse a rep who\n * has sent nothing, and the cap is a pace rather than a cost.\n */\nfunction crmEmailCount(snapshot: { get(field: string): unknown }): number {\n const value = Number(snapshot.get('count') ?? 0)\n return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0\n}\n\n/**\n * How many one-to-one emails the org has sent today, for a meter — never\n * for the cap, which {@link reserveCrmEmailSend} judges inside the\n * transaction that also takes the slot.\n */\nexport async function crmEmailsSentToday(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n now: Date = new Date(),\n): Promise<number> {\n return crmEmailCount(await crmEmailUsageRef(firestore, orgId, now).get())\n}\n\n/** One slot on one day's counter, held from the reservation to the send. */\nexport interface CrmEmailSendReservation {\n orgId: string\n /**\n * The UTC day the slot was taken on, so a release after midnight gives\n * the slot back to the day that lent it rather than to the new day.\n */\n day: string\n}\n\nexport type ReserveCrmEmailSendResult =\n | { ok: true; reservation: CrmEmailSendReservation; quota: CrmEmailQuotaResult }\n | { ok: false; quota: CrmEmailQuotaResult }\n\n/**\n * The plan's day, narrowed to the share of it a young workspace has earned.\n *\n * The new-sender ramp reaches the one-to-one path here and only here, so the\n * ceiling the transaction below judges against is one value with one origin.\n * The alternative — a full-allowance reservation with a ramp consulted beside\n * it — is two ceilings for one decision, and under two concurrent senders it\n * admits against whichever of them was read first.\n *\n * `included` is the only field the ramp moves; `resetsAt` is the day's, which\n * a share does not change, and `used` is what the transaction read.\n */\nfunction rampedCrmEmailQuota(\n plan: CrmEmailQuotaResult,\n ramp: EmailRampVerdict | null | undefined,\n): CrmEmailQuotaResult {\n const included = rampedDailyAllowance(plan.included, ramp)\n if (included >= plan.included) return plan\n return {\n ...plan,\n included,\n allowed: plan.used < included,\n remaining: Math.max(0, included - plan.used),\n }\n}\n\n/**\n * Claims ONE one-to-one send against today's cap, ATOMICALLY (AGL-2645).\n *\n * The counter used to be read before the send and incremented after\n * delivery, so two reps sending in the same window both passed the same\n * reading and both sent — `CRM_EMAIL_SENDS_PER_MINUTE` past the cap per\n * sender, in exactly the conditions the cap exists for. **A read-then-write\n * cap is not a cap**, the lesson `reserveCampaignEmailSends` learned for\n * campaigns; this is the same shape on the day counter.\n *\n * The transaction reads the counter, judges it with `checkCrmEmailQuota` —\n * the real plan table, `UNLIMITED` on Enterprise, zero on a tier with no\n * suite — and writes an ABSOLUTE value derived from that read, deliberately\n * not `FieldValue.increment`. Firestore aborts and re-runs the callback\n * when a document the transaction read has moved, so a second sender that\n * starts inside the first one's window re-reads the raised figure and is\n * refused. An increment would be atomic on the number and useless for the\n * decision, because the decision is made from a value the write never\n * proves it still held.\n *\n * A refused reservation writes NOTHING. A taken one is the count the cap\n * enforces and the billing page shows, from the moment it is taken: a send\n * the provider then refuses hands the slot back through\n * {@link releaseCrmEmailSend}, and a process that dies between the two\n * leaves the org one send short for the rest of the UTC day — conservative\n * in the direction a cap cares about, and healed at midnight, because each\n * day is its own document.\n *\n * ## The new-sender ramp rides the same read\n *\n * `ramp` narrows the plan's day to the share of it a young workspace has\n * earned, INSIDE the transaction and before the counter is judged — see\n * {@link rampedCrmEmailQuota}. Applying it here rather than in the caller is\n * the whole of what makes it safe: the ceiling and the count it is compared\n * against come out of one read, so the retry Firestore forces on a losing\n * transaction re-derives both. A ramp checked outside would be a second\n * ceiling, decided from a snapshot the write never proves it still held —\n * the defect this reservation was built to remove, one field over.\n *\n * The ramp is optional and an absent one is not a throttle: a caller that\n * did not resolve one gets the plan's day, which is what every surface but\n * the send path wants.\n */\nexport async function reserveCrmEmailSend(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n org: Partial<AglynOrgBilling> | null | undefined,\n now: Date = new Date(),\n ramp?: EmailRampVerdict | null,\n): Promise<ReserveCrmEmailSendResult> {\n const day = crmEmailUsageDayKey(now)\n const ref = crmEmailUsageRefForDay(firestore, orgId, day)\n return firestore.runTransaction(async (tx) => {\n const quota = rampedCrmEmailQuota(\n checkCrmEmailQuota(org, crmEmailCount(await tx.get(ref)), now),\n ramp,\n )\n if (!quota.allowed) return { ok: false, quota }\n tx.set(\n ref,\n { count: quota.used + 1, day, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n return { ok: true, reservation: { orgId, day }, quota }\n })\n}\n\n/**\n * Gives back a slot the provider did not use.\n *\n * Also a transaction, and also an absolute write from its own read: a\n * decrement computed from a stale figure would undo a slot another rep took\n * in the meantime, which is the same defect one direction over. Never\n * drives the counter below zero.\n *\n * **Never throws.** This runs on the failure path of a send that was\n * already refused, and the answer the rep gets is the provider's refusal; a\n * bookkeeping failure on top of it is logged, and its cost is one slot the\n * org does not get back until midnight — the safe direction.\n */\nexport async function releaseCrmEmailSend(\n firestore: FirebaseFirestore.Firestore,\n reservation: CrmEmailSendReservation | null | undefined,\n): Promise<void> {\n if (!reservation?.orgId || !reservation.day) return\n const ref = crmEmailUsageRefForDay(firestore, reservation.orgId, reservation.day)\n try {\n await firestore.runTransaction(async (tx) => {\n const used = crmEmailCount(await tx.get(ref))\n tx.set(\n ref,\n {\n count: Math.max(0, used - 1),\n day: reservation.day,\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n })\n } catch (error) {\n console.error('crm email reservation release failed', error)\n }\n}\n\n/*==========================================\n * THE LIST FIELDS, RESTAMPED FROM WHAT WAS STORED (AGL-3321).\n *\n * A CRM list queries fields derived from the record — its search tokens, a\n * lead's status and verdict keys, a contact's facet keys (`crmListFields`\n * in `@aglyn/aglyn`). A writer that holds the whole document spreads them;\n * a writer that PATCHED an input — a dotted facet field, an `arrayUnion` of\n * tags, a merged capture — cannot know the result without reading it, so\n * it restamps here after its own write: the record is read back in a\n * transaction and only the list fields it now carries wrongly are written.\n *\n * Never throws. A restamp is a consequence of a write that already landed,\n * and failing the caller would report the write as failed; a record left\n * stale is what the one-time backfill (`backfill-crm-list-fields.mjs`)\n * brings level, and the next write to it restamps it again.\n *=========================================*/\n\nexport interface CrmListFieldsRestamp {\n /** Records whose list fields were written. */\n restamped: number\n /** Records already current. */\n current: number\n /** Records that no longer exist. */\n missing: number\n}\n\n/**\n * Restamp one record's list fields from the record as stored.\n *\n * Then tell the plugins a record was written (AGL-3336): every server writer\n * of an org record passes here after its write, so this is the one place a\n * plugin that acts on the record AFTER any write — the CRM's sharing rules —\n * is told, without an edit to each writer. Not for a record that no longer\n * exists, and after the restamp's own transaction, never inside it.\n */\nexport async function restampCrmListFieldsAt(\n ref: FirebaseFirestore.DocumentReference,\n collection: CrmListCollection,\n): Promise<'restamped' | 'current' | 'missing'> {\n let outcome: 'restamped' | 'current' | 'missing'\n try {\n outcome = await ref.firestore.runTransaction(async (tx) => {\n const snapshot = await tx.get(ref)\n if (!snapshot.exists) return 'missing' as const\n const patch = crmListFieldsPatch(collection, snapshot.data() ?? {})\n if (!Object.keys(patch).length) return 'current' as const\n tx.update(ref, patch)\n return 'restamped' as const\n })\n } catch (error) {\n console.error(`[crm] list fields restamp failed for ${ref.path}`, error)\n return 'current'\n }\n if (outcome !== 'missing') {\n await notifyPluginRecordWritten({ path: ref.path, collection })\n }\n return outcome\n}\n\n/** Restamp several records of one collection, a few at a time. */\nexport async function restampCrmListFieldsOf(\n refs: readonly FirebaseFirestore.DocumentReference[],\n collection: CrmListCollection,\n): Promise<CrmListFieldsRestamp> {\n const result: CrmListFieldsRestamp = { restamped: 0, current: 0, missing: 0 }\n const queue = [...new Map(refs.map((ref) => [ref.path, ref])).values()]\n // Each is its own small transaction on its own record.\n const worker = async () => {\n for (let ref = queue.shift(); ref !== undefined; ref = queue.shift()) {\n const outcome = await restampCrmListFieldsAt(ref, collection)\n result[outcome] += 1\n }\n }\n await Promise.all(Array.from({ length: Math.min(RESTAMP_CONCURRENCY, queue.length) }, worker))\n return result\n}\n\n/** Restamp several records of one collection under `orgs/{orgId}/`, by id. */\nexport async function restampCrmListFields(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n collection: CrmListCollection,\n ids: readonly string[],\n): Promise<CrmListFieldsRestamp> {\n const records = firestore.collection('orgs').doc(orgId).collection(collection)\n return restampCrmListFieldsOf(\n [...new Set(ids.filter(Boolean))].map((id) => records.doc(id)),\n collection,\n )\n}\n\n/** Records restamped at once by {@link restampCrmListFields}. */\nconst RESTAMP_CONCURRENCY = 8\n"],"names":["checkCrmEmailQuota","checkCrmRecordsQuota","CRM_COLLECTIONS","CRM_EMAIL_USAGE_COLLECTION","crmActivityCeilingLink","crmEmailUsageDayKey","crmListFieldsPatch","rampedDailyAllowance","notifyPluginRecordWritten","FieldValue","countCrmRecords","orgRef","contacts","collection","contactsSnap","companiesSnap","dealsSnap","Promise","all","count","get","companies","deals","contactsCount","aggregateCount","companiesCount","dealsCount","crmRecordsCount","crmRecordsQuotaForOrg","org","counts","snapshot","value","Number","data","isFinite","Math","floor","countCrmActivitiesForRecord","link","lead","activities","where","field","id","crmEmailUsageRefForDay","firestore","orgId","day","doc","crmEmailUsageRef","now","Date","crmEmailCount","crmEmailsSentToday","rampedCrmEmailQuota","plan","ramp","included","allowed","used","remaining","max","reserveCrmEmailSend","ref","runTransaction","tx","quota","ok","set","updatedAt","serverTimestamp","merge","reservation","releaseCrmEmailSend","error","console","restampCrmListFieldsAt","outcome","exists","patch","Object","keys","length","update","path","restampCrmListFieldsOf","refs","result","restamped","current","missing","queue","Map","map","values","worker","shift","undefined","Array","from","min","RESTAMP_CONCURRENCY","restampCrmListFields","ids","records","Set","filter","Boolean"],"mappings":"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"}
|
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
export declare function isCronAuthorized(headers: Partial<Record<string, string>>): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Is this scheduled-route invocation a DRY RUN? (AGL-2084)
|
|
20
|
+
*
|
|
21
|
+
* `reap-plugin-artifacts` and `reverify-plugin-versions` each chose this rule
|
|
22
|
+
* deliberately and each spelled it out again inline, and `audit-archive` — the
|
|
23
|
+
* route that DELETES Firestore audit rows — was the copy that never got
|
|
24
|
+
* written. Four transcriptions of four lines is how the guard goes missing, so
|
|
25
|
+
* it lives here once and the routes call it.
|
|
26
|
+
*
|
|
27
|
+
* The rule:
|
|
28
|
+
* - An explicit `dryRun` wins, from the body or the query string, in BOTH
|
|
29
|
+
* directions: `?dryRun=0` on a GET writes, `{ dryRun: true }` on a POST
|
|
30
|
+
* does not. `'0'`, `'false'` and `false` are the only ways to turn it off,
|
|
31
|
+
* matching what the two original routes accepted.
|
|
32
|
+
* - With no `dryRun` at all the METHOD decides: a GET reports, a POST acts.
|
|
33
|
+
*
|
|
34
|
+
* Keying the default on the method rather than on the body being absent is the
|
|
35
|
+
* load-bearing half. `scheduled-crons.yml` fires every one of these routes with
|
|
36
|
+
* a bodyless POST, so defaulting an absent `dryRun` to true would silently stop
|
|
37
|
+
* the archival, the reaping and the re-verification — a worse failure than the
|
|
38
|
+
* one this guard exists to prevent, and an invisible one.
|
|
39
|
+
*/
|
|
40
|
+
export declare function isCronDryRun(request: {
|
|
41
|
+
method: string;
|
|
42
|
+
body?: unknown;
|
|
43
|
+
query?: Record<string, string | string[] | undefined>;
|
|
44
|
+
}): boolean;
|
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
* Authorizes a scheduled-route invocation (AGL-489), for every scheduled
|
|
18
|
+
* route the console serves: its own, and a plugin's served through the
|
|
19
|
+
* plugin API dispatcher (AGL-3080), which is why it lives in the data layer
|
|
20
|
+
* rather than in the app. Accepts either caller:
|
|
21
|
+
* - **Vercel Cron** — invokes the path with a GET and an auto-injected
|
|
22
|
+
* `Authorization: Bearer $CRON_SECRET` header (set when CRON_SECRET is
|
|
23
|
+
* configured on the project). This is how `apps/console/vercel.json`
|
|
24
|
+
* drives these routes in production.
|
|
25
|
+
* - **External scheduler** — a POST carrying `x-cron-secret: $CRON_SECRET`
|
|
26
|
+
* (the original contract, kept for GitHub Actions / manual runs).
|
|
27
|
+
*
|
|
28
|
+
* Returns false when CRON_SECRET is unset so an unconfigured deploy can't be
|
|
29
|
+
* triggered.
|
|
30
|
+
*/ import { timingSafeEqual } from "crypto";
|
|
31
|
+
// Constant-time string equality (AGL-512) so secret checks don't leak via
|
|
32
|
+
// response-timing. Length is compared first (not itself secret).
|
|
33
|
+
function safeEqual(a, b) {
|
|
34
|
+
const ab = Buffer.from(a);
|
|
35
|
+
const bb = Buffer.from(b);
|
|
36
|
+
if (ab.length !== bb.length) return false;
|
|
37
|
+
return timingSafeEqual(new Uint8Array(ab), new Uint8Array(bb));
|
|
38
|
+
}
|
|
39
|
+
export function isCronAuthorized(headers) {
|
|
40
|
+
var _headers_authorization, _headers_xcronsecret;
|
|
41
|
+
const secret = process.env.CRON_SECRET;
|
|
42
|
+
if (!secret) return false;
|
|
43
|
+
if (safeEqual((_headers_authorization = headers.authorization) != null ? _headers_authorization : '', `Bearer ${secret}`)) return true;
|
|
44
|
+
if (safeEqual((_headers_xcronsecret = headers['x-cron-secret']) != null ? _headers_xcronsecret : '', secret)) return true;
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Is this scheduled-route invocation a DRY RUN? (AGL-2084)
|
|
49
|
+
*
|
|
50
|
+
* `reap-plugin-artifacts` and `reverify-plugin-versions` each chose this rule
|
|
51
|
+
* deliberately and each spelled it out again inline, and `audit-archive` — the
|
|
52
|
+
* route that DELETES Firestore audit rows — was the copy that never got
|
|
53
|
+
* written. Four transcriptions of four lines is how the guard goes missing, so
|
|
54
|
+
* it lives here once and the routes call it.
|
|
55
|
+
*
|
|
56
|
+
* The rule:
|
|
57
|
+
* - An explicit `dryRun` wins, from the body or the query string, in BOTH
|
|
58
|
+
* directions: `?dryRun=0` on a GET writes, `{ dryRun: true }` on a POST
|
|
59
|
+
* does not. `'0'`, `'false'` and `false` are the only ways to turn it off,
|
|
60
|
+
* matching what the two original routes accepted.
|
|
61
|
+
* - With no `dryRun` at all the METHOD decides: a GET reports, a POST acts.
|
|
62
|
+
*
|
|
63
|
+
* Keying the default on the method rather than on the body being absent is the
|
|
64
|
+
* load-bearing half. `scheduled-crons.yml` fires every one of these routes with
|
|
65
|
+
* a bodyless POST, so defaulting an absent `dryRun` to true would silently stop
|
|
66
|
+
* the archival, the reaping and the re-verification — a worse failure than the
|
|
67
|
+
* one this guard exists to prevent, and an invisible one.
|
|
68
|
+
*/ export function isCronDryRun(request) {
|
|
69
|
+
var _ref;
|
|
70
|
+
var _request_body, _request_query;
|
|
71
|
+
const requested = (_ref = (_request_body = request.body) == null ? void 0 : _request_body.dryRun) != null ? _ref : (_request_query = request.query) == null ? void 0 : _request_query['dryRun'];
|
|
72
|
+
if (requested === undefined) return request.method === 'GET';
|
|
73
|
+
return requested !== '0' && requested !== 'false' && requested !== false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
//# sourceMappingURL=cron-auth.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/cron-auth.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 * Authorizes a scheduled-route invocation (AGL-489), for every scheduled\n * route the console serves: its own, and a plugin's served through the\n * plugin API dispatcher (AGL-3080), which is why it lives in the data layer\n * rather than in the app. Accepts either caller:\n * - **Vercel Cron** — invokes the path with a GET and an auto-injected\n * `Authorization: Bearer $CRON_SECRET` header (set when CRON_SECRET is\n * configured on the project). This is how `apps/console/vercel.json`\n * drives these routes in production.\n * - **External scheduler** — a POST carrying `x-cron-secret: $CRON_SECRET`\n * (the original contract, kept for GitHub Actions / manual runs).\n *\n * Returns false when CRON_SECRET is unset so an unconfigured deploy can't be\n * triggered.\n */\nimport { timingSafeEqual } from 'crypto'\n\n// Constant-time string equality (AGL-512) so secret checks don't leak via\n// response-timing. Length is compared first (not itself secret).\nfunction safeEqual(a: string, b: string): boolean {\n const ab = Buffer.from(a)\n const bb = Buffer.from(b)\n if (ab.length !== bb.length) return false\n return timingSafeEqual(new Uint8Array(ab), new Uint8Array(bb))\n}\n\nexport function isCronAuthorized(\n headers: Partial<Record<string, string>>,\n): boolean {\n const secret = process.env.CRON_SECRET\n if (!secret) return false\n if (safeEqual(headers.authorization ?? '', `Bearer ${secret}`)) return true\n if (safeEqual(headers['x-cron-secret'] ?? '', secret)) return true\n return false\n}\n\n/**\n * Is this scheduled-route invocation a DRY RUN? (AGL-2084)\n *\n * `reap-plugin-artifacts` and `reverify-plugin-versions` each chose this rule\n * deliberately and each spelled it out again inline, and `audit-archive` — the\n * route that DELETES Firestore audit rows — was the copy that never got\n * written. Four transcriptions of four lines is how the guard goes missing, so\n * it lives here once and the routes call it.\n *\n * The rule:\n * - An explicit `dryRun` wins, from the body or the query string, in BOTH\n * directions: `?dryRun=0` on a GET writes, `{ dryRun: true }` on a POST\n * does not. `'0'`, `'false'` and `false` are the only ways to turn it off,\n * matching what the two original routes accepted.\n * - With no `dryRun` at all the METHOD decides: a GET reports, a POST acts.\n *\n * Keying the default on the method rather than on the body being absent is the\n * load-bearing half. `scheduled-crons.yml` fires every one of these routes with\n * a bodyless POST, so defaulting an absent `dryRun` to true would silently stop\n * the archival, the reaping and the re-verification — a worse failure than the\n * one this guard exists to prevent, and an invisible one.\n */\nexport function isCronDryRun(request: {\n method: string\n body?: unknown\n query?: Record<string, string | string[] | undefined>\n}): boolean {\n const requested =\n (request.body as { dryRun?: unknown } | undefined)?.dryRun ??\n request.query?.['dryRun']\n if (requested === undefined) return request.method === 'GET'\n return requested !== '0' && requested !== 'false' && requested !== false\n}\n"],"names":["timingSafeEqual","safeEqual","a","b","ab","Buffer","from","bb","length","Uint8Array","isCronAuthorized","headers","secret","process","env","CRON_SECRET","authorization","isCronDryRun","request","requested","body","dryRun","query","undefined","method"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;CAcC,GACD,SAASA,eAAe,QAAQ,SAAQ;AAExC,0EAA0E;AAC1E,iEAAiE;AACjE,SAASC,UAAUC,CAAS,EAAEC,CAAS;IACrC,MAAMC,KAAKC,OAAOC,IAAI,CAACJ;IACvB,MAAMK,KAAKF,OAAOC,IAAI,CAACH;IACvB,IAAIC,GAAGI,MAAM,KAAKD,GAAGC,MAAM,EAAE,OAAO;IACpC,OAAOR,gBAAgB,IAAIS,WAAWL,KAAK,IAAIK,WAAWF;AAC5D;AAEA,OAAO,SAASG,iBACdC,OAAwC;QAI1BA,wBACAA;IAHd,MAAMC,SAASC,QAAQC,GAAG,CAACC,WAAW;IACtC,IAAI,CAACH,QAAQ,OAAO;IACpB,IAAIX,WAAUU,yBAAAA,QAAQK,aAAa,YAArBL,yBAAyB,IAAI,CAAC,OAAO,EAAEC,QAAQ,GAAG,OAAO;IACvE,IAAIX,WAAUU,uBAAAA,OAAO,CAAC,gBAAgB,YAAxBA,uBAA4B,IAAIC,SAAS,OAAO;IAC9D,OAAO;AACT;AAEA;;;;;;;;;;;;;;;;;;;;;CAqBC,GACD,OAAO,SAASK,aAAaC,OAI5B;;QAEIA,eACDA;IAFF,MAAMC,qBACHD,gBAAAA,QAAQE,IAAI,qBAAb,AAACF,cAAmDG,MAAM,oBAC1DH,iBAAAA,QAAQI,KAAK,qBAAbJ,cAAe,CAAC,SAAS;IAC3B,IAAIC,cAAcI,WAAW,OAAOL,QAAQM,MAAM,KAAK;IACvD,OAAOL,cAAc,OAAOA,cAAc,WAAWA,cAAc;AACrE"}
|