@aglyn/plugins-outreach 1.0.0-beta.164 → 1.0.0-beta.166

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/plugins-outreach",
3
- "version": "1.0.0-beta.164",
3
+ "version": "1.0.0-beta.166",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,18 +25,18 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.164",
29
- "@aglyn/shared-data-mdi": "1.0.0-beta.164",
30
- "@aglyn/shared-ui-email-campaigns": "1.0.0-beta.164",
31
- "@aglyn/shared-ui-jsx": "1.0.0-beta.164",
32
- "@aglyn/shared-ui-next": "1.0.0-beta.164",
33
- "@aglyn/shared-ui-snackstack": "1.0.0-beta.164",
34
- "@aglyn/shared-util-http": "1.0.0-beta.164",
35
- "@aglyn/shared-util-timestamp": "1.0.0-beta.164",
36
- "@aglyn/shared-util-tools": "1.0.0-beta.164",
37
- "@aglyn/tenant-data-admin": "1.0.0-beta.164",
38
- "@aglyn/tenant-feature-instance": "1.0.0-beta.164",
39
- "@aglyn/tenant-runtime": "1.0.0-beta.164",
28
+ "@aglyn/aglyn": "1.0.0-beta.166",
29
+ "@aglyn/shared-data-mdi": "1.0.0-beta.166",
30
+ "@aglyn/shared-ui-email-campaigns": "1.0.0-beta.166",
31
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.166",
32
+ "@aglyn/shared-ui-next": "1.0.0-beta.166",
33
+ "@aglyn/shared-ui-snackstack": "1.0.0-beta.166",
34
+ "@aglyn/shared-util-http": "1.0.0-beta.166",
35
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.166",
36
+ "@aglyn/shared-util-tools": "1.0.0-beta.166",
37
+ "@aglyn/tenant-data-admin": "1.0.0-beta.166",
38
+ "@aglyn/tenant-feature-instance": "1.0.0-beta.166",
39
+ "@aglyn/tenant-runtime": "1.0.0-beta.166",
40
40
  "@swc/helpers": "0.5.23"
41
41
  },
