@aglyn/tenant-data-admin 1.0.0-beta.217 → 1.0.0-beta.219
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -9
- package/src/lib/server/account-lock-mail.d.ts +74 -0
- package/src/lib/server/account-lock-mail.js +139 -0
- package/src/lib/server/account-lock-mail.js.map +1 -0
- package/src/lib/server/account-mail.d.ts +41 -0
- package/src/lib/server/account-mail.js +52 -0
- package/src/lib/server/account-mail.js.map +1 -0
- package/src/lib/server/api-v1-description.d.ts +112 -0
- package/src/lib/server/api-v1-description.js +126 -0
- package/src/lib/server/api-v1-description.js.map +1 -0
- package/src/lib/server/api-v1-kit.d.ts +156 -0
- package/src/lib/server/api-v1-kit.js +191 -0
- package/src/lib/server/api-v1-kit.js.map +1 -0
- package/src/lib/server/api-v1-resources.d.ts +144 -0
- package/src/lib/server/api-v1-resources.js +126 -0
- package/src/lib/server/api-v1-resources.js.map +1 -0
- package/src/lib/server/consent-group-change.d.ts +2 -1
- package/src/lib/server/consent-group-change.js +41 -16
- package/src/lib/server/consent-group-change.js.map +1 -1
- package/src/lib/server/contact-email-index.d.ts +22 -0
- package/src/lib/server/contact-email-index.js +108 -0
- package/src/lib/server/contact-email-index.js.map +1 -1
- package/src/lib/server/crm-email-activity.d.ts +1 -1
- package/src/lib/server/crm-email-activity.js +1 -1
- package/src/lib/server/crm-email-activity.js.map +1 -1
- package/src/lib/server/crm-inbound-email.d.ts +23 -0
- package/src/lib/server/crm-inbound-email.js +1 -16
- package/src/lib/server/crm-inbound-email.js.map +1 -1
- package/src/lib/server/crm-records.d.ts +9 -1
- package/src/lib/server/crm-records.js +19 -2
- package/src/lib/server/crm-records.js.map +1 -1
- package/src/lib/server/cron-auth.d.ts +44 -0
- package/src/lib/server/cron-auth.js +76 -0
- package/src/lib/server/cron-auth.js.map +1 -0
- package/src/lib/server/dataset-live-pages.js +6 -6
- package/src/lib/server/dataset-live-pages.js.map +1 -1
- package/src/lib/server/duplicate-resource.d.ts +26 -0
- package/src/lib/server/duplicate-resource.js +47 -25
- package/src/lib/server/duplicate-resource.js.map +1 -1
- package/src/lib/server/dynamic-list-materialize.js +4 -1
- package/src/lib/server/dynamic-list-materialize.js.map +1 -1
- package/src/lib/server/email-deliverability.js +21 -6
- package/src/lib/server/email-deliverability.js.map +1 -1
- package/src/lib/server/email-suppression.d.ts +83 -3
- package/src/lib/server/email-suppression.js +229 -3
- 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/lockdown.js +2 -0
- package/src/lib/server/lockdown.js.map +1 -1
- package/src/lib/server/media-download-tokens.js +2 -1
- package/src/lib/server/media-download-tokens.js.map +1 -1
- package/src/lib/server/member-email-aliases.d.ts +1 -1
- package/src/lib/server/member-email-aliases.js +1 -1
- package/src/lib/server/member-email-aliases.js.map +1 -1
- package/src/lib/server/new-account-announcement.d.ts +62 -0
- package/src/lib/server/new-account-announcement.js +77 -0
- package/src/lib/server/new-account-announcement.js.map +1 -0
- package/src/lib/server/notifications.d.ts +8 -2
- package/src/lib/server/notifications.js +18 -4
- package/src/lib/server/notifications.js.map +1 -1
- package/src/lib/server/org-member-notice.js +5 -1
- package/src/lib/server/org-member-notice.js.map +1 -1
- package/src/lib/server/organizations.js +4 -0
- package/src/lib/server/organizations.js.map +1 -1
- package/src/lib/server/{connect-payout-failure.d.ts → payment-provider-stripe-connect-payouts.d.ts} +3 -0
- package/src/lib/server/{connect-payout-failure.js → payment-provider-stripe-connect-payouts.js} +1 -1
- package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -0
- package/src/lib/server/{connect-account-status.d.ts → payment-provider-stripe-connect-status.d.ts} +3 -0
- package/src/lib/server/{connect-account-status.js → payment-provider-stripe-connect-status.js} +1 -1
- package/src/lib/server/payment-provider-stripe-connect-status.js.map +1 -0
- package/src/lib/server/{stripe-account-mode.d.ts → payment-provider-stripe-connect.d.ts} +45 -70
- package/src/lib/server/{stripe-account-mode.js → payment-provider-stripe-connect.js} +78 -52
- package/src/lib/server/payment-provider-stripe-connect.js.map +1 -0
- package/src/lib/server/payment-provider.d.ts +173 -0
- package/src/lib/server/payment-provider.js +70 -0
- package/src/lib/server/payment-provider.js.map +1 -0
- package/src/lib/server/personal-data-export.js +1 -1
- package/src/lib/server/personal-data-export.js.map +1 -1
- package/src/lib/server/rate-limit-store.js +13 -3
- package/src/lib/server/rate-limit-store.js.map +1 -1
- package/src/lib/server/realm-plugins.js +8 -3
- package/src/lib/server/realm-plugins.js.map +1 -1
- package/src/lib/server/retained-refusals.d.ts +142 -0
- package/src/lib/server/retained-refusals.js +325 -0
- package/src/lib/server/retained-refusals.js.map +1 -0
- package/src/lib/server/risk-notice.js +21 -3
- package/src/lib/server/risk-notice.js.map +1 -1
- package/src/lib/server/serve-media-cdn.js +2 -2
- package/src/lib/server/serve-media-cdn.js.map +1 -1
- package/src/lib/server/serve-plugin-fetch.d.ts +17 -7
- package/src/lib/server/serve-plugin-fetch.js +19 -8
- package/src/lib/server/serve-plugin-fetch.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/upsert-contact.d.ts +46 -1
- package/src/lib/server/upsert-contact.js +85 -18
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/tenancy.d.ts +4 -5
- package/src/tenancy.js +11 -6
- package/src/tenancy.js.map +1 -1
- package/src/lib/server/connect-account-status.js.map +0 -1
- package/src/lib/server/connect-payout-failure.js.map +0 -1
- package/src/lib/server/contact-lifecycle-floor.d.ts +0 -89
- package/src/lib/server/contact-lifecycle-floor.js +0 -91
- package/src/lib/server/contact-lifecycle-floor.js.map +0 -1
- package/src/lib/server/contact-merge.d.ts +0 -55
- package/src/lib/server/contact-merge.js +0 -288
- package/src/lib/server/contact-merge.js.map +0 -1
- package/src/lib/server/form-dataset-binding-token.d.ts +0 -35
- package/src/lib/server/form-dataset-binding-token.js +0 -107
- package/src/lib/server/form-dataset-binding-token.js.map +0 -1
- package/src/lib/server/stripe-account-mode.js.map +0 -1
|
@@ -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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"}
|
|
@@ -32,6 +32,29 @@ import type { ReceivedEmail } from '@aglyn/shared-util-email';
|
|
|
32
32
|
* `loadCrmInboundRoster`, and the caller writes the org's feed line, so a
|
|
33
33
|
* spec of this module needs a store and nothing else.
|
|
34
34
|
*/
|
|
35
|
+
/**
|
|
36
|
+
* The workspace's email capture address, as the org document carries it
|
|
37
|
+
* under `crmInbound` (AGL-2657): `crm+<token>@<capture domain>`.
|
|
38
|
+
*
|
|
39
|
+
* One token per organization, minted lazily by `crm/inbound-address` the
|
|
40
|
+
* first time a member asks for the address, and replaced by the same route
|
|
41
|
+
* on a rotation. Every site of the organization shares it: a captured
|
|
42
|
+
* message is filed on the record that matches its correspondent, and the
|
|
43
|
+
* record says which site it belongs to, so the address need not.
|
|
44
|
+
*
|
|
45
|
+
* The CRM's field, not the platform's: the organization type does not name
|
|
46
|
+
* it, and this module is its only reader and writer. Server-owned and
|
|
47
|
+
* member-readable: the token IS the address, so a client that could write
|
|
48
|
+
* it could point another workspace's mail here.
|
|
49
|
+
*/
|
|
50
|
+
export interface OrgCrmInbound {
|
|
51
|
+
/** The url-safe secret in the local part; `crm+<token>@…`. */
|
|
52
|
+
token: string;
|
|
53
|
+
/** When the first token was minted, epoch ms. */
|
|
54
|
+
createdAtMs: number;
|
|
55
|
+
/** When the token was last replaced, epoch ms; absent until it has been. */
|
|
56
|
+
rotatedAtMs?: number;
|
|
57
|
+
}
|
|
35
58
|
/** The most candidate addresses one message is matched by. */
|
|
36
59
|
export declare const CRM_INBOUND_CANDIDATE_MAX = 6;
|
|
37
60
|
/** The most sites whose leads are consulted for one message. */
|
|
@@ -20,22 +20,7 @@ import { visibleToHost } from "@aglyn/aglyn/app-utils/scope-tokens";
|
|
|
20
20
|
import { findContactByEmail } from "./contact-email-index.js";
|
|
21
21
|
import { createCrmEmailActivity, crmCapturedEmailActivityRef, newCrmActivityRef } from "./crm-email-activity.js";
|
|
22
22
|
import { countCrmActivitiesForRecord } from "./crm-records.js";
|
|
23
|
-
/**
|
|
24
|
-
* EMAIL CAPTURE (AGL-2657): the half with Firestore.
|
|
25
|
-
*
|
|
26
|
-
* The org's capture token — minted once, replaced on a rotation, found
|
|
27
|
-
* again from a message's recipient — and the filing of one received
|
|
28
|
-
* message: which record it was with, whether it has been filed before, and
|
|
29
|
-
* the row it becomes. The pure rules are `@aglyn/aglyn`'s
|
|
30
|
-
* `crm-inbound.ts`; this module is the reads and the one write.
|
|
31
|
-
*
|
|
32
|
-
* Firestore is a parameter throughout, as in `crm-records.ts` and
|
|
33
|
-
* `crm-email-activity.ts`: the routes hold one, and a module that reached
|
|
34
|
-
* for the Admin app itself would drag it into every spec that only wants
|
|
35
|
-
* the matching. The roster is read through that same store by
|
|
36
|
-
* `loadCrmInboundRoster`, and the caller writes the org's feed line, so a
|
|
37
|
-
* spec of this module needs a store and nothing else.
|
|
38
|
-
*/ /** The most candidate addresses one message is matched by. */ export const CRM_INBOUND_CANDIDATE_MAX = 6;
|
|
23
|
+
/** The most candidate addresses one message is matched by. */ export const CRM_INBOUND_CANDIDATE_MAX = 6;
|
|
39
24
|
/** The most sites whose leads are consulted for one message. */ export const CRM_INBOUND_LEAD_HOST_MAX = 50;
|
|
40
25
|
/**
|
|
41
26
|
* The org's capture token, minted the first time anybody asks and kept
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/crm-inbound-email.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 buildCrmCapturedEmailActivity,\n consentGroupForHost,\n contactCaptureHostIds,\n crmActivityLogHasRoom,\n crmCapturedEmailKey,\n crmInboundCandidates,\n crmInboundExcerpt,\n crmInboundRoster,\n type CrmActivityLink,\n type CrmEmailDirection,\n type CrmInboundCandidate,\n type CrmInboundMember,\n type CrmInboundRosterRow,\n crmScopeTokens,\n emailAddressOf,\n emailDomainOf,\n findMemberByEmailAddress,\n forwardedSection,\n htmlToPlainText,\n isCrmInboundAddress,\n isCrmInboundToken,\n mintCrmInboundToken,\n personKey,\n readContactFacet,\n} from '@aglyn/aglyn/server'\nimport { MEMBER_EMAIL_ALIASES_COLLECTION } from '@aglyn/aglyn/app-utils/member-email-aliases'\nimport type { OrgCrmInbound } from '@aglyn/aglyn/foundation'\nimport type { ReceivedEmail } from '@aglyn/shared-util-email'\nimport { visibleToHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport { findContactByEmail } from './contact-email-index'\nimport {\n createCrmEmailActivity,\n crmCapturedEmailActivityRef,\n newCrmActivityRef,\n} from './crm-email-activity'\nimport { countCrmActivitiesForRecord } from './crm-records'\n\n/**\n * EMAIL CAPTURE (AGL-2657): the half with Firestore.\n *\n * The org's capture token — minted once, replaced on a rotation, found\n * again from a message's recipient — and the filing of one received\n * message: which record it was with, whether it has been filed before, and\n * the row it becomes. The pure rules are `@aglyn/aglyn`'s\n * `crm-inbound.ts`; this module is the reads and the one write.\n *\n * Firestore is a parameter throughout, as in `crm-records.ts` and\n * `crm-email-activity.ts`: the routes hold one, and a module that reached\n * for the Admin app itself would drag it into every spec that only wants\n * the matching. The roster is read through that same store by\n * `loadCrmInboundRoster`, and the caller writes the org's feed line, so a\n * spec of this module needs a store and nothing else.\n */\n\n/** The most candidate addresses one message is matched by. */\nexport const CRM_INBOUND_CANDIDATE_MAX = 6\n\n/** The most sites whose leads are consulted for one message. */\nexport const CRM_INBOUND_LEAD_HOST_MAX = 50\n\nexport interface CrmInboundTokenResult {\n token: string\n createdAtMs: number\n rotatedAtMs?: number\n /** True when this call replaced a token that existed. */\n rotated: boolean\n}\n\n/**\n * The org's capture token, minted the first time anybody asks and kept\n * thereafter; with `rotate`, replaced. A transaction, so two members\n * opening the settings page at once mint one token and not two, and a\n * rotation lands on the token the reader saw.\n */\nexport async function ensureCrmInboundToken(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n options: { rotate?: boolean; nowMs?: number } = {},\n): Promise<CrmInboundTokenResult> {\n const rotate = options.rotate === true\n const nowMs = options.nowMs ?? Date.now()\n const orgRef = firestore.collection('orgs').doc(orgId)\n return firestore.runTransaction(async (tx) => {\n const snapshot = await tx.get(orgRef)\n if (!snapshot.exists) throw new Error(`[crm-inbound] unknown org ${orgId}`)\n const current = (snapshot.get('crmInbound') ?? null) as Partial<OrgCrmInbound> | null\n const held = isCrmInboundToken(current?.token)\n if (held && !rotate) {\n const createdAtMs = Number(current?.createdAtMs)\n return {\n token: String(current?.token),\n createdAtMs: Number.isFinite(createdAtMs) && createdAtMs > 0 ? createdAtMs : nowMs,\n ...(typeof current?.rotatedAtMs === 'number' ? { rotatedAtMs: current.rotatedAtMs } : {}),\n rotated: false,\n }\n }\n const createdAtMs =\n held && Number.isFinite(Number(current?.createdAtMs)) && Number(current?.createdAtMs) > 0\n ? Number(current?.createdAtMs)\n : nowMs\n const next: OrgCrmInbound = {\n token: mintCrmInboundToken(),\n createdAtMs,\n ...(held ? { rotatedAtMs: nowMs } : {}),\n }\n tx.update(orgRef, { crmInbound: next })\n return { ...next, rotated: held }\n })\n}\n\n/**\n * The org a capture token names, or `null`. One equality on\n * `crmInbound.token`, which Firestore indexes on its own; a token that is\n * not one by shape is refused before the query, so a malformed local part\n * costs no read.\n */\nexport async function findOrgByCrmInboundToken(\n firestore: FirebaseFirestore.Firestore,\n token: unknown,\n): Promise<{ orgId: string; org: Record<string, unknown> } | null> {\n if (!isCrmInboundToken(token)) return null\n const page = await firestore\n .collection('orgs')\n .where('crmInbound.token', '==', token)\n .limit(1)\n .get()\n const doc = page.docs[0]\n if (!doc) return null\n return { orgId: doc.id, org: { $id: doc.id, ...(doc.data() ?? {}) } }\n}\n\n/**\n * Every site of the organization, by id, up to the ceiling — the set a\n * message's lead lookup sweeps. One equality on `hosts.orgId`.\n */\nexport async function crmInboundHostIds(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<string[]> {\n const page = await firestore\n .collection('hosts')\n .where('orgId', '==', orgId)\n .limit(CRM_INBOUND_LEAD_HOST_MAX)\n .get()\n return page.docs.map((doc) => String(doc.id))\n}\n\n/** The record one of a message's addresses resolved to. */\nexport interface CrmInboundMatch {\n email: string\n direction: CrmEmailDirection\n kind: 'contact' | 'lead'\n /** Where the row is filed. */\n link: CrmActivityLink\n /** The site the row is stamped with — the record's own. */\n hostId: string\n /** Who may list the row — the record's own tokens. */\n visibleTo: string[]\n}\n\n/**\n * The first candidate that is a contact of the org — through the address\n * index, so a merged record's alternate address answers — or a lead on any\n * of its sites. Candidates are tried in the order the rule gave them and\n * the first hit wins; a contact is looked for before a lead at each\n * address, because a converted lead's address is its contact's.\n */\nexport async function matchCrmInboundCorrespondent(\n firestore: FirebaseFirestore.Firestore,\n input: {\n orgId: string\n org: Record<string, unknown>\n candidates: readonly CrmInboundCandidate[]\n hostIds: readonly string[]\n },\n): Promise<CrmInboundMatch | null> {\n const { orgId, org } = input\n const orgRef = firestore.collection('orgs').doc(orgId)\n const contacts = orgRef.collection('contacts')\n const tokensOf = (record: Record<string, unknown>): string[] => {\n const raw = record['visibleTo']\n return Array.isArray(raw) ? raw.map((token) => String(token)) : []\n }\n for (const candidate of input.candidates.slice(0, CRM_INBOUND_CANDIDATE_MAX)) {\n const contact = await findContactByEmail(contacts, candidate.email)\n if (contact) {\n const record = (contact.data() ?? {}) as Record<string, unknown>\n const hostId =\n String(record['hostId'] ?? '').trim() || contactCaptureHostIds(record)[0] || ''\n const link: CrmActivityLink = { contactId: contact.id }\n if (hostId) {\n // The company the capturing site files this person under, so the\n // row shows on the company's log as a sent email's does.\n const group = consentGroupForHost(org, hostId)\n const facet = readContactFacet(record, group.groupId)\n if (facet.companyId) link.companyId = facet.companyId\n }\n return {\n email: candidate.email,\n direction: candidate.direction,\n kind: 'contact',\n link,\n hostId,\n visibleTo: tokensOf(record),\n }\n }\n const key = personKey(candidate.email)\n if (!key) continue\n /*\n * ONE ROW, then which of the named sites may see it (AGL-3275).\n *\n * This used to read a lead per site because there was one per site. There\n * is one now, so the read is single and the LOOP is what it always really\n * was: deciding whose mailbox this reply belongs to. A site that cannot\n * see the row is not that site, which is what `visibleToHost` answers —\n * and skipping it is what keeps an agency's clients apart on a shared row.\n */\n const lead = await orgRef.collection('leads').doc(key).get()\n for (const hostId of input.hostIds.slice(0, CRM_INBOUND_LEAD_HOST_MAX)) {\n if (!lead.exists) break\n if (!visibleToHost(lead.get('visibleTo') as string[] | undefined, hostId)) continue\n const link: CrmActivityLink = { leadId: lead.id }\n // A converted lead's mail belongs on the contact it became as well.\n const converted = String(lead.get('convertedContactId') ?? '').trim()\n if (converted) link.contactId = converted\n return {\n email: candidate.email,\n direction: candidate.direction,\n kind: 'lead',\n link,\n hostId,\n // A lead carries no tokens of its own: the site's scope, as every\n // record created from that site is stamped.\n visibleTo: crmScopeTokens(org, consentGroupForHost(org, hostId)),\n }\n }\n }\n return null\n}\n\n/**\n * The workspace's roster as the filer matches against it: the member\n * documents and every member's stored alias document, two reads under the\n * org, reduced by `crmInboundRoster` to each member's sign-in address and\n * the aliases they have confirmed (AGL-2975).\n */\nexport async function loadCrmInboundRoster(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<CrmInboundMember[]> {\n const orgRef = firestore.collection('orgs').doc(orgId)\n const [members, aliases] = await Promise.all([\n orgRef.collection('members').get(),\n orgRef.collection(MEMBER_EMAIL_ALIASES_COLLECTION).get(),\n ])\n const rows: CrmInboundRosterRow[] = members.docs.map((doc) => ({\n ...((doc.data() ?? {}) as Omit<CrmInboundRosterRow, '$id'>),\n $id: doc.id,\n }))\n const aliasDocuments = new Map<string, unknown>(\n aliases.docs.map((doc) => [String(doc.id), doc.data()]),\n )\n return crmInboundRoster(rows, aliasDocuments)\n}\n\nexport type CrmInboundFileOutcome =\n /** A row was written. */\n | { outcome: 'filed'; activityId: string; match: CrmInboundMatch }\n /** The message had been filed before; nothing written. */\n | { outcome: 'duplicate'; activityId: string }\n /** No address on the message is a record of the org; nothing written. */\n | { outcome: 'unmatched'; senderDomain: string | null }\n /** The record's activity log is at its ceiling; nothing written. */\n | { outcome: 'ceiling'; match: CrmInboundMatch }\n\n/**\n * Files one received message on the record it was with (AGL-2657).\n *\n * The order is the cost: the candidate rule is arithmetic; the match is\n * the reads that decide everything after it; a message filed before is\n * answered off one read of the row it would write, before the ceiling's\n * aggregate; the ceiling comes last and refuses rather than dropping a row\n * on a full log. The write is a `create`, so two deliveries racing past\n * the read still yield one row. The BODY never leaves this function except\n * as the bounded excerpt in the row.\n */\nexport async function fileCrmInboundEmail(\n firestore: FirebaseFirestore.Firestore,\n input: {\n orgId: string\n org: Record<string, unknown>\n message: ReceivedEmail\n /** The capture domain, so the capture address is never the correspondent. */\n domain: string\n /** The roster, from `loadCrmInboundRoster`: sign-in addresses and confirmed aliases. */\n members: readonly CrmInboundMember[]\n hostIds: readonly string[]\n },\n): Promise<CrmInboundFileOutcome> {\n const { orgId, org, message, domain } = input\n const plain = (message.text.trim() ? message.text : htmlToPlainText(message.html)).replace(\n /\\r\\n?/g,\n '\\n',\n )\n const forwarded = forwardedSection(plain)\n const { sender, senderIsMember, candidates } = crmInboundCandidates({\n from: message.from,\n to: [...message.to, ...message.receivedFor],\n cc: [...message.cc, ...message.bcc],\n forwardedFrom: forwarded?.from ?? undefined,\n domain,\n members: input.members,\n })\n const senderDomain = emailDomainOf(message.from)\n if (!candidates.length) return { outcome: 'unmatched', senderDomain }\n\n const match = await matchCrmInboundCorrespondent(firestore, {\n orgId,\n org,\n candidates,\n hostIds: input.hostIds,\n })\n if (!match) return { outcome: 'unmatched', senderDomain }\n\n const key = crmCapturedEmailKey(message.messageId, message.id)\n const ref = key\n ? crmCapturedEmailActivityRef(firestore, orgId, key)\n : newCrmActivityRef(firestore, orgId)\n if (key) {\n const existing = await ref.get()\n if (existing.exists) return { outcome: 'duplicate', activityId: ref.id }\n }\n\n const orgRef = firestore.collection('orgs').doc(orgId)\n if (!crmActivityLogHasRoom(await countCrmActivitiesForRecord(orgRef, match.link))) {\n return { outcome: 'ceiling', match }\n }\n\n // The member the From is — by sign-in address, or by an alias exactly one\n // member confirmed. A shared alias names the team and no one person.\n const author = senderIsMember ? findMemberByEmailAddress(input.members, sender) : null\n // The address the message was written to, when it is worth showing: the\n // correspondent for a copied send, else the first recipient that is not\n // the capture address.\n const to =\n match.direction === 'outbound'\n ? match.email\n : (message.to\n .map((value) => emailAddressOf(value))\n .find((value) => value && !isCrmInboundAddress(value, domain)) ?? null)\n const activity = buildCrmCapturedEmailActivity({\n direction: match.direction,\n subject: message.subject,\n excerpt: crmInboundExcerpt(message.text, message.html),\n from: sender ?? message.from,\n to,\n messageId: message.messageId || message.id,\n inReplyTo: message.inReplyTo,\n atMs: message.receivedAtMs,\n byUid: author?.uid ?? '',\n byName: author?.name ?? null,\n link: match.link,\n hostId: match.hostId,\n visibleTo: match.visibleTo,\n })\n const created = await createCrmEmailActivity(ref, activity)\n return created === 'created'\n ? { outcome: 'filed', activityId: ref.id, match }\n : { outcome: 'duplicate', activityId: ref.id }\n}\n"],"names":["buildCrmCapturedEmailActivity","consentGroupForHost","contactCaptureHostIds","crmActivityLogHasRoom","crmCapturedEmailKey","crmInboundCandidates","crmInboundExcerpt","crmInboundRoster","crmScopeTokens","emailAddressOf","emailDomainOf","findMemberByEmailAddress","forwardedSection","htmlToPlainText","isCrmInboundAddress","isCrmInboundToken","mintCrmInboundToken","personKey","readContactFacet","MEMBER_EMAIL_ALIASES_COLLECTION","visibleToHost","findContactByEmail","createCrmEmailActivity","crmCapturedEmailActivityRef","newCrmActivityRef","countCrmActivitiesForRecord","CRM_INBOUND_CANDIDATE_MAX","CRM_INBOUND_LEAD_HOST_MAX","ensureCrmInboundToken","firestore","orgId","options","rotate","nowMs","Date","now","orgRef","collection","doc","runTransaction","tx","snapshot","get","exists","Error","current","held","token","createdAtMs","Number","String","isFinite","rotatedAtMs","rotated","next","update","crmInbound","findOrgByCrmInboundToken","page","where","limit","docs","id","org","$id","data","crmInboundHostIds","map","matchCrmInboundCorrespondent","input","contacts","tokensOf","record","raw","Array","isArray","candidate","candidates","slice","contact","email","hostId","trim","link","contactId","group","facet","groupId","companyId","direction","kind","visibleTo","key","lead","hostIds","leadId","converted","loadCrmInboundRoster","members","aliases","Promise","all","rows","aliasDocuments","Map","fileCrmInboundEmail","message","domain","plain","text","html","replace","forwarded","sender","senderIsMember","from","to","receivedFor","cc","bcc","forwardedFrom","undefined","senderDomain","length","outcome","match","messageId","ref","existing","activityId","author","value","find","activity","subject","excerpt","inReplyTo","atMs","receivedAtMs","byUid","uid","byName","name","created"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/crm-inbound-email.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 buildCrmCapturedEmailActivity,\n consentGroupForHost,\n contactCaptureHostIds,\n crmActivityLogHasRoom,\n crmCapturedEmailKey,\n crmInboundCandidates,\n crmInboundExcerpt,\n crmInboundRoster,\n type CrmActivityLink,\n type CrmEmailDirection,\n type CrmInboundCandidate,\n type CrmInboundMember,\n type CrmInboundRosterRow,\n crmScopeTokens,\n emailAddressOf,\n emailDomainOf,\n findMemberByEmailAddress,\n forwardedSection,\n htmlToPlainText,\n isCrmInboundAddress,\n isCrmInboundToken,\n mintCrmInboundToken,\n personKey,\n readContactFacet,\n} from '@aglyn/aglyn/server'\nimport { MEMBER_EMAIL_ALIASES_COLLECTION } from '@aglyn/aglyn/app-utils/member-email-aliases'\nimport type { ReceivedEmail } from '@aglyn/shared-util-email'\nimport { visibleToHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport { findContactByEmail } from './contact-email-index'\nimport {\n createCrmEmailActivity,\n crmCapturedEmailActivityRef,\n newCrmActivityRef,\n} from './crm-email-activity'\nimport { countCrmActivitiesForRecord } from './crm-records'\n\n/**\n * EMAIL CAPTURE (AGL-2657): the half with Firestore.\n *\n * The org's capture token — minted once, replaced on a rotation, found\n * again from a message's recipient — and the filing of one received\n * message: which record it was with, whether it has been filed before, and\n * the row it becomes. The pure rules are `@aglyn/aglyn`'s\n * `crm-inbound.ts`; this module is the reads and the one write.\n *\n * Firestore is a parameter throughout, as in `crm-records.ts` and\n * `crm-email-activity.ts`: the routes hold one, and a module that reached\n * for the Admin app itself would drag it into every spec that only wants\n * the matching. The roster is read through that same store by\n * `loadCrmInboundRoster`, and the caller writes the org's feed line, so a\n * spec of this module needs a store and nothing else.\n */\n\n/**\n * The workspace's email capture address, as the org document carries it\n * under `crmInbound` (AGL-2657): `crm+<token>@<capture domain>`.\n *\n * One token per organization, minted lazily by `crm/inbound-address` the\n * first time a member asks for the address, and replaced by the same route\n * on a rotation. Every site of the organization shares it: a captured\n * message is filed on the record that matches its correspondent, and the\n * record says which site it belongs to, so the address need not.\n *\n * The CRM's field, not the platform's: the organization type does not name\n * it, and this module is its only reader and writer. Server-owned and\n * member-readable: the token IS the address, so a client that could write\n * it could point another workspace's mail here.\n */\nexport interface OrgCrmInbound {\n /** The url-safe secret in the local part; `crm+<token>@…`. */\n token: string\n /** When the first token was minted, epoch ms. */\n createdAtMs: number\n /** When the token was last replaced, epoch ms; absent until it has been. */\n rotatedAtMs?: number\n}\n\n/** The most candidate addresses one message is matched by. */\nexport const CRM_INBOUND_CANDIDATE_MAX = 6\n\n/** The most sites whose leads are consulted for one message. */\nexport const CRM_INBOUND_LEAD_HOST_MAX = 50\n\nexport interface CrmInboundTokenResult {\n token: string\n createdAtMs: number\n rotatedAtMs?: number\n /** True when this call replaced a token that existed. */\n rotated: boolean\n}\n\n/**\n * The org's capture token, minted the first time anybody asks and kept\n * thereafter; with `rotate`, replaced. A transaction, so two members\n * opening the settings page at once mint one token and not two, and a\n * rotation lands on the token the reader saw.\n */\nexport async function ensureCrmInboundToken(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n options: { rotate?: boolean; nowMs?: number } = {},\n): Promise<CrmInboundTokenResult> {\n const rotate = options.rotate === true\n const nowMs = options.nowMs ?? Date.now()\n const orgRef = firestore.collection('orgs').doc(orgId)\n return firestore.runTransaction(async (tx) => {\n const snapshot = await tx.get(orgRef)\n if (!snapshot.exists) throw new Error(`[crm-inbound] unknown org ${orgId}`)\n const current = (snapshot.get('crmInbound') ?? null) as Partial<OrgCrmInbound> | null\n const held = isCrmInboundToken(current?.token)\n if (held && !rotate) {\n const createdAtMs = Number(current?.createdAtMs)\n return {\n token: String(current?.token),\n createdAtMs: Number.isFinite(createdAtMs) && createdAtMs > 0 ? createdAtMs : nowMs,\n ...(typeof current?.rotatedAtMs === 'number' ? { rotatedAtMs: current.rotatedAtMs } : {}),\n rotated: false,\n }\n }\n const createdAtMs =\n held && Number.isFinite(Number(current?.createdAtMs)) && Number(current?.createdAtMs) > 0\n ? Number(current?.createdAtMs)\n : nowMs\n const next: OrgCrmInbound = {\n token: mintCrmInboundToken(),\n createdAtMs,\n ...(held ? { rotatedAtMs: nowMs } : {}),\n }\n tx.update(orgRef, { crmInbound: next })\n return { ...next, rotated: held }\n })\n}\n\n/**\n * The org a capture token names, or `null`. One equality on\n * `crmInbound.token`, which Firestore indexes on its own; a token that is\n * not one by shape is refused before the query, so a malformed local part\n * costs no read.\n */\nexport async function findOrgByCrmInboundToken(\n firestore: FirebaseFirestore.Firestore,\n token: unknown,\n): Promise<{ orgId: string; org: Record<string, unknown> } | null> {\n if (!isCrmInboundToken(token)) return null\n const page = await firestore\n .collection('orgs')\n .where('crmInbound.token', '==', token)\n .limit(1)\n .get()\n const doc = page.docs[0]\n if (!doc) return null\n return { orgId: doc.id, org: { $id: doc.id, ...(doc.data() ?? {}) } }\n}\n\n/**\n * Every site of the organization, by id, up to the ceiling — the set a\n * message's lead lookup sweeps. One equality on `hosts.orgId`.\n */\nexport async function crmInboundHostIds(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<string[]> {\n const page = await firestore\n .collection('hosts')\n .where('orgId', '==', orgId)\n .limit(CRM_INBOUND_LEAD_HOST_MAX)\n .get()\n return page.docs.map((doc) => String(doc.id))\n}\n\n/** The record one of a message's addresses resolved to. */\nexport interface CrmInboundMatch {\n email: string\n direction: CrmEmailDirection\n kind: 'contact' | 'lead'\n /** Where the row is filed. */\n link: CrmActivityLink\n /** The site the row is stamped with — the record's own. */\n hostId: string\n /** Who may list the row — the record's own tokens. */\n visibleTo: string[]\n}\n\n/**\n * The first candidate that is a contact of the org — through the address\n * index, so a merged record's alternate address answers — or a lead on any\n * of its sites. Candidates are tried in the order the rule gave them and\n * the first hit wins; a contact is looked for before a lead at each\n * address, because a converted lead's address is its contact's.\n */\nexport async function matchCrmInboundCorrespondent(\n firestore: FirebaseFirestore.Firestore,\n input: {\n orgId: string\n org: Record<string, unknown>\n candidates: readonly CrmInboundCandidate[]\n hostIds: readonly string[]\n },\n): Promise<CrmInboundMatch | null> {\n const { orgId, org } = input\n const orgRef = firestore.collection('orgs').doc(orgId)\n const contacts = orgRef.collection('contacts')\n const tokensOf = (record: Record<string, unknown>): string[] => {\n const raw = record['visibleTo']\n return Array.isArray(raw) ? raw.map((token) => String(token)) : []\n }\n for (const candidate of input.candidates.slice(0, CRM_INBOUND_CANDIDATE_MAX)) {\n const contact = await findContactByEmail(contacts, candidate.email)\n if (contact) {\n const record = (contact.data() ?? {}) as Record<string, unknown>\n const hostId =\n String(record['hostId'] ?? '').trim() || contactCaptureHostIds(record)[0] || ''\n const link: CrmActivityLink = { contactId: contact.id }\n if (hostId) {\n // The company the capturing site files this person under, so the\n // row shows on the company's log as a sent email's does.\n const group = consentGroupForHost(org, hostId)\n const facet = readContactFacet(record, group.groupId)\n if (facet.companyId) link.companyId = facet.companyId\n }\n return {\n email: candidate.email,\n direction: candidate.direction,\n kind: 'contact',\n link,\n hostId,\n visibleTo: tokensOf(record),\n }\n }\n const key = personKey(candidate.email)\n if (!key) continue\n /*\n * ONE ROW, then which of the named sites may see it (AGL-3275).\n *\n * This used to read a lead per site because there was one per site. There\n * is one now, so the read is single and the LOOP is what it always really\n * was: deciding whose mailbox this reply belongs to. A site that cannot\n * see the row is not that site, which is what `visibleToHost` answers —\n * and skipping it is what keeps an agency's clients apart on a shared row.\n */\n const lead = await orgRef.collection('leads').doc(key).get()\n for (const hostId of input.hostIds.slice(0, CRM_INBOUND_LEAD_HOST_MAX)) {\n if (!lead.exists) break\n if (!visibleToHost(lead.get('visibleTo') as string[] | undefined, hostId)) continue\n const link: CrmActivityLink = { leadId: lead.id }\n // A converted lead's mail belongs on the contact it became as well.\n const converted = String(lead.get('convertedContactId') ?? '').trim()\n if (converted) link.contactId = converted\n return {\n email: candidate.email,\n direction: candidate.direction,\n kind: 'lead',\n link,\n hostId,\n // A lead carries no tokens of its own: the site's scope, as every\n // record created from that site is stamped.\n visibleTo: crmScopeTokens(org, consentGroupForHost(org, hostId)),\n }\n }\n }\n return null\n}\n\n/**\n * The workspace's roster as the filer matches against it: the member\n * documents and every member's stored alias document, two reads under the\n * org, reduced by `crmInboundRoster` to each member's sign-in address and\n * the aliases they have confirmed (AGL-2975).\n */\nexport async function loadCrmInboundRoster(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<CrmInboundMember[]> {\n const orgRef = firestore.collection('orgs').doc(orgId)\n const [members, aliases] = await Promise.all([\n orgRef.collection('members').get(),\n orgRef.collection(MEMBER_EMAIL_ALIASES_COLLECTION).get(),\n ])\n const rows: CrmInboundRosterRow[] = members.docs.map((doc) => ({\n ...((doc.data() ?? {}) as Omit<CrmInboundRosterRow, '$id'>),\n $id: doc.id,\n }))\n const aliasDocuments = new Map<string, unknown>(\n aliases.docs.map((doc) => [String(doc.id), doc.data()]),\n )\n return crmInboundRoster(rows, aliasDocuments)\n}\n\nexport type CrmInboundFileOutcome =\n /** A row was written. */\n | { outcome: 'filed'; activityId: string; match: CrmInboundMatch }\n /** The message had been filed before; nothing written. */\n | { outcome: 'duplicate'; activityId: string }\n /** No address on the message is a record of the org; nothing written. */\n | { outcome: 'unmatched'; senderDomain: string | null }\n /** The record's activity log is at its ceiling; nothing written. */\n | { outcome: 'ceiling'; match: CrmInboundMatch }\n\n/**\n * Files one received message on the record it was with (AGL-2657).\n *\n * The order is the cost: the candidate rule is arithmetic; the match is\n * the reads that decide everything after it; a message filed before is\n * answered off one read of the row it would write, before the ceiling's\n * aggregate; the ceiling comes last and refuses rather than dropping a row\n * on a full log. The write is a `create`, so two deliveries racing past\n * the read still yield one row. The BODY never leaves this function except\n * as the bounded excerpt in the row.\n */\nexport async function fileCrmInboundEmail(\n firestore: FirebaseFirestore.Firestore,\n input: {\n orgId: string\n org: Record<string, unknown>\n message: ReceivedEmail\n /** The capture domain, so the capture address is never the correspondent. */\n domain: string\n /** The roster, from `loadCrmInboundRoster`: sign-in addresses and confirmed aliases. */\n members: readonly CrmInboundMember[]\n hostIds: readonly string[]\n },\n): Promise<CrmInboundFileOutcome> {\n const { orgId, org, message, domain } = input\n const plain = (message.text.trim() ? message.text : htmlToPlainText(message.html)).replace(\n /\\r\\n?/g,\n '\\n',\n )\n const forwarded = forwardedSection(plain)\n const { sender, senderIsMember, candidates } = crmInboundCandidates({\n from: message.from,\n to: [...message.to, ...message.receivedFor],\n cc: [...message.cc, ...message.bcc],\n forwardedFrom: forwarded?.from ?? undefined,\n domain,\n members: input.members,\n })\n const senderDomain = emailDomainOf(message.from)\n if (!candidates.length) return { outcome: 'unmatched', senderDomain }\n\n const match = await matchCrmInboundCorrespondent(firestore, {\n orgId,\n org,\n candidates,\n hostIds: input.hostIds,\n })\n if (!match) return { outcome: 'unmatched', senderDomain }\n\n const key = crmCapturedEmailKey(message.messageId, message.id)\n const ref = key\n ? crmCapturedEmailActivityRef(firestore, orgId, key)\n : newCrmActivityRef(firestore, orgId)\n if (key) {\n const existing = await ref.get()\n if (existing.exists) return { outcome: 'duplicate', activityId: ref.id }\n }\n\n const orgRef = firestore.collection('orgs').doc(orgId)\n if (!crmActivityLogHasRoom(await countCrmActivitiesForRecord(orgRef, match.link))) {\n return { outcome: 'ceiling', match }\n }\n\n // The member the From is — by sign-in address, or by an alias exactly one\n // member confirmed. A shared alias names the team and no one person.\n const author = senderIsMember ? findMemberByEmailAddress(input.members, sender) : null\n // The address the message was written to, when it is worth showing: the\n // correspondent for a copied send, else the first recipient that is not\n // the capture address.\n const to =\n match.direction === 'outbound'\n ? match.email\n : (message.to\n .map((value) => emailAddressOf(value))\n .find((value) => value && !isCrmInboundAddress(value, domain)) ?? null)\n const activity = buildCrmCapturedEmailActivity({\n direction: match.direction,\n subject: message.subject,\n excerpt: crmInboundExcerpt(message.text, message.html),\n from: sender ?? message.from,\n to,\n messageId: message.messageId || message.id,\n inReplyTo: message.inReplyTo,\n atMs: message.receivedAtMs,\n byUid: author?.uid ?? '',\n byName: author?.name ?? null,\n link: match.link,\n hostId: match.hostId,\n visibleTo: match.visibleTo,\n })\n const created = await createCrmEmailActivity(ref, activity)\n return created === 'created'\n ? { outcome: 'filed', activityId: ref.id, match }\n : { outcome: 'duplicate', activityId: ref.id }\n}\n"],"names":["buildCrmCapturedEmailActivity","consentGroupForHost","contactCaptureHostIds","crmActivityLogHasRoom","crmCapturedEmailKey","crmInboundCandidates","crmInboundExcerpt","crmInboundRoster","crmScopeTokens","emailAddressOf","emailDomainOf","findMemberByEmailAddress","forwardedSection","htmlToPlainText","isCrmInboundAddress","isCrmInboundToken","mintCrmInboundToken","personKey","readContactFacet","MEMBER_EMAIL_ALIASES_COLLECTION","visibleToHost","findContactByEmail","createCrmEmailActivity","crmCapturedEmailActivityRef","newCrmActivityRef","countCrmActivitiesForRecord","CRM_INBOUND_CANDIDATE_MAX","CRM_INBOUND_LEAD_HOST_MAX","ensureCrmInboundToken","firestore","orgId","options","rotate","nowMs","Date","now","orgRef","collection","doc","runTransaction","tx","snapshot","get","exists","Error","current","held","token","createdAtMs","Number","String","isFinite","rotatedAtMs","rotated","next","update","crmInbound","findOrgByCrmInboundToken","page","where","limit","docs","id","org","$id","data","crmInboundHostIds","map","matchCrmInboundCorrespondent","input","contacts","tokensOf","record","raw","Array","isArray","candidate","candidates","slice","contact","email","hostId","trim","link","contactId","group","facet","groupId","companyId","direction","kind","visibleTo","key","lead","hostIds","leadId","converted","loadCrmInboundRoster","members","aliases","Promise","all","rows","aliasDocuments","Map","fileCrmInboundEmail","message","domain","plain","text","html","replace","forwarded","sender","senderIsMember","from","to","receivedFor","cc","bcc","forwardedFrom","undefined","senderDomain","length","outcome","match","messageId","ref","existing","activityId","author","value","find","activity","subject","excerpt","inReplyTo","atMs","receivedAtMs","byUid","uid","byName","name","created"],"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 = {
|