@aglyn/plugins-outreach 1.0.0-beta.151 → 1.0.0-beta.153

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 (64) hide show
  1. package/README.md +5 -1
  2. package/package.json +12 -12
  3. package/src/lib/components/compliance-section.js +1 -1
  4. package/src/lib/components/compliance-section.js.map +1 -1
  5. package/src/lib/components/enroll-dialog.js +142 -32
  6. package/src/lib/components/enroll-dialog.js.map +1 -1
  7. package/src/lib/components/enrollments-table.js +37 -8
  8. package/src/lib/components/enrollments-table.js.map +1 -1
  9. package/src/lib/components/mailbox-card.d.ts +3 -0
  10. package/src/lib/components/mailbox-card.js +44 -8
  11. package/src/lib/components/mailbox-card.js.map +1 -1
  12. package/src/lib/components/use-outreach-crm.d.ts +20 -1
  13. package/src/lib/components/use-outreach-crm.js +78 -4
  14. package/src/lib/components/use-outreach-crm.js.map +1 -1
  15. package/src/lib/components/use-outreach-mailbox-api.d.ts +2 -1
  16. package/src/lib/components/use-outreach-mailbox-api.js +5 -3
  17. package/src/lib/components/use-outreach-mailbox-api.js.map +1 -1
  18. package/src/lib/declarations.console-server.js +14 -0
  19. package/src/lib/declarations.console-server.js.map +1 -1
  20. package/src/lib/engine/compose.d.ts +7 -2
  21. package/src/lib/engine/compose.js +11 -5
  22. package/src/lib/engine/compose.js.map +1 -1
  23. package/src/lib/engine/enrollment-state.d.ts +6 -1
  24. package/src/lib/engine/enrollment-state.js +3 -1
  25. package/src/lib/engine/enrollment-state.js.map +1 -1
  26. package/src/lib/engine/opt-out-intent.js +1 -1
  27. package/src/lib/engine/opt-out-intent.js.map +1 -1
  28. package/src/lib/enrollment/enroll-people.d.ts +52 -7
  29. package/src/lib/enrollment/enroll-people.js +109 -12
  30. package/src/lib/enrollment/enroll-people.js.map +1 -1
  31. package/src/lib/enrollment/gate-lookups.d.ts +12 -3
  32. package/src/lib/enrollment/gate-lookups.js +17 -14
  33. package/src/lib/enrollment/gate-lookups.js.map +1 -1
  34. package/src/lib/mailboxes/mailbox-api.d.ts +27 -2
  35. package/src/lib/mailboxes/mailbox-api.js.map +1 -1
  36. package/src/lib/mailboxes/mailbox-routes.js +52 -8
  37. package/src/lib/mailboxes/mailbox-routes.js.map +1 -1
  38. package/src/lib/mailboxes/oauth-redirect.d.ts +15 -4
  39. package/src/lib/mailboxes/oauth-redirect.js +15 -6
  40. package/src/lib/mailboxes/oauth-redirect.js.map +1 -1
  41. package/src/lib/model/outreach-api.d.ts +39 -8
  42. package/src/lib/model/outreach-api.js.map +1 -1
  43. package/src/lib/model/outreach.types.d.ts +29 -4
  44. package/src/lib/model/outreach.types.js +9 -0
  45. package/src/lib/model/outreach.types.js.map +1 -1
  46. package/src/lib/model/stored-records.js +7 -0
  47. package/src/lib/model/stored-records.js.map +1 -1
  48. package/src/lib/routes/enroll-routes.js +112 -39
  49. package/src/lib/routes/enroll-routes.js.map +1 -1
  50. package/src/lib/routes/preview-routes.js +18 -5
  51. package/src/lib/routes/preview-routes.js.map +1 -1
  52. package/src/lib/runtime/lead-records.d.ts +26 -0
  53. package/src/lib/runtime/lead-records.js +84 -0
  54. package/src/lib/runtime/lead-records.js.map +1 -0
  55. package/src/lib/runtime/send-job.js +30 -12
  56. package/src/lib/runtime/send-job.js.map +1 -1
  57. package/src/lib/runtime/sync-job.js +11 -3
  58. package/src/lib/runtime/sync-job.js.map +1 -1
  59. package/src/lib/runtime/timeline.d.ts +6 -4
  60. package/src/lib/runtime/timeline.js +3 -7
  61. package/src/lib/runtime/timeline.js.map +1 -1
  62. package/src/lib/storage/outreach-records.d.ts +21 -2
  63. package/src/lib/storage/outreach-records.js +31 -2
  64. package/src/lib/storage/outreach-records.js.map +1 -1
