@aglyn/plugins-outreach 1.0.0-beta.152 → 1.0.0-beta.154

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +5 -1
  2. package/package.json +12 -12
  3. package/src/lib/components/enroll-dialog.js +142 -32
  4. package/src/lib/components/enroll-dialog.js.map +1 -1
  5. package/src/lib/components/enrollments-table.js +37 -8
  6. package/src/lib/components/enrollments-table.js.map +1 -1
  7. package/src/lib/components/mailbox-card.d.ts +1 -0
  8. package/src/lib/components/mailbox-card.js +25 -5
  9. package/src/lib/components/mailbox-card.js.map +1 -1
  10. package/src/lib/components/use-outreach-crm.d.ts +20 -1
  11. package/src/lib/components/use-outreach-crm.js +78 -4
  12. package/src/lib/components/use-outreach-crm.js.map +1 -1
  13. package/src/lib/declarations.console-server.js +14 -0
  14. package/src/lib/declarations.console-server.js.map +1 -1
  15. package/src/lib/engine/enrollment-state.d.ts +6 -1
  16. package/src/lib/engine/enrollment-state.js +3 -1
  17. package/src/lib/engine/enrollment-state.js.map +1 -1
  18. package/src/lib/enrollment/enroll-people.d.ts +52 -7
  19. package/src/lib/enrollment/enroll-people.js +109 -12
  20. package/src/lib/enrollment/enroll-people.js.map +1 -1
  21. package/src/lib/enrollment/gate-lookups.d.ts +12 -3
  22. package/src/lib/enrollment/gate-lookups.js +17 -14
  23. package/src/lib/enrollment/gate-lookups.js.map +1 -1
  24. package/src/lib/mailboxes/mailbox-api.d.ts +6 -0
  25. package/src/lib/mailboxes/mailbox-api.js.map +1 -1
  26. package/src/lib/mailboxes/mailbox-routes.js +11 -0
  27. package/src/lib/mailboxes/mailbox-routes.js.map +1 -1
  28. package/src/lib/model/outreach-api.d.ts +39 -8
  29. package/src/lib/model/outreach-api.js.map +1 -1
  30. package/src/lib/model/outreach.types.d.ts +28 -3
  31. package/src/lib/model/outreach.types.js +9 -0
  32. package/src/lib/model/outreach.types.js.map +1 -1
  33. package/src/lib/model/stored-records.js +7 -0
  34. package/src/lib/model/stored-records.js.map +1 -1
  35. package/src/lib/routes/enroll-routes.js +112 -39
  36. package/src/lib/routes/enroll-routes.js.map +1 -1
  37. package/src/lib/routes/preview-routes.js +18 -5
  38. package/src/lib/routes/preview-routes.js.map +1 -1
  39. package/src/lib/runtime/lead-records.d.ts +26 -0
  40. package/src/lib/runtime/lead-records.js +84 -0
  41. package/src/lib/runtime/lead-records.js.map +1 -0
  42. package/src/lib/runtime/send-job.js +30 -12
  43. package/src/lib/runtime/send-job.js.map +1 -1
  44. package/src/lib/runtime/sync-job.js +11 -3
  45. package/src/lib/runtime/sync-job.js.map +1 -1
  46. package/src/lib/runtime/timeline.d.ts +6 -4
  47. package/src/lib/runtime/timeline.js +3 -7
  48. package/src/lib/runtime/timeline.js.map +1 -1
  49. package/src/lib/storage/outreach-records.d.ts +21 -2
  50. package/src/lib/storage/outreach-records.js +31 -2
  51. package/src/lib/storage/outreach-records.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/components/use-outreach-crm.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'use client'\n\nimport {\n contactDisplayName,\n normalizeContactEmail,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport {\n CRM_COLLECTIONS,\n crmViewIsListed,\n normalizeCrmViewFilters,\n} from '@aglyn/aglyn/app-utils/crm'\nimport {\n CRM_EMAIL_TEMPLATES_LIMIT,\n crmEmailTemplateIsListed,\n normalizeCrmEmailTemplate,\n} from '@aglyn/aglyn/app-utils/crm-email-templates'\nimport { dynamicListDimensionsForCrmView } from '@aglyn/aglyn/app-utils/dynamic-list-rule'\nimport { nameSearchToken } from '@aglyn/aglyn/app-utils/name-search'\nimport { visibleToHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport {\n collection,\n getDocs,\n limit,\n onSnapshot,\n orderBy,\n query,\n where,\n} from 'firebase/firestore'\nimport { useEffect, useState } from 'react'\nimport type { OutreachLoad } from './use-outreach-data'\n\n/**\n * What Outreach reads from the organization's CRM (AGL-2980): the saved\n * Contacts views people can be enrolled from, the email templates a step\n * can send, and the contacts a search finds.\n *\n * Through the core's own CRM model — the collection names, the listing\n * rules, the view translation the dynamic-list sweep uses — and never\n * through the CRM plugin, which a plugin may not import. The Firestore\n * rules gate every one of these reads on `data.manage`, the same permission\n * the enroll routes ask for, so a member who cannot read the people cannot\n * enroll them either.\n */\n\nconst denied = (error: unknown) =>\n (error as { code?: unknown } | null)?.code === 'permission-denied'\n\n/** A saved Contacts view people can be enrolled from. */\nexport interface OutreachViewOption {\n id: string\n name: string\n}\n\n/** The most saved views the picker lists. */\nexport const OUTREACH_VIEWS_LIMIT = 50\n\n/**\n * The saved Contacts views this reader may enroll from: their own and the\n * shared ones — a colleague's private view is theirs — that translate whole\n * into an audience. A view filtering on something enrolling cannot apply is\n * not offered, because dropping that filter would enroll more people than\n * the view shows.\n */\nexport function useOutreachSavedViews(\n orgId: string | null,\n uid: string | null,\n): OutreachLoad<OutreachViewOption[]> {\n const firestore = useFirestore()\n const [result, setResult] = useState<OutreachLoad<OutreachViewOption[]>>({\n status: 'loading',\n data: [],\n })\n useEffect(() => {\n setResult({ status: 'loading', data: [] })\n if (!orgId) return undefined\n return onSnapshot(\n query(\n collection(firestore, 'orgs', orgId, CRM_COLLECTIONS.views),\n where('section', '==', 'contacts'),\n orderBy('name'),\n limit(OUTREACH_VIEWS_LIMIT),\n ),\n (snapshot) =>\n setResult({\n status: 'ready',\n data: snapshot.docs\n .map((entry) => ({\n id: entry.id,\n data: entry.data() as Record<string, unknown>,\n }))\n .filter(\n ({ data }) =>\n typeof data['name'] === 'string' &&\n data['name'] !== '' &&\n crmViewIsListed(\n {\n shared: data['shared'] === true,\n ownerUid: String(data['ownerUid'] ?? ''),\n },\n uid,\n ) &&\n dynamicListDimensionsForCrmView(\n normalizeCrmViewFilters(data['filters']),\n ).unsupported.length === 0,\n )\n .map(({ id, data }) => ({ id, name: String(data['name']) })),\n }),\n (error) => {\n if (!denied(error))\n console.error('[outreach] saved views could not be read', error)\n setResult({ status: denied(error) ? 'refused' : 'error', data: [] })\n },\n )\n }, [firestore, orgId, uid])\n return result\n}\n\n/** A CRM email template a step can send the body of. */\nexport interface OutreachTemplateOption {\n id: string\n name: string\n subject: string\n body: string\n}\n\n/**\n * The CRM email templates a step at this site can use: whole letters, not\n * snippets; the team's and this reader's own; written for the whole\n * organization or for this site.\n */\nexport function useOutreachEmailTemplates(\n orgId: string | null,\n hostId: string | null,\n uid: string | null,\n): OutreachLoad<OutreachTemplateOption[]> {\n const firestore = useFirestore()\n const [result, setResult] = useState<OutreachLoad<OutreachTemplateOption[]>>({\n status: 'loading',\n data: [],\n })\n useEffect(() => {\n setResult({ status: 'loading', data: [] })\n if (!orgId) return undefined\n return onSnapshot(\n query(\n collection(firestore, 'orgs', orgId, CRM_COLLECTIONS.emailTemplates),\n orderBy('name'),\n limit(CRM_EMAIL_TEMPLATES_LIMIT),\n ),\n (snapshot) =>\n setResult({\n status: 'ready',\n data: snapshot.docs\n .map((entry) => ({\n id: entry.id,\n template: normalizeCrmEmailTemplate(entry.data()),\n }))\n .filter(\n ({ template }) =>\n template.kind === 'template' &&\n crmEmailTemplateIsListed(template, uid) &&\n (!hostId || visibleToHost(template.visibleTo, hostId)),\n )\n .map(({ id, template }) => ({\n id,\n name: template.name,\n subject: template.subject,\n body: template.body,\n })),\n }),\n (error) => {\n if (!denied(error))\n console.error('[outreach] email templates could not be read', error)\n setResult({ status: denied(error) ? 'refused' : 'error', data: [] })\n },\n )\n }, [firestore, orgId, hostId, uid])\n return result\n}\n\n/** A contact a search found. */\nexport interface OutreachContactOption {\n id: string\n name: string\n email: string | null\n}\n\n/** The most contacts one search answers with. */\nexport const OUTREACH_SEARCH_LIMIT = 25\n\n/**\n * The contacts at `hostId` a search finds: by address when the text is one,\n * otherwise by a word of the name — the `nameTokens` the contacts carry for\n * the CRM's own search. `idle` until something is typed.\n */\nexport function useOutreachContactSearch(input: {\n orgId: string | null\n hostId: string | null\n contactGroupId: string | null\n text: string\n}): OutreachLoad<OutreachContactOption[]> & { idle: boolean } {\n const { orgId, hostId, contactGroupId, text } = input\n const firestore = useFirestore()\n const [result, setResult] = useState<OutreachLoad<OutreachContactOption[]>>({\n status: 'ready',\n data: [],\n })\n const email = normalizeContactEmail(text)\n const token = email ? '' : nameSearchToken(text)\n const idle = !orgId || !hostId || (!email && !token)\n useEffect(() => {\n if (idle || !orgId || !hostId) {\n setResult({ status: 'ready', data: [] })\n return undefined\n }\n let current = true\n setResult((previous) => ({ status: 'loading', data: previous.data }))\n const contacts = collection(firestore, 'orgs', orgId, 'contacts')\n // Settles the typing before it reads: one query per pause, not per key.\n const timer = setTimeout(() => {\n getDocs(\n email\n ? query(\n contacts,\n where('email', '==', email),\n limit(OUTREACH_SEARCH_LIMIT),\n )\n : query(\n contacts,\n where('nameTokens', 'array-contains', token),\n orderBy('nameLower'),\n limit(OUTREACH_SEARCH_LIMIT),\n ),\n )\n .then((snapshot) => {\n if (!current) return\n setResult({\n status: 'ready',\n data: snapshot.docs\n .map((entry) => ({\n id: entry.id,\n data: entry.data() as Record<string, unknown>,\n }))\n .filter(({ data }) =>\n visibleToHost(\n data['visibleTo'] as string[] | undefined,\n hostId,\n ),\n )\n .map(({ id, data }) => ({\n id,\n name: contactDisplayName(data, contactGroupId ?? hostId).trim(),\n email: normalizeContactEmail(data['email']),\n })),\n })\n })\n .catch((error: unknown) => {\n if (!current) return\n if (!denied(error))\n console.error('[outreach] the contact search failed', error)\n setResult({ status: denied(error) ? 'refused' : 'error', data: [] })\n })\n }, 300)\n return () => {\n current = false\n clearTimeout(timer)\n }\n }, [firestore, orgId, hostId, contactGroupId, email, token, idle])\n return { ...result, idle }\n}\n"],"names":["contactDisplayName","normalizeContactEmail","CRM_COLLECTIONS","crmViewIsListed","normalizeCrmViewFilters","CRM_EMAIL_TEMPLATES_LIMIT","crmEmailTemplateIsListed","normalizeCrmEmailTemplate","dynamicListDimensionsForCrmView","nameSearchToken","visibleToHost","useFirestore","collection","getDocs","limit","onSnapshot","orderBy","query","where","useEffect","useState","denied","error","code","OUTREACH_VIEWS_LIMIT","useOutreachSavedViews","orgId","uid","firestore","result","setResult","status","data","undefined","views","snapshot","docs","map","entry","id","filter","shared","ownerUid","String","unsupported","length","name","console","useOutreachEmailTemplates","hostId","emailTemplates","template","kind","visibleTo","subject","body","OUTREACH_SEARCH_LIMIT","useOutreachContactSearch","input","contactGroupId","text","email","token","idle","current","previous","contacts","timer","setTimeout","then","trim","catch","clearTimeout"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/components/use-outreach-crm.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'use client'\n\nimport {\n contactDisplayName,\n normalizeContactEmail,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport {\n CRM_COLLECTIONS,\n crmLeadDisplayName,\n crmViewIsListed,\n isCrmLeadOpen,\n normalizeCrmViewFilters,\n} from '@aglyn/aglyn/app-utils/crm'\nimport {\n CRM_EMAIL_TEMPLATES_LIMIT,\n crmEmailTemplateIsListed,\n normalizeCrmEmailTemplate,\n} from '@aglyn/aglyn/app-utils/crm-email-templates'\nimport { dynamicListDimensionsForCrmView } from '@aglyn/aglyn/app-utils/dynamic-list-rule'\nimport { nameSearchToken } from '@aglyn/aglyn/app-utils/name-search'\nimport { visibleToHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport {\n collection,\n getDocs,\n limit,\n onSnapshot,\n orderBy,\n query,\n where,\n} from 'firebase/firestore'\nimport { useEffect, useState } from 'react'\nimport type { OutreachLoad } from './use-outreach-data'\n\n/**\n * What Outreach reads from the organization's CRM (AGL-2980): the saved\n * Contacts views people can be enrolled from, the email templates a step\n * can send, and the contacts a search finds.\n *\n * Through the core's own CRM model — the collection names, the listing\n * rules, the view translation the dynamic-list sweep uses — and never\n * through the CRM plugin, which a plugin may not import. The Firestore\n * rules gate every one of these reads on `data.manage`, the same permission\n * the enroll routes ask for, so a member who cannot read the people cannot\n * enroll them either.\n */\n\nconst denied = (error: unknown) =>\n (error as { code?: unknown } | null)?.code === 'permission-denied'\n\n/** A saved Contacts or Leads view people can be enrolled from (AGL-3234). */\nexport interface OutreachViewOption {\n id: string\n name: string\n /** Which list the view is of; a Leads view selects the sequence's site's leads. */\n section: 'contacts' | 'leads'\n}\n\n/** The most saved views the picker lists. */\nexport const OUTREACH_VIEWS_LIMIT = 50\n\n/**\n * The saved Contacts views this reader may enroll from: their own and the\n * shared ones — a colleague's private view is theirs — that translate whole\n * into an audience. A view filtering on something enrolling cannot apply is\n * not offered, because dropping that filter would enroll more people than\n * the view shows.\n */\nexport function useOutreachSavedViews(\n orgId: string | null,\n uid: string | null,\n): OutreachLoad<OutreachViewOption[]> {\n const firestore = useFirestore()\n const [result, setResult] = useState<OutreachLoad<OutreachViewOption[]>>({\n status: 'loading',\n data: [],\n })\n useEffect(() => {\n setResult({ status: 'loading', data: [] })\n if (!orgId) return undefined\n return onSnapshot(\n query(\n collection(firestore, 'orgs', orgId, CRM_COLLECTIONS.views),\n where('section', 'in', ['contacts', 'leads']),\n orderBy('name'),\n limit(OUTREACH_VIEWS_LIMIT),\n ),\n (snapshot) =>\n setResult({\n status: 'ready',\n data: snapshot.docs\n .map((entry) => ({\n id: entry.id,\n data: entry.data() as Record<string, unknown>,\n }))\n .filter(\n ({ data }) =>\n typeof data['name'] === 'string' &&\n data['name'] !== '' &&\n crmViewIsListed(\n {\n shared: data['shared'] === true,\n ownerUid: String(data['ownerUid'] ?? ''),\n },\n uid,\n ) &&\n // A Leads view narrows by status alone, which enrolling can\n // always apply; a Contacts view must be readable as a list.\n (data['section'] === 'leads' ||\n dynamicListDimensionsForCrmView(\n normalizeCrmViewFilters(data['filters']),\n ).unsupported.length === 0),\n )\n .map(({ id, data }) => ({\n id,\n name: String(data['name']),\n section: data['section'] === 'leads' ? ('leads' as const) : ('contacts' as const),\n })),\n }),\n (error) => {\n if (!denied(error))\n console.error('[outreach] saved views could not be read', error)\n setResult({ status: denied(error) ? 'refused' : 'error', data: [] })\n },\n )\n }, [firestore, orgId, uid])\n return result\n}\n\n/** A CRM email template a step can send the body of. */\nexport interface OutreachTemplateOption {\n id: string\n name: string\n subject: string\n body: string\n}\n\n/**\n * The CRM email templates a step at this site can use: whole letters, not\n * snippets; the team's and this reader's own; written for the whole\n * organization or for this site.\n */\nexport function useOutreachEmailTemplates(\n orgId: string | null,\n hostId: string | null,\n uid: string | null,\n): OutreachLoad<OutreachTemplateOption[]> {\n const firestore = useFirestore()\n const [result, setResult] = useState<OutreachLoad<OutreachTemplateOption[]>>({\n status: 'loading',\n data: [],\n })\n useEffect(() => {\n setResult({ status: 'loading', data: [] })\n if (!orgId) return undefined\n return onSnapshot(\n query(\n collection(firestore, 'orgs', orgId, CRM_COLLECTIONS.emailTemplates),\n orderBy('name'),\n limit(CRM_EMAIL_TEMPLATES_LIMIT),\n ),\n (snapshot) =>\n setResult({\n status: 'ready',\n data: snapshot.docs\n .map((entry) => ({\n id: entry.id,\n template: normalizeCrmEmailTemplate(entry.data()),\n }))\n .filter(\n ({ template }) =>\n template.kind === 'template' &&\n crmEmailTemplateIsListed(template, uid) &&\n (!hostId || visibleToHost(template.visibleTo, hostId)),\n )\n .map(({ id, template }) => ({\n id,\n name: template.name,\n subject: template.subject,\n body: template.body,\n })),\n }),\n (error) => {\n if (!denied(error))\n console.error('[outreach] email templates could not be read', error)\n setResult({ status: denied(error) ? 'refused' : 'error', data: [] })\n },\n )\n }, [firestore, orgId, hostId, uid])\n return result\n}\n\n/** A contact a search found. */\nexport interface OutreachContactOption {\n id: string\n name: string\n email: string | null\n}\n\n/** The most contacts one search answers with. */\nexport const OUTREACH_SEARCH_LIMIT = 25\n\n/**\n * The contacts at `hostId` a search finds: by address when the text is one,\n * otherwise by a word of the name — the `nameTokens` the contacts carry for\n * the CRM's own search. `idle` until something is typed.\n */\nexport function useOutreachContactSearch(input: {\n orgId: string | null\n hostId: string | null\n contactGroupId: string | null\n text: string\n}): OutreachLoad<OutreachContactOption[]> & { idle: boolean } {\n const { orgId, hostId, contactGroupId, text } = input\n const firestore = useFirestore()\n const [result, setResult] = useState<OutreachLoad<OutreachContactOption[]>>({\n status: 'ready',\n data: [],\n })\n const email = normalizeContactEmail(text)\n const token = email ? '' : nameSearchToken(text)\n const idle = !orgId || !hostId || (!email && !token)\n useEffect(() => {\n if (idle || !orgId || !hostId) {\n setResult({ status: 'ready', data: [] })\n return undefined\n }\n let current = true\n setResult((previous) => ({ status: 'loading', data: previous.data }))\n const contacts = collection(firestore, 'orgs', orgId, 'contacts')\n // Settles the typing before it reads: one query per pause, not per key.\n const timer = setTimeout(() => {\n getDocs(\n email\n ? query(\n contacts,\n where('email', '==', email),\n limit(OUTREACH_SEARCH_LIMIT),\n )\n : query(\n contacts,\n where('nameTokens', 'array-contains', token),\n orderBy('nameLower'),\n limit(OUTREACH_SEARCH_LIMIT),\n ),\n )\n .then((snapshot) => {\n if (!current) return\n setResult({\n status: 'ready',\n data: snapshot.docs\n .map((entry) => ({\n id: entry.id,\n data: entry.data() as Record<string, unknown>,\n }))\n .filter(({ data }) =>\n visibleToHost(\n data['visibleTo'] as string[] | undefined,\n hostId,\n ),\n )\n .map(({ id, data }) => ({\n id,\n name: contactDisplayName(data, contactGroupId ?? hostId).trim(),\n email: normalizeContactEmail(data['email']),\n })),\n })\n })\n .catch((error: unknown) => {\n if (!current) return\n if (!denied(error))\n console.error('[outreach] the contact search failed', error)\n setResult({ status: denied(error) ? 'refused' : 'error', data: [] })\n })\n }, 300)\n return () => {\n current = false\n clearTimeout(timer)\n }\n }, [firestore, orgId, hostId, contactGroupId, email, token, idle])\n return { ...result, idle }\n}\n\n/** The most leads the picker reads — the Leads list's own window. */\nexport const OUTREACH_LEADS_WINDOW = 200\n\n/**\n * The open leads at `hostId` a search finds (AGL-3234): the site's most\n * recently seen leads, read once while the tab is open and narrowed in\n * the browser by a word of the name or the address — a lead carries no\n * search tokens, and the window is the Leads list's own. Converted and\n * closed leads are left out: they are a contact's, or a verdict's, to\n * enroll. `idle` until something is typed.\n */\nexport function useOutreachLeadSearch(input: {\n hostId: string | null\n text: string\n enabled: boolean\n}): OutreachLoad<OutreachContactOption[]> & { idle: boolean } {\n const { hostId, text, enabled } = input\n const firestore = useFirestore()\n const [window, setWindow] = useState<OutreachLoad<OutreachContactOption[]>>({\n status: 'ready',\n data: [],\n })\n useEffect(() => {\n if (!enabled || !hostId) {\n setWindow({ status: 'ready', data: [] })\n return undefined\n }\n let current = true\n setWindow({ status: 'loading', data: [] })\n getDocs(\n query(\n collection(firestore, 'hosts', hostId, 'leads'),\n orderBy('lastSeenAtMs', 'desc'),\n limit(OUTREACH_LEADS_WINDOW),\n ),\n )\n .then((snapshot) => {\n if (!current) return\n setWindow({\n status: 'ready',\n data: snapshot.docs\n .map((entry) => ({ id: entry.id, data: entry.data() as Record<string, unknown> }))\n .filter(({ data }) => isCrmLeadOpen(data as never) && !data['convertedContactId'])\n .map(({ id, data }) => ({\n id,\n name: crmLeadDisplayName(data),\n email: normalizeContactEmail(data['email']),\n })),\n })\n })\n .catch((error: unknown) => {\n if (!current) return\n if (!denied(error)) console.error('[outreach] the leads could not be read', error)\n setWindow({ status: denied(error) ? 'refused' : 'error', data: [] })\n })\n return () => {\n current = false\n }\n }, [firestore, hostId, enabled])\n const needle = text.trim().toLowerCase()\n const idle = !hostId || !needle\n const data = idle\n ? []\n : window.data\n .filter(\n (lead) =>\n lead.name.toLowerCase().includes(needle) ||\n (lead.email ?? '').includes(needle),\n )\n .slice(0, OUTREACH_SEARCH_LIMIT)\n return { ...window, data, idle }\n}\n"],"names":["contactDisplayName","normalizeContactEmail","CRM_COLLECTIONS","crmLeadDisplayName","crmViewIsListed","isCrmLeadOpen","normalizeCrmViewFilters","CRM_EMAIL_TEMPLATES_LIMIT","crmEmailTemplateIsListed","normalizeCrmEmailTemplate","dynamicListDimensionsForCrmView","nameSearchToken","visibleToHost","useFirestore","collection","getDocs","limit","onSnapshot","orderBy","query","where","useEffect","useState","denied","error","code","OUTREACH_VIEWS_LIMIT","useOutreachSavedViews","orgId","uid","firestore","result","setResult","status","data","undefined","views","snapshot","docs","map","entry","id","filter","shared","ownerUid","String","unsupported","length","name","section","console","useOutreachEmailTemplates","hostId","emailTemplates","template","kind","visibleTo","subject","body","OUTREACH_SEARCH_LIMIT","useOutreachContactSearch","input","contactGroupId","text","email","token","idle","current","previous","contacts","timer","setTimeout","then","trim","catch","clearTimeout","OUTREACH_LEADS_WINDOW","useOutreachLeadSearch","enabled","window","setWindow","needle","toLowerCase","lead","includes","slice"],"mappings":"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"}
@@ -17,6 +17,7 @@
17
17
  // boot needs one registry, not the whole server surface.
18
18
  import { registerPluginConsoleCron } from "@aglyn/aglyn/plugin-manager/plugin-console-crons";
19
19
  import { listPluginOrgErasers, registerPluginOrgEraser } from "@aglyn/aglyn/plugin-manager/plugin-org-erasure";
20
+ import { listPluginLeadConversionListeners, registerPluginLeadConversionListener } from "@aglyn/aglyn/plugin-manager/plugin-lead-conversion";
20
21
  import { registerPluginPersonEraser } from "@aglyn/aglyn/plugin-manager/plugin-person-erasure";
21
22
  import { listPluginUserErasers, registerPluginUserEraser } from "@aglyn/aglyn/plugin-manager/plugin-user-erasure";
22
23
  import { OUTREACH_PLUGIN_ID } from "./constants/bundle-common.js";
@@ -64,6 +65,19 @@ import { OUTREACH_SEND_JOB_ID, OUTREACH_SYNC_JOB_ID } from "./constants/runtime-
64
65
  registerPluginPersonEraser(async (request)=>(await runtime()).platformOutreachPersonEraser()(request), {
65
66
  pluginId: OUTREACH_PLUGIN_ID
66
67
  });
68
+ // A lead that converts takes its enrollments to the contact it became
69
+ // (AGL-3234): the same document, now naming the contact.
70
+ if (!listPluginLeadConversionListeners().includes(OUTREACH_PLUGIN_ID)) {
71
+ registerPluginLeadConversionListener(async (request)=>{
72
+ const [{ followOutreachLeadToContact }, platform] = await Promise.all([
73
+ import("./runtime/lead-records.js"),
74
+ runtime()
75
+ ]);
76
+ return followOutreachLeadToContact(platform.platformOutreachRuntimeDeps().firestore(), request);
77
+ }, {
78
+ pluginId: OUTREACH_PLUGIN_ID
79
+ });
80
+ }
67
81
  // The sending runtime, on the console's fifteen-minute tick.
68
82
  registerPluginConsoleCron({
69
83
  id: OUTREACH_SEND_JOB_ID,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/plugins/outreach/src/lib/declarations.console-server.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// Each registry from its own module, not the `@aglyn/aglyn/server` barrel:\n// boot needs one registry, not the whole server surface.\nimport { registerPluginConsoleCron } from '@aglyn/aglyn/plugin-manager/plugin-console-crons'\nimport {\n listPluginOrgErasers,\n registerPluginOrgEraser,\n} from '@aglyn/aglyn/plugin-manager/plugin-org-erasure'\nimport { registerPluginPersonEraser } from '@aglyn/aglyn/plugin-manager/plugin-person-erasure'\nimport {\n listPluginUserErasers,\n registerPluginUserEraser,\n} from '@aglyn/aglyn/plugin-manager/plugin-user-erasure'\nimport { OUTREACH_PLUGIN_ID } from './constants/bundle-common'\nimport { OUTREACH_SEND_JOB_ID, OUTREACH_SYNC_JOB_ID } from './constants/runtime-jobs'\n\n/**\n * Outreach's CONSOLE-ONLY server declarations (AGL-2978), named under\n * `consoleServerDeclarations` in `plugins.config.json` and run at the\n * console's boot.\n *\n * The erasers are registered here rather than from `serverDeclarations`,\n * which the tenant runtime also loads: the workspace eraser revokes each\n * rep's Google grant, and the account eraser (AGL-3106) revokes and deletes\n * the person's own, and both open a sealed token with `OUTREACH_TOKEN_KEY`,\n * which only the console holds. The tenant may not so much as bundle the code\n * that does (`outreach-credential-isolation.spec.ts`). Every erasure runs in\n * the console, so this is where the erasers are needed.\n *\n * The sending runtime (AGL-2981) is declared here too, for the same reason:\n * the send and sync jobs open each rep's sealed grant, so they run on the\n * console's `plugin-console-crons` tick and nowhere else, and the person\n * eraser removes the enrollments a workspace kept about someone it erases.\n *\n * Light at boot: every module that does the work is imported when a job, an\n * erasure or a tick first asks for it, so the boot cost is the registration.\n */\nexport function registerOutreachConsoleServerDeclarations(): void {\n // Idempotent against the REGISTRY, so a reset (a spec) registers again.\n if (!listPluginOrgErasers().includes(OUTREACH_PLUGIN_ID)) {\n registerPluginOrgEraser(\n async (request) => {\n const { createOutreachOrgEraser, defaultOutreachErasureDeps } = await import(\n './mailboxes/mailbox-erasure'\n )\n return createOutreachOrgEraser(defaultOutreachErasureDeps())(request)\n },\n { pluginId: OUTREACH_PLUGIN_ID },\n )\n }\n if (!listPluginUserErasers().includes(OUTREACH_PLUGIN_ID)) {\n registerPluginUserEraser(\n async (request) => {\n const { createOutreachUserEraser, defaultOutreachErasureDeps } = await import(\n './mailboxes/mailbox-erasure'\n )\n return createOutreachUserEraser(defaultOutreachErasureDeps())(request)\n },\n { pluginId: OUTREACH_PLUGIN_ID },\n )\n }\n // A person erased from a workspace takes their enrollments with them; the\n // do-not-contact entry stays, stripped of anything that names them.\n registerPluginPersonEraser(\n async (request) => (await runtime()).platformOutreachPersonEraser()(request),\n { pluginId: OUTREACH_PLUGIN_ID },\n )\n // The sending runtime, on the console's fifteen-minute tick.\n registerPluginConsoleCron(\n {\n id: OUTREACH_SEND_JOB_ID,\n label: 'Sequence sends',\n drives:\n 'Sends every sequence step that has come due, from the rep’s own connected mailbox, and files each on the contact’s timeline. If it stops, no sequence sends anything and every task step waits.',\n run: async (context) => {\n const [{ runOutreachSendJob }, platform] = await Promise.all([import('./runtime/send-job'), runtime()])\n return runOutreachSendJob(platform.platformOutreachRuntimeDeps(), context)\n },\n },\n { pluginId: OUTREACH_PLUGIN_ID },\n )\n registerPluginConsoleCron(\n {\n id: OUTREACH_SYNC_JOB_ID,\n label: 'Sequence replies and bounces',\n drives:\n 'Reads each connected mailbox for replies, out-of-office answers, opt-outs and bounces, and stops or postpones the sequence each one is about. If it stops, a person who replied or asked to be left alone keeps getting follow-ups, and a mailbox that bounces never pauses itself.',\n run: async (context) => {\n const [{ runOutreachSyncJob }, platform] = await Promise.all([import('./runtime/sync-job'), runtime()])\n return runOutreachSyncJob(platform.platformOutreachRuntimeDeps(), context)\n },\n },\n { pluginId: OUTREACH_PLUGIN_ID },\n )\n}\n\n/** The runtime's platform reach, loaded when a job or an erasure first runs. */\nconst runtime = () => import('./runtime/platform-runtime-deps')\n"],"names":["registerPluginConsoleCron","listPluginOrgErasers","registerPluginOrgEraser","registerPluginPersonEraser","listPluginUserErasers","registerPluginUserEraser","OUTREACH_PLUGIN_ID","OUTREACH_SEND_JOB_ID","OUTREACH_SYNC_JOB_ID","registerOutreachConsoleServerDeclarations","includes","request","createOutreachOrgEraser","defaultOutreachErasureDeps","pluginId","createOutreachUserEraser","runtime","platformOutreachPersonEraser","id","label","drives","run","context","runOutreachSendJob","platform","Promise","all","platformOutreachRuntimeDeps","runOutreachSyncJob"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,2EAA2E;AAC3E,yDAAyD;AACzD,SAASA,yBAAyB,QAAQ,mDAAkD;AAC5F,SACEC,oBAAoB,EACpBC,uBAAuB,QAClB,iDAAgD;AACvD,SAASC,0BAA0B,QAAQ,oDAAmD;AAC9F,SACEC,qBAAqB,EACrBC,wBAAwB,QACnB,kDAAiD;AACxD,SAASC,kBAAkB,QAAQ,+BAA2B;AAC9D,SAASC,oBAAoB,EAAEC,oBAAoB,QAAQ,8BAA0B;AAErF;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,SAASC;IACd,wEAAwE;IACxE,IAAI,CAACR,uBAAuBS,QAAQ,CAACJ,qBAAqB;QACxDJ,wBACE,OAAOS;YACL,MAAM,EAAEC,uBAAuB,EAAEC,0BAA0B,EAAE,GAAG,MAAM,MAAM,CAC1E;YAEF,OAAOD,wBAAwBC,8BAA8BF;QAC/D,GACA;YAAEG,UAAUR;QAAmB;IAEnC;IACA,IAAI,CAACF,wBAAwBM,QAAQ,CAACJ,qBAAqB;QACzDD,yBACE,OAAOM;YACL,MAAM,EAAEI,wBAAwB,EAAEF,0BAA0B,EAAE,GAAG,MAAM,MAAM,CAC3E;YAEF,OAAOE,yBAAyBF,8BAA8BF;QAChE,GACA;YAAEG,UAAUR;QAAmB;IAEnC;IACA,0EAA0E;IAC1E,oEAAoE;IACpEH,2BACE,OAAOQ,UAAY,AAAC,CAAA,MAAMK,SAAQ,EAAGC,4BAA4B,GAAGN,UACpE;QAAEG,UAAUR;IAAmB;IAEjC,6DAA6D;IAC7DN,0BACE;QACEkB,IAAIX;QACJY,OAAO;QACPC,QACE;QACFC,KAAK,OAAOC;YACV,MAAM,CAAC,EAAEC,kBAAkB,EAAE,EAAEC,SAAS,GAAG,MAAMC,QAAQC,GAAG,CAAC;gBAAC,MAAM,CAAC;gBAAuBV;aAAU;YACtG,OAAOO,mBAAmBC,SAASG,2BAA2B,IAAIL;QACpE;IACF,GACA;QAAER,UAAUR;IAAmB;IAEjCN,0BACE;QACEkB,IAAIV;QACJW,OAAO;QACPC,QACE;QACFC,KAAK,OAAOC;YACV,MAAM,CAAC,EAAEM,kBAAkB,EAAE,EAAEJ,SAAS,GAAG,MAAMC,QAAQC,GAAG,CAAC;gBAAC,MAAM,CAAC;gBAAuBV;aAAU;YACtG,OAAOY,mBAAmBJ,SAASG,2BAA2B,IAAIL;QACpE;IACF,GACA;QAAER,UAAUR;IAAmB;AAEnC;AAEA,8EAA8E,GAC9E,MAAMU,UAAU,IAAM,MAAM,CAAC"}
1
+ {"version":3,"sources":["../../../../../../libs/plugins/outreach/src/lib/declarations.console-server.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// Each registry from its own module, not the `@aglyn/aglyn/server` barrel:\n// boot needs one registry, not the whole server surface.\nimport { registerPluginConsoleCron } from '@aglyn/aglyn/plugin-manager/plugin-console-crons'\nimport {\n listPluginOrgErasers,\n registerPluginOrgEraser,\n} from '@aglyn/aglyn/plugin-manager/plugin-org-erasure'\nimport {\n listPluginLeadConversionListeners,\n registerPluginLeadConversionListener,\n} from '@aglyn/aglyn/plugin-manager/plugin-lead-conversion'\nimport { registerPluginPersonEraser } from '@aglyn/aglyn/plugin-manager/plugin-person-erasure'\nimport {\n listPluginUserErasers,\n registerPluginUserEraser,\n} from '@aglyn/aglyn/plugin-manager/plugin-user-erasure'\nimport { OUTREACH_PLUGIN_ID } from './constants/bundle-common'\nimport { OUTREACH_SEND_JOB_ID, OUTREACH_SYNC_JOB_ID } from './constants/runtime-jobs'\n\n/**\n * Outreach's CONSOLE-ONLY server declarations (AGL-2978), named under\n * `consoleServerDeclarations` in `plugins.config.json` and run at the\n * console's boot.\n *\n * The erasers are registered here rather than from `serverDeclarations`,\n * which the tenant runtime also loads: the workspace eraser revokes each\n * rep's Google grant, and the account eraser (AGL-3106) revokes and deletes\n * the person's own, and both open a sealed token with `OUTREACH_TOKEN_KEY`,\n * which only the console holds. The tenant may not so much as bundle the code\n * that does (`outreach-credential-isolation.spec.ts`). Every erasure runs in\n * the console, so this is where the erasers are needed.\n *\n * The sending runtime (AGL-2981) is declared here too, for the same reason:\n * the send and sync jobs open each rep's sealed grant, so they run on the\n * console's `plugin-console-crons` tick and nowhere else, and the person\n * eraser removes the enrollments a workspace kept about someone it erases.\n *\n * Light at boot: every module that does the work is imported when a job, an\n * erasure or a tick first asks for it, so the boot cost is the registration.\n */\nexport function registerOutreachConsoleServerDeclarations(): void {\n // Idempotent against the REGISTRY, so a reset (a spec) registers again.\n if (!listPluginOrgErasers().includes(OUTREACH_PLUGIN_ID)) {\n registerPluginOrgEraser(\n async (request) => {\n const { createOutreachOrgEraser, defaultOutreachErasureDeps } = await import(\n './mailboxes/mailbox-erasure'\n )\n return createOutreachOrgEraser(defaultOutreachErasureDeps())(request)\n },\n { pluginId: OUTREACH_PLUGIN_ID },\n )\n }\n if (!listPluginUserErasers().includes(OUTREACH_PLUGIN_ID)) {\n registerPluginUserEraser(\n async (request) => {\n const { createOutreachUserEraser, defaultOutreachErasureDeps } = await import(\n './mailboxes/mailbox-erasure'\n )\n return createOutreachUserEraser(defaultOutreachErasureDeps())(request)\n },\n { pluginId: OUTREACH_PLUGIN_ID },\n )\n }\n // A person erased from a workspace takes their enrollments with them; the\n // do-not-contact entry stays, stripped of anything that names them.\n registerPluginPersonEraser(\n async (request) => (await runtime()).platformOutreachPersonEraser()(request),\n { pluginId: OUTREACH_PLUGIN_ID },\n )\n // A lead that converts takes its enrollments to the contact it became\n // (AGL-3234): the same document, now naming the contact.\n if (!listPluginLeadConversionListeners().includes(OUTREACH_PLUGIN_ID)) {\n registerPluginLeadConversionListener(\n async (request) => {\n const [{ followOutreachLeadToContact }, platform] = await Promise.all([\n import('./runtime/lead-records'),\n runtime(),\n ])\n return followOutreachLeadToContact(platform.platformOutreachRuntimeDeps().firestore(), request)\n },\n { pluginId: OUTREACH_PLUGIN_ID },\n )\n }\n // The sending runtime, on the console's fifteen-minute tick.\n registerPluginConsoleCron(\n {\n id: OUTREACH_SEND_JOB_ID,\n label: 'Sequence sends',\n drives:\n 'Sends every sequence step that has come due, from the rep’s own connected mailbox, and files each on the contact’s timeline. If it stops, no sequence sends anything and every task step waits.',\n run: async (context) => {\n const [{ runOutreachSendJob }, platform] = await Promise.all([import('./runtime/send-job'), runtime()])\n return runOutreachSendJob(platform.platformOutreachRuntimeDeps(), context)\n },\n },\n { pluginId: OUTREACH_PLUGIN_ID },\n )\n registerPluginConsoleCron(\n {\n id: OUTREACH_SYNC_JOB_ID,\n label: 'Sequence replies and bounces',\n drives:\n 'Reads each connected mailbox for replies, out-of-office answers, opt-outs and bounces, and stops or postpones the sequence each one is about. If it stops, a person who replied or asked to be left alone keeps getting follow-ups, and a mailbox that bounces never pauses itself.',\n run: async (context) => {\n const [{ runOutreachSyncJob }, platform] = await Promise.all([import('./runtime/sync-job'), runtime()])\n return runOutreachSyncJob(platform.platformOutreachRuntimeDeps(), context)\n },\n },\n { pluginId: OUTREACH_PLUGIN_ID },\n )\n}\n\n/** The runtime's platform reach, loaded when a job or an erasure first runs. */\nconst runtime = () => import('./runtime/platform-runtime-deps')\n"],"names":["registerPluginConsoleCron","listPluginOrgErasers","registerPluginOrgEraser","listPluginLeadConversionListeners","registerPluginLeadConversionListener","registerPluginPersonEraser","listPluginUserErasers","registerPluginUserEraser","OUTREACH_PLUGIN_ID","OUTREACH_SEND_JOB_ID","OUTREACH_SYNC_JOB_ID","registerOutreachConsoleServerDeclarations","includes","request","createOutreachOrgEraser","defaultOutreachErasureDeps","pluginId","createOutreachUserEraser","runtime","platformOutreachPersonEraser","followOutreachLeadToContact","platform","Promise","all","platformOutreachRuntimeDeps","firestore","id","label","drives","run","context","runOutreachSendJob","runOutreachSyncJob"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,2EAA2E;AAC3E,yDAAyD;AACzD,SAASA,yBAAyB,QAAQ,mDAAkD;AAC5F,SACEC,oBAAoB,EACpBC,uBAAuB,QAClB,iDAAgD;AACvD,SACEC,iCAAiC,EACjCC,oCAAoC,QAC/B,qDAAoD;AAC3D,SAASC,0BAA0B,QAAQ,oDAAmD;AAC9F,SACEC,qBAAqB,EACrBC,wBAAwB,QACnB,kDAAiD;AACxD,SAASC,kBAAkB,QAAQ,+BAA2B;AAC9D,SAASC,oBAAoB,EAAEC,oBAAoB,QAAQ,8BAA0B;AAErF;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,SAASC;IACd,wEAAwE;IACxE,IAAI,CAACV,uBAAuBW,QAAQ,CAACJ,qBAAqB;QACxDN,wBACE,OAAOW;YACL,MAAM,EAAEC,uBAAuB,EAAEC,0BAA0B,EAAE,GAAG,MAAM,MAAM,CAC1E;YAEF,OAAOD,wBAAwBC,8BAA8BF;QAC/D,GACA;YAAEG,UAAUR;QAAmB;IAEnC;IACA,IAAI,CAACF,wBAAwBM,QAAQ,CAACJ,qBAAqB;QACzDD,yBACE,OAAOM;YACL,MAAM,EAAEI,wBAAwB,EAAEF,0BAA0B,EAAE,GAAG,MAAM,MAAM,CAC3E;YAEF,OAAOE,yBAAyBF,8BAA8BF;QAChE,GACA;YAAEG,UAAUR;QAAmB;IAEnC;IACA,0EAA0E;IAC1E,oEAAoE;IACpEH,2BACE,OAAOQ,UAAY,AAAC,CAAA,MAAMK,SAAQ,EAAGC,4BAA4B,GAAGN,UACpE;QAAEG,UAAUR;IAAmB;IAEjC,sEAAsE;IACtE,yDAAyD;IACzD,IAAI,CAACL,oCAAoCS,QAAQ,CAACJ,qBAAqB;QACrEJ,qCACE,OAAOS;YACL,MAAM,CAAC,EAAEO,2BAA2B,EAAE,EAAEC,SAAS,GAAG,MAAMC,QAAQC,GAAG,CAAC;gBACpE,MAAM,CAAC;gBACPL;aACD;YACD,OAAOE,4BAA4BC,SAASG,2BAA2B,GAAGC,SAAS,IAAIZ;QACzF,GACA;YAAEG,UAAUR;QAAmB;IAEnC;IACA,6DAA6D;IAC7DR,0BACE;QACE0B,IAAIjB;QACJkB,OAAO;QACPC,QACE;QACFC,KAAK,OAAOC;YACV,MAAM,CAAC,EAAEC,kBAAkB,EAAE,EAAEV,SAAS,GAAG,MAAMC,QAAQC,GAAG,CAAC;gBAAC,MAAM,CAAC;gBAAuBL;aAAU;YACtG,OAAOa,mBAAmBV,SAASG,2BAA2B,IAAIM;QACpE;IACF,GACA;QAAEd,UAAUR;IAAmB;IAEjCR,0BACE;QACE0B,IAAIhB;QACJiB,OAAO;QACPC,QACE;QACFC,KAAK,OAAOC;YACV,MAAM,CAAC,EAAEE,kBAAkB,EAAE,EAAEX,SAAS,GAAG,MAAMC,QAAQC,GAAG,CAAC;gBAAC,MAAM,CAAC;gBAAuBL;aAAU;YACtG,OAAOc,mBAAmBX,SAASG,2BAA2B,IAAIM;QACpE;IACF,GACA;QAAEd,UAAUR;IAAmB;AAEnC;AAEA,8EAA8E,GAC9E,MAAMU,UAAU,IAAM,MAAM,CAAC"}
@@ -14,7 +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 OutreachAttestations, type OutreachEnrollment, type OutreachEnrollmentStatus, type OutreachMailbox, type OutreachSequence, type OutreachStopReason } from '../model/outreach.types';
17
+ import { type OutreachAttestations, type OutreachEnrollment, type OutreachEnrollmentStatus, type OutreachEnrollmentTarget, type OutreachMailbox, type OutreachSequence, type OutreachStopReason } from '../model/outreach.types';
18
18
  import { type OutreachRandom } from './schedule';
19
19
  /** Where each status may go next. */
20
20
  export declare const OUTREACH_ENROLLMENT_TRANSITIONS: Readonly<Record<OutreachEnrollmentStatus, readonly OutreachEnrollmentStatus[]>>;
@@ -88,7 +88,12 @@ export interface OutreachEnrollmentDraft {
88
88
  id: string;
89
89
  sequence: Pick<OutreachSequence, 'id' | 'hostId' | 'mailboxId' | 'steps' | 'settings'>;
90
90
  mailbox: ScheduleMailbox;
91
+ /** The record the person is (AGL-3234); a contact when absent. */
92
+ target?: OutreachEnrollmentTarget;
93
+ /** The contact's id; `''` for a lead. */
91
94
  contactId: string;
95
+ /** The lead's person key, for an enrollment made on a lead. */
96
+ leadId?: string | null;
92
97
  /** The contact's name as the sending site knows it; `''` for none. */
93
98
  contactName?: string | null;
94
99
  /** The address, as the gates normalized it. */
@@ -188,11 +188,13 @@ const detailOf = (value)=>{
188
188
  * door that enrolls, so an enrollment made from a contact and one made from
189
189
  * a list are the same shape. Call it after the gates allowed the person.
190
190
  */ export function buildOutreachEnrollment(draft) {
191
- var _draft_contactName, _draft_email, _draft_attestations;
191
+ var _draft_target, _draft_leadId, _draft_contactName, _draft_email, _draft_attestations;
192
192
  return {
193
193
  id: draft.id,
194
194
  sequenceId: draft.sequence.id,
195
+ target: (_draft_target = draft.target) != null ? _draft_target : 'contact',
195
196
  contactId: draft.contactId,
197
+ leadId: (_draft_leadId = draft.leadId) != null ? _draft_leadId : null,
196
198
  contactName: String((_draft_contactName = draft.contactName) != null ? _draft_contactName : '').replace(/\s+/g, ' ').trim().slice(0, 200),
197
199
  email: String((_draft_email = draft.email) != null ? _draft_email : '').trim().toLowerCase(),
198
200
  hostId: draft.sequence.hostId,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/enrollment-state.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 * ONE PERSON'S RUN THROUGH A SEQUENCE (AGL-2979).\n *\n * ## The state machine\n *\n * active ──► paused ──► active\n * │ │\n * ├──────────┴──► replied · bounced · opted_out · stopped\n * └─► finished · failed\n *\n * An enrollment sends only while `active`. Everything that ends the sending\n * records why (`stopReason`, `stopDetail`, when, and who). Ending the\n * sending does not end what can still be learned from the thread: a reply,\n * a bounce or an opt-out that arrives after the last step, after a member\n * stopped it or after a send failed is still recorded, and a person who\n * replied and then asked to be left alone moves on to `opted_out` — an\n * opt-out is the one fact every later sequence has to be able to find.\n * `bounced` and `opted_out` are final.\n *\n * Every function returns a PATCH — the fields to write — and never the\n * whole document, so a writer applies it with its own SDK and its own\n * timestamps.\n *==========================================*/\n\nimport {\n OUTREACH_ATTESTATION_KINDS,\n OUTREACH_STOP_REASONS_BY_STATUS,\n type OutreachAttestationKind,\n type OutreachAttestations,\n type OutreachEnrollment,\n type OutreachEnrollmentStatus,\n type OutreachMailbox,\n type OutreachSequence,\n type OutreachStopReason,\n} from '../model/outreach.types'\nimport { normalizeOutreachPersonalLine } from './gates'\nimport { effectiveOutreachWindow, type OutreachRandom, scheduleOutreachDue } from './schedule'\nimport { isInThreadEmailStep } from './sequence-validation'\n\n/** Where each status may go next. */\nexport const OUTREACH_ENROLLMENT_TRANSITIONS: Readonly<\n Record<OutreachEnrollmentStatus, readonly OutreachEnrollmentStatus[]>\n> = {\n active: ['paused', 'finished', 'replied', 'bounced', 'opted_out', 'stopped', 'failed'],\n paused: ['active', 'replied', 'bounced', 'opted_out', 'stopped'],\n finished: ['replied', 'bounced', 'opted_out'],\n stopped: ['replied', 'bounced', 'opted_out'],\n failed: ['replied', 'bounced', 'opted_out'],\n replied: ['opted_out'],\n bounced: [],\n opted_out: [],\n}\n\nexport function canTransitionOutreachEnrollment(\n from: OutreachEnrollmentStatus,\n to: OutreachEnrollmentStatus,\n): boolean {\n return OUTREACH_ENROLLMENT_TRANSITIONS[from]?.includes(to) === true\n}\n\n/** Something that happened to an enrollment. `atMs` is when it happened. */\nexport type OutreachEnrollmentEvent =\n | { type: 'pause'; atMs: number; byUid: string | null; detail?: string | null }\n | { type: 'resume'; atMs: number; byUid: string | null }\n | {\n type: 'stop'\n atMs: number\n byUid: string | null\n reason?: Extract<OutreachStopReason, 'manual' | 'gate' | 'sequence_archived'>\n detail?: string | null\n }\n | { type: 'reply'; atMs: number; detail?: string | null }\n | {\n type: 'opt_out'\n atMs: number\n reason: Extract<OutreachStopReason, 'opt_out_reply' | 'unsubscribe' | 'do_not_contact'>\n detail?: string | null\n }\n | { type: 'bounce'; atMs: number; detail?: string | null }\n | { type: 'send_failed'; atMs: number; detail?: string | null }\n\n/** The status fields an event writes. */\nexport interface OutreachEnrollmentStatusPatch {\n status: OutreachEnrollmentStatus\n stopReason: OutreachStopReason | null\n stopDetail: string | null\n stoppedAtMs: number | null\n stoppedByUid: string | null\n nextDueAtMs: number | null\n}\n\n/**\n * `patch` is what to write, or `null` when there is nothing to write — the\n * enrollment is already there, or the event is not allowed, in which case\n * `error` says why.\n */\nexport interface OutreachEnrollmentEventResult {\n patch: OutreachEnrollmentStatusPatch | null\n error: string | null\n}\n\nconst STATUS_WORDS: Record<OutreachEnrollmentStatus, string> = {\n active: 'is active',\n paused: 'is paused',\n finished: 'finished',\n replied: 'got a reply',\n bounced: 'bounced',\n opted_out: 'opted out',\n stopped: 'was stopped',\n failed: 'failed',\n}\n\nconst EVENT_TARGET: Record<OutreachEnrollmentEvent['type'], OutreachEnrollmentStatus> = {\n pause: 'paused',\n resume: 'active',\n stop: 'stopped',\n reply: 'replied',\n opt_out: 'opted_out',\n bounce: 'bounced',\n send_failed: 'failed',\n}\n\nconst detailOf = (value: unknown): string | null => {\n const text = typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''\n return text ? text.slice(0, 500) : null\n}\n\n/** What an event does to an enrollment's status — see the module note. */\nexport function applyOutreachEnrollmentEvent(\n enrollment: Pick<OutreachEnrollment, 'status' | 'nextDueAtMs'>,\n event: OutreachEnrollmentEvent,\n): OutreachEnrollmentEventResult {\n const from = enrollment.status\n const to = EVENT_TARGET[event.type]\n if (!to) return { patch: null, error: 'That is not something that happens to an enrollment.' }\n if (from === to) return { patch: null, error: null }\n if (!canTransitionOutreachEnrollment(from, to)) {\n return {\n patch: null,\n error: `This enrollment ${STATUS_WORDS[from]}, so it can't be marked as one that ${STATUS_WORDS[to]}.`,\n }\n }\n if (event.type === 'resume') {\n return {\n patch: {\n status: 'active',\n stopReason: null,\n stopDetail: null,\n stoppedAtMs: null,\n stoppedByUid: null,\n nextDueAtMs: enrollment.nextDueAtMs,\n },\n error: null,\n }\n }\n const reason: OutreachStopReason =\n event.type === 'pause'\n ? 'manual'\n : event.type === 'stop'\n ? (event.reason ?? 'manual')\n : event.type === 'reply'\n ? 'reply'\n : event.type === 'opt_out'\n ? event.reason\n : event.type === 'bounce'\n ? 'hard_bounce'\n : 'send_failed'\n const allowed = OUTREACH_STOP_REASONS_BY_STATUS[to as keyof typeof OUTREACH_STOP_REASONS_BY_STATUS]\n if (!allowed?.includes(reason)) {\n return { patch: null, error: `\"${reason}\" isn't a reason an enrollment ${STATUS_WORDS[to]}.` }\n }\n const byUid = 'byUid' in event && typeof event.byUid === 'string' && event.byUid ? event.byUid : null\n return {\n patch: {\n status: to,\n stopReason: reason,\n stopDetail: detailOf(event.detail),\n stoppedAtMs: event.atMs,\n stoppedByUid: byUid,\n // A paused enrollment keeps its place, so resuming it picks up where it\n // stopped; every other status has nothing left waiting.\n nextDueAtMs: to === 'paused' ? enrollment.nextDueAtMs : null,\n },\n error: null,\n }\n}\n\ntype ScheduleMailbox = Pick<OutreachMailbox, 'timezone' | 'window'>\ntype ScheduleSequence = Pick<OutreachSequence, 'steps' | 'settings'>\n\n/** When a new enrollment's first step comes due, or `null` when it cannot be placed. */\nexport function planOutreachFirstDue(input: {\n sequence: ScheduleSequence\n mailbox: ScheduleMailbox\n enrolledAtMs: number\n random: OutreachRandom\n}): number | null {\n const first = input.sequence.steps?.[0]\n if (!first) return null\n return scheduleOutreachDue({\n fromMs: input.enrolledAtMs,\n delayBusinessDays: first.delayBusinessDays,\n timeZone: input.mailbox.timezone,\n window: effectiveOutreachWindow(input.sequence.settings?.window, input.mailbox.window),\n random: input.random,\n })\n}\n\n/** Stamps the attestations a rep ticked with who ticked them and when; unknown kinds are dropped. */\nexport function outreachAttestationsFrom(\n kinds: readonly unknown[],\n uid: string,\n atMs: number,\n): OutreachAttestations {\n const attestations: OutreachAttestations = {}\n for (const kind of kinds ?? []) {\n if ((OUTREACH_ATTESTATION_KINDS as readonly unknown[]).includes(kind)) {\n attestations[kind as OutreachAttestationKind] = { uid, atMs }\n }\n }\n return attestations\n}\n\nexport interface OutreachEnrollmentDraft {\n id: string\n sequence: Pick<OutreachSequence, 'id' | 'hostId' | 'mailboxId' | 'steps' | 'settings'>\n mailbox: ScheduleMailbox\n contactId: string\n /** The contact's name as the sending site knows it; `''` for none. */\n contactName?: string | null\n /** The address, as the gates normalized it. */\n email: string\n /** The gates' `cold`. */\n cold: boolean\n personalLine: string\n attestations: OutreachAttestations\n enrolledByUid: string\n nowMs: number\n random: OutreachRandom\n}\n\n/**\n * A new enrollment document, first step scheduled — one builder for every\n * door that enrolls, so an enrollment made from a contact and one made from\n * a list are the same shape. Call it after the gates allowed the person.\n */\nexport function buildOutreachEnrollment(draft: OutreachEnrollmentDraft): OutreachEnrollment {\n return {\n id: draft.id,\n sequenceId: draft.sequence.id,\n contactId: draft.contactId,\n contactName: String(draft.contactName ?? '').replace(/\\s+/g, ' ').trim().slice(0, 200),\n email: String(draft.email ?? '').trim().toLowerCase(),\n hostId: draft.sequence.hostId,\n mailboxId: draft.sequence.mailboxId,\n stepIndex: 0,\n nextDueAtMs: planOutreachFirstDue({\n sequence: draft.sequence,\n mailbox: draft.mailbox,\n enrolledAtMs: draft.nowMs,\n random: draft.random,\n }),\n status: 'active',\n stopReason: null,\n stopDetail: null,\n stoppedAtMs: null,\n stoppedByUid: null,\n personalLine: normalizeOutreachPersonalLine(draft.personalLine),\n cold: draft.cold === true,\n attestations: { ...(draft.attestations ?? {}) },\n enrolledByUid: draft.enrolledByUid,\n gmailThreadId: null,\n gmailThreadIds: [],\n threadSubject: null,\n messageIds: [],\n lastSentAtMs: null,\n createdAtMs: draft.nowMs,\n updatedAtMs: draft.nowMs,\n }\n}\n\n/** What the provider reported for an email step that was sent. */\nexport interface OutreachSentEmail {\n /** The `Message-ID` header the message went out with, angle brackets included. */\n messageId: string\n /** The provider's thread id. */\n threadId: string\n /** The subject as sent. */\n subject: string\n}\n\nexport interface OutreachStepCompletionInput {\n enrollment: Pick<\n OutreachEnrollment,\n 'status' | 'stepIndex' | 'gmailThreadId' | 'gmailThreadIds' | 'messageIds' | 'threadSubject'\n >\n sequence: ScheduleSequence\n mailbox: ScheduleMailbox\n /** When the step ran. */\n completedAtMs: number\n /** For an email step, what was sent; not read for a task. */\n sent?: OutreachSentEmail | null\n random: OutreachRandom\n}\n\n/** The fields a completed step writes. */\nexport interface OutreachStepCompletionPatch {\n status: 'active' | 'finished'\n stepIndex: number\n nextDueAtMs: number | null\n lastSentAtMs: number\n gmailThreadId?: string\n gmailThreadIds?: string[]\n threadSubject?: string\n messageIds?: string[]\n}\n\nexport interface OutreachStepCompletionResult {\n patch: OutreachStepCompletionPatch | null\n /**\n * Why there is no patch, or — beside one — that the next step could not\n * be placed on the calendar (a window that never opens, a zone `Intl` does\n * not know), which leaves the enrollment with nothing due until a member\n * fixes the window and resumes it.\n */\n error: string | null\n}\n\n/**\n * What running the current step writes: the send recorded on the thread\n * fields, and the next step scheduled — or the enrollment `finished` when\n * that was the last.\n */\nexport function planOutreachStepCompletion(\n input: OutreachStepCompletionInput,\n): OutreachStepCompletionResult {\n const { enrollment, sequence } = input\n if (enrollment.status !== 'active') {\n return { patch: null, error: 'Only an active enrollment runs its steps.' }\n }\n const steps = sequence.steps ?? []\n const step = steps[enrollment.stepIndex]\n if (!step) return { patch: null, error: 'This enrollment has no step at its position.' }\n const patch: OutreachStepCompletionPatch = {\n status: 'active',\n stepIndex: enrollment.stepIndex + 1,\n nextDueAtMs: null,\n lastSentAtMs: input.completedAtMs,\n }\n if (step.kind === 'email') {\n const sent = input.sent\n const messageId = String(sent?.messageId ?? '').trim()\n const threadId = String(sent?.threadId ?? '').trim()\n if (!messageId || !threadId) {\n return { patch: null, error: 'An email step needs the sent message and thread ids.' }\n }\n const threadIds = [...(enrollment.gmailThreadIds ?? [])]\n if (!threadIds.includes(threadId)) threadIds.push(threadId)\n patch.gmailThreadId = threadId\n patch.gmailThreadIds = threadIds\n if (isInThreadEmailStep(steps, enrollment.stepIndex) && enrollment.threadSubject) {\n patch.messageIds = [...(enrollment.messageIds ?? []), messageId]\n } else {\n patch.threadSubject = String(sent.subject ?? '').trim()\n patch.messageIds = [messageId]\n }\n }\n const next = steps[patch.stepIndex]\n if (!next) {\n patch.status = 'finished'\n return { patch, error: null }\n }\n patch.nextDueAtMs = scheduleOutreachDue({\n fromMs: input.completedAtMs,\n delayBusinessDays: next.delayBusinessDays,\n timeZone: input.mailbox.timezone,\n window: effectiveOutreachWindow(sequence.settings?.window, input.mailbox.window),\n random: input.random,\n })\n return {\n patch,\n error:\n patch.nextDueAtMs === null\n ? \"The next step couldn't be scheduled: check the sending window and the mailbox's timezone.\"\n : null,\n }\n}\n"],"names":["OUTREACH_ATTESTATION_KINDS","OUTREACH_STOP_REASONS_BY_STATUS","normalizeOutreachPersonalLine","effectiveOutreachWindow","scheduleOutreachDue","isInThreadEmailStep","OUTREACH_ENROLLMENT_TRANSITIONS","active","paused","finished","stopped","failed","replied","bounced","opted_out","canTransitionOutreachEnrollment","from","to","includes","STATUS_WORDS","EVENT_TARGET","pause","resume","stop","reply","opt_out","bounce","send_failed","detailOf","value","text","replace","trim","slice","applyOutreachEnrollmentEvent","enrollment","event","status","type","patch","error","stopReason","stopDetail","stoppedAtMs","stoppedByUid","nextDueAtMs","reason","allowed","byUid","detail","atMs","planOutreachFirstDue","input","first","sequence","steps","fromMs","enrolledAtMs","delayBusinessDays","timeZone","mailbox","timezone","window","settings","random","outreachAttestationsFrom","kinds","uid","attestations","kind","buildOutreachEnrollment","draft","id","sequenceId","contactId","contactName","String","email","toLowerCase","hostId","mailboxId","stepIndex","nowMs","personalLine","cold","enrolledByUid","gmailThreadId","gmailThreadIds","threadSubject","messageIds","lastSentAtMs","createdAtMs","updatedAtMs","planOutreachStepCompletion","step","completedAtMs","sent","messageId","threadId","threadIds","push","subject","next"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/enrollment-state.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 * ONE PERSON'S RUN THROUGH A SEQUENCE (AGL-2979).\n *\n * ## The state machine\n *\n * active ──► paused ──► active\n * │ │\n * ├──────────┴──► replied · bounced · opted_out · stopped\n * └─► finished · failed\n *\n * An enrollment sends only while `active`. Everything that ends the sending\n * records why (`stopReason`, `stopDetail`, when, and who). Ending the\n * sending does not end what can still be learned from the thread: a reply,\n * a bounce or an opt-out that arrives after the last step, after a member\n * stopped it or after a send failed is still recorded, and a person who\n * replied and then asked to be left alone moves on to `opted_out` — an\n * opt-out is the one fact every later sequence has to be able to find.\n * `bounced` and `opted_out` are final.\n *\n * Every function returns a PATCH — the fields to write — and never the\n * whole document, so a writer applies it with its own SDK and its own\n * timestamps.\n *==========================================*/\n\nimport {\n OUTREACH_ATTESTATION_KINDS,\n OUTREACH_STOP_REASONS_BY_STATUS,\n type OutreachAttestationKind,\n type OutreachAttestations,\n type OutreachEnrollment,\n type OutreachEnrollmentStatus,\n type OutreachEnrollmentTarget,\n type OutreachMailbox,\n type OutreachSequence,\n type OutreachStopReason,\n} from '../model/outreach.types'\nimport { normalizeOutreachPersonalLine } from './gates'\nimport { effectiveOutreachWindow, type OutreachRandom, scheduleOutreachDue } from './schedule'\nimport { isInThreadEmailStep } from './sequence-validation'\n\n/** Where each status may go next. */\nexport const OUTREACH_ENROLLMENT_TRANSITIONS: Readonly<\n Record<OutreachEnrollmentStatus, readonly OutreachEnrollmentStatus[]>\n> = {\n active: ['paused', 'finished', 'replied', 'bounced', 'opted_out', 'stopped', 'failed'],\n paused: ['active', 'replied', 'bounced', 'opted_out', 'stopped'],\n finished: ['replied', 'bounced', 'opted_out'],\n stopped: ['replied', 'bounced', 'opted_out'],\n failed: ['replied', 'bounced', 'opted_out'],\n replied: ['opted_out'],\n bounced: [],\n opted_out: [],\n}\n\nexport function canTransitionOutreachEnrollment(\n from: OutreachEnrollmentStatus,\n to: OutreachEnrollmentStatus,\n): boolean {\n return OUTREACH_ENROLLMENT_TRANSITIONS[from]?.includes(to) === true\n}\n\n/** Something that happened to an enrollment. `atMs` is when it happened. */\nexport type OutreachEnrollmentEvent =\n | { type: 'pause'; atMs: number; byUid: string | null; detail?: string | null }\n | { type: 'resume'; atMs: number; byUid: string | null }\n | {\n type: 'stop'\n atMs: number\n byUid: string | null\n reason?: Extract<OutreachStopReason, 'manual' | 'gate' | 'sequence_archived'>\n detail?: string | null\n }\n | { type: 'reply'; atMs: number; detail?: string | null }\n | {\n type: 'opt_out'\n atMs: number\n reason: Extract<OutreachStopReason, 'opt_out_reply' | 'unsubscribe' | 'do_not_contact'>\n detail?: string | null\n }\n | { type: 'bounce'; atMs: number; detail?: string | null }\n | { type: 'send_failed'; atMs: number; detail?: string | null }\n\n/** The status fields an event writes. */\nexport interface OutreachEnrollmentStatusPatch {\n status: OutreachEnrollmentStatus\n stopReason: OutreachStopReason | null\n stopDetail: string | null\n stoppedAtMs: number | null\n stoppedByUid: string | null\n nextDueAtMs: number | null\n}\n\n/**\n * `patch` is what to write, or `null` when there is nothing to write — the\n * enrollment is already there, or the event is not allowed, in which case\n * `error` says why.\n */\nexport interface OutreachEnrollmentEventResult {\n patch: OutreachEnrollmentStatusPatch | null\n error: string | null\n}\n\nconst STATUS_WORDS: Record<OutreachEnrollmentStatus, string> = {\n active: 'is active',\n paused: 'is paused',\n finished: 'finished',\n replied: 'got a reply',\n bounced: 'bounced',\n opted_out: 'opted out',\n stopped: 'was stopped',\n failed: 'failed',\n}\n\nconst EVENT_TARGET: Record<OutreachEnrollmentEvent['type'], OutreachEnrollmentStatus> = {\n pause: 'paused',\n resume: 'active',\n stop: 'stopped',\n reply: 'replied',\n opt_out: 'opted_out',\n bounce: 'bounced',\n send_failed: 'failed',\n}\n\nconst detailOf = (value: unknown): string | null => {\n const text = typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''\n return text ? text.slice(0, 500) : null\n}\n\n/** What an event does to an enrollment's status — see the module note. */\nexport function applyOutreachEnrollmentEvent(\n enrollment: Pick<OutreachEnrollment, 'status' | 'nextDueAtMs'>,\n event: OutreachEnrollmentEvent,\n): OutreachEnrollmentEventResult {\n const from = enrollment.status\n const to = EVENT_TARGET[event.type]\n if (!to) return { patch: null, error: 'That is not something that happens to an enrollment.' }\n if (from === to) return { patch: null, error: null }\n if (!canTransitionOutreachEnrollment(from, to)) {\n return {\n patch: null,\n error: `This enrollment ${STATUS_WORDS[from]}, so it can't be marked as one that ${STATUS_WORDS[to]}.`,\n }\n }\n if (event.type === 'resume') {\n return {\n patch: {\n status: 'active',\n stopReason: null,\n stopDetail: null,\n stoppedAtMs: null,\n stoppedByUid: null,\n nextDueAtMs: enrollment.nextDueAtMs,\n },\n error: null,\n }\n }\n const reason: OutreachStopReason =\n event.type === 'pause'\n ? 'manual'\n : event.type === 'stop'\n ? (event.reason ?? 'manual')\n : event.type === 'reply'\n ? 'reply'\n : event.type === 'opt_out'\n ? event.reason\n : event.type === 'bounce'\n ? 'hard_bounce'\n : 'send_failed'\n const allowed = OUTREACH_STOP_REASONS_BY_STATUS[to as keyof typeof OUTREACH_STOP_REASONS_BY_STATUS]\n if (!allowed?.includes(reason)) {\n return { patch: null, error: `\"${reason}\" isn't a reason an enrollment ${STATUS_WORDS[to]}.` }\n }\n const byUid = 'byUid' in event && typeof event.byUid === 'string' && event.byUid ? event.byUid : null\n return {\n patch: {\n status: to,\n stopReason: reason,\n stopDetail: detailOf(event.detail),\n stoppedAtMs: event.atMs,\n stoppedByUid: byUid,\n // A paused enrollment keeps its place, so resuming it picks up where it\n // stopped; every other status has nothing left waiting.\n nextDueAtMs: to === 'paused' ? enrollment.nextDueAtMs : null,\n },\n error: null,\n }\n}\n\ntype ScheduleMailbox = Pick<OutreachMailbox, 'timezone' | 'window'>\ntype ScheduleSequence = Pick<OutreachSequence, 'steps' | 'settings'>\n\n/** When a new enrollment's first step comes due, or `null` when it cannot be placed. */\nexport function planOutreachFirstDue(input: {\n sequence: ScheduleSequence\n mailbox: ScheduleMailbox\n enrolledAtMs: number\n random: OutreachRandom\n}): number | null {\n const first = input.sequence.steps?.[0]\n if (!first) return null\n return scheduleOutreachDue({\n fromMs: input.enrolledAtMs,\n delayBusinessDays: first.delayBusinessDays,\n timeZone: input.mailbox.timezone,\n window: effectiveOutreachWindow(input.sequence.settings?.window, input.mailbox.window),\n random: input.random,\n })\n}\n\n/** Stamps the attestations a rep ticked with who ticked them and when; unknown kinds are dropped. */\nexport function outreachAttestationsFrom(\n kinds: readonly unknown[],\n uid: string,\n atMs: number,\n): OutreachAttestations {\n const attestations: OutreachAttestations = {}\n for (const kind of kinds ?? []) {\n if ((OUTREACH_ATTESTATION_KINDS as readonly unknown[]).includes(kind)) {\n attestations[kind as OutreachAttestationKind] = { uid, atMs }\n }\n }\n return attestations\n}\n\nexport interface OutreachEnrollmentDraft {\n id: string\n sequence: Pick<OutreachSequence, 'id' | 'hostId' | 'mailboxId' | 'steps' | 'settings'>\n mailbox: ScheduleMailbox\n /** The record the person is (AGL-3234); a contact when absent. */\n target?: OutreachEnrollmentTarget\n /** The contact's id; `''` for a lead. */\n contactId: string\n /** The lead's person key, for an enrollment made on a lead. */\n leadId?: string | null\n /** The contact's name as the sending site knows it; `''` for none. */\n contactName?: string | null\n /** The address, as the gates normalized it. */\n email: string\n /** The gates' `cold`. */\n cold: boolean\n personalLine: string\n attestations: OutreachAttestations\n enrolledByUid: string\n nowMs: number\n random: OutreachRandom\n}\n\n/**\n * A new enrollment document, first step scheduled — one builder for every\n * door that enrolls, so an enrollment made from a contact and one made from\n * a list are the same shape. Call it after the gates allowed the person.\n */\nexport function buildOutreachEnrollment(draft: OutreachEnrollmentDraft): OutreachEnrollment {\n return {\n id: draft.id,\n sequenceId: draft.sequence.id,\n target: draft.target ?? 'contact',\n contactId: draft.contactId,\n leadId: draft.leadId ?? null,\n contactName: String(draft.contactName ?? '').replace(/\\s+/g, ' ').trim().slice(0, 200),\n email: String(draft.email ?? '').trim().toLowerCase(),\n hostId: draft.sequence.hostId,\n mailboxId: draft.sequence.mailboxId,\n stepIndex: 0,\n nextDueAtMs: planOutreachFirstDue({\n sequence: draft.sequence,\n mailbox: draft.mailbox,\n enrolledAtMs: draft.nowMs,\n random: draft.random,\n }),\n status: 'active',\n stopReason: null,\n stopDetail: null,\n stoppedAtMs: null,\n stoppedByUid: null,\n personalLine: normalizeOutreachPersonalLine(draft.personalLine),\n cold: draft.cold === true,\n attestations: { ...(draft.attestations ?? {}) },\n enrolledByUid: draft.enrolledByUid,\n gmailThreadId: null,\n gmailThreadIds: [],\n threadSubject: null,\n messageIds: [],\n lastSentAtMs: null,\n createdAtMs: draft.nowMs,\n updatedAtMs: draft.nowMs,\n }\n}\n\n/** What the provider reported for an email step that was sent. */\nexport interface OutreachSentEmail {\n /** The `Message-ID` header the message went out with, angle brackets included. */\n messageId: string\n /** The provider's thread id. */\n threadId: string\n /** The subject as sent. */\n subject: string\n}\n\nexport interface OutreachStepCompletionInput {\n enrollment: Pick<\n OutreachEnrollment,\n 'status' | 'stepIndex' | 'gmailThreadId' | 'gmailThreadIds' | 'messageIds' | 'threadSubject'\n >\n sequence: ScheduleSequence\n mailbox: ScheduleMailbox\n /** When the step ran. */\n completedAtMs: number\n /** For an email step, what was sent; not read for a task. */\n sent?: OutreachSentEmail | null\n random: OutreachRandom\n}\n\n/** The fields a completed step writes. */\nexport interface OutreachStepCompletionPatch {\n status: 'active' | 'finished'\n stepIndex: number\n nextDueAtMs: number | null\n lastSentAtMs: number\n gmailThreadId?: string\n gmailThreadIds?: string[]\n threadSubject?: string\n messageIds?: string[]\n}\n\nexport interface OutreachStepCompletionResult {\n patch: OutreachStepCompletionPatch | null\n /**\n * Why there is no patch, or — beside one — that the next step could not\n * be placed on the calendar (a window that never opens, a zone `Intl` does\n * not know), which leaves the enrollment with nothing due until a member\n * fixes the window and resumes it.\n */\n error: string | null\n}\n\n/**\n * What running the current step writes: the send recorded on the thread\n * fields, and the next step scheduled — or the enrollment `finished` when\n * that was the last.\n */\nexport function planOutreachStepCompletion(\n input: OutreachStepCompletionInput,\n): OutreachStepCompletionResult {\n const { enrollment, sequence } = input\n if (enrollment.status !== 'active') {\n return { patch: null, error: 'Only an active enrollment runs its steps.' }\n }\n const steps = sequence.steps ?? []\n const step = steps[enrollment.stepIndex]\n if (!step) return { patch: null, error: 'This enrollment has no step at its position.' }\n const patch: OutreachStepCompletionPatch = {\n status: 'active',\n stepIndex: enrollment.stepIndex + 1,\n nextDueAtMs: null,\n lastSentAtMs: input.completedAtMs,\n }\n if (step.kind === 'email') {\n const sent = input.sent\n const messageId = String(sent?.messageId ?? '').trim()\n const threadId = String(sent?.threadId ?? '').trim()\n if (!messageId || !threadId) {\n return { patch: null, error: 'An email step needs the sent message and thread ids.' }\n }\n const threadIds = [...(enrollment.gmailThreadIds ?? [])]\n if (!threadIds.includes(threadId)) threadIds.push(threadId)\n patch.gmailThreadId = threadId\n patch.gmailThreadIds = threadIds\n if (isInThreadEmailStep(steps, enrollment.stepIndex) && enrollment.threadSubject) {\n patch.messageIds = [...(enrollment.messageIds ?? []), messageId]\n } else {\n patch.threadSubject = String(sent.subject ?? '').trim()\n patch.messageIds = [messageId]\n }\n }\n const next = steps[patch.stepIndex]\n if (!next) {\n patch.status = 'finished'\n return { patch, error: null }\n }\n patch.nextDueAtMs = scheduleOutreachDue({\n fromMs: input.completedAtMs,\n delayBusinessDays: next.delayBusinessDays,\n timeZone: input.mailbox.timezone,\n window: effectiveOutreachWindow(sequence.settings?.window, input.mailbox.window),\n random: input.random,\n })\n return {\n patch,\n error:\n patch.nextDueAtMs === null\n ? \"The next step couldn't be scheduled: check the sending window and the mailbox's timezone.\"\n : null,\n }\n}\n"],"names":["OUTREACH_ATTESTATION_KINDS","OUTREACH_STOP_REASONS_BY_STATUS","normalizeOutreachPersonalLine","effectiveOutreachWindow","scheduleOutreachDue","isInThreadEmailStep","OUTREACH_ENROLLMENT_TRANSITIONS","active","paused","finished","stopped","failed","replied","bounced","opted_out","canTransitionOutreachEnrollment","from","to","includes","STATUS_WORDS","EVENT_TARGET","pause","resume","stop","reply","opt_out","bounce","send_failed","detailOf","value","text","replace","trim","slice","applyOutreachEnrollmentEvent","enrollment","event","status","type","patch","error","stopReason","stopDetail","stoppedAtMs","stoppedByUid","nextDueAtMs","reason","allowed","byUid","detail","atMs","planOutreachFirstDue","input","first","sequence","steps","fromMs","enrolledAtMs","delayBusinessDays","timeZone","mailbox","timezone","window","settings","random","outreachAttestationsFrom","kinds","uid","attestations","kind","buildOutreachEnrollment","draft","id","sequenceId","target","contactId","leadId","contactName","String","email","toLowerCase","hostId","mailboxId","stepIndex","nowMs","personalLine","cold","enrolledByUid","gmailThreadId","gmailThreadIds","threadSubject","messageIds","lastSentAtMs","createdAtMs","updatedAtMs","planOutreachStepCompletion","step","completedAtMs","sent","messageId","threadId","threadIds","push","subject","next"],"mappings":"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"}
@@ -16,14 +16,44 @@
16
16
  */
17
17
  import { type OutreachGateLookups } from '../engine/gates';
18
18
  import { type OutreachAttestations, type OutreachSequenceSettings } from '../model/outreach.types';
19
- import type { OutreachEnrollBlock, OutreachEnrollPreviewPerson } from '../model/outreach-api';
19
+ import type { OutreachEnrollBlock, OutreachEnrollPreviewPerson, OutreachPersonRef } from '../model/outreach-api';
20
20
  type Firestore = FirebaseFirestore.Firestore;
21
- /** One person as the preview and the enroll read them. */
21
+ /**
22
+ * One person as the preview and the enroll read them: a contact, or a lead
23
+ * the sequence's site holds (AGL-3234).
24
+ *
25
+ * ## A lead is judged as the contact it would be
26
+ *
27
+ * The gates, the merge fields and the country rule all read a CONTACT
28
+ * document — its facet's sources, stage, address, company and title. A lead
29
+ * carries the same facts in its own shape, so rather than teach every gate
30
+ * two records, a lead is handed to them as {@link leadAsContact}: a
31
+ * contact-shaped view whose one facet, under the sequence's group, holds
32
+ * what the lead holds. An imported lead has no inbound source and is cold;
33
+ * one that wrote in through a form is not; a lead is never a customer; its
34
+ * address decides its country; and `{{contact.firstName}}` or
35
+ * `{{contact.company}}` in a step reads the lead's name and company text,
36
+ * so a sequence written for contacts sends to leads unchanged — which is
37
+ * also what lets an enrollment made on a lead keep sending after the lead
38
+ * converts and the view becomes the real contact.
39
+ */
22
40
  export interface OutreachEnrollCandidate {
41
+ /** The record's own id: the contact's, or the lead's person key. */
42
+ personId: string;
43
+ target: 'contact' | 'lead';
44
+ /** The contact's id; `''` for a lead. */
23
45
  contactId: string;
24
- /** The contact document, or `null` when it no longer exists. */
46
+ /** The lead's person key; `null` for a contact. */
47
+ leadId: string | null;
48
+ /**
49
+ * The contact document (`orgs/{orgId}/contacts/{id}`), or for a lead the
50
+ * contact-shaped view of it — see the module note. `null` when the record
51
+ * no longer exists.
52
+ */
25
53
  contact: Record<string, unknown> | null;
26
- /** Whether the sequence's site may see the contact. */
54
+ /** The lead document as stored, for a lead; `null` for a contact. */
55
+ lead: Record<string, unknown> | null;
56
+ /** Whether the sequence's site may see the record. A lead is the site's own. */
27
57
  visible: boolean;
28
58
  /** The name the sequence's site knows them by; `''` for none. */
29
59
  name: string;
@@ -31,16 +61,31 @@ export interface OutreachEnrollCandidate {
31
61
  email: string | null;
32
62
  /** The company the contact is filed under at that site, when there is one. */
33
63
  company: Record<string, unknown> | null;
64
+ /** A lead already converted: the contact it became. Enroll that instead. */
65
+ convertedContactId: string | null;
66
+ /** A lead whose address the workspace already holds as a contact. Enroll that instead. */
67
+ heldAsContactId: string | null;
34
68
  }
35
69
  /**
36
- * The contacts, as the sequence's site knows them, and the company each is
37
- * filed under there — two batched reads for everyone.
70
+ * A lead as the gates and the merge fields read a contact — see the
71
+ * module note. The facet is the sequence's group's, which is the one every
72
+ * reader asks for. The lead's `sources` — `form:{id}`, `booking`, `import`,
73
+ * `manual`, `api`, `signup` — become the facet's source flags by kind, so
74
+ * the inbound rule reads them as it reads a contact's.
75
+ */
76
+ export declare function leadAsContact(lead: Record<string, unknown>, contactGroupId: string): Record<string, unknown>;
77
+ /**
78
+ * The people, as the sequence's site knows them, and the company each
79
+ * contact is filed under there — batched reads for everyone: the contacts
80
+ * and the leads by id, the companies once, and for each lead one address
81
+ * lookup, because a lead the workspace already holds as a contact is that
82
+ * contact's to enroll.
38
83
  */
39
84
  export declare function readOutreachEnrollCandidates(firestore: Firestore, input: {
40
85
  orgId: string;
41
86
  hostId: string;
42
87
  contactGroupId: string;
43
- contactIds: readonly string[];
88
+ people: readonly OutreachPersonRef[];
44
89
  }): Promise<OutreachEnrollCandidate[]>;
45
90
  /** What stands in the way before the gates are asked anything. */
46
91
  export declare function outreachCandidateBlocks(candidate: OutreachEnrollCandidate, facts: {
@@ -48,10 +48,45 @@ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_wi
48
48
  * time by being enrolled again, and two members confirming the same person
49
49
  * at once write one enrollment.
50
50
  *==========================================*/ import { normalizeContactEmail, contactDisplayName, readContactFacet } from "@aglyn/aglyn/app-utils/contacts";
51
+ import { crmLeadDisplayName, crmLeadStatus } from "@aglyn/aglyn/app-utils/crm";
51
52
  import { visibleToHost } from "@aglyn/aglyn/app-utils/scope-tokens";
53
+ import { findContactByEmail } from "@aglyn/tenant-data-admin/server/contact-email-index";
52
54
  import { evaluateOutreachGates } from "../engine/gates.js";
53
55
  import { outreachAttestationsFrom } from "../engine/enrollment-state.js";
54
56
  import { OUTREACH_ATTESTATION_KINDS } from "../model/outreach.types.js";
57
+ /**
58
+ * A lead as the gates and the merge fields read a contact — see the
59
+ * module note. The facet is the sequence's group's, which is the one every
60
+ * reader asks for. The lead's `sources` — `form:{id}`, `booking`, `import`,
61
+ * `manual`, `api`, `signup` — become the facet's source flags by kind, so
62
+ * the inbound rule reads them as it reads a contact's.
63
+ */ export function leadAsContact(lead, contactGroupId) {
64
+ const sources = {};
65
+ for (const raw of Array.isArray(lead['sources']) ? lead['sources'] : []){
66
+ const source = String(raw);
67
+ const kind = source.startsWith('form:') ? 'form' : source === 'signup' ? 'member' : source;
68
+ if (kind) sources[kind] = true;
69
+ }
70
+ return {
71
+ email: lead['email'],
72
+ name: lead['name'],
73
+ facets: {
74
+ [contactGroupId]: _extends({
75
+ sources
76
+ }, lead['address'] && typeof lead['address'] === 'object' ? {
77
+ address: lead['address']
78
+ } : {}, typeof lead['company'] === 'string' ? {
79
+ companyName: lead['company']
80
+ } : {}, typeof lead['jobTitle'] === 'string' ? {
81
+ jobTitle: lead['jobTitle']
82
+ } : {}, typeof lead['phone'] === 'string' ? {
83
+ phone: lead['phone']
84
+ } : {}, Array.isArray(lead['tags']) ? {
85
+ tags: lead['tags']
86
+ } : {})
87
+ }
88
+ };
89
+ }
55
90
  /** `getAll` takes this many references at most per call. */ const GET_ALL_CHUNK = 300;
56
91
  async function getAllChunked(firestore, refs) {
57
92
  const snapshots = [];
@@ -61,25 +96,54 @@ async function getAllChunked(firestore, refs) {
61
96
  return snapshots;
62
97
  }
63
98
  /**
64
- * The contacts, as the sequence's site knows them, and the company each is
65
- * filed under there — two batched reads for everyone.
99
+ * The people, as the sequence's site knows them, and the company each
100
+ * contact is filed under there — batched reads for everyone: the contacts
101
+ * and the leads by id, the companies once, and for each lead one address
102
+ * lookup, because a lead the workspace already holds as a contact is that
103
+ * contact's to enroll.
66
104
  */ export async function readOutreachEnrollCandidates(firestore, input) {
67
- if (!input.contactIds.length) return [];
105
+ if (!input.people.length) return [];
68
106
  const org = firestore.collection('orgs').doc(input.orgId);
69
- const snapshots = await getAllChunked(firestore, input.contactIds.map((id)=>org.collection('contacts').doc(id)));
70
- const read = input.contactIds.map((contactId, index)=>{
107
+ const leads = firestore.collection('hosts').doc(input.hostId).collection('leads');
108
+ const snapshots = await getAllChunked(firestore, input.people.map((person)=>person.kind === 'lead' ? leads.doc(person.id) : org.collection('contacts').doc(person.id)));
109
+ const read = await Promise.all(input.people.map(async (person, index)=>{
71
110
  var _readContactFacet_companyId;
72
111
  var _snapshots_index;
73
112
  const data = ((_snapshots_index = snapshots[index]) == null ? void 0 : _snapshots_index.exists) ? snapshots[index].data() : null;
113
+ if (person.kind === 'lead') {
114
+ const email = data ? normalizeContactEmail(data['email']) : null;
115
+ const held = data && email ? await findContactByEmail(org.collection('contacts'), email) : null;
116
+ return {
117
+ personId: person.id,
118
+ target: 'lead',
119
+ contactId: '',
120
+ leadId: person.id,
121
+ contact: data ? leadAsContact(data, input.contactGroupId) : null,
122
+ lead: data,
123
+ // A lead is the site's own record: no scope to check.
124
+ visible: data !== null,
125
+ name: data ? crmLeadDisplayName(data) : '',
126
+ email,
127
+ companyId: '',
128
+ convertedContactId: data && typeof data['convertedContactId'] === 'string' ? data['convertedContactId'] : null,
129
+ heldAsContactId: held ? held.id : null
130
+ };
131
+ }
74
132
  return {
75
- contactId,
133
+ personId: person.id,
134
+ target: 'contact',
135
+ contactId: person.id,
136
+ leadId: null,
76
137
  contact: data,
138
+ lead: null,
77
139
  visible: data ? visibleToHost(data['visibleTo'], input.hostId) : false,
78
140
  name: data ? contactDisplayName(data, input.contactGroupId).trim() : '',
79
141
  email: data ? normalizeContactEmail(data['email']) : null,
80
- companyId: data ? String((_readContactFacet_companyId = readContactFacet(data, input.contactGroupId).companyId) != null ? _readContactFacet_companyId : '') : ''
142
+ companyId: data ? String((_readContactFacet_companyId = readContactFacet(data, input.contactGroupId).companyId) != null ? _readContactFacet_companyId : '') : '',
143
+ convertedContactId: null,
144
+ heldAsContactId: null
81
145
  };
82
- });
146
+ }));
83
147
  const companyIds = [
84
148
  ...new Set(read.map((entry)=>entry.companyId).filter(Boolean))
85
149
  ];
@@ -100,9 +164,22 @@ async function getAllChunked(firestore, refs) {
100
164
  });
101
165
  });
102
166
  }
167
+ /** The person as every answer names them. */ function personOf(candidate) {
168
+ return {
169
+ personId: candidate.personId,
170
+ target: candidate.target,
171
+ contactId: candidate.contactId,
172
+ leadId: candidate.leadId
173
+ };
174
+ }
103
175
  /** What stands in the way before the gates are asked anything. */ export function outreachCandidateBlocks(candidate, facts) {
104
176
  if (!candidate.contact) {
105
- return [
177
+ return candidate.target === 'lead' ? [
178
+ {
179
+ code: 'lead_missing',
180
+ reason: "This lead no longer exists in this sequence's site's CRM."
181
+ }
182
+ ] : [
106
183
  {
107
184
  code: 'contact_missing',
108
185
  reason: 'This contact no longer exists in the CRM.'
@@ -110,6 +187,27 @@ async function getAllChunked(firestore, refs) {
110
187
  ];
111
188
  }
112
189
  const blocks = [];
190
+ if (candidate.target === 'lead') {
191
+ // A lead that converted, or whose address the workspace already holds
192
+ // as a contact, is that contact's to enroll — one person, one record.
193
+ if (candidate.convertedContactId) {
194
+ blocks.push({
195
+ code: 'lead_converted',
196
+ reason: 'This lead was converted into a contact. Enroll the contact instead.'
197
+ });
198
+ } else if (candidate.heldAsContactId) {
199
+ blocks.push({
200
+ code: 'lead_is_contact',
201
+ reason: 'This address is already a contact in your CRM. Enroll the contact instead.'
202
+ });
203
+ }
204
+ if (crmLeadStatus(candidate.lead) === 'unqualified') {
205
+ blocks.push({
206
+ code: 'lead_unqualified',
207
+ reason: 'This lead was closed as unqualified. Reopen it before enrolling.'
208
+ });
209
+ }
210
+ }
113
211
  if (!candidate.visible) {
114
212
  blocks.push({
115
213
  code: 'not_in_site',
@@ -143,11 +241,10 @@ const blocksOf = (blocks)=>blocks.map(({ code, reason })=>({
143
241
  }));
144
242
  /** Where one person stands before the rep confirms anything — see the module note. */ export function previewOutreachPerson(question, facts) {
145
243
  const { candidate } = question;
146
- const person = {
147
- contactId: candidate.contactId,
244
+ const person = _extends({}, personOf(candidate), {
148
245
  name: candidate.name,
149
246
  email: candidate.email
150
- };
247
+ });
151
248
  const early = outreachCandidateBlocks(candidate, facts);
152
249
  if (early.length) {
153
250
  return _extends({}, person, {