@aglyn/plugins-outreach 1.0.0-beta.196 → 1.0.0-beta.198
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 +14 -14
- package/src/lib/components/compliance-section.d.ts +3 -1
- package/src/lib/components/compliance-section.js +110 -45
- package/src/lib/components/compliance-section.js.map +1 -1
- package/src/lib/components/enroll-dialog.d.ts +7 -1
- package/src/lib/components/enroll-dialog.js +106 -3
- package/src/lib/components/enroll-dialog.js.map +1 -1
- package/src/lib/components/mailbox-card.js +11 -2
- package/src/lib/components/mailbox-card.js.map +1 -1
- package/src/lib/components/outreach-ui.js +1 -0
- package/src/lib/components/outreach-ui.js.map +1 -1
- package/src/lib/components/sequence-detail.js +1 -0
- package/src/lib/components/sequence-detail.js.map +1 -1
- package/src/lib/components/sequence-editor.d.ts +2 -0
- package/src/lib/components/sequence-editor.js +51 -26
- package/src/lib/components/sequence-editor.js.map +1 -1
- package/src/lib/components/sequence-step-card.d.ts +7 -0
- package/src/lib/components/sequence-step-card.js +37 -14
- package/src/lib/components/sequence-step-card.js.map +1 -1
- package/src/lib/components/sequences-section.js +12 -3
- package/src/lib/components/sequences-section.js.map +1 -1
- package/src/lib/components/step-test-dialog.d.ts +31 -0
- package/src/lib/components/step-test-dialog.js +247 -0
- package/src/lib/components/step-test-dialog.js.map +1 -0
- package/src/lib/components/use-outreach-api.d.ts +8 -1
- package/src/lib/components/use-outreach-api.js +4 -0
- package/src/lib/components/use-outreach-api.js.map +1 -1
- package/src/lib/components/use-outreach-data.d.ts +6 -1
- package/src/lib/components/use-outreach-data.js +6 -3
- package/src/lib/components/use-outreach-data.js.map +1 -1
- package/src/lib/constants/api-routes.d.ts +7 -0
- package/src/lib/constants/api-routes.js +6 -0
- package/src/lib/constants/api-routes.js.map +1 -1
- package/src/lib/engine/delivery-status.d.ts +8 -0
- package/src/lib/engine/delivery-status.js +10 -5
- package/src/lib/engine/delivery-status.js.map +1 -1
- package/src/lib/engine/enrollment-state.d.ts +2 -0
- package/src/lib/engine/enrollment-state.js +2 -2
- package/src/lib/engine/enrollment-state.js.map +1 -1
- package/src/lib/engine/gates.d.ts +28 -1
- package/src/lib/engine/gates.js +28 -1
- package/src/lib/engine/gates.js.map +1 -1
- package/src/lib/engine/mail-gateway.d.ts +97 -0
- package/src/lib/engine/mail-gateway.js +226 -0
- package/src/lib/engine/mail-gateway.js.map +1 -0
- package/src/lib/engine/mailbox-health.d.ts +3 -3
- package/src/lib/engine/mailbox-health.js +17 -11
- package/src/lib/engine/mailbox-health.js.map +1 -1
- package/src/lib/engine/thread-classification.d.ts +2 -0
- package/src/lib/engine/thread-classification.js +2 -1
- package/src/lib/engine/thread-classification.js.map +1 -1
- package/src/lib/enrollment/enroll-people.d.ts +6 -1
- package/src/lib/enrollment/enroll-people.js +18 -4
- package/src/lib/enrollment/enroll-people.js.map +1 -1
- package/src/lib/enrollment/gate-lookups.d.ts +7 -0
- package/src/lib/enrollment/gate-lookups.js +11 -2
- package/src/lib/enrollment/gate-lookups.js.map +1 -1
- package/src/lib/model/outreach-api.d.ts +35 -1
- package/src/lib/model/outreach-api.js.map +1 -1
- package/src/lib/model/outreach.types.d.ts +85 -3
- package/src/lib/model/outreach.types.js +21 -2
- package/src/lib/model/outreach.types.js.map +1 -1
- package/src/lib/model/step-preview.d.ts +7 -1
- package/src/lib/model/step-preview.js +3 -2
- package/src/lib/model/step-preview.js.map +1 -1
- package/src/lib/routes/enroll-routes.js +23 -4
- package/src/lib/routes/enroll-routes.js.map +1 -1
- package/src/lib/routes/enrollment-routes.js +15 -1
- package/src/lib/routes/enrollment-routes.js.map +1 -1
- package/src/lib/routes/platform-deps.d.ts +2 -2
- package/src/lib/routes/platform-deps.js +2 -2
- package/src/lib/routes/platform-deps.js.map +1 -1
- package/src/lib/routes/preview-routes.d.ts +24 -0
- package/src/lib/routes/preview-routes.js +99 -95
- package/src/lib/routes/preview-routes.js.map +1 -1
- package/src/lib/routes/register-routes.d.ts +7 -0
- package/src/lib/routes/register-routes.js +27 -0
- package/src/lib/routes/register-routes.js.map +1 -1
- package/src/lib/routes/route-deps.d.ts +6 -0
- package/src/lib/routes/route-deps.js.map +1 -1
- package/src/lib/routes/step-test-routes.d.ts +88 -0
- package/src/lib/routes/step-test-routes.js +275 -0
- package/src/lib/routes/step-test-routes.js.map +1 -0
- package/src/lib/runtime/click-events.js +10 -0
- package/src/lib/runtime/click-events.js.map +1 -1
- package/src/lib/runtime/click-link.d.ts +11 -0
- package/src/lib/runtime/click-link.js +9 -4
- package/src/lib/runtime/click-link.js.map +1 -1
- package/src/lib/runtime/click-route.js +3 -0
- package/src/lib/runtime/click-route.js.map +1 -1
- package/src/lib/runtime/gateway-ledger.d.ts +46 -0
- package/src/lib/runtime/gateway-ledger.js +113 -0
- package/src/lib/runtime/gateway-ledger.js.map +1 -0
- package/src/lib/runtime/platform-runtime-deps.js +3 -0
- package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
- package/src/lib/runtime/runtime-deps.d.ts +6 -0
- package/src/lib/runtime/runtime-deps.js.map +1 -1
- package/src/lib/runtime/send-job.js +48 -0
- package/src/lib/runtime/send-job.js.map +1 -1
- package/src/lib/runtime/sync-job.d.ts +2 -0
- package/src/lib/runtime/sync-job.js +40 -3
- package/src/lib/runtime/sync-job.js.map +1 -1
- package/src/lib/storage/domain-intel-store.d.ts +107 -0
- package/src/lib/storage/domain-intel-store.js +320 -0
- package/src/lib/storage/domain-intel-store.js.map +1 -0
|
@@ -243,6 +243,16 @@ const blocksOf = (blocks)=>blocks.map(({ code, reason })=>({
|
|
|
243
243
|
code,
|
|
244
244
|
reason
|
|
245
245
|
}));
|
|
246
|
+
/**
|
|
247
|
+
* The gateway as the dialog shows it (AGL-3326): the standing the lookups
|
|
248
|
+
* read, and whether the gates would hold a send into it.
|
|
249
|
+
*/ function gatewayOf(question, hold) {
|
|
250
|
+
var _question_lookups;
|
|
251
|
+
const standing = (_question_lookups = question.lookups) == null ? void 0 : _question_lookups.gateway;
|
|
252
|
+
return standing ? _extends({}, standing, {
|
|
253
|
+
hold: hold !== null
|
|
254
|
+
}) : null;
|
|
255
|
+
}
|
|
246
256
|
/** Where one person stands before the rep confirms anything — see the module note. */ export function previewOutreachPerson(question, facts) {
|
|
247
257
|
const { candidate } = question;
|
|
248
258
|
const person = _extends({}, personOf(candidate), {
|
|
@@ -259,7 +269,8 @@ const blocksOf = (blocks)=>blocks.map(({ code, reason })=>({
|
|
|
259
269
|
requires: {
|
|
260
270
|
personalLine: false,
|
|
261
271
|
attestations: []
|
|
262
|
-
}
|
|
272
|
+
},
|
|
273
|
+
gateway: null
|
|
263
274
|
});
|
|
264
275
|
}
|
|
265
276
|
const ask = (personalLine, attestations)=>{
|
|
@@ -278,7 +289,8 @@ const blocksOf = (blocks)=>blocks.map(({ code, reason })=>({
|
|
|
278
289
|
const asIs = ask('', {});
|
|
279
290
|
const known = {
|
|
280
291
|
cold: asIs.cold,
|
|
281
|
-
country: asIs.country.country
|
|
292
|
+
country: asIs.country.country,
|
|
293
|
+
gateway: gatewayOf(question, asIs.hold)
|
|
282
294
|
};
|
|
283
295
|
if (asIs.allowed) {
|
|
284
296
|
return _extends({}, person, known, {
|
|
@@ -326,7 +338,8 @@ const blocksOf = (blocks)=>blocks.map(({ code, reason })=>({
|
|
|
326
338
|
blocks: early,
|
|
327
339
|
cold: false,
|
|
328
340
|
email: question.candidate.email,
|
|
329
|
-
attestations
|
|
341
|
+
attestations,
|
|
342
|
+
hold: null
|
|
330
343
|
};
|
|
331
344
|
}
|
|
332
345
|
const result = evaluateOutreachGates({
|
|
@@ -344,7 +357,8 @@ const blocksOf = (blocks)=>blocks.map(({ code, reason })=>({
|
|
|
344
357
|
blocks: blocksOf(result.blocks),
|
|
345
358
|
cold: result.cold,
|
|
346
359
|
email: result.email,
|
|
347
|
-
attestations
|
|
360
|
+
attestations,
|
|
361
|
+
hold: result.hold
|
|
348
362
|
};
|
|
349
363
|
}
|
|
350
364
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/enrollment/enroll-people.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 * WHO CAN BE ENROLLED, AND WHAT THE REP STILL OWES (AGL-2980).\n *\n * The enroll dialog shows every person a view or a search named, each\n * marked eligible, blocked, or waiting on the rep; Confirm asks the same\n * question again, on the server, with what the rep supplied. Both answers\n * come from the engine's `evaluateOutreachGates` — this module only reads\n * the people and asks it the right questions.\n *\n * ## Three questions of one pure function\n *\n * The gates refuse a cold contact until the rep writes a personal line and\n * ticks the attestations, and refuse an address in an unknown country until\n * the rep confirms it is a US business address. So a preview asks:\n *\n * 1. as the person stands, with nothing confirmed — allowed means\n * ELIGIBLE;\n * 2. as if the rep confirmed everything they can — refused means BLOCKED,\n * for the reasons that remain, which no tick can clear;\n * 3. otherwise the person is WAITING ON THE REP, for what (1) found\n * missing.\n *\n * The confirmation in (2) is hypothetical and never stored: Confirm stamps\n * only what the rep ticked, with their uid and the time, and asks again.\n *\n * ## One enrollment per person per sequence, ever\n *\n * The enrollment's id is the sequence and the contact\n * (`outreachEnrollmentId`), and it is written with `create()`. A person who\n * finished, replied or was stopped is not sent the same emails a second\n * time by being enrolled again, and two members confirming the same person\n * at once write one enrollment.\n *==========================================*/\n\nimport { normalizeContactEmail, contactDisplayName, readContactFacet } from '@aglyn/aglyn/app-utils/contacts'\nimport { crmLeadDisplayName, crmLeadStatus } from '@aglyn/aglyn/app-utils/crm'\nimport { visibleToHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport { findContactByEmail } from '@aglyn/tenant-data-admin/server/contact-email-index'\nimport {\n evaluateOutreachGates,\n type OutreachGateBlock,\n type OutreachGateLookups,\n} from '../engine/gates'\nimport { outreachAttestationsFrom } from '../engine/enrollment-state'\nimport {\n OUTREACH_ATTESTATION_KINDS,\n type OutreachAttestationKind,\n type OutreachAttestations,\n type OutreachSequenceSettings,\n} from '../model/outreach.types'\nimport type {\n OutreachEnrollBlock,\n OutreachEnrollPreviewPerson,\n OutreachPersonRef,\n} from '../model/outreach-api'\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/**\n * One person as the preview and the enroll read them: a contact, or a lead\n * the sequence's site holds (AGL-3234).\n *\n * ## A lead is judged as the contact it would be\n *\n * The gates, the merge fields and the country rule all read a CONTACT\n * document — its facet's sources, stage, address, company and title. A lead\n * carries the same facts in its own shape, so rather than teach every gate\n * two records, a lead is handed to them as {@link leadAsContact}: a\n * contact-shaped view whose one facet, under the sequence's group, holds\n * what the lead holds. An imported lead has no inbound source and is cold;\n * one that wrote in through a form is not; a lead is never a customer; its\n * address decides its country; and `{{contact.firstName}}` or\n * `{{contact.company}}` in a step reads the lead's name and company text,\n * so a sequence written for contacts sends to leads unchanged — which is\n * also what lets an enrollment made on a lead keep sending after the lead\n * converts and the view becomes the real contact.\n */\nexport interface OutreachEnrollCandidate {\n /** The record's own id: the contact's, or the lead's person key. */\n personId: string\n target: 'contact' | 'lead'\n /** The contact's id; `''` for a lead. */\n contactId: string\n /** The lead's person key; `null` for a contact. */\n leadId: string | null\n /**\n * The contact document (`orgs/{orgId}/contacts/{id}`), or for a lead the\n * contact-shaped view of it — see the module note. `null` when the record\n * no longer exists.\n */\n contact: Record<string, unknown> | null\n /** The lead document as stored, for a lead; `null` for a contact. */\n lead: Record<string, unknown> | null\n /** Whether the sequence's site may see the record. A lead is the site's own. */\n visible: boolean\n /** The name the sequence's site knows them by; `''` for none. */\n name: string\n /** The primary address, normalized, or `null` when there is none. */\n email: string | null\n /** The company the contact is filed under at that site, when there is one. */\n company: Record<string, unknown> | null\n /** A lead already converted: the contact it became. Enroll that instead. */\n convertedContactId: string | null\n /** A lead whose address the workspace already holds as a contact. Enroll that instead. */\n heldAsContactId: string | null\n}\n\n/**\n * A lead as the gates and the merge fields read a contact — see the\n * module note. The facet is the sequence's group's, which is the one every\n * reader asks for. The lead's `sources` — `form:{id}`, `booking`, `import`,\n * `manual`, `api`, `signup` — become the facet's source flags by kind, so\n * the inbound rule reads them as it reads a contact's.\n */\nexport function leadAsContact(\n lead: Record<string, unknown>,\n contactGroupId: string,\n): Record<string, unknown> {\n const sources: Record<string, true> = {}\n for (const raw of Array.isArray(lead['sources']) ? lead['sources'] : []) {\n const source = String(raw)\n const kind = source.startsWith('form:') ? 'form' : source === 'signup' ? 'member' : source\n if (kind) sources[kind] = true\n }\n return {\n email: lead['email'],\n name: lead['name'],\n // The verdict on the address (AGL-3245) is the lead's own top-level\n // field, as it is the contact's, so the gates quote it either way.\n ...(lead['emailState'] && typeof lead['emailState'] === 'object' ? { emailState: lead['emailState'] } : {}),\n facets: {\n [contactGroupId]: {\n sources,\n ...(lead['address'] && typeof lead['address'] === 'object' ? { address: lead['address'] } : {}),\n ...(typeof lead['company'] === 'string' ? { companyName: lead['company'] } : {}),\n ...(typeof lead['jobTitle'] === 'string' ? { jobTitle: lead['jobTitle'] } : {}),\n ...(typeof lead['phone'] === 'string' ? { phone: lead['phone'] } : {}),\n ...(Array.isArray(lead['tags']) ? { tags: lead['tags'] } : {}),\n },\n },\n }\n}\n\n/** `getAll` takes this many references at most per call. */\nconst GET_ALL_CHUNK = 300\n\nasync function getAllChunked(\n firestore: Firestore,\n refs: readonly FirebaseFirestore.DocumentReference[],\n): Promise<FirebaseFirestore.DocumentSnapshot[]> {\n const snapshots: FirebaseFirestore.DocumentSnapshot[] = []\n for (let start = 0; start < refs.length; start += GET_ALL_CHUNK) {\n snapshots.push(...(await firestore.getAll(...refs.slice(start, start + GET_ALL_CHUNK))))\n }\n return snapshots\n}\n\n/**\n * The people, as the sequence's site knows them, and the company each\n * contact is filed under there — batched reads for everyone: the contacts\n * and the leads by id, the companies once, and for each lead one address\n * lookup, because a lead the workspace already holds as a contact is that\n * contact's to enroll.\n */\nexport async function readOutreachEnrollCandidates(\n firestore: Firestore,\n input: { orgId: string; hostId: string; contactGroupId: string; people: readonly OutreachPersonRef[] },\n): Promise<OutreachEnrollCandidate[]> {\n if (!input.people.length) return []\n const org = firestore.collection('orgs').doc(input.orgId)\n // Beside the contacts, on the org (AGL-3275).\n const leads = org.collection('leads')\n const snapshots = await getAllChunked(\n firestore,\n input.people.map((person) =>\n person.kind === 'lead' ? leads.doc(person.id) : org.collection('contacts').doc(person.id),\n ),\n )\n const read = await Promise.all(\n input.people.map(async (person, index) => {\n const data = snapshots[index]?.exists ? (snapshots[index].data() as Record<string, unknown>) : null\n if (person.kind === 'lead') {\n const email = data ? normalizeContactEmail(data['email']) : null\n const held =\n data && email ? await findContactByEmail(org.collection('contacts'), email) : null\n return {\n personId: person.id,\n target: 'lead' as const,\n contactId: '',\n leadId: person.id,\n contact: data ? leadAsContact(data, input.contactGroupId) : null,\n lead: data,\n // A lead is the site's own record: no scope to check.\n visible: data !== null,\n name: data ? crmLeadDisplayName(data) : '',\n email,\n companyId: '',\n convertedContactId: data && typeof data['convertedContactId'] === 'string' ? data['convertedContactId'] : null,\n heldAsContactId: held ? held.id : null,\n }\n }\n return {\n personId: person.id,\n target: 'contact' as const,\n contactId: person.id,\n leadId: null,\n contact: data,\n lead: null,\n visible: data ? visibleToHost(data['visibleTo'] as string[] | undefined, input.hostId) : false,\n name: data ? contactDisplayName(data, input.contactGroupId).trim() : '',\n email: data ? normalizeContactEmail(data['email']) : null,\n companyId: data ? String(readContactFacet(data, input.contactGroupId).companyId ?? '') : '',\n convertedContactId: null,\n heldAsContactId: null,\n }\n }),\n )\n const companyIds = [...new Set(read.map((entry) => entry.companyId).filter(Boolean))]\n const companies = new Map<string, Record<string, unknown>>()\n if (companyIds.length) {\n const found = await getAllChunked(\n firestore,\n companyIds.map((id) => org.collection('companies').doc(id)),\n )\n found.forEach((snapshot, index) => {\n if (snapshot.exists) companies.set(companyIds[index], snapshot.data() as Record<string, unknown>)\n })\n }\n return read.map(({ companyId, ...candidate }) => ({\n ...candidate,\n company: companyId ? (companies.get(companyId) ?? null) : null,\n }))\n}\n\n/** The person as every answer names them. */\nfunction personOf(candidate: OutreachEnrollCandidate) {\n return {\n personId: candidate.personId,\n target: candidate.target,\n contactId: candidate.contactId,\n leadId: candidate.leadId,\n }\n}\n\n/** What stands in the way before the gates are asked anything. */\nexport function outreachCandidateBlocks(\n candidate: OutreachEnrollCandidate,\n facts: { siteName: string; alreadyInSequence: boolean },\n): OutreachEnrollBlock[] {\n if (!candidate.contact) {\n return candidate.target === 'lead'\n ? [{ code: 'lead_missing', reason: \"This lead no longer exists in this sequence's site's CRM.\" }]\n : [{ code: 'contact_missing', reason: 'This contact no longer exists in the CRM.' }]\n }\n const blocks: OutreachEnrollBlock[] = []\n if (candidate.target === 'lead') {\n // A lead that converted, or whose address the workspace already holds\n // as a contact, is that contact's to enroll — one person, one record.\n if (candidate.convertedContactId) {\n blocks.push({\n code: 'lead_converted',\n reason: 'This lead was converted into a contact. Enroll the contact instead.',\n })\n } else if (candidate.heldAsContactId) {\n blocks.push({\n code: 'lead_is_contact',\n reason: 'This address is already a contact in your CRM. Enroll the contact instead.',\n })\n }\n if (crmLeadStatus(candidate.lead) === 'unqualified') {\n blocks.push({\n code: 'lead_unqualified',\n reason: 'This lead was closed as unqualified. Reopen it before enrolling.',\n })\n }\n }\n if (!candidate.visible) {\n blocks.push({\n code: 'not_in_site',\n reason: `This contact isn't in ${facts.siteName || \"this sequence's site\"}'s CRM.`,\n })\n }\n if (!candidate.email) {\n blocks.push({ code: 'no_email', reason: 'This contact has no email address.' })\n }\n if (facts.alreadyInSequence) {\n blocks.push({\n code: 'already_in_sequence',\n reason: \"This person has already been in this sequence, so they won't be sent its emails again.\",\n })\n }\n return blocks\n}\n\n/** Every attestation, stamped for a hypothetical question only — never stored. */\nconst EVERYTHING_CONFIRMED: OutreachAttestations = Object.fromEntries(\n OUTREACH_ATTESTATION_KINDS.map((kind) => [kind, { uid: 'preview', atMs: 1 }]),\n)\n\nconst blocksOf = (blocks: readonly OutreachGateBlock[]): OutreachEnrollBlock[] =>\n blocks.map(({ code, reason }) => ({ code, reason }))\n\nexport interface OutreachGateQuestion {\n candidate: OutreachEnrollCandidate\n lookups: OutreachGateLookups\n /** The sequence's settings, its countries already capped by the organization's. */\n settings: OutreachSequenceSettings\n contactGroupId: string\n}\n\n/** Where one person stands before the rep confirms anything — see the module note. */\nexport function previewOutreachPerson(\n question: OutreachGateQuestion,\n facts: { siteName: string; alreadyInSequence: boolean },\n): OutreachEnrollPreviewPerson {\n const { candidate } = question\n const person = {\n ...personOf(candidate),\n name: candidate.name,\n email: candidate.email,\n }\n const early = outreachCandidateBlocks(candidate, facts)\n if (early.length) {\n return {\n ...person,\n cold: false,\n country: null,\n status: 'blocked',\n blocks: early,\n requires: { personalLine: false, attestations: [] },\n }\n }\n const ask = (personalLine: string, attestations: OutreachAttestations) =>\n evaluateOutreachGates({\n email: candidate.email ?? '',\n contact: candidate.contact,\n contactGroupId: question.contactGroupId,\n company: candidate.company,\n settings: question.settings,\n personalLine,\n attestations,\n lookups: question.lookups,\n })\n const asIs = ask('', {})\n const known = { cold: asIs.cold, country: asIs.country.country }\n if (asIs.allowed) {\n return {\n ...person,\n ...known,\n status: 'eligible',\n blocks: [],\n requires: { personalLine: false, attestations: [] },\n }\n }\n // The line is only checked for being present, so any sentence stands in.\n const confirmed = ask('A personal line.', EVERYTHING_CONFIRMED)\n if (!confirmed.allowed) {\n return {\n ...person,\n ...known,\n status: 'blocked',\n blocks: blocksOf(confirmed.blocks),\n requires: { personalLine: false, attestations: [] },\n }\n }\n const attestations: OutreachAttestationKind[] = asIs.cold\n ? [...asIs.missingAttestations]\n : asIs.blocks.some((block) => block.code === 'country_unknown')\n ? ['us_business_address']\n : []\n return {\n ...person,\n ...known,\n status: 'needs_confirmation',\n blocks: [],\n requires: {\n personalLine: asIs.blocks.some((block) => block.code === 'personal_line_missing'),\n attestations,\n },\n }\n}\n\nexport interface OutreachEnrollDecision {\n allowed: boolean\n blocks: OutreachEnrollBlock[]\n cold: boolean\n /** The address as the gates normalized it. */\n email: string | null\n /** What the rep confirmed, stamped with who and when. */\n attestations: OutreachAttestations\n}\n\n/** Confirm's answer for one person, with what the rep supplied — see the module note. */\nexport function decideOutreachEnrollment(\n question: OutreachGateQuestion,\n facts: { siteName: string; alreadyInSequence: boolean },\n supplied: {\n personalLine: string\n attestations: readonly unknown[]\n uid: string\n nowMs: number\n },\n): OutreachEnrollDecision {\n const attestations = outreachAttestationsFrom(supplied.attestations, supplied.uid, supplied.nowMs)\n const early = outreachCandidateBlocks(question.candidate, facts)\n if (early.length) {\n return { allowed: false, blocks: early, cold: false, email: question.candidate.email, attestations }\n }\n const result = evaluateOutreachGates({\n email: question.candidate.email ?? '',\n contact: question.candidate.contact,\n contactGroupId: question.contactGroupId,\n company: question.candidate.company,\n settings: question.settings,\n personalLine: supplied.personalLine,\n attestations,\n lookups: question.lookups,\n })\n return {\n allowed: result.allowed,\n blocks: blocksOf(result.blocks),\n cold: result.cold,\n email: result.email,\n attestations,\n }\n}\n"],"names":["normalizeContactEmail","contactDisplayName","readContactFacet","crmLeadDisplayName","crmLeadStatus","visibleToHost","findContactByEmail","evaluateOutreachGates","outreachAttestationsFrom","OUTREACH_ATTESTATION_KINDS","leadAsContact","lead","contactGroupId","sources","raw","Array","isArray","source","String","kind","startsWith","email","name","emailState","facets","address","companyName","jobTitle","phone","tags","GET_ALL_CHUNK","getAllChunked","firestore","refs","snapshots","start","length","push","getAll","slice","readOutreachEnrollCandidates","input","people","org","collection","doc","orgId","leads","map","person","id","read","Promise","all","index","data","exists","held","personId","target","contactId","leadId","contact","visible","companyId","convertedContactId","heldAsContactId","hostId","trim","companyIds","Set","entry","filter","Boolean","companies","Map","found","forEach","snapshot","set","candidate","company","get","personOf","outreachCandidateBlocks","facts","code","reason","blocks","siteName","alreadyInSequence","EVERYTHING_CONFIRMED","Object","fromEntries","uid","atMs","blocksOf","previewOutreachPerson","question","early","cold","country","status","requires","personalLine","attestations","ask","settings","lookups","asIs","known","allowed","confirmed","missingAttestations","some","block","decideOutreachEnrollment","supplied","nowMs","result"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/enrollment/enroll-people.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 * WHO CAN BE ENROLLED, AND WHAT THE REP STILL OWES (AGL-2980).\n *\n * The enroll dialog shows every person a view or a search named, each\n * marked eligible, blocked, or waiting on the rep; Confirm asks the same\n * question again, on the server, with what the rep supplied. Both answers\n * come from the engine's `evaluateOutreachGates` — this module only reads\n * the people and asks it the right questions.\n *\n * ## Three questions of one pure function\n *\n * The gates refuse a cold contact until the rep writes a personal line and\n * ticks the attestations, and refuse an address in an unknown country until\n * the rep confirms it is a US business address. So a preview asks:\n *\n * 1. as the person stands, with nothing confirmed — allowed means\n * ELIGIBLE;\n * 2. as if the rep confirmed everything they can — refused means BLOCKED,\n * for the reasons that remain, which no tick can clear;\n * 3. otherwise the person is WAITING ON THE REP, for what (1) found\n * missing.\n *\n * The confirmation in (2) is hypothetical and never stored: Confirm stamps\n * only what the rep ticked, with their uid and the time, and asks again.\n *\n * ## One enrollment per person per sequence, ever\n *\n * The enrollment's id is the sequence and the contact\n * (`outreachEnrollmentId`), and it is written with `create()`. A person who\n * finished, replied or was stopped is not sent the same emails a second\n * time by being enrolled again, and two members confirming the same person\n * at once write one enrollment.\n *==========================================*/\n\nimport { normalizeContactEmail, contactDisplayName, readContactFacet } from '@aglyn/aglyn/app-utils/contacts'\nimport { crmLeadDisplayName, crmLeadStatus } from '@aglyn/aglyn/app-utils/crm'\nimport { visibleToHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport { findContactByEmail } from '@aglyn/tenant-data-admin/server/contact-email-index'\nimport {\n evaluateOutreachGates,\n type OutreachGateBlock,\n type OutreachGateHold,\n type OutreachGateLookups,\n} from '../engine/gates'\nimport { outreachAttestationsFrom } from '../engine/enrollment-state'\nimport {\n OUTREACH_ATTESTATION_KINDS,\n type OutreachAttestationKind,\n type OutreachAttestations,\n type OutreachSequenceSettings,\n} from '../model/outreach.types'\nimport type {\n OutreachEnrollBlock,\n OutreachEnrollGateway,\n OutreachEnrollPreviewPerson,\n OutreachPersonRef,\n} from '../model/outreach-api'\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/**\n * One person as the preview and the enroll read them: a contact, or a lead\n * the sequence's site holds (AGL-3234).\n *\n * ## A lead is judged as the contact it would be\n *\n * The gates, the merge fields and the country rule all read a CONTACT\n * document — its facet's sources, stage, address, company and title. A lead\n * carries the same facts in its own shape, so rather than teach every gate\n * two records, a lead is handed to them as {@link leadAsContact}: a\n * contact-shaped view whose one facet, under the sequence's group, holds\n * what the lead holds. An imported lead has no inbound source and is cold;\n * one that wrote in through a form is not; a lead is never a customer; its\n * address decides its country; and `{{contact.firstName}}` or\n * `{{contact.company}}` in a step reads the lead's name and company text,\n * so a sequence written for contacts sends to leads unchanged — which is\n * also what lets an enrollment made on a lead keep sending after the lead\n * converts and the view becomes the real contact.\n */\nexport interface OutreachEnrollCandidate {\n /** The record's own id: the contact's, or the lead's person key. */\n personId: string\n target: 'contact' | 'lead'\n /** The contact's id; `''` for a lead. */\n contactId: string\n /** The lead's person key; `null` for a contact. */\n leadId: string | null\n /**\n * The contact document (`orgs/{orgId}/contacts/{id}`), or for a lead the\n * contact-shaped view of it — see the module note. `null` when the record\n * no longer exists.\n */\n contact: Record<string, unknown> | null\n /** The lead document as stored, for a lead; `null` for a contact. */\n lead: Record<string, unknown> | null\n /** Whether the sequence's site may see the record. A lead is the site's own. */\n visible: boolean\n /** The name the sequence's site knows them by; `''` for none. */\n name: string\n /** The primary address, normalized, or `null` when there is none. */\n email: string | null\n /** The company the contact is filed under at that site, when there is one. */\n company: Record<string, unknown> | null\n /** A lead already converted: the contact it became. Enroll that instead. */\n convertedContactId: string | null\n /** A lead whose address the workspace already holds as a contact. Enroll that instead. */\n heldAsContactId: string | null\n}\n\n/**\n * A lead as the gates and the merge fields read a contact — see the\n * module note. The facet is the sequence's group's, which is the one every\n * reader asks for. The lead's `sources` — `form:{id}`, `booking`, `import`,\n * `manual`, `api`, `signup` — become the facet's source flags by kind, so\n * the inbound rule reads them as it reads a contact's.\n */\nexport function leadAsContact(\n lead: Record<string, unknown>,\n contactGroupId: string,\n): Record<string, unknown> {\n const sources: Record<string, true> = {}\n for (const raw of Array.isArray(lead['sources']) ? lead['sources'] : []) {\n const source = String(raw)\n const kind = source.startsWith('form:') ? 'form' : source === 'signup' ? 'member' : source\n if (kind) sources[kind] = true\n }\n return {\n email: lead['email'],\n name: lead['name'],\n // The verdict on the address (AGL-3245) is the lead's own top-level\n // field, as it is the contact's, so the gates quote it either way.\n ...(lead['emailState'] && typeof lead['emailState'] === 'object' ? { emailState: lead['emailState'] } : {}),\n facets: {\n [contactGroupId]: {\n sources,\n ...(lead['address'] && typeof lead['address'] === 'object' ? { address: lead['address'] } : {}),\n ...(typeof lead['company'] === 'string' ? { companyName: lead['company'] } : {}),\n ...(typeof lead['jobTitle'] === 'string' ? { jobTitle: lead['jobTitle'] } : {}),\n ...(typeof lead['phone'] === 'string' ? { phone: lead['phone'] } : {}),\n ...(Array.isArray(lead['tags']) ? { tags: lead['tags'] } : {}),\n },\n },\n }\n}\n\n/** `getAll` takes this many references at most per call. */\nconst GET_ALL_CHUNK = 300\n\nasync function getAllChunked(\n firestore: Firestore,\n refs: readonly FirebaseFirestore.DocumentReference[],\n): Promise<FirebaseFirestore.DocumentSnapshot[]> {\n const snapshots: FirebaseFirestore.DocumentSnapshot[] = []\n for (let start = 0; start < refs.length; start += GET_ALL_CHUNK) {\n snapshots.push(...(await firestore.getAll(...refs.slice(start, start + GET_ALL_CHUNK))))\n }\n return snapshots\n}\n\n/**\n * The people, as the sequence's site knows them, and the company each\n * contact is filed under there — batched reads for everyone: the contacts\n * and the leads by id, the companies once, and for each lead one address\n * lookup, because a lead the workspace already holds as a contact is that\n * contact's to enroll.\n */\nexport async function readOutreachEnrollCandidates(\n firestore: Firestore,\n input: { orgId: string; hostId: string; contactGroupId: string; people: readonly OutreachPersonRef[] },\n): Promise<OutreachEnrollCandidate[]> {\n if (!input.people.length) return []\n const org = firestore.collection('orgs').doc(input.orgId)\n // Beside the contacts, on the org (AGL-3275).\n const leads = org.collection('leads')\n const snapshots = await getAllChunked(\n firestore,\n input.people.map((person) =>\n person.kind === 'lead' ? leads.doc(person.id) : org.collection('contacts').doc(person.id),\n ),\n )\n const read = await Promise.all(\n input.people.map(async (person, index) => {\n const data = snapshots[index]?.exists ? (snapshots[index].data() as Record<string, unknown>) : null\n if (person.kind === 'lead') {\n const email = data ? normalizeContactEmail(data['email']) : null\n const held =\n data && email ? await findContactByEmail(org.collection('contacts'), email) : null\n return {\n personId: person.id,\n target: 'lead' as const,\n contactId: '',\n leadId: person.id,\n contact: data ? leadAsContact(data, input.contactGroupId) : null,\n lead: data,\n // A lead is the site's own record: no scope to check.\n visible: data !== null,\n name: data ? crmLeadDisplayName(data) : '',\n email,\n companyId: '',\n convertedContactId: data && typeof data['convertedContactId'] === 'string' ? data['convertedContactId'] : null,\n heldAsContactId: held ? held.id : null,\n }\n }\n return {\n personId: person.id,\n target: 'contact' as const,\n contactId: person.id,\n leadId: null,\n contact: data,\n lead: null,\n visible: data ? visibleToHost(data['visibleTo'] as string[] | undefined, input.hostId) : false,\n name: data ? contactDisplayName(data, input.contactGroupId).trim() : '',\n email: data ? normalizeContactEmail(data['email']) : null,\n companyId: data ? String(readContactFacet(data, input.contactGroupId).companyId ?? '') : '',\n convertedContactId: null,\n heldAsContactId: null,\n }\n }),\n )\n const companyIds = [...new Set(read.map((entry) => entry.companyId).filter(Boolean))]\n const companies = new Map<string, Record<string, unknown>>()\n if (companyIds.length) {\n const found = await getAllChunked(\n firestore,\n companyIds.map((id) => org.collection('companies').doc(id)),\n )\n found.forEach((snapshot, index) => {\n if (snapshot.exists) companies.set(companyIds[index], snapshot.data() as Record<string, unknown>)\n })\n }\n return read.map(({ companyId, ...candidate }) => ({\n ...candidate,\n company: companyId ? (companies.get(companyId) ?? null) : null,\n }))\n}\n\n/** The person as every answer names them. */\nfunction personOf(candidate: OutreachEnrollCandidate) {\n return {\n personId: candidate.personId,\n target: candidate.target,\n contactId: candidate.contactId,\n leadId: candidate.leadId,\n }\n}\n\n/** What stands in the way before the gates are asked anything. */\nexport function outreachCandidateBlocks(\n candidate: OutreachEnrollCandidate,\n facts: { siteName: string; alreadyInSequence: boolean },\n): OutreachEnrollBlock[] {\n if (!candidate.contact) {\n return candidate.target === 'lead'\n ? [{ code: 'lead_missing', reason: \"This lead no longer exists in this sequence's site's CRM.\" }]\n : [{ code: 'contact_missing', reason: 'This contact no longer exists in the CRM.' }]\n }\n const blocks: OutreachEnrollBlock[] = []\n if (candidate.target === 'lead') {\n // A lead that converted, or whose address the workspace already holds\n // as a contact, is that contact's to enroll — one person, one record.\n if (candidate.convertedContactId) {\n blocks.push({\n code: 'lead_converted',\n reason: 'This lead was converted into a contact. Enroll the contact instead.',\n })\n } else if (candidate.heldAsContactId) {\n blocks.push({\n code: 'lead_is_contact',\n reason: 'This address is already a contact in your CRM. Enroll the contact instead.',\n })\n }\n if (crmLeadStatus(candidate.lead) === 'unqualified') {\n blocks.push({\n code: 'lead_unqualified',\n reason: 'This lead was closed as unqualified. Reopen it before enrolling.',\n })\n }\n }\n if (!candidate.visible) {\n blocks.push({\n code: 'not_in_site',\n reason: `This contact isn't in ${facts.siteName || \"this sequence's site\"}'s CRM.`,\n })\n }\n if (!candidate.email) {\n blocks.push({ code: 'no_email', reason: 'This contact has no email address.' })\n }\n if (facts.alreadyInSequence) {\n blocks.push({\n code: 'already_in_sequence',\n reason: \"This person has already been in this sequence, so they won't be sent its emails again.\",\n })\n }\n return blocks\n}\n\n/** Every attestation, stamped for a hypothetical question only — never stored. */\nconst EVERYTHING_CONFIRMED: OutreachAttestations = Object.fromEntries(\n OUTREACH_ATTESTATION_KINDS.map((kind) => [kind, { uid: 'preview', atMs: 1 }]),\n)\n\nconst blocksOf = (blocks: readonly OutreachGateBlock[]): OutreachEnrollBlock[] =>\n blocks.map(({ code, reason }) => ({ code, reason }))\n\nexport interface OutreachGateQuestion {\n candidate: OutreachEnrollCandidate\n lookups: OutreachGateLookups\n /** The sequence's settings, its countries already capped by the organization's. */\n settings: OutreachSequenceSettings\n contactGroupId: string\n}\n\n/**\n * The gateway as the dialog shows it (AGL-3326): the standing the lookups\n * read, and whether the gates would hold a send into it.\n */\nfunction gatewayOf(question: OutreachGateQuestion, hold: OutreachGateHold | null): OutreachEnrollGateway | null {\n const standing = question.lookups?.gateway\n return standing ? { ...standing, hold: hold !== null } : null\n}\n\n/** Where one person stands before the rep confirms anything — see the module note. */\nexport function previewOutreachPerson(\n question: OutreachGateQuestion,\n facts: { siteName: string; alreadyInSequence: boolean },\n): OutreachEnrollPreviewPerson {\n const { candidate } = question\n const person = {\n ...personOf(candidate),\n name: candidate.name,\n email: candidate.email,\n }\n const early = outreachCandidateBlocks(candidate, facts)\n if (early.length) {\n return {\n ...person,\n cold: false,\n country: null,\n status: 'blocked',\n blocks: early,\n requires: { personalLine: false, attestations: [] },\n gateway: null,\n }\n }\n const ask = (personalLine: string, attestations: OutreachAttestations) =>\n evaluateOutreachGates({\n email: candidate.email ?? '',\n contact: candidate.contact,\n contactGroupId: question.contactGroupId,\n company: candidate.company,\n settings: question.settings,\n personalLine,\n attestations,\n lookups: question.lookups,\n })\n const asIs = ask('', {})\n const known = { cold: asIs.cold, country: asIs.country.country, gateway: gatewayOf(question, asIs.hold) }\n if (asIs.allowed) {\n return {\n ...person,\n ...known,\n status: 'eligible',\n blocks: [],\n requires: { personalLine: false, attestations: [] },\n }\n }\n // The line is only checked for being present, so any sentence stands in.\n const confirmed = ask('A personal line.', EVERYTHING_CONFIRMED)\n if (!confirmed.allowed) {\n return {\n ...person,\n ...known,\n status: 'blocked',\n blocks: blocksOf(confirmed.blocks),\n requires: { personalLine: false, attestations: [] },\n }\n }\n const attestations: OutreachAttestationKind[] = asIs.cold\n ? [...asIs.missingAttestations]\n : asIs.blocks.some((block) => block.code === 'country_unknown')\n ? ['us_business_address']\n : []\n return {\n ...person,\n ...known,\n status: 'needs_confirmation',\n blocks: [],\n requires: {\n personalLine: asIs.blocks.some((block) => block.code === 'personal_line_missing'),\n attestations,\n },\n }\n}\n\nexport interface OutreachEnrollDecision {\n allowed: boolean\n blocks: OutreachEnrollBlock[]\n cold: boolean\n /** The address as the gates normalized it. */\n email: string | null\n /** What the rep confirmed, stamped with who and when. */\n attestations: OutreachAttestations\n /**\n * The gateway hold the gates would put on the send (AGL-3326), which the\n * member enrolling past the red chip releases; `null` when none applies.\n */\n hold: OutreachGateHold | null\n}\n\n/** Confirm's answer for one person, with what the rep supplied — see the module note. */\nexport function decideOutreachEnrollment(\n question: OutreachGateQuestion,\n facts: { siteName: string; alreadyInSequence: boolean },\n supplied: {\n personalLine: string\n attestations: readonly unknown[]\n uid: string\n nowMs: number\n },\n): OutreachEnrollDecision {\n const attestations = outreachAttestationsFrom(supplied.attestations, supplied.uid, supplied.nowMs)\n const early = outreachCandidateBlocks(question.candidate, facts)\n if (early.length) {\n return { allowed: false, blocks: early, cold: false, email: question.candidate.email, attestations, hold: null }\n }\n const result = evaluateOutreachGates({\n email: question.candidate.email ?? '',\n contact: question.candidate.contact,\n contactGroupId: question.contactGroupId,\n company: question.candidate.company,\n settings: question.settings,\n personalLine: supplied.personalLine,\n attestations,\n lookups: question.lookups,\n })\n return {\n allowed: result.allowed,\n blocks: blocksOf(result.blocks),\n cold: result.cold,\n email: result.email,\n attestations,\n hold: result.hold,\n }\n}\n"],"names":["normalizeContactEmail","contactDisplayName","readContactFacet","crmLeadDisplayName","crmLeadStatus","visibleToHost","findContactByEmail","evaluateOutreachGates","outreachAttestationsFrom","OUTREACH_ATTESTATION_KINDS","leadAsContact","lead","contactGroupId","sources","raw","Array","isArray","source","String","kind","startsWith","email","name","emailState","facets","address","companyName","jobTitle","phone","tags","GET_ALL_CHUNK","getAllChunked","firestore","refs","snapshots","start","length","push","getAll","slice","readOutreachEnrollCandidates","input","people","org","collection","doc","orgId","leads","map","person","id","read","Promise","all","index","data","exists","held","personId","target","contactId","leadId","contact","visible","companyId","convertedContactId","heldAsContactId","hostId","trim","companyIds","Set","entry","filter","Boolean","companies","Map","found","forEach","snapshot","set","candidate","company","get","personOf","outreachCandidateBlocks","facts","code","reason","blocks","siteName","alreadyInSequence","EVERYTHING_CONFIRMED","Object","fromEntries","uid","atMs","blocksOf","gatewayOf","question","hold","standing","lookups","gateway","previewOutreachPerson","early","cold","country","status","requires","personalLine","attestations","ask","settings","asIs","known","allowed","confirmed","missingAttestations","some","block","decideOutreachEnrollment","supplied","nowMs","result"],"mappings":"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"}
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
17
|
import type { OutreachGateLookups } from '../engine/gates';
|
|
18
|
+
import { type OutreachDomainIntelReadInput } from '../storage/domain-intel-store';
|
|
18
19
|
/**
|
|
19
20
|
* One person to look up: the record they are, and the address the steps go
|
|
20
21
|
* to. `personId` keys the answer — the contact's id, or the lead's person
|
|
@@ -47,6 +48,12 @@ export interface ReadOutreachGateLookupsInput {
|
|
|
47
48
|
* site's own does. Omitted, it does not.
|
|
48
49
|
*/
|
|
49
50
|
consentAwaitsConfirmation?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* The MX resolver and the clock the domain intel is read with (AGL-3326):
|
|
53
|
+
* a domain's gateway is looked up once a week and cached on the org, and
|
|
54
|
+
* its ledger is read for the windows the hold and the chip judge.
|
|
55
|
+
*/
|
|
56
|
+
gateway: OutreachDomainIntelReadInput;
|
|
50
57
|
people: readonly OutreachGateLookupPerson[];
|
|
51
58
|
}
|
|
52
59
|
type Firestore = FirebaseFirestore.Firestore;
|
|
@@ -43,6 +43,7 @@ import { personKey } from "@aglyn/aglyn/app-utils/person-key";
|
|
|
43
43
|
import { loadCrmInboundRoster } from "@aglyn/tenant-data-admin/server/crm-inbound-email";
|
|
44
44
|
import { EMAIL_SUPPRESSIONS_COLLECTION, HOST_SUPPRESSIONS_SUBCOLLECTION } from "@aglyn/tenant-data-admin/server/email-suppression";
|
|
45
45
|
import { lookupOutreachDoNotContact, lookupOutreachDoNotContactDomains } from "../storage/do-not-contact-store.js";
|
|
46
|
+
import { readOutreachGatewayStandings } from "../storage/domain-intel-store.js";
|
|
46
47
|
import { outreachOrgCollection } from "../storage/outreach-records.js";
|
|
47
48
|
/** `getAll` takes this many references at most per call. */ const GET_ALL_CHUNK = 300;
|
|
48
49
|
async function getAllChunked(firestore, refs) {
|
|
@@ -109,7 +110,7 @@ async function getAllChunked(firestore, refs) {
|
|
|
109
110
|
];
|
|
110
111
|
const enrollments = outreachOrgCollection(firestore, orgId, 'enrollments');
|
|
111
112
|
const activities = firestore.collection('orgs').doc(orgId).collection(CRM_COLLECTIONS.activities);
|
|
112
|
-
const [platform, site, sales, doNotContact, doNotContactDomain, roster, perPerson] = await Promise.all([
|
|
113
|
+
const [platform, site, sales, doNotContact, doNotContactDomain, gateway, roster, perPerson] = await Promise.all([
|
|
113
114
|
keyedLookup(firestore, people, (key)=>firestore.collection(EMAIL_SUPPRESSIONS_COLLECTION).doc(key), // A released record no longer suppresses.
|
|
114
115
|
(snapshot)=>snapshot.exists && !snapshot.get('releasedAt'), 'platform suppression'),
|
|
115
116
|
keyedGroupLookup(firestore, people, hostIds, (host, key)=>host.collection(HOST_SUPPRESSIONS_SUBCOLLECTION).doc(key), (snapshot)=>snapshot.exists, // An entry on any site of the group suppresses.
|
|
@@ -129,6 +130,13 @@ async function getAllChunked(firestore, refs) {
|
|
|
129
130
|
// The domain half of the same list (AGL-3244), one `getAll` over the
|
|
130
131
|
// distinct domains the people are at.
|
|
131
132
|
lookupOutreachDoNotContactDomains(firestore, orgId, people.map((person)=>person.email)),
|
|
133
|
+
// The mail gateway in front of each domain and its record here
|
|
134
|
+
// (AGL-3326), one read over the distinct domains and one over the
|
|
135
|
+
// gateways they name.
|
|
136
|
+
readOutreachGatewayStandings(firestore, orgId, people.map((person)=>person.email), input.gateway).catch((error)=>{
|
|
137
|
+
console.error('[outreach] mail gateway lookup failed; reading as unchecked', error);
|
|
138
|
+
return new Map();
|
|
139
|
+
}),
|
|
132
140
|
loadCrmInboundRoster(firestore, orgId).catch((error)=>{
|
|
133
141
|
console.error('[outreach] roster lookup failed; reading as unchecked', error);
|
|
134
142
|
return null;
|
|
@@ -177,7 +185,7 @@ async function getAllChunked(firestore, refs) {
|
|
|
177
185
|
]));
|
|
178
186
|
const lookups = new Map();
|
|
179
187
|
for (const person of people){
|
|
180
|
-
var _platform_get, _site_get, _sales_get, _doNotContact_get, _doNotContactDomain_get, _ref, _ref1;
|
|
188
|
+
var _platform_get, _site_get, _sales_get, _doNotContact_get, _doNotContactDomain_get, _gateway_get, _ref, _ref1;
|
|
181
189
|
const own = byPerson.get(person.personId);
|
|
182
190
|
lookups.set(person.personId, {
|
|
183
191
|
platformSuppressed: (_platform_get = platform.get(person.personId)) != null ? _platform_get : null,
|
|
@@ -185,6 +193,7 @@ async function getAllChunked(firestore, refs) {
|
|
|
185
193
|
salesTopicState: (_sales_get = sales.get(person.personId)) != null ? _sales_get : null,
|
|
186
194
|
doNotContact: (_doNotContact_get = doNotContact.get(person.email)) != null ? _doNotContact_get : null,
|
|
187
195
|
doNotContactDomain: (_doNotContactDomain_get = doNotContactDomain.get(person.email)) != null ? _doNotContactDomain_get : null,
|
|
196
|
+
gateway: (_gateway_get = gateway.get(person.email)) != null ? _gateway_get : null,
|
|
188
197
|
workspaceMembers: roster,
|
|
189
198
|
openEnrollments: (_ref = own == null ? void 0 : own.open) != null ? _ref : null,
|
|
190
199
|
hasInboundEmail: (_ref1 = own == null ? void 0 : own.inbound) != null ? _ref1 : null
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/enrollment/gate-lookups.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 LOOKUPS THE ENROLLMENT GATES DECIDE ON (AGL-2980).\n *\n * `evaluateOutreachGates` is pure: it reads a contact as stored and the\n * RESULTS of the reads the runtime makes. This is those reads, for many\n * people at once — the enroll preview asks about everyone a view names in\n * one pass, and the sending runtime asks about one person before each send\n * with the same function, so the two can never consult different lists.\n *\n * Server only. Every list keyed by address is read with one `getAll` over\n * `personKey` ids — the suppression lists, the sales topic opt-outs, the\n * do-not-contact list — so a lookup costs one round trip however many\n * people it covers, and needs no index that could go missing. The roster\n * is read once. The two per-person facts that are queries — the person's\n * other enrollments and whether they ever wrote in — are equality filters\n * on single fields.\n *\n * ## A read that failed is `null`, and `null` refuses\n *\n * Each lookup is caught on its own and answers `null` for everyone it\n * covered, which the gates read as \"we couldn't check\" and refuse: a\n * person who asked not to be emailed must not be emailed because a read\n * timed out. One failed list does not blank the others.\n */\n\nimport { consentGroupTopicState } from '@aglyn/aglyn/app-utils/consent-groups'\nimport { CRM_COLLECTIONS } from '@aglyn/aglyn/app-utils/crm'\nimport {\n EMAIL_TOPIC_SALES,\n readTopicSubscriptionState,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n type TopicSubscriptionState,\n} from '@aglyn/aglyn/app-utils/email-topics'\nimport type { MemberAddresses } from '@aglyn/aglyn/app-utils/member-email-aliases'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport { loadCrmInboundRoster } from '@aglyn/tenant-data-admin/server/crm-inbound-email'\nimport {\n EMAIL_SUPPRESSIONS_COLLECTION,\n HOST_SUPPRESSIONS_SUBCOLLECTION,\n} from '@aglyn/tenant-data-admin/server/email-suppression'\nimport type { OutreachGateLookups } from '../engine/gates'\nimport type { OutreachEnrollmentStatus } from '../model/outreach.types'\nimport {\n lookupOutreachDoNotContact,\n lookupOutreachDoNotContactDomains,\n} from '../storage/do-not-contact-store'\nimport { outreachOrgCollection } from '../storage/outreach-records'\n\n/**\n * One person to look up: the record they are, and the address the steps go\n * to. `personId` keys the answer — the contact's id, or the lead's person\n * key (AGL-3234); a lead has no contact to look up enrollments or inbound\n * email by, so those reads go by the lead instead.\n */\nexport interface OutreachGateLookupPerson {\n personId: string\n /** The contact's id, or `null` for a lead. */\n contactId: string | null\n /** The lead's person key, or `null` for a contact. */\n leadId: string | null\n email: string\n}\n\nexport interface ReadOutreachGateLookupsInput {\n orgId: string\n /** The sequence's site: whose suppression list and topic opt-outs apply. */\n hostId: string\n /**\n * Every site in the sequence site's consent group, the site included —\n * `consentGroupForHost(org, hostId).hostIds`. An org that declared several\n * sites one sender is one sender to the person who unsubscribed from any of\n * them, so the site suppression list and the sales opt-outs are read on\n * each. Omitted, the site alone.\n */\n consentHostIds?: readonly string[]\n /**\n * The same group's `awaitsConfirmation`: whether a sibling's pending\n * confirmation counts in the sales stream's standing as the sequence\n * site's own does. Omitted, it does not.\n */\n consentAwaitsConfirmation?: boolean\n people: readonly OutreachGateLookupPerson[]\n}\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** `getAll` takes this many references at most per call. */\nconst GET_ALL_CHUNK = 300\n\nasync function getAllChunked(\n firestore: Firestore,\n refs: readonly FirebaseFirestore.DocumentReference[],\n): Promise<FirebaseFirestore.DocumentSnapshot[]> {\n const snapshots: FirebaseFirestore.DocumentSnapshot[] = []\n for (let start = 0; start < refs.length; start += GET_ALL_CHUNK) {\n snapshots.push(...(await firestore.getAll(...refs.slice(start, start + GET_ALL_CHUNK))))\n }\n return snapshots\n}\n\n/**\n * One keyed list, read for every person at once: `answer` turns each\n * person's document into their answer. An address that cannot be keyed has\n * no document anywhere and is answered as unchecked, and so is everyone\n * when the read fails.\n */\nasync function keyedLookup<T>(\n firestore: Firestore,\n people: readonly OutreachGateLookupPerson[],\n collection: (key: string) => FirebaseFirestore.DocumentReference,\n answer: (snapshot: FirebaseFirestore.DocumentSnapshot) => T,\n label: string,\n): Promise<Map<string, T | null>> {\n const answers = new Map<string, T | null>()\n const keyed: Array<{ personId: string; key: string }> = []\n for (const person of people) {\n const key = personKey(person.email)\n if (key) keyed.push({ personId: person.personId, key })\n else answers.set(person.personId, null)\n }\n if (!keyed.length) return answers\n try {\n const snapshots = await getAllChunked(\n firestore,\n keyed.map((entry) => collection(entry.key)),\n )\n keyed.forEach((entry, index) => answers.set(entry.personId, answer(snapshots[index])))\n } catch (error) {\n console.error(`[outreach] ${label} lookup failed; reading as unchecked`, error)\n for (const entry of keyed) answers.set(entry.personId, null)\n }\n return answers\n}\n\n/**\n * The same keyed lookup on every site of a consent group, folded into one\n * answer per person by `fold`. A read that failed anywhere is `null` for the\n * people it covered, so a sibling's unreadable list refuses as the site's own\n * would. A group of one is one lookup, exactly as the site alone.\n */\nasync function keyedGroupLookup<T>(\n firestore: Firestore,\n people: readonly OutreachGateLookupPerson[],\n hostIds: readonly string[],\n collection: (host: FirebaseFirestore.DocumentReference, key: string) => FirebaseFirestore.DocumentReference,\n answer: (snapshot: FirebaseFirestore.DocumentSnapshot) => T,\n fold: (answers: readonly T[]) => T,\n label: string,\n): Promise<Map<string, T | null>> {\n const bySite = await Promise.all(\n hostIds.map((id) => {\n const host = firestore.collection('hosts').doc(id)\n return keyedLookup(firestore, people, (key) => collection(host, key), answer, label)\n }),\n )\n if (bySite.length === 1) return bySite[0]\n const folded = new Map<string, T | null>()\n for (const person of people) {\n const answers = bySite.map((site) => site.get(person.personId) ?? null)\n folded.set(\n person.personId,\n answers.some((answer) => answer === null) ? null : fold(answers as T[]),\n )\n }\n return folded\n}\n\n/** Every lookup the gates take, for each person, keyed by person id. */\nexport async function readOutreachGateLookups(\n firestore: Firestore,\n input: ReadOutreachGateLookupsInput,\n): Promise<Map<string, OutreachGateLookups>> {\n const { orgId, hostId, people } = input\n // The sequence's site first, then the rest of its consent group.\n const hostIds = [\n hostId,\n ...new Set((input.consentHostIds ?? []).filter((id) => id && id !== hostId)),\n ]\n const enrollments = outreachOrgCollection(firestore, orgId, 'enrollments')\n const activities = firestore.collection('orgs').doc(orgId).collection(CRM_COLLECTIONS.activities)\n\n const [platform, site, sales, doNotContact, doNotContactDomain, roster, perPerson] = await Promise.all([\n keyedLookup(\n firestore,\n people,\n (key) => firestore.collection(EMAIL_SUPPRESSIONS_COLLECTION).doc(key),\n // A released record no longer suppresses.\n (snapshot) => snapshot.exists && !snapshot.get('releasedAt'),\n 'platform suppression',\n ),\n keyedGroupLookup(\n firestore,\n people,\n hostIds,\n (host, key) => host.collection(HOST_SUPPRESSIONS_SUBCOLLECTION).doc(key),\n (snapshot) => snapshot.exists,\n // An entry on any site of the group suppresses.\n (answers) => answers.some(Boolean),\n 'site suppression',\n ),\n keyedGroupLookup<TopicSubscriptionState>(\n firestore,\n people,\n hostIds,\n (host, key) => host.collection(TOPIC_OPT_OUTS_SUBCOLLECTION).doc(key),\n (snapshot) =>\n readTopicSubscriptionState(\n ((snapshot.exists ? snapshot.get('topics') : null) ?? {})[EMAIL_TOPIC_SALES],\n ),\n // The group's standing, the way the topic filter folds it: leaving the\n // sales stream anywhere in the group is leaving the sender, and a\n // sibling's pending confirmation counts only where the org said the\n // group waits. What a pending standing means for a sales email is the\n // gates' question, not this one.\n (states) =>\n consentGroupTopicState(\n { awaitsConfirmation: input.consentAwaitsConfirmation === true },\n states,\n ),\n 'sales topic',\n ),\n lookupOutreachDoNotContact(\n firestore,\n orgId,\n people.map((person) => person.email),\n ),\n // The domain half of the same list (AGL-3244), one `getAll` over the\n // distinct domains the people are at.\n lookupOutreachDoNotContactDomains(\n firestore,\n orgId,\n people.map((person) => person.email),\n ),\n loadCrmInboundRoster(firestore, orgId).catch((error: unknown): MemberAddresses[] | null => {\n console.error('[outreach] roster lookup failed; reading as unchecked', error)\n return null\n }),\n Promise.all(\n people.map(async (person) => {\n const open = await Promise.all([\n person.contactId\n ? enrollments.where('contactId', '==', person.contactId).get()\n : enrollments.where('leadId', '==', person.leadId ?? '').get(),\n enrollments.where('email', '==', person.email).get(),\n ])\n .then(([byContact, byEmail]) => {\n const found = new Map<string, { id: string; sequenceId: string; status: OutreachEnrollmentStatus }>()\n for (const doc of [...byContact.docs, ...byEmail.docs]) {\n found.set(doc.id, {\n id: doc.id,\n sequenceId: String(doc.get('sequenceId') ?? ''),\n status: doc.get('status') as OutreachEnrollmentStatus,\n })\n }\n return [...found.values()]\n })\n .catch((error: unknown) => {\n console.error('[outreach] enrollment lookup failed; reading as unchecked', error)\n return null\n })\n // An email the person wrote: filed on the contact, or on the lead\n // while they were one (AGL-3234).\n const inbound = await activities\n .where(person.contactId ? 'contactId' : 'leadId', '==', person.contactId ?? person.leadId ?? '')\n .where('direction', '==', 'inbound')\n .limit(1)\n .get()\n .then((snapshot) => !snapshot.empty)\n .catch((error: unknown) => {\n console.error('[outreach] inbound email lookup failed; reading as cold', error)\n return null\n })\n return { personId: person.personId, open, inbound }\n }),\n ),\n ])\n\n const byPerson = new Map(perPerson.map((entry) => [entry.personId, entry]))\n const lookups = new Map<string, OutreachGateLookups>()\n for (const person of people) {\n const own = byPerson.get(person.personId)\n lookups.set(person.personId, {\n platformSuppressed: platform.get(person.personId) ?? null,\n hostSuppressed: site.get(person.personId) ?? null,\n salesTopicState: sales.get(person.personId) ?? null,\n doNotContact: doNotContact.get(person.email) ?? null,\n doNotContactDomain: doNotContactDomain.get(person.email) ?? null,\n workspaceMembers: roster,\n openEnrollments: own?.open ?? null,\n hasInboundEmail: own?.inbound ?? null,\n })\n }\n return lookups\n}\n"],"names":["consentGroupTopicState","CRM_COLLECTIONS","EMAIL_TOPIC_SALES","readTopicSubscriptionState","TOPIC_OPT_OUTS_SUBCOLLECTION","personKey","loadCrmInboundRoster","EMAIL_SUPPRESSIONS_COLLECTION","HOST_SUPPRESSIONS_SUBCOLLECTION","lookupOutreachDoNotContact","lookupOutreachDoNotContactDomains","outreachOrgCollection","GET_ALL_CHUNK","getAllChunked","firestore","refs","snapshots","start","length","push","getAll","slice","keyedLookup","people","collection","answer","label","answers","Map","keyed","person","key","email","personId","set","map","entry","forEach","index","error","console","keyedGroupLookup","hostIds","fold","bySite","Promise","all","id","host","doc","folded","site","get","some","readOutreachGateLookups","input","orgId","hostId","Set","consentHostIds","filter","enrollments","activities","platform","sales","doNotContact","doNotContactDomain","roster","perPerson","snapshot","exists","Boolean","states","awaitsConfirmation","consentAwaitsConfirmation","catch","open","contactId","where","leadId","then","byContact","byEmail","found","docs","sequenceId","String","status","values","inbound","limit","empty","byPerson","lookups","own","platformSuppressed","hostSuppressed","salesTopicState","workspaceMembers","openEnrollments","hasInboundEmail"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/enrollment/gate-lookups.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 LOOKUPS THE ENROLLMENT GATES DECIDE ON (AGL-2980).\n *\n * `evaluateOutreachGates` is pure: it reads a contact as stored and the\n * RESULTS of the reads the runtime makes. This is those reads, for many\n * people at once — the enroll preview asks about everyone a view names in\n * one pass, and the sending runtime asks about one person before each send\n * with the same function, so the two can never consult different lists.\n *\n * Server only. Every list keyed by address is read with one `getAll` over\n * `personKey` ids — the suppression lists, the sales topic opt-outs, the\n * do-not-contact list — so a lookup costs one round trip however many\n * people it covers, and needs no index that could go missing. The roster\n * is read once. The two per-person facts that are queries — the person's\n * other enrollments and whether they ever wrote in — are equality filters\n * on single fields.\n *\n * ## A read that failed is `null`, and `null` refuses\n *\n * Each lookup is caught on its own and answers `null` for everyone it\n * covered, which the gates read as \"we couldn't check\" and refuse: a\n * person who asked not to be emailed must not be emailed because a read\n * timed out. One failed list does not blank the others.\n */\n\nimport { consentGroupTopicState } from '@aglyn/aglyn/app-utils/consent-groups'\nimport { CRM_COLLECTIONS } from '@aglyn/aglyn/app-utils/crm'\nimport {\n EMAIL_TOPIC_SALES,\n readTopicSubscriptionState,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n type TopicSubscriptionState,\n} from '@aglyn/aglyn/app-utils/email-topics'\nimport type { MemberAddresses } from '@aglyn/aglyn/app-utils/member-email-aliases'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport { loadCrmInboundRoster } from '@aglyn/tenant-data-admin/server/crm-inbound-email'\nimport {\n EMAIL_SUPPRESSIONS_COLLECTION,\n HOST_SUPPRESSIONS_SUBCOLLECTION,\n} from '@aglyn/tenant-data-admin/server/email-suppression'\nimport type { OutreachGateLookups } from '../engine/gates'\nimport type { OutreachEnrollmentStatus } from '../model/outreach.types'\nimport {\n lookupOutreachDoNotContact,\n lookupOutreachDoNotContactDomains,\n} from '../storage/do-not-contact-store'\nimport { type OutreachDomainIntelReadInput, readOutreachGatewayStandings } from '../storage/domain-intel-store'\nimport { outreachOrgCollection } from '../storage/outreach-records'\n\n/**\n * One person to look up: the record they are, and the address the steps go\n * to. `personId` keys the answer — the contact's id, or the lead's person\n * key (AGL-3234); a lead has no contact to look up enrollments or inbound\n * email by, so those reads go by the lead instead.\n */\nexport interface OutreachGateLookupPerson {\n personId: string\n /** The contact's id, or `null` for a lead. */\n contactId: string | null\n /** The lead's person key, or `null` for a contact. */\n leadId: string | null\n email: string\n}\n\nexport interface ReadOutreachGateLookupsInput {\n orgId: string\n /** The sequence's site: whose suppression list and topic opt-outs apply. */\n hostId: string\n /**\n * Every site in the sequence site's consent group, the site included —\n * `consentGroupForHost(org, hostId).hostIds`. An org that declared several\n * sites one sender is one sender to the person who unsubscribed from any of\n * them, so the site suppression list and the sales opt-outs are read on\n * each. Omitted, the site alone.\n */\n consentHostIds?: readonly string[]\n /**\n * The same group's `awaitsConfirmation`: whether a sibling's pending\n * confirmation counts in the sales stream's standing as the sequence\n * site's own does. Omitted, it does not.\n */\n consentAwaitsConfirmation?: boolean\n /**\n * The MX resolver and the clock the domain intel is read with (AGL-3326):\n * a domain's gateway is looked up once a week and cached on the org, and\n * its ledger is read for the windows the hold and the chip judge.\n */\n gateway: OutreachDomainIntelReadInput\n people: readonly OutreachGateLookupPerson[]\n}\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** `getAll` takes this many references at most per call. */\nconst GET_ALL_CHUNK = 300\n\nasync function getAllChunked(\n firestore: Firestore,\n refs: readonly FirebaseFirestore.DocumentReference[],\n): Promise<FirebaseFirestore.DocumentSnapshot[]> {\n const snapshots: FirebaseFirestore.DocumentSnapshot[] = []\n for (let start = 0; start < refs.length; start += GET_ALL_CHUNK) {\n snapshots.push(...(await firestore.getAll(...refs.slice(start, start + GET_ALL_CHUNK))))\n }\n return snapshots\n}\n\n/**\n * One keyed list, read for every person at once: `answer` turns each\n * person's document into their answer. An address that cannot be keyed has\n * no document anywhere and is answered as unchecked, and so is everyone\n * when the read fails.\n */\nasync function keyedLookup<T>(\n firestore: Firestore,\n people: readonly OutreachGateLookupPerson[],\n collection: (key: string) => FirebaseFirestore.DocumentReference,\n answer: (snapshot: FirebaseFirestore.DocumentSnapshot) => T,\n label: string,\n): Promise<Map<string, T | null>> {\n const answers = new Map<string, T | null>()\n const keyed: Array<{ personId: string; key: string }> = []\n for (const person of people) {\n const key = personKey(person.email)\n if (key) keyed.push({ personId: person.personId, key })\n else answers.set(person.personId, null)\n }\n if (!keyed.length) return answers\n try {\n const snapshots = await getAllChunked(\n firestore,\n keyed.map((entry) => collection(entry.key)),\n )\n keyed.forEach((entry, index) => answers.set(entry.personId, answer(snapshots[index])))\n } catch (error) {\n console.error(`[outreach] ${label} lookup failed; reading as unchecked`, error)\n for (const entry of keyed) answers.set(entry.personId, null)\n }\n return answers\n}\n\n/**\n * The same keyed lookup on every site of a consent group, folded into one\n * answer per person by `fold`. A read that failed anywhere is `null` for the\n * people it covered, so a sibling's unreadable list refuses as the site's own\n * would. A group of one is one lookup, exactly as the site alone.\n */\nasync function keyedGroupLookup<T>(\n firestore: Firestore,\n people: readonly OutreachGateLookupPerson[],\n hostIds: readonly string[],\n collection: (host: FirebaseFirestore.DocumentReference, key: string) => FirebaseFirestore.DocumentReference,\n answer: (snapshot: FirebaseFirestore.DocumentSnapshot) => T,\n fold: (answers: readonly T[]) => T,\n label: string,\n): Promise<Map<string, T | null>> {\n const bySite = await Promise.all(\n hostIds.map((id) => {\n const host = firestore.collection('hosts').doc(id)\n return keyedLookup(firestore, people, (key) => collection(host, key), answer, label)\n }),\n )\n if (bySite.length === 1) return bySite[0]\n const folded = new Map<string, T | null>()\n for (const person of people) {\n const answers = bySite.map((site) => site.get(person.personId) ?? null)\n folded.set(\n person.personId,\n answers.some((answer) => answer === null) ? null : fold(answers as T[]),\n )\n }\n return folded\n}\n\n/** Every lookup the gates take, for each person, keyed by person id. */\nexport async function readOutreachGateLookups(\n firestore: Firestore,\n input: ReadOutreachGateLookupsInput,\n): Promise<Map<string, OutreachGateLookups>> {\n const { orgId, hostId, people } = input\n // The sequence's site first, then the rest of its consent group.\n const hostIds = [\n hostId,\n ...new Set((input.consentHostIds ?? []).filter((id) => id && id !== hostId)),\n ]\n const enrollments = outreachOrgCollection(firestore, orgId, 'enrollments')\n const activities = firestore.collection('orgs').doc(orgId).collection(CRM_COLLECTIONS.activities)\n\n const [platform, site, sales, doNotContact, doNotContactDomain, gateway, roster, perPerson] = await Promise.all([\n keyedLookup(\n firestore,\n people,\n (key) => firestore.collection(EMAIL_SUPPRESSIONS_COLLECTION).doc(key),\n // A released record no longer suppresses.\n (snapshot) => snapshot.exists && !snapshot.get('releasedAt'),\n 'platform suppression',\n ),\n keyedGroupLookup(\n firestore,\n people,\n hostIds,\n (host, key) => host.collection(HOST_SUPPRESSIONS_SUBCOLLECTION).doc(key),\n (snapshot) => snapshot.exists,\n // An entry on any site of the group suppresses.\n (answers) => answers.some(Boolean),\n 'site suppression',\n ),\n keyedGroupLookup<TopicSubscriptionState>(\n firestore,\n people,\n hostIds,\n (host, key) => host.collection(TOPIC_OPT_OUTS_SUBCOLLECTION).doc(key),\n (snapshot) =>\n readTopicSubscriptionState(\n ((snapshot.exists ? snapshot.get('topics') : null) ?? {})[EMAIL_TOPIC_SALES],\n ),\n // The group's standing, the way the topic filter folds it: leaving the\n // sales stream anywhere in the group is leaving the sender, and a\n // sibling's pending confirmation counts only where the org said the\n // group waits. What a pending standing means for a sales email is the\n // gates' question, not this one.\n (states) =>\n consentGroupTopicState(\n { awaitsConfirmation: input.consentAwaitsConfirmation === true },\n states,\n ),\n 'sales topic',\n ),\n lookupOutreachDoNotContact(\n firestore,\n orgId,\n people.map((person) => person.email),\n ),\n // The domain half of the same list (AGL-3244), one `getAll` over the\n // distinct domains the people are at.\n lookupOutreachDoNotContactDomains(\n firestore,\n orgId,\n people.map((person) => person.email),\n ),\n // The mail gateway in front of each domain and its record here\n // (AGL-3326), one read over the distinct domains and one over the\n // gateways they name.\n readOutreachGatewayStandings(\n firestore,\n orgId,\n people.map((person) => person.email),\n input.gateway,\n ).catch((error: unknown) => {\n console.error('[outreach] mail gateway lookup failed; reading as unchecked', error)\n return new Map<string, null>()\n }),\n loadCrmInboundRoster(firestore, orgId).catch((error: unknown): MemberAddresses[] | null => {\n console.error('[outreach] roster lookup failed; reading as unchecked', error)\n return null\n }),\n Promise.all(\n people.map(async (person) => {\n const open = await Promise.all([\n person.contactId\n ? enrollments.where('contactId', '==', person.contactId).get()\n : enrollments.where('leadId', '==', person.leadId ?? '').get(),\n enrollments.where('email', '==', person.email).get(),\n ])\n .then(([byContact, byEmail]) => {\n const found = new Map<string, { id: string; sequenceId: string; status: OutreachEnrollmentStatus }>()\n for (const doc of [...byContact.docs, ...byEmail.docs]) {\n found.set(doc.id, {\n id: doc.id,\n sequenceId: String(doc.get('sequenceId') ?? ''),\n status: doc.get('status') as OutreachEnrollmentStatus,\n })\n }\n return [...found.values()]\n })\n .catch((error: unknown) => {\n console.error('[outreach] enrollment lookup failed; reading as unchecked', error)\n return null\n })\n // An email the person wrote: filed on the contact, or on the lead\n // while they were one (AGL-3234).\n const inbound = await activities\n .where(person.contactId ? 'contactId' : 'leadId', '==', person.contactId ?? person.leadId ?? '')\n .where('direction', '==', 'inbound')\n .limit(1)\n .get()\n .then((snapshot) => !snapshot.empty)\n .catch((error: unknown) => {\n console.error('[outreach] inbound email lookup failed; reading as cold', error)\n return null\n })\n return { personId: person.personId, open, inbound }\n }),\n ),\n ])\n\n const byPerson = new Map(perPerson.map((entry) => [entry.personId, entry]))\n const lookups = new Map<string, OutreachGateLookups>()\n for (const person of people) {\n const own = byPerson.get(person.personId)\n lookups.set(person.personId, {\n platformSuppressed: platform.get(person.personId) ?? null,\n hostSuppressed: site.get(person.personId) ?? null,\n salesTopicState: sales.get(person.personId) ?? null,\n doNotContact: doNotContact.get(person.email) ?? null,\n doNotContactDomain: doNotContactDomain.get(person.email) ?? null,\n gateway: gateway.get(person.email) ?? null,\n workspaceMembers: roster,\n openEnrollments: own?.open ?? null,\n hasInboundEmail: own?.inbound ?? null,\n })\n }\n return lookups\n}\n"],"names":["consentGroupTopicState","CRM_COLLECTIONS","EMAIL_TOPIC_SALES","readTopicSubscriptionState","TOPIC_OPT_OUTS_SUBCOLLECTION","personKey","loadCrmInboundRoster","EMAIL_SUPPRESSIONS_COLLECTION","HOST_SUPPRESSIONS_SUBCOLLECTION","lookupOutreachDoNotContact","lookupOutreachDoNotContactDomains","readOutreachGatewayStandings","outreachOrgCollection","GET_ALL_CHUNK","getAllChunked","firestore","refs","snapshots","start","length","push","getAll","slice","keyedLookup","people","collection","answer","label","answers","Map","keyed","person","key","email","personId","set","map","entry","forEach","index","error","console","keyedGroupLookup","hostIds","fold","bySite","Promise","all","id","host","doc","folded","site","get","some","readOutreachGateLookups","input","orgId","hostId","Set","consentHostIds","filter","enrollments","activities","platform","sales","doNotContact","doNotContactDomain","gateway","roster","perPerson","snapshot","exists","Boolean","states","awaitsConfirmation","consentAwaitsConfirmation","catch","open","contactId","where","leadId","then","byContact","byEmail","found","docs","sequenceId","String","status","values","inbound","limit","empty","byPerson","lookups","own","platformSuppressed","hostSuppressed","salesTopicState","workspaceMembers","openEnrollments","hasInboundEmail"],"mappings":"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"}
|
|
@@ -24,12 +24,13 @@
|
|
|
24
24
|
* surface that names no site.
|
|
25
25
|
*/
|
|
26
26
|
import type { OutreachComposeError } from '../engine/compose';
|
|
27
|
+
import type { OutreachGatewayStanding } from '../engine/mail-gateway';
|
|
27
28
|
import type { OutreachValidationIssue } from '../engine/sequence-validation';
|
|
28
29
|
import type { OutreachComplianceIssue } from './compliance-settings';
|
|
29
30
|
import type { OutreachAttestationKind, OutreachComplianceSettingsDocument, OutreachDoNotContactDomainEntry, OutreachEnrollment, OutreachSequence, OutreachStepOverrideSource } from './outreach.types';
|
|
30
31
|
import type { OutreachSequenceDraft, OutreachSequenceIssue } from './sequence-draft';
|
|
31
32
|
/** Every reason these routes refuse with, for a caller to branch on. */
|
|
32
|
-
export type OutreachRouteRefusalReason = 'unauthenticated' | 'email-unverified' | 'org-required' | 'not-a-member' | 'not-org-wide' | 'permission' | 'entitlement' | 'method-not-allowed' | 'invalid-request' | 'invalid-domain' | 'invalid-settings' | 'invalid-sequence' | 'sequence-not-found' | 'sequence-archived' | 'sequence-not-active' | 'activation-refused' | 'delete-refused' | 'mailbox-unavailable' | 'enrollment-not-found' | 'transition-refused' | 'view-not-found' | 'view-unsupported' | 'contact-not-found' | 'link-domain-refused' | 'curation-unavailable' | 'curation-refused' | 'invalid-override';
|
|
33
|
+
export type OutreachRouteRefusalReason = 'unauthenticated' | 'email-unverified' | 'org-required' | 'not-a-member' | 'not-org-wide' | 'permission' | 'entitlement' | 'method-not-allowed' | 'invalid-request' | 'invalid-domain' | 'invalid-settings' | 'invalid-sequence' | 'sequence-not-found' | 'sequence-archived' | 'sequence-not-active' | 'activation-refused' | 'delete-refused' | 'mailbox-unavailable' | 'enrollment-not-found' | 'transition-refused' | 'view-not-found' | 'view-unsupported' | 'contact-not-found' | 'link-domain-refused' | 'rate-limited' | 'google-unavailable' | 'curation-unavailable' | 'curation-refused' | 'invalid-override';
|
|
33
34
|
/** A refusal, in the one shape every route answers with. */
|
|
34
35
|
export interface OutreachRouteRefusal {
|
|
35
36
|
/** A sentence written for the person reading it. */
|
|
@@ -134,6 +135,17 @@ export interface OutreachEnrollBlock {
|
|
|
134
135
|
code: string;
|
|
135
136
|
reason: string;
|
|
136
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* The mail gateway in front of one person's domain (AGL-3326), and what it
|
|
140
|
+
* did with this organization's mail this week — what the Check-people chip
|
|
141
|
+
* shows — with `hold` set when the engine would hold the send into it: the
|
|
142
|
+
* gateway refused the sender twice in the last thirty days and delivered
|
|
143
|
+
* nothing. A held person is not blocked; they default to un-ticked, and
|
|
144
|
+
* ticking them is the member's say-so.
|
|
145
|
+
*/
|
|
146
|
+
export interface OutreachEnrollGateway extends OutreachGatewayStanding {
|
|
147
|
+
hold: boolean;
|
|
148
|
+
}
|
|
137
149
|
export interface OutreachEnrollPreviewPerson {
|
|
138
150
|
/** The key the dialog and the confirm name this person by: the record's own id. */
|
|
139
151
|
personId: string;
|
|
@@ -158,6 +170,8 @@ export interface OutreachEnrollPreviewPerson {
|
|
|
158
170
|
personalLine: boolean;
|
|
159
171
|
attestations: OutreachAttestationKind[];
|
|
160
172
|
};
|
|
173
|
+
/** The mail gateway in front of their domain (AGL-3326); `null` when it could not be read. */
|
|
174
|
+
gateway: OutreachEnrollGateway | null;
|
|
161
175
|
}
|
|
162
176
|
export interface OutreachEnrollPreviewResponse {
|
|
163
177
|
ok: true;
|
|
@@ -329,6 +343,26 @@ export interface OutreachPreviewResponse {
|
|
|
329
343
|
/** Why it could not be written, when it could not. */
|
|
330
344
|
error: OutreachComposeError | null;
|
|
331
345
|
}
|
|
346
|
+
/**
|
|
347
|
+
* `POST outreach/steps/test` — one step of a saved sequence, rendered
|
|
348
|
+
* exactly as `outreach/preview` renders it (the same person, personal
|
|
349
|
+
* line and step) and sent through the sequence's mailbox to the member.
|
|
350
|
+
*/
|
|
351
|
+
export interface OutreachStepTestRequest extends OutreachPreviewRequest {
|
|
352
|
+
/** Where the test goes; the member's own address when absent or empty. */
|
|
353
|
+
to?: string;
|
|
354
|
+
}
|
|
355
|
+
export interface OutreachStepTestResponse {
|
|
356
|
+
ok: true;
|
|
357
|
+
stepIndex: number;
|
|
358
|
+
/** The address the test went to. */
|
|
359
|
+
sentTo: string;
|
|
360
|
+
/** The subject as sent, `[Test]` prefix included. */
|
|
361
|
+
subject: string;
|
|
362
|
+
sentAtMs: number;
|
|
363
|
+
/** How many tests the mailbox has sent on its local day, this one included. */
|
|
364
|
+
testsToday: number;
|
|
365
|
+
}
|
|
332
366
|
/**
|
|
333
367
|
* `POST outreach/curate/draft` — this person's own copies of the sequence's
|
|
334
368
|
* email steps, drafted by the workspace's AI. Names EITHER a person about to
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/model/outreach-api.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 CONTRACT OF OUTREACH'S SETTINGS, SEQUENCE AND ENROLLMENT ROUTES\n * (AGL-2980): what the console sends, what the routes answer, and every\n * reason one refuses. Client-safe — types and the batch limit only.\n *\n * Every request names its organization (`orgId`, in the query of a `GET`\n * and the JSON body of a `POST`): the routes serve an organization-level\n * surface that names no site.\n */\n\nimport type { OutreachComposeError } from '../engine/compose'\nimport type { OutreachValidationIssue } from '../engine/sequence-validation'\nimport type { OutreachComplianceIssue } from './compliance-settings'\nimport type {\n OutreachAttestationKind,\n OutreachComplianceSettingsDocument,\n OutreachDoNotContactDomainEntry,\n OutreachEnrollment,\n OutreachSequence,\n OutreachStepOverrideSource,\n} from './outreach.types'\nimport type { OutreachSequenceDraft, OutreachSequenceIssue } from './sequence-draft'\n\n/** Every reason these routes refuse with, for a caller to branch on. */\nexport type OutreachRouteRefusalReason =\n | 'unauthenticated'\n | 'email-unverified'\n | 'org-required'\n | 'not-a-member'\n | 'not-org-wide'\n | 'permission'\n | 'entitlement'\n | 'method-not-allowed'\n | 'invalid-request'\n | 'invalid-domain'\n | 'invalid-settings'\n | 'invalid-sequence'\n | 'sequence-not-found'\n | 'sequence-archived'\n | 'sequence-not-active'\n | 'activation-refused'\n | 'delete-refused'\n | 'mailbox-unavailable'\n | 'enrollment-not-found'\n | 'transition-refused'\n | 'view-not-found'\n | 'view-unsupported'\n | 'contact-not-found'\n | 'link-domain-refused'\n | 'curation-unavailable'\n | 'curation-refused'\n | 'invalid-override'\n\n/** A refusal, in the one shape every route answers with. */\nexport interface OutreachRouteRefusal {\n /** A sentence written for the person reading it. */\n error: string\n reason: OutreachRouteRefusalReason\n /** The field-level reasons behind an `invalid-*` or `activation-refused` refusal. */\n issues?: ReadonlyArray<OutreachComplianceIssue | OutreachSequenceIssue>\n}\n\n/*==========================================\n * COMPLIANCE SETTINGS\n *==========================================*/\n\n/** `GET outreach/settings?orgId` — and the answer to a save. */\nexport interface OutreachSettingsResponse {\n ok: true\n settings: OutreachComplianceSettingsDocument\n}\n\n/** `POST outreach/settings` */\nexport interface OutreachSettingsSaveRequest {\n orgId: string\n legalName: string\n brandName: string\n postalAddress: string\n allowedCountries: string[]\n}\n\n/** The answer to a save: the settings as stored, and whether anything changed. */\nexport interface OutreachSettingsSaveResponse extends OutreachSettingsResponse {\n changed: boolean\n}\n\n/*==========================================\n * SEQUENCES\n *==========================================*/\n\n/** `POST outreach/sequences/save` — a new sequence without `sequenceId`, an edit with one. */\nexport interface OutreachSequenceSaveRequest {\n orgId: string\n sequenceId?: string\n sequence: OutreachSequenceDraft\n}\n\nexport interface OutreachSequenceSaveResponse {\n ok: true\n sequence: OutreachSequence\n created: boolean\n /** What the save allowed but probably isn't what was meant. */\n warnings: OutreachSequenceIssue[]\n}\n\n/** What `outreach/sequences/status` does to a sequence. */\nexport type OutreachSequenceAction = 'activate' | 'pause' | 'archive'\n\n/** `POST outreach/sequences/status` */\nexport interface OutreachSequenceStatusRequest {\n orgId: string\n sequenceId: string\n action: OutreachSequenceAction\n}\n\nexport interface OutreachSequenceStatusResponse {\n ok: true\n sequence: OutreachSequence\n /** The open enrollments an archive stopped; `0` for anything else. */\n stoppedEnrollments: number\n}\n\n/** `POST outreach/sequences/delete` — a draft nobody was ever enrolled in. */\nexport interface OutreachSequenceDeleteRequest {\n orgId: string\n sequenceId: string\n}\n\n/*==========================================\n * ENROLLING\n *==========================================*/\n\n/** The most people one preview or one enroll reads. */\nexport const OUTREACH_ENROLL_BATCH_MAX = 50\n\n/**\n * Which CRM record a person to enroll is (AGL-3234): a contact by its\n * document id, or a lead the sequence's site holds by its person key.\n */\nexport type OutreachPersonRef = { kind: 'contact'; id: string } | { kind: 'lead'; id: string }\n\n/**\n * Where the people to enroll come from: a saved Contacts or Leads view,\n * contacts picked by search, or leads picked from the sequence's site.\n */\nexport type OutreachEnrollSource =\n | { kind: 'view'; viewId: string }\n | { kind: 'contacts'; contactIds: string[] }\n | { kind: 'leads'; leadIds: string[] }\n\n/** `POST outreach/enroll/preview` */\nexport interface OutreachEnrollPreviewRequest {\n orgId: string\n sequenceId: string\n source: OutreachEnrollSource\n}\n\n/**\n * Where one person stands:\n *\n * - `eligible` — enrolled on Confirm as they are;\n * - `needs_confirmation` — enrolled once the rep writes the personal line\n * and ticks the attestations `requires` names;\n * - `blocked` — refused whatever the rep confirms, for the `blocks` given.\n */\nexport type OutreachEnrollPreviewStatus = 'eligible' | 'needs_confirmation' | 'blocked'\n\n/** One reason a person can't be enrolled, in a sentence a rep can act on. */\nexport interface OutreachEnrollBlock {\n code: string\n reason: string\n}\n\nexport interface OutreachEnrollPreviewPerson {\n /** The key the dialog and the confirm name this person by: the record's own id. */\n personId: string\n /** The record the person is (AGL-3234). */\n target: 'contact' | 'lead'\n /** The contact's id; `''` for a lead. */\n contactId: string\n /** The lead's person key; `null` for a contact. */\n leadId: string | null\n name: string\n /** The address the steps would go to, or `null` when the contact has none. */\n email: string | null\n /** No inbound capture behind them — the heavier rules apply. */\n cold: boolean\n /** ISO-3166-1 alpha-2, when anything names one. */\n country: string | null\n status: OutreachEnrollPreviewStatus\n /** Why a `blocked` person is refused; empty otherwise. */\n blocks: OutreachEnrollBlock[]\n /** What the rep must supply before a `needs_confirmation` person is enrolled. */\n requires: {\n personalLine: boolean\n attestations: OutreachAttestationKind[]\n }\n}\n\nexport interface OutreachEnrollPreviewResponse {\n ok: true\n people: OutreachEnrollPreviewPerson[]\n /** Everyone the source named, before the batch limit. */\n total: number\n /** True when the source named more people than one enrollment reads. */\n truncated: boolean\n}\n\n/**\n * One person's confirmed copy of one step, as the console sends it\n * (AGL-3324): what the member read and ticked the confirmation for. The\n * route validates it again and stamps who and when.\n */\nexport interface OutreachStepOverrideRequest {\n stepIndex: number\n /** Read only where the email starts a thread. */\n subject?: string\n body: string\n source: OutreachStepOverrideSource\n /** An AI draft the member changed before confirming it. */\n edited?: boolean\n /** The prompt the AI was given, returned by the draft route, for the audit log. */\n prompt?: string\n /** The model that drafted it, returned by the draft route. */\n model?: string\n}\n\n/** One person to enroll, with what the rep supplied for them: a contact by id, or a lead by key. */\nexport interface OutreachEnrollPersonRequest {\n contactId?: string\n leadId?: string\n personalLine?: string\n attestations?: OutreachAttestationKind[]\n /** The steps the member curated for this person before enrolling them (AGL-3324). */\n stepOverrides?: OutreachStepOverrideRequest[]\n}\n\n/** `POST outreach/enroll` — the gates are checked again, here, before anything is written. */\nexport interface OutreachEnrollRequest {\n orgId: string\n sequenceId: string\n people: OutreachEnrollPersonRequest[]\n}\n\ninterface OutreachEnrollOutcomePerson {\n personId: string\n target: 'contact' | 'lead'\n contactId: string\n leadId: string | null\n email: string | null\n}\n\nexport type OutreachEnrollOutcome =\n | (OutreachEnrollOutcomePerson & { outcome: 'enrolled'; enrollmentId: string })\n | (OutreachEnrollOutcomePerson & { outcome: 'blocked'; blocks: OutreachEnrollBlock[] })\n\nexport interface OutreachEnrollResponse {\n ok: true\n results: OutreachEnrollOutcome[]\n enrolled: number\n}\n\n/*==========================================\n * ENROLLMENTS\n *==========================================*/\n\n/** What a member may do to one enrollment. */\nexport type OutreachEnrollmentAction = 'pause' | 'resume' | 'stop' | 'do_not_contact'\n\n/** `POST outreach/enrollments/action` */\nexport interface OutreachEnrollmentActionRequest {\n orgId: string\n enrollmentId: string\n action: OutreachEnrollmentAction\n /** Why, for a stop or a do-not-contact. */\n detail?: string\n}\n\nexport interface OutreachEnrollmentActionResponse {\n ok: true\n /** False when the enrollment was already where the action would put it. */\n changed: boolean\n enrollment: OutreachEnrollment\n /** Other open enrollments of the same address a do-not-contact stopped. */\n stoppedOthers: number\n}\n\n/*==========================================\n * DO NOT CONTACT: DOMAINS (AGL-3244)\n *==========================================*/\n\n/** `GET outreach/do-not-contact/domains?orgId` — and the answer to a change. */\nexport interface OutreachDoNotContactDomainsResponse {\n ok: true\n /** Every domain on the list, alphabetically. */\n domains: OutreachDoNotContactDomainEntry[]\n}\n\n/** `POST outreach/do-not-contact/domains` */\nexport interface OutreachDoNotContactDomainRequest {\n orgId: string\n action: 'add' | 'remove'\n /** The domain, or an address at it. */\n domain: string\n /** Why, for an add. */\n detail?: string\n}\n\n/** The answer to a change: the list as it stands, and whether anything changed. */\nexport interface OutreachDoNotContactDomainResponse extends OutreachDoNotContactDomainsResponse {\n changed: boolean\n /** The domain as the list spells it. */\n domain: string\n}\n\n/*==========================================\n * LINK DOMAINS (AGL-3306)\n *==========================================*/\n\n/** Where a mailbox domain's click-tracking host stands. */\nexport type OutreachLinkDomainStatus = 'not-set-up' | 'requested' | 'records-issued' | 'verified' | 'failed'\n\n/** One DNS record the member publishes, as the sending-domain cards print them. */\nexport interface OutreachLinkDomainRecord {\n type: string\n name: string\n value: string\n required: boolean\n note: string\n}\n\n/**\n * One sending domain of the organization's connected mailboxes, and its\n * `links.` host: what tracked links in mail from it read now, and what is\n * left to do for them to read `https://links.<domain>/<id>`.\n */\nexport interface OutreachLinkDomain {\n /** The mailboxes' sending domain. */\n domain: string\n /** `links.<domain>`. */\n host: string\n status: OutreachLinkDomainStatus\n /** The records to publish; empty until the host is set up. */\n records: OutreachLinkDomainRecord[]\n /** Why it is not verified, in a sentence; `null` when nothing is wrong. */\n detail: string | null\n /** How a tracked link in mail from this domain starts today. */\n linkPrefix: string | null\n checkedAtMs: number | null\n verifiedAtMs: number | null\n}\n\n/** `GET outreach/link-domains?orgId` */\nexport interface OutreachLinkDomainsResponse {\n ok: true\n domains: OutreachLinkDomain[]\n /** Whether the member may set a host up, check it, or remove it. */\n canManage: boolean\n}\n\n/** `POST outreach/link-domains` */\nexport interface OutreachLinkDomainRequest {\n orgId: string\n domain: string\n action: 'set-up' | 'check' | 'remove'\n}\n\nexport interface OutreachLinkDomainResponse extends OutreachLinkDomainsResponse {\n /** The domain the action was for. */\n domain: string\n}\n\n/*==========================================\n * PREVIEW\n *==========================================*/\n\n/** `POST outreach/preview` — one email of a saved sequence, as a person would get it. */\nexport interface OutreachPreviewRequest {\n orgId: string\n sequenceId: string\n /** The contact to write it to; a sample person when absent. */\n contactId?: string\n /** Or a lead the sequence's site holds (AGL-3234), written to as the contact it would be. */\n leadId?: string\n personalLine?: string\n /** Which step; the first email when absent. */\n stepIndex?: number\n /** The person's copies of steps as the dialog holds them (AGL-3324), so the preview shows the curated version. */\n stepOverrides?: OutreachStepOverrideRequest[]\n}\n\nexport interface OutreachPreviewResponse {\n ok: true\n stepIndex: number\n subject: string\n /** The plain text, footer included. */\n text: string\n unresolvedFields: string[]\n /** Why it could not be written, when it could not. */\n error: OutreachComposeError | null\n}\n\n/*==========================================\n * CURATING (AGL-3324)\n *==========================================*/\n\n/**\n * `POST outreach/curate/draft` — this person's own copies of the sequence's\n * email steps, drafted by the workspace's AI. Names EITHER a person about to\n * be enrolled (the sequence, a contact or a lead, the personal line the rep\n * is writing) OR an enrollment, whose next email step is drafted. Nothing is\n * stored: the answer is what the member reads before confirming.\n */\nexport interface OutreachCurateDraftRequest {\n orgId: string\n sequenceId?: string\n contactId?: string\n leadId?: string\n personalLine?: string\n enrollmentId?: string\n /** Which email steps; every email step for a person to enroll, the next one for an enrollment, when absent. */\n stepIndexes?: number[]\n}\n\n/** One drafted step, with what the validator said about it. */\nexport interface OutreachCurateDraft {\n stepIndex: number\n /** `null` for an in-thread email, which is sent as `Re:` the thread's subject. */\n subject: string | null\n body: string\n /** Errors and warnings; a draft with an error cannot be confirmed as it stands. */\n issues: OutreachValidationIssue[]\n}\n\nexport interface OutreachCurateDraftResponse {\n ok: true\n drafts: OutreachCurateDraft[]\n /** The prompt the AI was given and the model that answered, for the override's audit record. */\n prompt: string\n model: string\n}\n\n/** `POST outreach/curate/save` — stores a confirmed copy of one step on an enrollment, or clears it with `override: null`. */\nexport interface OutreachCurateSaveRequest {\n orgId: string\n enrollmentId: string\n stepIndex: number\n override: Omit<OutreachStepOverrideRequest, 'stepIndex'> | null\n}\n\nexport interface OutreachCurateSaveResponse {\n ok: true\n enrollment: OutreachEnrollment\n}\n"],"names":["OUTREACH_ENROLL_BATCH_MAX"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;CAQC,GAwHD;;4CAE4C,GAE5C,qDAAqD,GACrD,OAAO,MAAMA,4BAA4B,GAAE"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/model/outreach-api.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 CONTRACT OF OUTREACH'S SETTINGS, SEQUENCE AND ENROLLMENT ROUTES\n * (AGL-2980): what the console sends, what the routes answer, and every\n * reason one refuses. Client-safe — types and the batch limit only.\n *\n * Every request names its organization (`orgId`, in the query of a `GET`\n * and the JSON body of a `POST`): the routes serve an organization-level\n * surface that names no site.\n */\n\nimport type { OutreachComposeError } from '../engine/compose'\nimport type { OutreachGatewayStanding } from '../engine/mail-gateway'\nimport type { OutreachValidationIssue } from '../engine/sequence-validation'\nimport type { OutreachComplianceIssue } from './compliance-settings'\nimport type {\n OutreachAttestationKind,\n OutreachComplianceSettingsDocument,\n OutreachDoNotContactDomainEntry,\n OutreachEnrollment,\n OutreachSequence,\n OutreachStepOverrideSource,\n} from './outreach.types'\nimport type { OutreachSequenceDraft, OutreachSequenceIssue } from './sequence-draft'\n\n/** Every reason these routes refuse with, for a caller to branch on. */\nexport type OutreachRouteRefusalReason =\n | 'unauthenticated'\n | 'email-unverified'\n | 'org-required'\n | 'not-a-member'\n | 'not-org-wide'\n | 'permission'\n | 'entitlement'\n | 'method-not-allowed'\n | 'invalid-request'\n | 'invalid-domain'\n | 'invalid-settings'\n | 'invalid-sequence'\n | 'sequence-not-found'\n | 'sequence-archived'\n | 'sequence-not-active'\n | 'activation-refused'\n | 'delete-refused'\n | 'mailbox-unavailable'\n | 'enrollment-not-found'\n | 'transition-refused'\n | 'view-not-found'\n | 'view-unsupported'\n | 'contact-not-found'\n | 'link-domain-refused'\n | 'rate-limited'\n | 'google-unavailable'\n | 'curation-unavailable'\n | 'curation-refused'\n | 'invalid-override'\n\n/** A refusal, in the one shape every route answers with. */\nexport interface OutreachRouteRefusal {\n /** A sentence written for the person reading it. */\n error: string\n reason: OutreachRouteRefusalReason\n /** The field-level reasons behind an `invalid-*` or `activation-refused` refusal. */\n issues?: ReadonlyArray<OutreachComplianceIssue | OutreachSequenceIssue>\n}\n\n/*==========================================\n * COMPLIANCE SETTINGS\n *==========================================*/\n\n/** `GET outreach/settings?orgId` — and the answer to a save. */\nexport interface OutreachSettingsResponse {\n ok: true\n settings: OutreachComplianceSettingsDocument\n}\n\n/** `POST outreach/settings` */\nexport interface OutreachSettingsSaveRequest {\n orgId: string\n legalName: string\n brandName: string\n postalAddress: string\n allowedCountries: string[]\n}\n\n/** The answer to a save: the settings as stored, and whether anything changed. */\nexport interface OutreachSettingsSaveResponse extends OutreachSettingsResponse {\n changed: boolean\n}\n\n/*==========================================\n * SEQUENCES\n *==========================================*/\n\n/** `POST outreach/sequences/save` — a new sequence without `sequenceId`, an edit with one. */\nexport interface OutreachSequenceSaveRequest {\n orgId: string\n sequenceId?: string\n sequence: OutreachSequenceDraft\n}\n\nexport interface OutreachSequenceSaveResponse {\n ok: true\n sequence: OutreachSequence\n created: boolean\n /** What the save allowed but probably isn't what was meant. */\n warnings: OutreachSequenceIssue[]\n}\n\n/** What `outreach/sequences/status` does to a sequence. */\nexport type OutreachSequenceAction = 'activate' | 'pause' | 'archive'\n\n/** `POST outreach/sequences/status` */\nexport interface OutreachSequenceStatusRequest {\n orgId: string\n sequenceId: string\n action: OutreachSequenceAction\n}\n\nexport interface OutreachSequenceStatusResponse {\n ok: true\n sequence: OutreachSequence\n /** The open enrollments an archive stopped; `0` for anything else. */\n stoppedEnrollments: number\n}\n\n/** `POST outreach/sequences/delete` — a draft nobody was ever enrolled in. */\nexport interface OutreachSequenceDeleteRequest {\n orgId: string\n sequenceId: string\n}\n\n/*==========================================\n * ENROLLING\n *==========================================*/\n\n/** The most people one preview or one enroll reads. */\nexport const OUTREACH_ENROLL_BATCH_MAX = 50\n\n/**\n * Which CRM record a person to enroll is (AGL-3234): a contact by its\n * document id, or a lead the sequence's site holds by its person key.\n */\nexport type OutreachPersonRef = { kind: 'contact'; id: string } | { kind: 'lead'; id: string }\n\n/**\n * Where the people to enroll come from: a saved Contacts or Leads view,\n * contacts picked by search, or leads picked from the sequence's site.\n */\nexport type OutreachEnrollSource =\n | { kind: 'view'; viewId: string }\n | { kind: 'contacts'; contactIds: string[] }\n | { kind: 'leads'; leadIds: string[] }\n\n/** `POST outreach/enroll/preview` */\nexport interface OutreachEnrollPreviewRequest {\n orgId: string\n sequenceId: string\n source: OutreachEnrollSource\n}\n\n/**\n * Where one person stands:\n *\n * - `eligible` — enrolled on Confirm as they are;\n * - `needs_confirmation` — enrolled once the rep writes the personal line\n * and ticks the attestations `requires` names;\n * - `blocked` — refused whatever the rep confirms, for the `blocks` given.\n */\nexport type OutreachEnrollPreviewStatus = 'eligible' | 'needs_confirmation' | 'blocked'\n\n/** One reason a person can't be enrolled, in a sentence a rep can act on. */\nexport interface OutreachEnrollBlock {\n code: string\n reason: string\n}\n\n/**\n * The mail gateway in front of one person's domain (AGL-3326), and what it\n * did with this organization's mail this week — what the Check-people chip\n * shows — with `hold` set when the engine would hold the send into it: the\n * gateway refused the sender twice in the last thirty days and delivered\n * nothing. A held person is not blocked; they default to un-ticked, and\n * ticking them is the member's say-so.\n */\nexport interface OutreachEnrollGateway extends OutreachGatewayStanding {\n hold: boolean\n}\n\nexport interface OutreachEnrollPreviewPerson {\n /** The key the dialog and the confirm name this person by: the record's own id. */\n personId: string\n /** The record the person is (AGL-3234). */\n target: 'contact' | 'lead'\n /** The contact's id; `''` for a lead. */\n contactId: string\n /** The lead's person key; `null` for a contact. */\n leadId: string | null\n name: string\n /** The address the steps would go to, or `null` when the contact has none. */\n email: string | null\n /** No inbound capture behind them — the heavier rules apply. */\n cold: boolean\n /** ISO-3166-1 alpha-2, when anything names one. */\n country: string | null\n status: OutreachEnrollPreviewStatus\n /** Why a `blocked` person is refused; empty otherwise. */\n blocks: OutreachEnrollBlock[]\n /** What the rep must supply before a `needs_confirmation` person is enrolled. */\n requires: {\n personalLine: boolean\n attestations: OutreachAttestationKind[]\n }\n /** The mail gateway in front of their domain (AGL-3326); `null` when it could not be read. */\n gateway: OutreachEnrollGateway | null\n}\n\nexport interface OutreachEnrollPreviewResponse {\n ok: true\n people: OutreachEnrollPreviewPerson[]\n /** Everyone the source named, before the batch limit. */\n total: number\n /** True when the source named more people than one enrollment reads. */\n truncated: boolean\n}\n\n/**\n * One person's confirmed copy of one step, as the console sends it\n * (AGL-3324): what the member read and ticked the confirmation for. The\n * route validates it again and stamps who and when.\n */\nexport interface OutreachStepOverrideRequest {\n stepIndex: number\n /** Read only where the email starts a thread. */\n subject?: string\n body: string\n source: OutreachStepOverrideSource\n /** An AI draft the member changed before confirming it. */\n edited?: boolean\n /** The prompt the AI was given, returned by the draft route, for the audit log. */\n prompt?: string\n /** The model that drafted it, returned by the draft route. */\n model?: string\n}\n\n/** One person to enroll, with what the rep supplied for them: a contact by id, or a lead by key. */\nexport interface OutreachEnrollPersonRequest {\n contactId?: string\n leadId?: string\n personalLine?: string\n attestations?: OutreachAttestationKind[]\n /** The steps the member curated for this person before enrolling them (AGL-3324). */\n stepOverrides?: OutreachStepOverrideRequest[]\n}\n\n/** `POST outreach/enroll` — the gates are checked again, here, before anything is written. */\nexport interface OutreachEnrollRequest {\n orgId: string\n sequenceId: string\n people: OutreachEnrollPersonRequest[]\n}\n\ninterface OutreachEnrollOutcomePerson {\n personId: string\n target: 'contact' | 'lead'\n contactId: string\n leadId: string | null\n email: string | null\n}\n\nexport type OutreachEnrollOutcome =\n | (OutreachEnrollOutcomePerson & { outcome: 'enrolled'; enrollmentId: string })\n | (OutreachEnrollOutcomePerson & { outcome: 'blocked'; blocks: OutreachEnrollBlock[] })\n\nexport interface OutreachEnrollResponse {\n ok: true\n results: OutreachEnrollOutcome[]\n enrolled: number\n}\n\n/*==========================================\n * ENROLLMENTS\n *==========================================*/\n\n/** What a member may do to one enrollment. */\nexport type OutreachEnrollmentAction = 'pause' | 'resume' | 'stop' | 'do_not_contact'\n\n/** `POST outreach/enrollments/action` */\nexport interface OutreachEnrollmentActionRequest {\n orgId: string\n enrollmentId: string\n action: OutreachEnrollmentAction\n /** Why, for a stop or a do-not-contact. */\n detail?: string\n}\n\nexport interface OutreachEnrollmentActionResponse {\n ok: true\n /** False when the enrollment was already where the action would put it. */\n changed: boolean\n enrollment: OutreachEnrollment\n /** Other open enrollments of the same address a do-not-contact stopped. */\n stoppedOthers: number\n}\n\n/*==========================================\n * DO NOT CONTACT: DOMAINS (AGL-3244)\n *==========================================*/\n\n/** `GET outreach/do-not-contact/domains?orgId` — and the answer to a change. */\nexport interface OutreachDoNotContactDomainsResponse {\n ok: true\n /** Every domain on the list, alphabetically. */\n domains: OutreachDoNotContactDomainEntry[]\n}\n\n/** `POST outreach/do-not-contact/domains` */\nexport interface OutreachDoNotContactDomainRequest {\n orgId: string\n action: 'add' | 'remove'\n /** The domain, or an address at it. */\n domain: string\n /** Why, for an add. */\n detail?: string\n}\n\n/** The answer to a change: the list as it stands, and whether anything changed. */\nexport interface OutreachDoNotContactDomainResponse extends OutreachDoNotContactDomainsResponse {\n changed: boolean\n /** The domain as the list spells it. */\n domain: string\n}\n\n/*==========================================\n * LINK DOMAINS (AGL-3306)\n *==========================================*/\n\n/** Where a mailbox domain's click-tracking host stands. */\nexport type OutreachLinkDomainStatus = 'not-set-up' | 'requested' | 'records-issued' | 'verified' | 'failed'\n\n/** One DNS record the member publishes, as the sending-domain cards print them. */\nexport interface OutreachLinkDomainRecord {\n type: string\n name: string\n value: string\n required: boolean\n note: string\n}\n\n/**\n * One sending domain of the organization's connected mailboxes, and its\n * `links.` host: what tracked links in mail from it read now, and what is\n * left to do for them to read `https://links.<domain>/<id>`.\n */\nexport interface OutreachLinkDomain {\n /** The mailboxes' sending domain. */\n domain: string\n /** `links.<domain>`. */\n host: string\n status: OutreachLinkDomainStatus\n /** The records to publish; empty until the host is set up. */\n records: OutreachLinkDomainRecord[]\n /** Why it is not verified, in a sentence; `null` when nothing is wrong. */\n detail: string | null\n /** How a tracked link in mail from this domain starts today. */\n linkPrefix: string | null\n checkedAtMs: number | null\n verifiedAtMs: number | null\n}\n\n/** `GET outreach/link-domains?orgId` */\nexport interface OutreachLinkDomainsResponse {\n ok: true\n domains: OutreachLinkDomain[]\n /** Whether the member may set a host up, check it, or remove it. */\n canManage: boolean\n}\n\n/** `POST outreach/link-domains` */\nexport interface OutreachLinkDomainRequest {\n orgId: string\n domain: string\n action: 'set-up' | 'check' | 'remove'\n}\n\nexport interface OutreachLinkDomainResponse extends OutreachLinkDomainsResponse {\n /** The domain the action was for. */\n domain: string\n}\n\n/*==========================================\n * PREVIEW\n *==========================================*/\n\n/** `POST outreach/preview` — one email of a saved sequence, as a person would get it. */\nexport interface OutreachPreviewRequest {\n orgId: string\n sequenceId: string\n /** The contact to write it to; a sample person when absent. */\n contactId?: string\n /** Or a lead the sequence's site holds (AGL-3234), written to as the contact it would be. */\n leadId?: string\n personalLine?: string\n /** Which step; the first email when absent. */\n stepIndex?: number\n /** The person's copies of steps as the dialog holds them (AGL-3324), so the preview shows the curated version. */\n stepOverrides?: OutreachStepOverrideRequest[]\n}\n\nexport interface OutreachPreviewResponse {\n ok: true\n stepIndex: number\n subject: string\n /** The plain text, footer included. */\n text: string\n unresolvedFields: string[]\n /** Why it could not be written, when it could not. */\n error: OutreachComposeError | null\n}\n\n/*==========================================\n * A TEST OF ONE STEP (AGL-3325)\n *==========================================*/\n\n/**\n * `POST outreach/steps/test` — one step of a saved sequence, rendered\n * exactly as `outreach/preview` renders it (the same person, personal\n * line and step) and sent through the sequence's mailbox to the member.\n */\nexport interface OutreachStepTestRequest extends OutreachPreviewRequest {\n /** Where the test goes; the member's own address when absent or empty. */\n to?: string\n}\n\nexport interface OutreachStepTestResponse {\n ok: true\n stepIndex: number\n /** The address the test went to. */\n sentTo: string\n /** The subject as sent, `[Test]` prefix included. */\n subject: string\n sentAtMs: number\n /** How many tests the mailbox has sent on its local day, this one included. */\n testsToday: number\n}\n\n/*==========================================\n * CURATING (AGL-3324)\n *==========================================*/\n\n/**\n * `POST outreach/curate/draft` — this person's own copies of the sequence's\n * email steps, drafted by the workspace's AI. Names EITHER a person about to\n * be enrolled (the sequence, a contact or a lead, the personal line the rep\n * is writing) OR an enrollment, whose next email step is drafted. Nothing is\n * stored: the answer is what the member reads before confirming.\n */\nexport interface OutreachCurateDraftRequest {\n orgId: string\n sequenceId?: string\n contactId?: string\n leadId?: string\n personalLine?: string\n enrollmentId?: string\n /** Which email steps; every email step for a person to enroll, the next one for an enrollment, when absent. */\n stepIndexes?: number[]\n}\n\n/** One drafted step, with what the validator said about it. */\nexport interface OutreachCurateDraft {\n stepIndex: number\n /** `null` for an in-thread email, which is sent as `Re:` the thread's subject. */\n subject: string | null\n body: string\n /** Errors and warnings; a draft with an error cannot be confirmed as it stands. */\n issues: OutreachValidationIssue[]\n}\n\nexport interface OutreachCurateDraftResponse {\n ok: true\n drafts: OutreachCurateDraft[]\n /** The prompt the AI was given and the model that answered, for the override's audit record. */\n prompt: string\n model: string\n}\n\n/** `POST outreach/curate/save` — stores a confirmed copy of one step on an enrollment, or clears it with `override: null`. */\nexport interface OutreachCurateSaveRequest {\n orgId: string\n enrollmentId: string\n stepIndex: number\n override: Omit<OutreachStepOverrideRequest, 'stepIndex'> | null\n}\n\nexport interface OutreachCurateSaveResponse {\n ok: true\n enrollment: OutreachEnrollment\n}\n"],"names":["OUTREACH_ENROLL_BATCH_MAX"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;CAQC,GA2HD;;4CAE4C,GAE5C,qDAAqD,GACrD,OAAO,MAAMA,4BAA4B,GAAE"}
|