@@ -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, {
@@ -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 { visibleToHost } from '@aglyn/aglyn/app-utils/scope-tokens'\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 { OutreachEnrollBlock, OutreachEnrollPreviewPerson } from '../model/outreach-api'\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** One person as the preview and the enroll read them. */\nexport interface OutreachEnrollCandidate {\n contactId: string\n /** The contact document, or `null` when it no longer exists. */\n contact: Record<string, unknown> | null\n /** Whether the sequence's site may see the contact. */\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}\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 contacts, as the sequence's site knows them, and the company each is\n * filed under there — two batched reads for everyone.\n */\nexport async function readOutreachEnrollCandidates(\n firestore: Firestore,\n input: { orgId: string; hostId: string; contactGroupId: string; contactIds: readonly string[] },\n): Promise<OutreachEnrollCandidate[]> {\n if (!input.contactIds.length) return []\n const org = firestore.collection('orgs').doc(input.orgId)\n const snapshots = await getAllChunked(\n firestore,\n input.contactIds.map((id) => org.collection('contacts').doc(id)),\n )\n const read = input.contactIds.map((contactId, index) => {\n const data = snapshots[index]?.exists ? (snapshots[index].data() as Record<string, unknown>) : null\n return {\n contactId,\n contact: data,\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 }\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/** 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 [{ code: 'contact_missing', reason: 'This contact no longer exists in the CRM.' }]\n }\n const blocks: OutreachEnrollBlock[] = []\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 contactId: candidate.contactId,\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","visibleToHost","evaluateOutreachGates","outreachAttestationsFrom","OUTREACH_ATTESTATION_KINDS","GET_ALL_CHUNK","getAllChunked","firestore","refs","snapshots","start","length","push","getAll","slice","readOutreachEnrollCandidates","input","contactIds","org","collection","doc","orgId","map","id","read","contactId","index","data","exists","contact","visible","hostId","name","contactGroupId","trim","email","companyId","String","companyIds","Set","entry","filter","Boolean","companies","Map","found","forEach","snapshot","set","candidate","company","get","outreachCandidateBlocks","facts","code","reason","blocks","siteName","alreadyInSequence","EVERYTHING_CONFIRMED","Object","fromEntries","kind","uid","atMs","blocksOf","previewOutreachPerson","question","person","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 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","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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"}
@@ -15,9 +15,18 @@
15
15
  * limitations under the License.
16
16
  */
17
17
  import type { OutreachGateLookups } from '../engine/gates';
18
- /** One person to look up: the contact, and the address the steps go to. */
18
+ /**
19
+ * One person to look up: the record they are, and the address the steps go
20
+ * to. `personId` keys the answer — the contact's id, or the lead's person
21
+ * key (AGL-3234); a lead has no contact to look up enrollments or inbound
22
+ * email by, so those reads go by the lead instead.
23
+ */
19
24
  export interface OutreachGateLookupPerson {
20
- contactId: string;
25
+ personId: string;
26
+ /** The contact's id, or `null` for a lead. */
27
+ contactId: string | null;
28
+ /** The lead's person key, or `null` for a contact. */
29
+ leadId: string | null;
21
30
  email: string;
22
31
  }
23
32
  export interface ReadOutreachGateLookupsInput {
@@ -27,6 +36,6 @@ export interface ReadOutreachGateLookupsInput {
27
36
  people: readonly OutreachGateLookupPerson[];
28
37
  }
29
38
  type Firestore = FirebaseFirestore.Firestore;
30
- /** Every lookup the gates take, for each person, keyed by contact id. */
39
+ /** Every lookup the gates take, for each person, keyed by person id. */
31
40
  export declare function readOutreachGateLookups(firestore: Firestore, input: ReadOutreachGateLookupsInput): Promise<Map<string, OutreachGateLookups>>;
32
41
  export {};
@@ -62,22 +62,22 @@ async function getAllChunked(firestore, refs) {
62
62
  for (const person of people){
63
63
  const key = personKey(person.email);
64
64
  if (key) keyed.push({
65
- contactId: person.contactId,
65
+ personId: person.personId,
66
66
  key
67
67
  });
68
- else answers.set(person.contactId, null);
68
+ else answers.set(person.personId, null);
69
69
  }
70
70
  if (!keyed.length) return answers;
71
71
  try {
72
72
  const snapshots = await getAllChunked(firestore, keyed.map((entry)=>collection(entry.key)));
73
- keyed.forEach((entry, index)=>answers.set(entry.contactId, answer(snapshots[index])));
73
+ keyed.forEach((entry, index)=>answers.set(entry.personId, answer(snapshots[index])));
74
74
  } catch (error) {
75
75
  console.error(`[outreach] ${label} lookup failed; reading as unchecked`, error);
76
- for (const entry of keyed)answers.set(entry.contactId, null);
76
+ for (const entry of keyed)answers.set(entry.personId, null);
77
77
  }
78
78
  return answers;
79
79
  }
80
- /** Every lookup the gates take, for each person, keyed by contact id. */ export async function readOutreachGateLookups(firestore, input) {
80
+ /** Every lookup the gates take, for each person, keyed by person id. */ export async function readOutreachGateLookups(firestore, input) {
81
81
  const { orgId, hostId, people } = input;
82
82
  const host = firestore.collection('hosts').doc(hostId);
83
83
  const enrollments = outreachOrgCollection(firestore, orgId, 'enrollments');
@@ -96,8 +96,9 @@ async function getAllChunked(firestore, refs) {
96
96
  return null;
97
97
  }),
98
98
  Promise.all(people.map(async (person)=>{
99
+ var _person_leadId, _ref, _person_contactId;
99
100
  const open = await Promise.all([
100
- enrollments.where('contactId', '==', person.contactId).get(),
101
+ person.contactId ? enrollments.where('contactId', '==', person.contactId).get() : enrollments.where('leadId', '==', (_person_leadId = person.leadId) != null ? _person_leadId : '').get(),
101
102
  enrollments.where('email', '==', person.email).get()
102
103
  ]).then(([byContact, byEmail])=>{
103
104
  const found = new Map();
@@ -119,29 +120,31 @@ async function getAllChunked(firestore, refs) {
119
120
  console.error('[outreach] enrollment lookup failed; reading as unchecked', error);
120
121
  return null;
121
122
  });
122
- const inbound = await activities.where('contactId', '==', person.contactId).where('direction', '==', 'inbound').limit(1).get().then((snapshot)=>!snapshot.empty).catch((error)=>{
123
+ // An email the person wrote: filed on the contact, or on the lead
124
+ // while they were one (AGL-3234).
125
+ const inbound = await activities.where(person.contactId ? 'contactId' : 'leadId', '==', (_ref = (_person_contactId = person.contactId) != null ? _person_contactId : person.leadId) != null ? _ref : '').where('direction', '==', 'inbound').limit(1).get().then((snapshot)=>!snapshot.empty).catch((error)=>{
123
126
  console.error('[outreach] inbound email lookup failed; reading as cold', error);
124
127
  return null;
125
128
  });
126
129
  return {
127
- contactId: person.contactId,
130
+ personId: person.personId,
128
131
  open,
129
132
  inbound
130
133
  };
131
134
  }))
132
135
  ]);
133
136
  const byPerson = new Map(perPerson.map((entry)=>[
134
- entry.contactId,
137
+ entry.personId,
135
138
  entry
136
139
  ]));
137
140
  const lookups = new Map();
138
141
  for (const person of people){
139
142
  var _platform_get, _site_get, _sales_get, _doNotContact_get, _ref, _ref1;
140
- const own = byPerson.get(person.contactId);
141
- lookups.set(person.contactId, {
142
- platformSuppressed: (_platform_get = platform.get(person.contactId)) != null ? _platform_get : null,
143
- hostSuppressed: (_site_get = site.get(person.contactId)) != null ? _site_get : null,
144
- salesTopicState: (_sales_get = sales.get(person.contactId)) != null ? _sales_get : null,
143
+ const own = byPerson.get(person.personId);
144
+ lookups.set(person.personId, {
145
+ platformSuppressed: (_platform_get = platform.get(person.personId)) != null ? _platform_get : null,
146
+ hostSuppressed: (_site_get = site.get(person.personId)) != null ? _site_get : null,
147
+ salesTopicState: (_sales_get = sales.get(person.personId)) != null ? _sales_get : null,
145
148
  doNotContact: (_doNotContact_get = doNotContact.get(person.email)) != null ? _doNotContact_get : null,
146
149
  workspaceMembers: roster,
147
150
  openEnrollments: (_ref = own == null ? void 0 : own.open) != null ? _ref : null,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/enrollment/gate-lookups.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * THE LOOKUPS THE ENROLLMENT GATES DECIDE ON (AGL-2980).\n *\n * `evaluateOutreachGates` is pure: it reads a contact as stored and the\n * RESULTS of the reads the runtime makes. This is those reads, for many\n * people at once — the enroll preview asks about everyone a view names in\n * one pass, and the sending runtime asks about one person before each send\n * with the same function, so the two can never consult different lists.\n *\n * Server only. Every list keyed by address is read with one `getAll` over\n * `personKey` ids — the suppression lists, the sales topic opt-outs, the\n * do-not-contact list — so a lookup costs one round trip however many\n * people it covers, and needs no index that could go missing. The roster\n * is read once. The two per-person facts that are queries — the person's\n * other enrollments and whether they ever wrote in — are equality filters\n * on single fields.\n *\n * ## A read that failed is `null`, and `null` refuses\n *\n * Each lookup is caught on its own and answers `null` for everyone it\n * covered, which the gates read as \"we couldn't check\" and refuse: a\n * person who asked not to be emailed must not be emailed because a read\n * timed out. One failed list does not blank the others.\n */\n\nimport { CRM_COLLECTIONS } from '@aglyn/aglyn/app-utils/crm'\nimport {\n EMAIL_TOPIC_SALES,\n readTopicSubscriptionState,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n type TopicSubscriptionState,\n} from '@aglyn/aglyn/app-utils/email-topics'\nimport type { MemberAddresses } from '@aglyn/aglyn/app-utils/member-email-aliases'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport { loadCrmInboundRoster } from '@aglyn/tenant-data-admin/server/crm-inbound-email'\nimport {\n EMAIL_SUPPRESSIONS_COLLECTION,\n HOST_SUPPRESSIONS_SUBCOLLECTION,\n} from '@aglyn/tenant-data-admin/server/email-suppression'\nimport type { OutreachGateLookups } from '../engine/gates'\nimport type { OutreachEnrollmentStatus } from '../model/outreach.types'\nimport { lookupOutreachDoNotContact } from '../storage/do-not-contact-store'\nimport { outreachOrgCollection } from '../storage/outreach-records'\n\n/** One person to look up: the contact, and the address the steps go to. */\nexport interface OutreachGateLookupPerson {\n contactId: string\n email: string\n}\n\nexport interface ReadOutreachGateLookupsInput {\n orgId: string\n /** The sequence's site: whose suppression list and topic opt-outs apply. */\n hostId: string\n people: readonly OutreachGateLookupPerson[]\n}\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** `getAll` takes this many references at most per call. */\nconst GET_ALL_CHUNK = 300\n\nasync function getAllChunked(\n firestore: Firestore,\n refs: readonly FirebaseFirestore.DocumentReference[],\n): Promise<FirebaseFirestore.DocumentSnapshot[]> {\n const snapshots: FirebaseFirestore.DocumentSnapshot[] = []\n for (let start = 0; start < refs.length; start += GET_ALL_CHUNK) {\n snapshots.push(...(await firestore.getAll(...refs.slice(start, start + GET_ALL_CHUNK))))\n }\n return snapshots\n}\n\n/**\n * One keyed list, read for every person at once: `answer` turns each\n * person's document into their answer. An address that cannot be keyed has\n * no document anywhere and is answered as unchecked, and so is everyone\n * when the read fails.\n */\nasync function keyedLookup<T>(\n firestore: Firestore,\n people: readonly OutreachGateLookupPerson[],\n collection: (key: string) => FirebaseFirestore.DocumentReference,\n answer: (snapshot: FirebaseFirestore.DocumentSnapshot) => T,\n label: string,\n): Promise<Map<string, T | null>> {\n const answers = new Map<string, T | null>()\n const keyed: Array<{ contactId: string; key: string }> = []\n for (const person of people) {\n const key = personKey(person.email)\n if (key) keyed.push({ contactId: person.contactId, key })\n else answers.set(person.contactId, null)\n }\n if (!keyed.length) return answers\n try {\n const snapshots = await getAllChunked(\n firestore,\n keyed.map((entry) => collection(entry.key)),\n )\n keyed.forEach((entry, index) => answers.set(entry.contactId, answer(snapshots[index])))\n } catch (error) {\n console.error(`[outreach] ${label} lookup failed; reading as unchecked`, error)\n for (const entry of keyed) answers.set(entry.contactId, null)\n }\n return answers\n}\n\n/** Every lookup the gates take, for each person, keyed by contact id. */\nexport async function readOutreachGateLookups(\n firestore: Firestore,\n input: ReadOutreachGateLookupsInput,\n): Promise<Map<string, OutreachGateLookups>> {\n const { orgId, hostId, people } = input\n const host = firestore.collection('hosts').doc(hostId)\n const enrollments = outreachOrgCollection(firestore, orgId, 'enrollments')\n const activities = firestore.collection('orgs').doc(orgId).collection(CRM_COLLECTIONS.activities)\n\n const [platform, site, sales, doNotContact, roster, perPerson] = await Promise.all([\n keyedLookup(\n firestore,\n people,\n (key) => firestore.collection(EMAIL_SUPPRESSIONS_COLLECTION).doc(key),\n // A released record no longer suppresses.\n (snapshot) => snapshot.exists && !snapshot.get('releasedAt'),\n 'platform suppression',\n ),\n keyedLookup(\n firestore,\n people,\n (key) => host.collection(HOST_SUPPRESSIONS_SUBCOLLECTION).doc(key),\n (snapshot) => snapshot.exists,\n 'site suppression',\n ),\n keyedLookup<TopicSubscriptionState>(\n firestore,\n people,\n (key) => host.collection(TOPIC_OPT_OUTS_SUBCOLLECTION).doc(key),\n (snapshot) =>\n readTopicSubscriptionState(\n ((snapshot.exists ? snapshot.get('topics') : null) ?? {})[EMAIL_TOPIC_SALES],\n ),\n 'sales topic',\n ),\n lookupOutreachDoNotContact(\n firestore,\n orgId,\n people.map((person) => person.email),\n ),\n loadCrmInboundRoster(firestore, orgId).catch((error: unknown): MemberAddresses[] | null => {\n console.error('[outreach] roster lookup failed; reading as unchecked', error)\n return null\n }),\n Promise.all(\n people.map(async (person) => {\n const open = await Promise.all([\n enrollments.where('contactId', '==', person.contactId).get(),\n enrollments.where('email', '==', person.email).get(),\n ])\n .then(([byContact, byEmail]) => {\n const found = new Map<string, { id: string; sequenceId: string; status: OutreachEnrollmentStatus }>()\n for (const doc of [...byContact.docs, ...byEmail.docs]) {\n found.set(doc.id, {\n id: doc.id,\n sequenceId: String(doc.get('sequenceId') ?? ''),\n status: doc.get('status') as OutreachEnrollmentStatus,\n })\n }\n return [...found.values()]\n })\n .catch((error: unknown) => {\n console.error('[outreach] enrollment lookup failed; reading as unchecked', error)\n return null\n })\n const inbound = await activities\n .where('contactId', '==', person.contactId)\n .where('direction', '==', 'inbound')\n .limit(1)\n .get()\n .then((snapshot) => !snapshot.empty)\n .catch((error: unknown) => {\n console.error('[outreach] inbound email lookup failed; reading as cold', error)\n return null\n })\n return { contactId: person.contactId, open, inbound }\n }),\n ),\n ])\n\n const byPerson = new Map(perPerson.map((entry) => [entry.contactId, entry]))\n const lookups = new Map<string, OutreachGateLookups>()\n for (const person of people) {\n const own = byPerson.get(person.contactId)\n lookups.set(person.contactId, {\n platformSuppressed: platform.get(person.contactId) ?? null,\n hostSuppressed: site.get(person.contactId) ?? null,\n salesTopicState: sales.get(person.contactId) ?? null,\n doNotContact: doNotContact.get(person.email) ?? null,\n workspaceMembers: roster,\n openEnrollments: own?.open ?? null,\n hasInboundEmail: own?.inbound ?? null,\n })\n }\n return lookups\n}\n"],"names":["CRM_COLLECTIONS","EMAIL_TOPIC_SALES","readTopicSubscriptionState","TOPIC_OPT_OUTS_SUBCOLLECTION","personKey","loadCrmInboundRoster","EMAIL_SUPPRESSIONS_COLLECTION","HOST_SUPPRESSIONS_SUBCOLLECTION","lookupOutreachDoNotContact","outreachOrgCollection","GET_ALL_CHUNK","getAllChunked","firestore","refs","snapshots","start","length","push","getAll","slice","keyedLookup","people","collection","answer","label","answers","Map","keyed","person","key","email","contactId","set","map","entry","forEach","index","error","console","readOutreachGateLookups","input","orgId","hostId","host","doc","enrollments","activities","platform","site","sales","doNotContact","roster","perPerson","Promise","all","snapshot","exists","get","catch","open","where","then","byContact","byEmail","found","docs","id","sequenceId","String","status","values","inbound","limit","empty","byPerson","lookups","own","platformSuppressed","hostSuppressed","salesTopicState","workspaceMembers","openEnrollments","hasInboundEmail"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/enrollment/gate-lookups.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * THE LOOKUPS THE ENROLLMENT GATES DECIDE ON (AGL-2980).\n *\n * `evaluateOutreachGates` is pure: it reads a contact as stored and the\n * RESULTS of the reads the runtime makes. This is those reads, for many\n * people at once — the enroll preview asks about everyone a view names in\n * one pass, and the sending runtime asks about one person before each send\n * with the same function, so the two can never consult different lists.\n *\n * Server only. Every list keyed by address is read with one `getAll` over\n * `personKey` ids — the suppression lists, the sales topic opt-outs, the\n * do-not-contact list — so a lookup costs one round trip however many\n * people it covers, and needs no index that could go missing. The roster\n * is read once. The two per-person facts that are queries — the person's\n * other enrollments and whether they ever wrote in — are equality filters\n * on single fields.\n *\n * ## A read that failed is `null`, and `null` refuses\n *\n * Each lookup is caught on its own and answers `null` for everyone it\n * covered, which the gates read as \"we couldn't check\" and refuse: a\n * person who asked not to be emailed must not be emailed because a read\n * timed out. One failed list does not blank the others.\n */\n\nimport { CRM_COLLECTIONS } from '@aglyn/aglyn/app-utils/crm'\nimport {\n EMAIL_TOPIC_SALES,\n readTopicSubscriptionState,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n type TopicSubscriptionState,\n} from '@aglyn/aglyn/app-utils/email-topics'\nimport type { MemberAddresses } from '@aglyn/aglyn/app-utils/member-email-aliases'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport { loadCrmInboundRoster } from '@aglyn/tenant-data-admin/server/crm-inbound-email'\nimport {\n EMAIL_SUPPRESSIONS_COLLECTION,\n HOST_SUPPRESSIONS_SUBCOLLECTION,\n} from '@aglyn/tenant-data-admin/server/email-suppression'\nimport type { OutreachGateLookups } from '../engine/gates'\nimport type { OutreachEnrollmentStatus } from '../model/outreach.types'\nimport { lookupOutreachDoNotContact } from '../storage/do-not-contact-store'\nimport { outreachOrgCollection } from '../storage/outreach-records'\n\n/**\n * One person to look up: the record they are, and the address the steps go\n * to. `personId` keys the answer — the contact's id, or the lead's person\n * key (AGL-3234); a lead has no contact to look up enrollments or inbound\n * email by, so those reads go by the lead instead.\n */\nexport interface OutreachGateLookupPerson {\n personId: string\n /** The contact's id, or `null` for a lead. */\n contactId: string | null\n /** The lead's person key, or `null` for a contact. */\n leadId: string | null\n email: string\n}\n\nexport interface ReadOutreachGateLookupsInput {\n orgId: string\n /** The sequence's site: whose suppression list and topic opt-outs apply. */\n hostId: string\n people: readonly OutreachGateLookupPerson[]\n}\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** `getAll` takes this many references at most per call. */\nconst GET_ALL_CHUNK = 300\n\nasync function getAllChunked(\n firestore: Firestore,\n refs: readonly FirebaseFirestore.DocumentReference[],\n): Promise<FirebaseFirestore.DocumentSnapshot[]> {\n const snapshots: FirebaseFirestore.DocumentSnapshot[] = []\n for (let start = 0; start < refs.length; start += GET_ALL_CHUNK) {\n snapshots.push(...(await firestore.getAll(...refs.slice(start, start + GET_ALL_CHUNK))))\n }\n return snapshots\n}\n\n/**\n * One keyed list, read for every person at once: `answer` turns each\n * person's document into their answer. An address that cannot be keyed has\n * no document anywhere and is answered as unchecked, and so is everyone\n * when the read fails.\n */\nasync function keyedLookup<T>(\n firestore: Firestore,\n people: readonly OutreachGateLookupPerson[],\n collection: (key: string) => FirebaseFirestore.DocumentReference,\n answer: (snapshot: FirebaseFirestore.DocumentSnapshot) => T,\n label: string,\n): Promise<Map<string, T | null>> {\n const answers = new Map<string, T | null>()\n const keyed: Array<{ personId: string; key: string }> = []\n for (const person of people) {\n const key = personKey(person.email)\n if (key) keyed.push({ personId: person.personId, key })\n else answers.set(person.personId, null)\n }\n if (!keyed.length) return answers\n try {\n const snapshots = await getAllChunked(\n firestore,\n keyed.map((entry) => collection(entry.key)),\n )\n keyed.forEach((entry, index) => answers.set(entry.personId, answer(snapshots[index])))\n } catch (error) {\n console.error(`[outreach] ${label} lookup failed; reading as unchecked`, error)\n for (const entry of keyed) answers.set(entry.personId, null)\n }\n return answers\n}\n\n/** Every lookup the gates take, for each person, keyed by person id. */\nexport async function readOutreachGateLookups(\n firestore: Firestore,\n input: ReadOutreachGateLookupsInput,\n): Promise<Map<string, OutreachGateLookups>> {\n const { orgId, hostId, people } = input\n const host = firestore.collection('hosts').doc(hostId)\n const enrollments = outreachOrgCollection(firestore, orgId, 'enrollments')\n const activities = firestore.collection('orgs').doc(orgId).collection(CRM_COLLECTIONS.activities)\n\n const [platform, site, sales, doNotContact, roster, perPerson] = await Promise.all([\n keyedLookup(\n firestore,\n people,\n (key) => firestore.collection(EMAIL_SUPPRESSIONS_COLLECTION).doc(key),\n // A released record no longer suppresses.\n (snapshot) => snapshot.exists && !snapshot.get('releasedAt'),\n 'platform suppression',\n ),\n keyedLookup(\n firestore,\n people,\n (key) => host.collection(HOST_SUPPRESSIONS_SUBCOLLECTION).doc(key),\n (snapshot) => snapshot.exists,\n 'site suppression',\n ),\n keyedLookup<TopicSubscriptionState>(\n firestore,\n people,\n (key) => host.collection(TOPIC_OPT_OUTS_SUBCOLLECTION).doc(key),\n (snapshot) =>\n readTopicSubscriptionState(\n ((snapshot.exists ? snapshot.get('topics') : null) ?? {})[EMAIL_TOPIC_SALES],\n ),\n 'sales topic',\n ),\n lookupOutreachDoNotContact(\n firestore,\n orgId,\n people.map((person) => person.email),\n ),\n loadCrmInboundRoster(firestore, orgId).catch((error: unknown): MemberAddresses[] | null => {\n console.error('[outreach] roster lookup failed; reading as unchecked', error)\n return null\n }),\n Promise.all(\n people.map(async (person) => {\n const open = await Promise.all([\n person.contactId\n ? enrollments.where('contactId', '==', person.contactId).get()\n : enrollments.where('leadId', '==', person.leadId ?? '').get(),\n enrollments.where('email', '==', person.email).get(),\n ])\n .then(([byContact, byEmail]) => {\n const found = new Map<string, { id: string; sequenceId: string; status: OutreachEnrollmentStatus }>()\n for (const doc of [...byContact.docs, ...byEmail.docs]) {\n found.set(doc.id, {\n id: doc.id,\n sequenceId: String(doc.get('sequenceId') ?? ''),\n status: doc.get('status') as OutreachEnrollmentStatus,\n })\n }\n return [...found.values()]\n })\n .catch((error: unknown) => {\n console.error('[outreach] enrollment lookup failed; reading as unchecked', error)\n return null\n })\n // An email the person wrote: filed on the contact, or on the lead\n // while they were one (AGL-3234).\n const inbound = await activities\n .where(person.contactId ? 'contactId' : 'leadId', '==', person.contactId ?? person.leadId ?? '')\n .where('direction', '==', 'inbound')\n .limit(1)\n .get()\n .then((snapshot) => !snapshot.empty)\n .catch((error: unknown) => {\n console.error('[outreach] inbound email lookup failed; reading as cold', error)\n return null\n })\n return { personId: person.personId, open, inbound }\n }),\n ),\n ])\n\n const byPerson = new Map(perPerson.map((entry) => [entry.personId, entry]))\n const lookups = new Map<string, OutreachGateLookups>()\n for (const person of people) {\n const own = byPerson.get(person.personId)\n lookups.set(person.personId, {\n platformSuppressed: platform.get(person.personId) ?? null,\n hostSuppressed: site.get(person.personId) ?? null,\n salesTopicState: sales.get(person.personId) ?? null,\n doNotContact: doNotContact.get(person.email) ?? null,\n workspaceMembers: roster,\n openEnrollments: own?.open ?? null,\n hasInboundEmail: own?.inbound ?? null,\n })\n }\n return lookups\n}\n"],"names":["CRM_COLLECTIONS","EMAIL_TOPIC_SALES","readTopicSubscriptionState","TOPIC_OPT_OUTS_SUBCOLLECTION","personKey","loadCrmInboundRoster","EMAIL_SUPPRESSIONS_COLLECTION","HOST_SUPPRESSIONS_SUBCOLLECTION","lookupOutreachDoNotContact","outreachOrgCollection","GET_ALL_CHUNK","getAllChunked","firestore","refs","snapshots","start","length","push","getAll","slice","keyedLookup","people","collection","answer","label","answers","Map","keyed","person","key","email","personId","set","map","entry","forEach","index","error","console","readOutreachGateLookups","input","orgId","hostId","host","doc","enrollments","activities","platform","site","sales","doNotContact","roster","perPerson","Promise","all","snapshot","exists","get","catch","open","contactId","where","leadId","then","byContact","byEmail","found","docs","id","sequenceId","String","status","values","inbound","limit","empty","byPerson","lookups","own","platformSuppressed","hostSuppressed","salesTopicState","workspaceMembers","openEnrollments","hasInboundEmail"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GAED,SAASA,eAAe,QAAQ,6BAA4B;AAC5D,SACEC,iBAAiB,EACjBC,0BAA0B,EAC1BC,4BAA4B,QAEvB,sCAAqC;AAE5C,SAASC,SAAS,QAAQ,oCAAmC;AAC7D,SAASC,oBAAoB,QAAQ,oDAAmD;AACxF,SACEC,6BAA6B,EAC7BC,+BAA+B,QAC1B,oDAAmD;AAG1D,SAASC,0BAA0B,QAAQ,qCAAiC;AAC5E,SAASC,qBAAqB,QAAQ,iCAA6B;AA0BnE,0DAA0D,GAC1D,MAAMC,gBAAgB;AAEtB,eAAeC,cACbC,SAAoB,EACpBC,IAAoD;IAEpD,MAAMC,YAAkD,EAAE;IAC1D,IAAK,IAAIC,QAAQ,GAAGA,QAAQF,KAAKG,MAAM,EAAED,SAASL,cAAe;QAC/DI,UAAUG,IAAI,IAAK,MAAML,UAAUM,MAAM,IAAIL,KAAKM,KAAK,CAACJ,OAAOA,QAAQL;IACzE;IACA,OAAOI;AACT;AAEA;;;;;CAKC,GACD,eAAeM,YACbR,SAAoB,EACpBS,MAA2C,EAC3CC,UAAgE,EAChEC,MAA2D,EAC3DC,KAAa;IAEb,MAAMC,UAAU,IAAIC;IACpB,MAAMC,QAAkD,EAAE;IAC1D,KAAK,MAAMC,UAAUP,OAAQ;QAC3B,MAAMQ,MAAMzB,UAAUwB,OAAOE,KAAK;QAClC,IAAID,KAAKF,MAAMV,IAAI,CAAC;YAAEc,UAAUH,OAAOG,QAAQ;YAAEF;QAAI;aAChDJ,QAAQO,GAAG,CAACJ,OAAOG,QAAQ,EAAE;IACpC;IACA,IAAI,CAACJ,MAAMX,MAAM,EAAE,OAAOS;IAC1B,IAAI;QACF,MAAMX,YAAY,MAAMH,cACtBC,WACAe,MAAMM,GAAG,CAAC,CAACC,QAAUZ,WAAWY,MAAML,GAAG;QAE3CF,MAAMQ,OAAO,CAAC,CAACD,OAAOE,QAAUX,QAAQO,GAAG,CAACE,MAAMH,QAAQ,EAAER,OAAOT,SAAS,CAACsB,MAAM;IACrF,EAAE,OAAOC,OAAO;QACdC,QAAQD,KAAK,CAAC,CAAC,WAAW,EAAEb,MAAM,oCAAoC,CAAC,EAAEa;QACzE,KAAK,MAAMH,SAASP,MAAOF,QAAQO,GAAG,CAACE,MAAMH,QAAQ,EAAE;IACzD;IACA,OAAON;AACT;AAEA,sEAAsE,GACtE,OAAO,eAAec,wBACpB3B,SAAoB,EACpB4B,KAAmC;IAEnC,MAAM,EAAEC,KAAK,EAAEC,MAAM,EAAErB,MAAM,EAAE,GAAGmB;IAClC,MAAMG,OAAO/B,UAAUU,UAAU,CAAC,SAASsB,GAAG,CAACF;IAC/C,MAAMG,cAAcpC,sBAAsBG,WAAW6B,OAAO;IAC5D,MAAMK,aAAalC,UAAUU,UAAU,CAAC,QAAQsB,GAAG,CAACH,OAAOnB,UAAU,CAACtB,gBAAgB8C,UAAU;IAEhG,MAAM,CAACC,UAAUC,MAAMC,OAAOC,cAAcC,QAAQC,UAAU,GAAG,MAAMC,QAAQC,GAAG,CAAC;QACjFlC,YACER,WACAS,QACA,CAACQ,MAAQjB,UAAUU,UAAU,CAAChB,+BAA+BsC,GAAG,CAACf,MACjE,0CAA0C;QAC1C,CAAC0B,WAAaA,SAASC,MAAM,IAAI,CAACD,SAASE,GAAG,CAAC,eAC/C;QAEFrC,YACER,WACAS,QACA,CAACQ,MAAQc,KAAKrB,UAAU,CAACf,iCAAiCqC,GAAG,CAACf,MAC9D,CAAC0B,WAAaA,SAASC,MAAM,EAC7B;QAEFpC,YACER,WACAS,QACA,CAACQ,MAAQc,KAAKrB,UAAU,CAACnB,8BAA8ByC,GAAG,CAACf,MAC3D,CAAC0B;gBAEKA;mBADJrD,2BACE,EAAEqD,OAAAA,SAASC,MAAM,GAAGD,SAASE,GAAG,CAAC,YAAY,gBAA3CF,OAAoD,CAAC,EAAE,CAACtD,kBAAkB;WAEhF;QAEFO,2BACEI,WACA6B,OACApB,OAAOY,GAAG,CAAC,CAACL,SAAWA,OAAOE,KAAK;QAErCzB,qBAAqBO,WAAW6B,OAAOiB,KAAK,CAAC,CAACrB;YAC5CC,QAAQD,KAAK,CAAC,yDAAyDA;YACvE,OAAO;QACT;QACAgB,QAAQC,GAAG,CACTjC,OAAOY,GAAG,CAAC,OAAOL;gBAIwBA,gBAqBkBA,MAAAA;YAxB1D,MAAM+B,OAAO,MAAMN,QAAQC,GAAG,CAAC;gBAC7B1B,OAAOgC,SAAS,GACZf,YAAYgB,KAAK,CAAC,aAAa,MAAMjC,OAAOgC,SAAS,EAAEH,GAAG,KAC1DZ,YAAYgB,KAAK,CAAC,UAAU,OAAMjC,iBAAAA,OAAOkC,MAAM,YAAblC,iBAAiB,IAAI6B,GAAG;gBAC9DZ,YAAYgB,KAAK,CAAC,SAAS,MAAMjC,OAAOE,KAAK,EAAE2B,GAAG;aACnD,EACEM,IAAI,CAAC,CAAC,CAACC,WAAWC,QAAQ;gBACzB,MAAMC,QAAQ,IAAIxC;gBAClB,KAAK,MAAMkB,OAAO;uBAAIoB,UAAUG,IAAI;uBAAKF,QAAQE,IAAI;iBAAC,CAAE;wBAGjCvB;oBAFrBsB,MAAMlC,GAAG,CAACY,IAAIwB,EAAE,EAAE;wBAChBA,IAAIxB,IAAIwB,EAAE;wBACVC,YAAYC,QAAO1B,WAAAA,IAAIa,GAAG,CAAC,yBAARb,WAAyB;wBAC5C2B,QAAQ3B,IAAIa,GAAG,CAAC;oBAClB;gBACF;gBACA,OAAO;uBAAIS,MAAMM,MAAM;iBAAG;YAC5B,GACCd,KAAK,CAAC,CAACrB;gBACNC,QAAQD,KAAK,CAAC,6DAA6DA;gBAC3E,OAAO;YACT;YACF,kEAAkE;YAClE,kCAAkC;YAClC,MAAMoC,UAAU,MAAM3B,WACnBe,KAAK,CAACjC,OAAOgC,SAAS,GAAG,cAAc,UAAU,OAAMhC,QAAAA,oBAAAA,OAAOgC,SAAS,YAAhBhC,oBAAoBA,OAAOkC,MAAM,YAAjClC,OAAqC,IAC5FiC,KAAK,CAAC,aAAa,MAAM,WACzBa,KAAK,CAAC,GACNjB,GAAG,GACHM,IAAI,CAAC,CAACR,WAAa,CAACA,SAASoB,KAAK,EAClCjB,KAAK,CAAC,CAACrB;gBACNC,QAAQD,KAAK,CAAC,2DAA2DA;gBACzE,OAAO;YACT;YACF,OAAO;gBAAEN,UAAUH,OAAOG,QAAQ;gBAAE4B;gBAAMc;YAAQ;QACpD;KAEH;IAED,MAAMG,WAAW,IAAIlD,IAAI0B,UAAUnB,GAAG,CAAC,CAACC,QAAU;YAACA,MAAMH,QAAQ;YAAEG;SAAM;IACzE,MAAM2C,UAAU,IAAInD;IACpB,KAAK,MAAME,UAAUP,OAAQ;YAGL0B,eACJC,WACCC,YACHC;QALhB,MAAM4B,MAAMF,SAASnB,GAAG,CAAC7B,OAAOG,QAAQ;QACxC8C,QAAQ7C,GAAG,CAACJ,OAAOG,QAAQ,EAAE;YAC3BgD,kBAAkB,GAAEhC,gBAAAA,SAASU,GAAG,CAAC7B,OAAOG,QAAQ,aAA5BgB,gBAAiC;YACrDiC,cAAc,GAAEhC,YAAAA,KAAKS,GAAG,CAAC7B,OAAOG,QAAQ,aAAxBiB,YAA6B;YAC7CiC,eAAe,GAAEhC,aAAAA,MAAMQ,GAAG,CAAC7B,OAAOG,QAAQ,aAAzBkB,aAA8B;YAC/CC,YAAY,GAAEA,oBAAAA,aAAaO,GAAG,CAAC7B,OAAOE,KAAK,aAA7BoB,oBAAkC;YAChDgC,kBAAkB/B;YAClBgC,eAAe,UAAEL,uBAAAA,IAAKnB,IAAI,mBAAI;YAC9ByB,eAAe,WAAEN,uBAAAA,IAAKL,OAAO,oBAAI;QACnC;IACF;IACA,OAAOI;AACT"}
@@ -26,9 +26,25 @@ export interface OutreachApiRefusal {
26
26
  error: string;
27
27
  reason: OutreachApiRefusalReason;
28
28
  }
29
+ /** A reason `GET outreach/mailboxes/availability` answers `configured: false`. */
30
+ export type OutreachMailboxAvailabilityGate = {
31
+ gate: 'google';
32
+ missing: string[];
33
+ } | {
34
+ gate: 'state';
35
+ } | {
36
+ gate: 'redirect';
37
+ };
29
38
  /** `GET outreach/mailboxes/availability` */
30
39
  export interface OutreachMailboxAvailability {
31
40
  configured: boolean;
41
+ /**
42
+ * When not configured, which gates refused, so an operator reading the
43
+ * response knows what to set: the Google client (`google`, with the names
44
+ * of the missing variables), the state signing secret (`state`), or the
45
+ * console origin to register (`redirect`). Absent when configured.
46
+ */
47
+ missing?: OutreachMailboxAvailabilityGate[];
32
48
  /**
33
49
  * Whether the viewer is an organization owner or admin, who may change,
34
50
  * pause and disconnect any member's mailbox. The routes decide this on
@@ -69,6 +85,12 @@ export interface OutreachMailboxSettingsRequest {
69
85
  dailyCap?: number;
70
86
  window?: OutreachSendWindow;
71
87
  timezone?: string;
88
+ /**
89
+ * Whether the warm-up ramp applies (AGL-3228). `false` clears it, so an
90
+ * established mailbox sends at its cap from today; `true` starts one now
91
+ * when none is running.
92
+ */
93
+ warmUp?: boolean;
72
94
  }
73
95
  /** `POST outreach/mailboxes/status` */
74
96
  export interface OutreachMailboxStatusRequest {
@@ -80,10 +102,13 @@ export interface OutreachMailboxResponse {
80
102
  ok: true;
81
103
  mailbox: OutreachMailbox;
82
104
  }
83
- /** `POST outreach/mailboxes/test` */
105
+ /**
106
+ * `POST outreach/mailboxes/test` — `{ mailboxId, to? }`. `to` is the address
107
+ * the test goes to; left off, the account's own (AGL-3228).
108
+ */
84
109
  export interface OutreachMailboxTestResponse {
85
110
  ok: true;
86
- /** The account's own address the test went to. */
111
+ /** The address the test went to. */
87
112
  sentTo: string;
88
113
  gmailMessageId: string;
89
114
  sentAtMs: number;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/mailboxes/mailbox-api.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { OutreachMailbox, OutreachSendWindow } from '../model/outreach.types'\n\n/**\n * THE MAILBOX ROUTES' CONTRACT (AGL-2978): what the Mailboxes panel sends,\n * what the routes answer, and the fragment a connect comes back through.\n * Client-safe — types, constants and two string functions.\n */\n\n/** Every reason a mailbox route refuses with, for a caller to branch on. */\nexport type OutreachApiRefusalReason =\n | 'unauthenticated'\n | 'email-unverified'\n | 'org-required'\n | 'not-a-member'\n | 'not-org-wide'\n | 'permission'\n | 'entitlement'\n | 'not-configured'\n | 'method-not-allowed'\n | 'invalid-request'\n | 'mailbox-not-found'\n | 'not-your-mailbox'\n | 'mailbox-limit'\n | 'state-invalid'\n | 'state-expired'\n | 'state-user-mismatch'\n | 'state-org-mismatch'\n | 'state-replayed'\n | 'state-superseded'\n | 'code-rejected'\n | 'refresh-token-missing'\n | 'scopes-missing'\n | 'identity-unverified'\n | 'account-mismatch'\n | 'reconnect-required'\n | 'invalid-settings'\n | 'rate-limited'\n | 'google-unavailable'\n\nexport interface OutreachApiRefusal {\n error: string\n reason: OutreachApiRefusalReason\n}\n\n/** `GET outreach/mailboxes/availability` */\nexport interface OutreachMailboxAvailability {\n configured: boolean\n /**\n * Whether the viewer is an organization owner or admin, who may change,\n * pause and disconnect any member's mailbox. The routes decide this on\n * every call; the panel reads it to offer only what will be allowed.\n */\n canManageAll: boolean\n}\n\n/** `POST outreach/mailboxes/connect` */\nexport interface OutreachConnectRequest {\n orgId: string\n}\nexport interface OutreachConnectResponse {\n /** Google's consent address; the browser goes there. */\n url: string\n}\n\n/** `POST outreach/mailboxes/connect/complete` */\nexport interface OutreachConnectCompleteRequest {\n orgId: string\n code: string\n state: string\n /** The browser's IANA timezone, for a new mailbox's sending window. */\n timezone?: string\n}\nexport interface OutreachConnectCompleteResponse {\n ok: true\n mailbox: OutreachMailbox\n /** False when an existing mailbox for the account was reconnected. */\n created: boolean\n /** Pending addresses of the member's that Gmail's send-as list confirmed. */\n confirmedAliases: string[]\n}\n\n/** `POST outreach/mailboxes/settings` — every field but the ids is optional. */\nexport interface OutreachMailboxSettingsRequest {\n orgId: string\n mailboxId: string\n sendAs?: string\n displayName?: string\n dailyCap?: number\n window?: OutreachSendWindow\n timezone?: string\n}\n\n/** `POST outreach/mailboxes/status` */\nexport interface OutreachMailboxStatusRequest {\n orgId: string\n mailboxId: string\n paused: boolean\n}\n\nexport interface OutreachMailboxResponse {\n ok: true\n mailbox: OutreachMailbox\n}\n\n/** `POST outreach/mailboxes/test` */\nexport interface OutreachMailboxTestResponse {\n ok: true\n /** The account's own address the test went to. */\n sentTo: string\n gmailMessageId: string\n sentAtMs: number\n}\n\n/** `POST outreach/mailboxes/disconnect` */\nexport interface OutreachMailboxDisconnectResponse {\n ok: true\n /**\n * What became of the grant at Google: `revoked`, `already-invalid`,\n * `kept-for-other-mailbox` when another mailbox still uses the account,\n * or `failed` when Google could not be told — the stored grant is deleted\n * either way.\n */\n revocation: 'revoked' | 'already-invalid' | 'kept-for-other-mailbox' | 'failed'\n}\n\n/**\n * The fragment key a connect returns through (AGL-2978).\n *\n * Google redirects to the callback route with the authorization code in the\n * query; the callback moves it into the FRAGMENT of the Mailboxes page, which\n * no server receives — not in a request line, not in a `Referer` — and the\n * page takes it out of the address bar before it finishes the connect with\n * the member's own session. The cross-domain session handoff uses the\n * fragment for the same reason.\n */\nexport const OUTREACH_CONNECT_FRAGMENT_KEY = 'outreachConnect'\n\n/** Why a connect came back without a code. */\nexport type OutreachConnectReturnError = 'access_denied' | 'expired' | 'google_error'\n\nexport type OutreachConnectReturn =\n | { kind: 'code'; code: string; state: string }\n | { kind: 'error'; reason: OutreachConnectReturnError }\n\n/** The fragment for a connect's return, without the leading `#`. */\nexport function buildConnectReturnFragment(value: OutreachConnectReturn): string {\n const params = new URLSearchParams()\n if (value.kind === 'code') {\n params.set(OUTREACH_CONNECT_FRAGMENT_KEY, 'code')\n params.set('code', value.code)\n params.set('state', value.state)\n } else {\n params.set(OUTREACH_CONNECT_FRAGMENT_KEY, 'error')\n params.set('reason', value.reason)\n }\n return params.toString()\n}\n\nconst RETURN_ERRORS: readonly OutreachConnectReturnError[] = ['access_denied', 'expired', 'google_error']\n\n/** A connect's return read off a location hash, or `null` when it carries none. */\nexport function parseConnectReturnFragment(hash: string | null | undefined): OutreachConnectReturn | null {\n const params = new URLSearchParams(String(hash ?? '').replace(/^#/, ''))\n const kind = params.get(OUTREACH_CONNECT_FRAGMENT_KEY)\n if (kind === 'code') {\n const code = params.get('code') ?? ''\n const state = params.get('state') ?? ''\n return code && state ? { kind: 'code', code, state } : null\n }\n if (kind === 'error') {\n const reason = params.get('reason') as OutreachConnectReturnError | null\n return { kind: 'error', reason: reason && RETURN_ERRORS.includes(reason) ? reason : 'google_error' }\n }\n return null\n}\n"],"names":["OUTREACH_CONNECT_FRAGMENT_KEY","buildConnectReturnFragment","value","params","URLSearchParams","kind","set","code","state","reason","toString","RETURN_ERRORS","parseConnectReturnFragment","hash","String","replace","get","includes"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GA+HD;;;;;;;;;CASC,GACD,OAAO,MAAMA,gCAAgC,kBAAiB;AAS9D,kEAAkE,GAClE,OAAO,SAASC,2BAA2BC,KAA4B;IACrE,MAAMC,SAAS,IAAIC;IACnB,IAAIF,MAAMG,IAAI,KAAK,QAAQ;QACzBF,OAAOG,GAAG,CAACN,+BAA+B;QAC1CG,OAAOG,GAAG,CAAC,QAAQJ,MAAMK,IAAI;QAC7BJ,OAAOG,GAAG,CAAC,SAASJ,MAAMM,KAAK;IACjC,OAAO;QACLL,OAAOG,GAAG,CAACN,+BAA+B;QAC1CG,OAAOG,GAAG,CAAC,UAAUJ,MAAMO,MAAM;IACnC;IACA,OAAON,OAAOO,QAAQ;AACxB;AAEA,MAAMC,gBAAuD;IAAC;IAAiB;IAAW;CAAe;AAEzG,iFAAiF,GACjF,OAAO,SAASC,2BAA2BC,IAA+B;IACxE,MAAMV,SAAS,IAAIC,gBAAgBU,OAAOD,eAAAA,OAAQ,IAAIE,OAAO,CAAC,MAAM;IACpE,MAAMV,OAAOF,OAAOa,GAAG,CAAChB;IACxB,IAAIK,SAAS,QAAQ;YACNF,aACCA;QADd,MAAMI,QAAOJ,cAAAA,OAAOa,GAAG,CAAC,mBAAXb,cAAsB;QACnC,MAAMK,SAAQL,eAAAA,OAAOa,GAAG,CAAC,oBAAXb,eAAuB;QACrC,OAAOI,QAAQC,QAAQ;YAAEH,MAAM;YAAQE;YAAMC;QAAM,IAAI;IACzD;IACA,IAAIH,SAAS,SAAS;QACpB,MAAMI,SAASN,OAAOa,GAAG,CAAC;QAC1B,OAAO;YAAEX,MAAM;YAASI,QAAQA,UAAUE,cAAcM,QAAQ,CAACR,UAAUA,SAAS;QAAe;IACrG;IACA,OAAO;AACT"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/mailboxes/mailbox-api.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { OutreachMailbox, OutreachSendWindow } from '../model/outreach.types'\n\n/**\n * THE MAILBOX ROUTES' CONTRACT (AGL-2978): what the Mailboxes panel sends,\n * what the routes answer, and the fragment a connect comes back through.\n * Client-safe — types, constants and two string functions.\n */\n\n/** Every reason a mailbox route refuses with, for a caller to branch on. */\nexport type OutreachApiRefusalReason =\n | 'unauthenticated'\n | 'email-unverified'\n | 'org-required'\n | 'not-a-member'\n | 'not-org-wide'\n | 'permission'\n | 'entitlement'\n | 'not-configured'\n | 'method-not-allowed'\n | 'invalid-request'\n | 'mailbox-not-found'\n | 'not-your-mailbox'\n | 'mailbox-limit'\n | 'state-invalid'\n | 'state-expired'\n | 'state-user-mismatch'\n | 'state-org-mismatch'\n | 'state-replayed'\n | 'state-superseded'\n | 'code-rejected'\n | 'refresh-token-missing'\n | 'scopes-missing'\n | 'identity-unverified'\n | 'account-mismatch'\n | 'reconnect-required'\n | 'invalid-settings'\n | 'rate-limited'\n | 'google-unavailable'\n\nexport interface OutreachApiRefusal {\n error: string\n reason: OutreachApiRefusalReason\n}\n\n/** A reason `GET outreach/mailboxes/availability` answers `configured: false`. */\nexport type OutreachMailboxAvailabilityGate =\n | { gate: 'google'; missing: string[] }\n | { gate: 'state' }\n | { gate: 'redirect' }\n\n/** `GET outreach/mailboxes/availability` */\nexport interface OutreachMailboxAvailability {\n configured: boolean\n /**\n * When not configured, which gates refused, so an operator reading the\n * response knows what to set: the Google client (`google`, with the names\n * of the missing variables), the state signing secret (`state`), or the\n * console origin to register (`redirect`). Absent when configured.\n */\n missing?: OutreachMailboxAvailabilityGate[]\n /**\n * Whether the viewer is an organization owner or admin, who may change,\n * pause and disconnect any member's mailbox. The routes decide this on\n * every call; the panel reads it to offer only what will be allowed.\n */\n canManageAll: boolean\n}\n\n/** `POST outreach/mailboxes/connect` */\nexport interface OutreachConnectRequest {\n orgId: string\n}\nexport interface OutreachConnectResponse {\n /** Google's consent address; the browser goes there. */\n url: string\n}\n\n/** `POST outreach/mailboxes/connect/complete` */\nexport interface OutreachConnectCompleteRequest {\n orgId: string\n code: string\n state: string\n /** The browser's IANA timezone, for a new mailbox's sending window. */\n timezone?: string\n}\nexport interface OutreachConnectCompleteResponse {\n ok: true\n mailbox: OutreachMailbox\n /** False when an existing mailbox for the account was reconnected. */\n created: boolean\n /** Pending addresses of the member's that Gmail's send-as list confirmed. */\n confirmedAliases: string[]\n}\n\n/** `POST outreach/mailboxes/settings` — every field but the ids is optional. */\nexport interface OutreachMailboxSettingsRequest {\n orgId: string\n mailboxId: string\n sendAs?: string\n displayName?: string\n dailyCap?: number\n window?: OutreachSendWindow\n timezone?: string\n /**\n * Whether the warm-up ramp applies (AGL-3228). `false` clears it, so an\n * established mailbox sends at its cap from today; `true` starts one now\n * when none is running.\n */\n warmUp?: boolean\n}\n\n/** `POST outreach/mailboxes/status` */\nexport interface OutreachMailboxStatusRequest {\n orgId: string\n mailboxId: string\n paused: boolean\n}\n\nexport interface OutreachMailboxResponse {\n ok: true\n mailbox: OutreachMailbox\n}\n\n/**\n * `POST outreach/mailboxes/test` — `{ mailboxId, to? }`. `to` is the address\n * the test goes to; left off, the account's own (AGL-3228).\n */\nexport interface OutreachMailboxTestResponse {\n ok: true\n /** The address the test went to. */\n sentTo: string\n gmailMessageId: string\n sentAtMs: number\n}\n\n/** `POST outreach/mailboxes/disconnect` */\nexport interface OutreachMailboxDisconnectResponse {\n ok: true\n /**\n * What became of the grant at Google: `revoked`, `already-invalid`,\n * `kept-for-other-mailbox` when another mailbox still uses the account,\n * or `failed` when Google could not be told — the stored grant is deleted\n * either way.\n */\n revocation: 'revoked' | 'already-invalid' | 'kept-for-other-mailbox' | 'failed'\n}\n\n/**\n * The fragment key a connect returns through (AGL-2978).\n *\n * Google redirects to the callback route with the authorization code in the\n * query; the callback moves it into the FRAGMENT of the Mailboxes page, which\n * no server receives — not in a request line, not in a `Referer` — and the\n * page takes it out of the address bar before it finishes the connect with\n * the member's own session. The cross-domain session handoff uses the\n * fragment for the same reason.\n */\nexport const OUTREACH_CONNECT_FRAGMENT_KEY = 'outreachConnect'\n\n/** Why a connect came back without a code. */\nexport type OutreachConnectReturnError = 'access_denied' | 'expired' | 'google_error'\n\nexport type OutreachConnectReturn =\n | { kind: 'code'; code: string; state: string }\n | { kind: 'error'; reason: OutreachConnectReturnError }\n\n/** The fragment for a connect's return, without the leading `#`. */\nexport function buildConnectReturnFragment(value: OutreachConnectReturn): string {\n const params = new URLSearchParams()\n if (value.kind === 'code') {\n params.set(OUTREACH_CONNECT_FRAGMENT_KEY, 'code')\n params.set('code', value.code)\n params.set('state', value.state)\n } else {\n params.set(OUTREACH_CONNECT_FRAGMENT_KEY, 'error')\n params.set('reason', value.reason)\n }\n return params.toString()\n}\n\nconst RETURN_ERRORS: readonly OutreachConnectReturnError[] = ['access_denied', 'expired', 'google_error']\n\n/** A connect's return read off a location hash, or `null` when it carries none. */\nexport function parseConnectReturnFragment(hash: string | null | undefined): OutreachConnectReturn | null {\n const params = new URLSearchParams(String(hash ?? '').replace(/^#/, ''))\n const kind = params.get(OUTREACH_CONNECT_FRAGMENT_KEY)\n if (kind === 'code') {\n const code = params.get('code') ?? ''\n const state = params.get('state') ?? ''\n return code && state ? { kind: 'code', code, state } : null\n }\n if (kind === 'error') {\n const reason = params.get('reason') as OutreachConnectReturnError | null\n return { kind: 'error', reason: reason && RETURN_ERRORS.includes(reason) ? reason : 'google_error' }\n }\n return null\n}\n"],"names":["OUTREACH_CONNECT_FRAGMENT_KEY","buildConnectReturnFragment","value","params","URLSearchParams","kind","set","code","state","reason","toString","RETURN_ERRORS","parseConnectReturnFragment","hash","String","replace","get","includes"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAqJD;;;;;;;;;CASC,GACD,OAAO,MAAMA,gCAAgC,kBAAiB;AAS9D,kEAAkE,GAClE,OAAO,SAASC,2BAA2BC,KAA4B;IACrE,MAAMC,SAAS,IAAIC;IACnB,IAAIF,MAAMG,IAAI,KAAK,QAAQ;QACzBF,OAAOG,GAAG,CAACN,+BAA+B;QAC1CG,OAAOG,GAAG,CAAC,QAAQJ,MAAMK,IAAI;QAC7BJ,OAAOG,GAAG,CAAC,SAASJ,MAAMM,KAAK;IACjC,OAAO;QACLL,OAAOG,GAAG,CAACN,+BAA+B;QAC1CG,OAAOG,GAAG,CAAC,UAAUJ,MAAMO,MAAM;IACnC;IACA,OAAON,OAAOO,QAAQ;AACxB;AAEA,MAAMC,gBAAuD;IAAC;IAAiB;IAAW;CAAe;AAEzG,iFAAiF,GACjF,OAAO,SAASC,2BAA2BC,IAA+B;IACxE,MAAMV,SAAS,IAAIC,gBAAgBU,OAAOD,eAAAA,OAAQ,IAAIE,OAAO,CAAC,MAAM;IACpE,MAAMV,OAAOF,OAAOa,GAAG,CAAChB;IACxB,IAAIK,SAAS,QAAQ;YACNF,aACCA;QADd,MAAMI,QAAOJ,cAAAA,OAAOa,GAAG,CAAC,mBAAXb,cAAsB;QACnC,MAAMK,SAAQL,eAAAA,OAAOa,GAAG,CAAC,oBAAXb,eAAuB;QACrC,OAAOI,QAAQC,QAAQ;YAAEH,MAAM;YAAQE;YAAMC;QAAM,IAAI;IACzD;IACA,IAAIH,SAAS,SAAS;QACpB,MAAMI,SAASN,OAAOa,GAAG,CAAC;QAC1B,OAAO;YAAEX,MAAM;YAASI,QAAQA,UAAUE,cAAcM,QAAQ,CAACR,UAAUA,SAAS;QAAe;IACrG;IACA,OAAO;AACT"}