42
42
  "peerDependencies": {
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * WHAT A SEQUENCE SAYS ON THE PERSON'S TIMELINE ABOUT ITSELF (AGL-3274).
19
+ *
20
+ * The sends and the replies were already there (AGL-2981); the two facts
21
+ * that frame them were not. A reader of a lead's Activity saw an email go
22
+ * out with no line saying the person had been put in a sequence, and saw
23
+ * the sequence's last email with no line saying why there was no next one.
24
+ * Two entries close that: "Enrolled in <sequence>" when the enroll route
25
+ * takes the person, with the campaigns the enrollment carried them into,
26
+ * and "<sequence> stopped" when a reply or an opt-out ends it. A bounce
27
+ * files nothing here — the send's own entry already reads Bounced with
28
+ * the server's words (AGL-3245), and a second line would say it twice.
29
+ *
30
+ * Pure, so the route and the runtime compose the same words and the
31
+ * backfill (`tools/scripts/lib/enrollment-activity-backfill.mjs`) can
32
+ * restate them and be pinned against this file. Each entry has ONE key
33
+ * per enrollment, so a run that files again finds what it wrote.
34
+ */
35
+ /** The author every entry the runtime files is signed with. */
36
+ export declare const OUTREACH_TIMELINE_BY_NAME = "Sequences";
37
+ /** An entry as the timeline seam takes it: the caller's key, the words. */
38
+ export interface OutreachTimelineEntry {
39
+ dedupeKey: string;
40
+ body: string;
41
+ }
42
+ /** The key the enrollment's own entry is filed under, restated by the backfill. */
43
+ export declare function outreachEnrolledEntryKey(enrollmentId: string): string;
44
+ /**
45
+ * "Enrolled in <sequence>", and the campaigns it carried the person into
46
+ * when the sequence was in any, named. A sequence with no name — never
47
+ * stored, but a document can lose one — reads "a sequence".
48
+ */
49
+ export declare function outreachEnrolledEntry(input: {
50
+ enrollmentId: string;
51
+ sequenceName: string;
52
+ campaignNames: readonly string[];
53
+ }): OutreachTimelineEntry;
54
+ /** Why a sequence stopped, in the two ways a person ends one. */
55
+ export type OutreachStoppedReason = 'replied' | 'opted_out';
56
+ /** "<sequence> stopped: they replied" — the line under the last send. */
57
+ export declare function outreachStoppedEntry(input: {
58
+ enrollmentId: string;
59
+ sequenceName: string;
60
+ reason: OutreachStoppedReason;
61
+ }): OutreachTimelineEntry;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /**
17
+ * WHAT A SEQUENCE SAYS ON THE PERSON'S TIMELINE ABOUT ITSELF (AGL-3274).
18
+ *
19
+ * The sends and the replies were already there (AGL-2981); the two facts
20
+ * that frame them were not. A reader of a lead's Activity saw an email go
21
+ * out with no line saying the person had been put in a sequence, and saw
22
+ * the sequence's last email with no line saying why there was no next one.
23
+ * Two entries close that: "Enrolled in <sequence>" when the enroll route
24
+ * takes the person, with the campaigns the enrollment carried them into,
25
+ * and "<sequence> stopped" when a reply or an opt-out ends it. A bounce
26
+ * files nothing here — the send's own entry already reads Bounced with
27
+ * the server's words (AGL-3245), and a second line would say it twice.
28
+ *
29
+ * Pure, so the route and the runtime compose the same words and the
30
+ * backfill (`tools/scripts/lib/enrollment-activity-backfill.mjs`) can
31
+ * restate them and be pinned against this file. Each entry has ONE key
32
+ * per enrollment, so a run that files again finds what it wrote.
33
+ */ /** The author every entry the runtime files is signed with. */ export const OUTREACH_TIMELINE_BY_NAME = 'Sequences';
34
+ /** The key the enrollment's own entry is filed under, restated by the backfill. */ export function outreachEnrolledEntryKey(enrollmentId) {
35
+ return `enrolled:${enrollmentId}`;
36
+ }
37
+ /**
38
+ * "Enrolled in <sequence>", and the campaigns it carried the person into
39
+ * when the sequence was in any, named. A sequence with no name — never
40
+ * stored, but a document can lose one — reads "a sequence".
41
+ */ export function outreachEnrolledEntry(input) {
42
+ const sequence = input.sequenceName.trim() || 'a sequence';
43
+ const campaigns = input.campaignNames.map((name)=>name.trim()).filter(Boolean);
44
+ return {
45
+ dedupeKey: outreachEnrolledEntryKey(input.enrollmentId),
46
+ body: campaigns.length ? `Enrolled in ${sequence}\nFiled under ${campaigns.join(', ')}` : `Enrolled in ${sequence}`
47
+ };
48
+ }
49
+ /** "<sequence> stopped: they replied" — the line under the last send. */ export function outreachStoppedEntry(input) {
50
+ const sequence = input.sequenceName.trim() || 'The sequence';
51
+ return {
52
+ dedupeKey: `stopped:${input.enrollmentId}`,
53
+ body: `${sequence} stopped: ${input.reason === 'replied' ? 'they replied' : 'they opted out'}`
54
+ };
55
+ }
56
+
57
+ //# sourceMappingURL=enrollment-activity.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/enrollment-activity.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * WHAT A SEQUENCE SAYS ON THE PERSON'S TIMELINE ABOUT ITSELF (AGL-3274).\n *\n * The sends and the replies were already there (AGL-2981); the two facts\n * that frame them were not. A reader of a lead's Activity saw an email go\n * out with no line saying the person had been put in a sequence, and saw\n * the sequence's last email with no line saying why there was no next one.\n * Two entries close that: \"Enrolled in <sequence>\" when the enroll route\n * takes the person, with the campaigns the enrollment carried them into,\n * and \"<sequence> stopped\" when a reply or an opt-out ends it. A bounce\n * files nothing here — the send's own entry already reads Bounced with\n * the server's words (AGL-3245), and a second line would say it twice.\n *\n * Pure, so the route and the runtime compose the same words and the\n * backfill (`tools/scripts/lib/enrollment-activity-backfill.mjs`) can\n * restate them and be pinned against this file. Each entry has ONE key\n * per enrollment, so a run that files again finds what it wrote.\n */\n\n/** The author every entry the runtime files is signed with. */\nexport const OUTREACH_TIMELINE_BY_NAME = 'Sequences'\n\n/** An entry as the timeline seam takes it: the caller's key, the words. */\nexport interface OutreachTimelineEntry {\n dedupeKey: string\n body: string\n}\n\n/** The key the enrollment's own entry is filed under, restated by the backfill. */\nexport function outreachEnrolledEntryKey(enrollmentId: string): string {\n return `enrolled:${enrollmentId}`\n}\n\n/**\n * \"Enrolled in <sequence>\", and the campaigns it carried the person into\n * when the sequence was in any, named. A sequence with no name — never\n * stored, but a document can lose one — reads \"a sequence\".\n */\nexport function outreachEnrolledEntry(input: {\n enrollmentId: string\n sequenceName: string\n campaignNames: readonly string[]\n}): OutreachTimelineEntry {\n const sequence = input.sequenceName.trim() || 'a sequence'\n const campaigns = input.campaignNames.map((name) => name.trim()).filter(Boolean)\n return {\n dedupeKey: outreachEnrolledEntryKey(input.enrollmentId),\n body: campaigns.length\n ? `Enrolled in ${sequence}\\nFiled under ${campaigns.join(', ')}`\n : `Enrolled in ${sequence}`,\n }\n}\n\n/** Why a sequence stopped, in the two ways a person ends one. */\nexport type OutreachStoppedReason = 'replied' | 'opted_out'\n\n/** \"<sequence> stopped: they replied\" — the line under the last send. */\nexport function outreachStoppedEntry(input: {\n enrollmentId: string\n sequenceName: string\n reason: OutreachStoppedReason\n}): OutreachTimelineEntry {\n const sequence = input.sequenceName.trim() || 'The sequence'\n return {\n dedupeKey: `stopped:${input.enrollmentId}`,\n body: `${sequence} stopped: ${input.reason === 'replied' ? 'they replied' : 'they opted out'}`,\n }\n}\n"],"names":["OUTREACH_TIMELINE_BY_NAME","outreachEnrolledEntryKey","enrollmentId","outreachEnrolledEntry","input","sequence","sequenceName","trim","campaigns","campaignNames","map","name","filter","Boolean","dedupeKey","body","length","join","outreachStoppedEntry","reason"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;CAiBC,GAED,6DAA6D,GAC7D,OAAO,MAAMA,4BAA4B,YAAW;AAQpD,iFAAiF,GACjF,OAAO,SAASC,yBAAyBC,YAAoB;IAC3D,OAAO,CAAC,SAAS,EAAEA,cAAc;AACnC;AAEA;;;;CAIC,GACD,OAAO,SAASC,sBAAsBC,KAIrC;IACC,MAAMC,WAAWD,MAAME,YAAY,CAACC,IAAI,MAAM;IAC9C,MAAMC,YAAYJ,MAAMK,aAAa,CAACC,GAAG,CAAC,CAACC,OAASA,KAAKJ,IAAI,IAAIK,MAAM,CAACC;IACxE,OAAO;QACLC,WAAWb,yBAAyBG,MAAMF,YAAY;QACtDa,MAAMP,UAAUQ,MAAM,GAClB,CAAC,YAAY,EAAEX,SAAS,cAAc,EAAEG,UAAUS,IAAI,CAAC,OAAO,GAC9D,CAAC,YAAY,EAAEZ,UAAU;IAC/B;AACF;AAKA,uEAAuE,GACvE,OAAO,SAASa,qBAAqBd,KAIpC;IACC,MAAMC,WAAWD,MAAME,YAAY,CAACC,IAAI,MAAM;IAC9C,OAAO;QACLO,WAAW,CAAC,QAAQ,EAAEV,MAAMF,YAAY,EAAE;QAC1Ca,MAAM,GAAGV,SAAS,UAAU,EAAED,MAAMe,MAAM,KAAK,YAAY,iBAAiB,kBAAkB;IAChG;AACF"}
@@ -107,7 +107,8 @@ async function getAllChunked(firestore, refs) {
107
107
  */ export async function readOutreachEnrollCandidates(firestore, input) {
108
108
  if (!input.people.length) return [];
109
109
  const org = firestore.collection('orgs').doc(input.orgId);
110
- const leads = firestore.collection('hosts').doc(input.hostId).collection('leads');
110
+ // Beside the contacts, on the org (AGL-3275).
111
+ const leads = org.collection('leads');
111
112
  const snapshots = await getAllChunked(firestore, input.people.map((person)=>person.kind === 'lead' ? leads.doc(person.id) : org.collection('contacts').doc(person.id)));
112
113
  const read = await Promise.all(input.people.map(async (person, index)=>{
113
114
  var _readContactFacet_companyId;
@@ -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 const leads = firestore.collection('hosts').doc(input.hostId).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","hostId","map","person","id","read","Promise","all","index","data","exists","held","personId","target","contactId","leadId","contact","visible","companyId","convertedContactId","heldAsContactId","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 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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"}
@@ -14,6 +14,7 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
+ import type { PluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline';
17
18
  import type { PluginWebApiHandler } from '@aglyn/aglyn/server';
18
19
  import { type OutreachRouteDeps } from './route-deps';
19
20
  import { type OutreachRouteExtraPermission } from './route-gate';
@@ -54,6 +55,12 @@ export interface OutreachEnrollRouteDeps extends OutreachRouteDeps {
54
55
  emails: string[];
55
56
  complete: boolean;
56
57
  }>;
58
+ /**
59
+ * The workspace's record system on the timeline seam (AGL-3274), when a
60
+ * plugin keeps one: the enroll files "Enrolled in" on the person's record
61
+ * — the runtime's `timeline`, reached the same way.
62
+ */
63
+ timeline(): PluginRecordTimelineWriter | null;
57
64
  }
58
65
  export interface OutreachEnrollRoutes {
59
66
  preview: PluginWebApiHandler;
@@ -16,17 +16,20 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
16
16
  * limitations under the License.
17
17
  */ import { CAMPAIGN_MEMBERSHIP_FIELD, contactCampaignFieldPath, normalizeCampaignIds } from "@aglyn/aglyn/app-utils/campaign-membership";
18
18
  import { consentGroupForHost } from "@aglyn/aglyn/app-utils/consent-groups";
19
+ import { scopeTokensForHost } from "@aglyn/aglyn/app-utils/scope-tokens";
19
20
  import { CRM_COLLECTIONS, crmLeadStatus, crmViewIsListed, isCrmLeadOpen, normalizeCrmViewFilters } from "@aglyn/aglyn/app-utils/crm";
20
21
  import { dynamicListDimensionsForCrmView } from "@aglyn/aglyn/app-utils/dynamic-list-rule";
21
22
  import { findContactByEmail } from "@aglyn/tenant-data-admin/server/contact-email-index";
22
23
  import { FieldValue } from "firebase-admin/firestore";
24
+ import { outreachEnrolledEntry } from "../engine/enrollment-activity.js";
23
25
  import { buildOutreachEnrollment, planOutreachFirstDue } from "../engine/enrollment-state.js";
24
26
  import { decideOutreachEnrollment, previewOutreachPerson, readOutreachEnrollCandidates } from "../enrollment/enroll-people.js";
25
27
  import { readOutreachGateLookups } from "../enrollment/gate-lookups.js";
26
28
  import { effectiveOutreachAllowedCountries } from "../model/compliance-settings.js";
27
29
  import { OUTREACH_ENROLL_BATCH_MAX } from "../model/outreach-api.js";
28
30
  import { readOutreachComplianceSettingsDoc } from "../storage/compliance-settings-store.js";
29
- import { outreachEnrollmentId, outreachOrgCollection, readStoredOutreachMailbox, readStoredOutreachSequence } from "../storage/outreach-records.js";
31
+ import { fileOutreachNote } from "../runtime/timeline.js";
32
+ import { outreachEnrollmentId, outreachEnrollmentLink, outreachOrgCollection, readStoredOutreachMailbox, readStoredOutreachSequence } from "../storage/outreach-records.js";
30
33
  import { OUTREACH_SEQUENCE_ACTIVITY_TARGET } from "./route-deps.js";
31
34
  import { outreachRouteGate } from "./route-gate.js";
32
35
  import { outreachMethodNotAllowed, outreachOk, outreachRefusal, readOutreachDocumentId, readOutreachJsonBody } from "./route-http.js";
@@ -150,13 +153,20 @@ const uniqueIds = (values)=>Array.isArray(values) ? [
150
153
  * The people a saved LEADS view selects (AGL-3234): the sequence's site's
151
154
  * most recently seen leads, narrowed by the view's status clause the way
152
155
  * the Leads list narrows its window — open leads when the view names no
153
- * status, one status when it does, everything for `all`. A lead is the
154
- * site's own, so there is no address to resolve: the person key IS the id.
155
- */ async function leadsViewPeople(firestore, sequence, filters) {
156
+ * status, one status when it does, everything for `all`. The person key IS
157
+ * the id, so there is no address to resolve — and since AGL-3275 the window is
158
+ * narrowed by `visibleTo` rather than by the collection's parent.
159
+ */ async function leadsViewPeople(firestore, orgId, sequence, filters) {
156
160
  var _ref;
157
161
  const statusClause = filters.find((clause)=>clause.field === 'status' && clause.op === 'equals');
158
162
  const wanted = String((_ref = statusClause == null ? void 0 : statusClause.value) != null ? _ref : 'open');
159
- const window = await firestore.collection('hosts').doc(sequence.hostId).collection('leads').orderBy('lastSeenAtMs', 'desc').limit(LEADS_VIEW_WINDOW + 1).get();
163
+ /*
164
+ * SCOPED (AGL-3275). The collection is org-wide, so the window is narrowed
165
+ * to what the sequence's site may see before it is ordered — otherwise an
166
+ * agency's sequence would offer another client's people to enroll. The
167
+ * composite index for `visibleTo array-contains-any` + `lastSeenAtMs desc`
168
+ * is in `cloud/firestore.indexes.json`.
169
+ */ const window = await firestore.collection('orgs').doc(orgId).collection('leads').where('visibleTo', 'array-contains-any', scopeTokensForHost(sequence.hostId)).orderBy('lastSeenAtMs', 'desc').limit(LEADS_VIEW_WINDOW + 1).get();
160
170
  const matching = window.docs.slice(0, LEADS_VIEW_WINDOW).filter((doc)=>{
161
171
  const lead = doc.data();
162
172
  if (wanted === 'all') return true;
@@ -186,7 +196,7 @@ export function createOutreachEnrollRoutes(deps) {
186
196
  const campaignIds = normalizeCampaignIds(sequence.campaignIds);
187
197
  if (!campaignIds.length) return;
188
198
  try {
189
- const ref = candidate.target === 'lead' && candidate.leadId ? firestore.collection('hosts').doc(sequence.hostId).collection('leads').doc(candidate.leadId) : candidate.contactId ? firestore.collection('orgs').doc(orgId).collection('contacts').doc(candidate.contactId) : null;
199
+ const ref = candidate.target === 'lead' && candidate.leadId ? firestore.collection('orgs').doc(orgId).collection('leads').doc(candidate.leadId) : candidate.contactId ? firestore.collection('orgs').doc(orgId).collection('contacts').doc(candidate.contactId) : null;
190
200
  if (ref) {
191
201
  const field = candidate.target === 'lead' ? CAMPAIGN_MEMBERSHIP_FIELD : contactCampaignFieldPath(contactGroupId);
192
202
  await ref.update({
@@ -209,6 +219,26 @@ export function createOutreachEnrollRoutes(deps) {
209
219
  }
210
220
  }
211
221
  /**
222
+ * The names of the sequence's campaigns as they stand, for the entry the
223
+ * enroll files on the person's record (AGL-3274). Read once per request,
224
+ * not per person; a container that is gone answers nothing and the entry
225
+ * names the rest.
226
+ */ async function sequenceCampaignNames(firestore, sequence) {
227
+ const campaignIds = normalizeCampaignIds(sequence.campaignIds);
228
+ if (!campaignIds.length) return [];
229
+ try {
230
+ const containers = firestore.collection('hosts').doc(sequence.hostId).collection('emailCampaigns');
231
+ const found = await firestore.getAll(...campaignIds.map((id)=>containers.doc(id)));
232
+ return found.map((snapshot)=>{
233
+ var _snapshot_get;
234
+ return String((_snapshot_get = snapshot.get('name')) != null ? _snapshot_get : '').trim();
235
+ }).filter(Boolean);
236
+ } catch (error) {
237
+ console.error('[outreach] the sequence’s campaigns could not be named for the record', error);
238
+ return [];
239
+ }
240
+ }
241
+ /**
212
242
  * The people a source names, the batch limit applied: every contact a
213
243
  * search picked, every lead picked from the sequence's site, or the
214
244
  * people a saved Contacts or Leads view selects there — contacts found by
@@ -243,7 +273,7 @@ export function createOutreachEnrollRoutes(deps) {
243
273
  return outreachRefusal(404, 'view-not-found', 'That saved view no longer exists.');
244
274
  }
245
275
  if (data['section'] === 'leads') {
246
- return leadsViewPeople(firestore, sequence, normalizeCrmViewFilters(data['filters']));
276
+ return leadsViewPeople(firestore, caller.orgId, sequence, normalizeCrmViewFilters(data['filters']));
247
277
  }
248
278
  if (data['section'] !== 'contacts') {
249
279
  return outreachRefusal(400, 'view-unsupported', 'Enroll from a saved view of Contacts or Leads.');
@@ -364,6 +394,7 @@ export function createOutreachEnrollRoutes(deps) {
364
394
  ...requested.values()
365
395
  ].map((entry)=>entry.ref));
366
396
  const enrollments = outreachOrgCollection(firestore, caller.orgId, 'enrollments');
397
+ const campaignNames = await sequenceCampaignNames(firestore, sequence);
367
398
  const results = await Promise.all(candidates.map(async (candidate)=>{
368
399
  var _requested_get, _lookups_get;
369
400
  const named = {
@@ -430,6 +461,21 @@ export function createOutreachEnrollRoutes(deps) {
430
461
  });
431
462
  }
432
463
  await joinSequenceCampaigns(firestore, caller.orgId, sequence, context.contactGroupId, candidate, nowMs);
464
+ // The person's record says so (AGL-3274): "Enrolled in <sequence>",
465
+ // with the campaigns it carried them into, once per enrollment.
466
+ const entry = outreachEnrolledEntry({
467
+ enrollmentId: id,
468
+ sequenceName: sequence.name,
469
+ campaignNames
470
+ });
471
+ await fileOutreachNote(deps, {
472
+ orgId: caller.orgId,
473
+ hostId: sequence.hostId,
474
+ link: outreachEnrollmentLink(enrollment),
475
+ dedupeKey: entry.dedupeKey,
476
+ body: entry.body,
477
+ atMs: nowMs
478
+ });
433
479
  return _extends({}, named, {
434
480
  email: decision.email,
435
481
  outcome: 'enrolled',