@aglyn/plugins-outreach 1.0.0-beta.194 → 1.0.0-beta.196

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 (57) hide show
  1. package/package.json +14 -14
  2. package/src/lib/components/curate-step-dialog.d.ts +50 -0
  3. package/src/lib/components/curate-step-dialog.js +235 -0
  4. package/src/lib/components/curate-step-dialog.js.map +1 -0
  5. package/src/lib/components/curated-step-editor.d.ts +80 -0
  6. package/src/lib/components/curated-step-editor.js +255 -0
  7. package/src/lib/components/curated-step-editor.js.map +1 -0
  8. package/src/lib/components/enroll-dialog.d.ts +15 -0
  9. package/src/lib/components/enroll-dialog.js +154 -13
  10. package/src/lib/components/enroll-dialog.js.map +1 -1
  11. package/src/lib/components/enrollments-table.d.ts +2 -0
  12. package/src/lib/components/enrollments-table.js +62 -8
  13. package/src/lib/components/enrollments-table.js.map +1 -1
  14. package/src/lib/components/use-outreach-api.d.ts +8 -1
  15. package/src/lib/components/use-outreach-api.js +12 -0
  16. package/src/lib/components/use-outreach-api.js.map +1 -1
  17. package/src/lib/constants/api-routes.d.ts +8 -0
  18. package/src/lib/constants/api-routes.js +6 -0
  19. package/src/lib/constants/api-routes.js.map +1 -1
  20. package/src/lib/engine/compose.d.ts +12 -2
  21. package/src/lib/engine/compose.js +32 -6
  22. package/src/lib/engine/compose.js.map +1 -1
  23. package/src/lib/engine/curation-prompt.d.ts +73 -0
  24. package/src/lib/engine/curation-prompt.js +151 -0
  25. package/src/lib/engine/curation-prompt.js.map +1 -0
  26. package/src/lib/engine/enrollment-activity.d.ts +24 -0
  27. package/src/lib/engine/enrollment-activity.js +25 -0
  28. package/src/lib/engine/enrollment-activity.js.map +1 -1
  29. package/src/lib/engine/sequence-validation.d.ts +10 -1
  30. package/src/lib/engine/sequence-validation.js +54 -0
  31. package/src/lib/engine/sequence-validation.js.map +1 -1
  32. package/src/lib/enrollment/step-overrides.d.ts +53 -0
  33. package/src/lib/enrollment/step-overrides.js +140 -0
  34. package/src/lib/enrollment/step-overrides.js.map +1 -0
  35. package/src/lib/model/outreach-api.d.ts +69 -2
  36. package/src/lib/model/outreach-api.js.map +1 -1
  37. package/src/lib/model/outreach.types.d.ts +36 -0
  38. package/src/lib/model/outreach.types.js +18 -0
  39. package/src/lib/model/outreach.types.js.map +1 -1
  40. package/src/lib/model/step-preview.d.ts +3 -1
  41. package/src/lib/model/step-preview.js +6 -3
  42. package/src/lib/model/step-preview.js.map +1 -1
  43. package/src/lib/model/stored-records.d.ts +9 -1
  44. package/src/lib/model/stored-records.js +47 -2
  45. package/src/lib/model/stored-records.js.map +1 -1
  46. package/src/lib/routes/curate-routes.d.ts +63 -0
  47. package/src/lib/routes/curate-routes.js +424 -0
  48. package/src/lib/routes/curate-routes.js.map +1 -0
  49. package/src/lib/routes/enroll-routes.d.ts +7 -0
  50. package/src/lib/routes/enroll-routes.js +58 -4
  51. package/src/lib/routes/enroll-routes.js.map +1 -1
  52. package/src/lib/routes/preview-routes.js +15 -1
  53. package/src/lib/routes/preview-routes.js.map +1 -1
  54. package/src/lib/routes/register-routes.js +17 -0
  55. package/src/lib/routes/register-routes.js.map +1 -1
  56. package/src/lib/runtime/send-job.js +2 -0
  57. package/src/lib/runtime/send-job.js.map +1 -1
@@ -0,0 +1,424 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { readCampaignIds, readContactCampaignIds } from "@aglyn/aglyn/app-utils/campaign-membership";
18
+ import { consentGroupForHost } from "@aglyn/aglyn/app-utils/consent-groups";
19
+ import { readContactFacet } from "@aglyn/aglyn/app-utils/contacts";
20
+ import { CRM_COLLECTIONS } from "@aglyn/aglyn/app-utils/crm";
21
+ import { normalizeCrmEmailTemplate } from "@aglyn/aglyn/app-utils/crm-email-templates";
22
+ import { FieldValue } from "firebase-admin/firestore";
23
+ import { OUTREACH_CURATION_SYSTEM, outreachCurationPrompt, parseOutreachCurationAnswer } from "../engine/curation-prompt.js";
24
+ import { outreachCuratedEntry } from "../engine/enrollment-activity.js";
25
+ import { OUTREACH_OPEN_ENROLLMENT_STATUSES } from "../engine/gates.js";
26
+ import { hasOutreachValidationErrors, isInThreadEmailStep, validateOutreachCuratedStep } from "../engine/sequence-validation.js";
27
+ import { readOutreachEnrollCandidates } from "../enrollment/enroll-people.js";
28
+ import { readOutreachStepOverrideRequest } from "../enrollment/step-overrides.js";
29
+ import { OUTREACH_MAX_EMAIL_STEPS } from "../model/outreach.types.js";
30
+ import { fileOutreachNote } from "../runtime/timeline.js";
31
+ import { outreachEnrollmentLink, outreachEnrollmentPerson, outreachOrgCollection, readStoredOutreachEnrollment, readStoredOutreachMailbox, readStoredOutreachSequence } from "../storage/outreach-records.js";
32
+ import { OUTREACH_READS_PEOPLE } from "./enroll-routes.js";
33
+ import { OUTREACH_SEQUENCE_ACTIVITY_TARGET } from "./route-deps.js";
34
+ import { outreachRouteGate } from "./route-gate.js";
35
+ import { outreachMethodNotAllowed, outreachOk, outreachRefusal, readOutreachDocumentId, readOutreachJsonBody } from "./route-http.js";
36
+ /**
37
+ * CURATING ONE PERSON'S EMAILS (AGL-3324): `outreach/curate/draft` and
38
+ * `outreach/curate/save`.
39
+ *
40
+ * A sequence's step is one template for everyone plus the personal line;
41
+ * what an agent-run outbound tool does instead is write each send for each
42
+ * prospect from what it knows about them, and let the seller approve or
43
+ * edit. These two routes are that, with the member's confirmation kept as
44
+ * the one thing that stores anything.
45
+ *
46
+ * DRAFT reads the person the way enrolling reads them — the contact, or the
47
+ * lead as the contact it would be, with the company filed beside them — and
48
+ * asks the workspace's text generator, on the core's seam, for this
49
+ * person's copy of the email steps named: every email step for a person
50
+ * about to be enrolled, the next one for an enrollment. The answer is
51
+ * validated against the playbook's rules and returned with each draft's
52
+ * issues, the prompt and the model. NOTHING IS STORED: the member reads it
53
+ * in the dialog, edits it, ticks the confirmation, and then either enrolls
54
+ * (the enroll route stores the copies with the enrollment) or saves.
55
+ *
56
+ * SAVE stores one confirmed copy on an open enrollment, for a step that
57
+ * has not gone out, validated again — the request is not trusted to have
58
+ * shown the member what it sends — and files "Curated step N" on the
59
+ * person's record with who wrote the words. `override: null` clears a copy,
60
+ * so the step goes back to the sequence's own words.
61
+ *
62
+ * Both read the CRM, so both ask for `data.manage` beside `outreach.use`;
63
+ * the generator applies its own rules — the generation permission, the
64
+ * plan, the switches, the allotments — to the member named, and meters the
65
+ * draft on the member's month and the org's. Outreach never reaches the AI
66
+ * plugin: the seam is the platform's, and a workspace where no plugin
67
+ * generates text is told so in the route's words.
68
+ */ /** The purpose the generator's ledger files a draft under. */ export const OUTREACH_CURATION_PURPOSE = 'outreach-curate';
69
+ /** The most tokens one drafting answer may run to: four short emails, with room. */ const CURATION_MAX_TOKENS = 2400;
70
+ /** The org activity line a saved copy writes. */ export function outreachCuratedActivity(stepIndex, cleared) {
71
+ return cleared ? `Cleared the curated copy of step ${stepIndex + 1} of a sequence` : `Curated step ${stepIndex + 1} of a sequence for one person`;
72
+ }
73
+ /** Every email step's index, in order. */ function emailStepIndexes(sequence) {
74
+ return sequence.steps.map((step, index)=>step.kind === 'email' ? index : -1).filter((index)=>index >= 0);
75
+ }
76
+ /** The step indexes a request names, or the default for its subject; a refusal for anything else. */ function readStepIndexes(raw, sequence, fallback) {
77
+ if (raw === undefined) return fallback;
78
+ if (!Array.isArray(raw) || !raw.length) {
79
+ return outreachRefusal(400, 'invalid-request', 'Name the email steps to draft.');
80
+ }
81
+ const wanted = [
82
+ ...new Set(raw.map((value)=>Number(value)))
83
+ ];
84
+ const emails = emailStepIndexes(sequence);
85
+ if (wanted.some((index)=>!emails.includes(index))) {
86
+ return outreachRefusal(400, 'invalid-request', 'A curated step is one of the sequence’s email steps.');
87
+ }
88
+ if (wanted.length > OUTREACH_MAX_EMAIL_STEPS) {
89
+ return outreachRefusal(400, 'invalid-request', `Draft at most ${OUTREACH_MAX_EMAIL_STEPS} emails at a time.`);
90
+ }
91
+ return wanted.sort((a, b)=>a - b);
92
+ }
93
+ /** What the request is about: a person to enroll, or an enrollment's next step. */ async function draftSubject(firestore, caller, body) {
94
+ const enrollmentId = body['enrollmentId'] === undefined ? null : readOutreachDocumentId(body['enrollmentId']);
95
+ if (body['enrollmentId'] !== undefined && !enrollmentId) {
96
+ return outreachRefusal(400, 'invalid-request', 'Name the enrollment to curate.');
97
+ }
98
+ if (enrollmentId) {
99
+ const snapshot = await outreachOrgCollection(firestore, caller.orgId, 'enrollments').doc(enrollmentId).get();
100
+ const enrollment = readStoredOutreachEnrollment(enrollmentId, snapshot.exists ? snapshot.data() : undefined);
101
+ if (!enrollment) return outreachRefusal(404, 'enrollment-not-found', 'That enrollment no longer exists.');
102
+ if (!OUTREACH_OPEN_ENROLLMENT_STATUSES.includes(enrollment.status)) {
103
+ return outreachRefusal(409, 'transition-refused', 'This enrollment has ended, so nothing more is sent to them.');
104
+ }
105
+ const sequenceSnapshot = await outreachOrgCollection(firestore, caller.orgId, 'sequences').doc(enrollment.sequenceId).get();
106
+ const sequence = readStoredOutreachSequence(enrollment.sequenceId, sequenceSnapshot.exists ? sequenceSnapshot.data() : undefined);
107
+ if (!sequence) return outreachRefusal(404, 'sequence-not-found', 'That sequence no longer exists.');
108
+ // The next email step that has not gone out: the one the enrollment is
109
+ // on when it is an email, else the first email after it.
110
+ const next = emailStepIndexes(sequence).find((index)=>index >= enrollment.stepIndex);
111
+ const stepIndexes = readStepIndexes(body['stepIndexes'], sequence, next === undefined ? [] : [
112
+ next
113
+ ]);
114
+ if (stepIndexes instanceof Response) return stepIndexes;
115
+ if (stepIndexes.some((index)=>index < enrollment.stepIndex)) {
116
+ return outreachRefusal(409, 'transition-refused', 'That step already went out to this person.');
117
+ }
118
+ if (!stepIndexes.length) {
119
+ return outreachRefusal(409, 'transition-refused', 'Every email in this sequence has gone out to this person.');
120
+ }
121
+ return {
122
+ sequence,
123
+ person: outreachEnrollmentPerson(enrollment),
124
+ personalLine: enrollment.personalLine,
125
+ enrollment,
126
+ stepIndexes
127
+ };
128
+ }
129
+ const sequenceId = readOutreachDocumentId(body['sequenceId']);
130
+ if (!sequenceId) return outreachRefusal(400, 'invalid-request', 'Name the sequence to draft for.');
131
+ const contactId = body['contactId'] === undefined ? null : readOutreachDocumentId(body['contactId']);
132
+ const leadId = body['leadId'] === undefined ? null : readOutreachDocumentId(body['leadId']);
133
+ const person = contactId ? {
134
+ kind: 'contact',
135
+ id: contactId
136
+ } : leadId ? {
137
+ kind: 'lead',
138
+ id: leadId
139
+ } : null;
140
+ if (!person) return outreachRefusal(400, 'invalid-request', 'Name the contact or the lead to draft for.');
141
+ const snapshot = await outreachOrgCollection(firestore, caller.orgId, 'sequences').doc(sequenceId).get();
142
+ const sequence = readStoredOutreachSequence(sequenceId, snapshot.exists ? snapshot.data() : undefined);
143
+ if (!sequence) return outreachRefusal(404, 'sequence-not-found', 'That sequence no longer exists.');
144
+ const stepIndexes = readStepIndexes(body['stepIndexes'], sequence, emailStepIndexes(sequence));
145
+ if (stepIndexes instanceof Response) return stepIndexes;
146
+ if (!stepIndexes.length) {
147
+ return outreachRefusal(400, 'invalid-request', 'This sequence sends no email, so there is nothing to draft.');
148
+ }
149
+ return {
150
+ sequence,
151
+ person,
152
+ personalLine: typeof body['personalLine'] === 'string' ? body['personalLine'] : '',
153
+ enrollment: null,
154
+ stepIndexes
155
+ };
156
+ }
157
+ const text = (value)=>typeof value === 'string' ? value.trim() : '';
158
+ const texts = (value)=>Array.isArray(value) ? value.map(text).filter(Boolean) : [];
159
+ /**
160
+ * The names of the campaigns the person is filed under — the org's
161
+ * containers, `orgs/{orgId}/emailCampaigns`. A container that is gone names
162
+ * nothing; a read that fails names none, and the draft goes without.
163
+ */ async function campaignNames(firestore, orgId, campaignIds) {
164
+ if (!campaignIds.length) return [];
165
+ try {
166
+ const containers = firestore.collection('orgs').doc(orgId).collection('emailCampaigns');
167
+ const found = await firestore.getAll(...campaignIds.map((id)=>containers.doc(id)));
168
+ return found.map((snapshot)=>text(snapshot.get('name'))).filter(Boolean);
169
+ } catch (error) {
170
+ console.error('[outreach] the person’s campaigns could not be named for the draft', error);
171
+ return [];
172
+ }
173
+ }
174
+ /** What the CRM holds about the person, for the prompt. */ async function curationFacts(firestore, caller, subject, candidate, contactGroupId) {
175
+ var _candidate_contact, _candidate_lead, _candidate_company, _facet_sources;
176
+ const contact = (_candidate_contact = candidate.contact) != null ? _candidate_contact : {};
177
+ const facet = readContactFacet(contact, contactGroupId);
178
+ const lead = (_candidate_lead = candidate.lead) != null ? _candidate_lead : {};
179
+ const company = (_candidate_company = candidate.company) != null ? _candidate_company : {};
180
+ const campaignIds = candidate.target === 'lead' ? readCampaignIds(lead) : readContactCampaignIds(contact, contactGroupId);
181
+ const [mailboxSnapshot, host, campaigns] = await Promise.all([
182
+ subject.sequence.mailboxId ? outreachOrgCollection(firestore, caller.orgId, 'mailboxes').doc(subject.sequence.mailboxId).get() : null,
183
+ subject.sequence.hostId ? firestore.collection('hosts').doc(subject.sequence.hostId).get() : null,
184
+ campaignNames(firestore, caller.orgId, campaignIds)
185
+ ]);
186
+ const mailbox = readStoredOutreachMailbox(subject.sequence.mailboxId, (mailboxSnapshot == null ? void 0 : mailboxSnapshot.exists) ? mailboxSnapshot.data() : undefined);
187
+ return {
188
+ name: candidate.name || text(contact['name']),
189
+ company: text(facet['companyName']) || text(lead['company']) || text(company['name']),
190
+ title: text(facet['jobTitle']) || text(lead['jobTitle']),
191
+ website: text(company['website']) || text(company['domain']) || text(lead['website']) || text(lead['domain']),
192
+ tags: [
193
+ ...new Set([
194
+ ...texts(facet['tags']),
195
+ ...texts(company['tags'])
196
+ ])
197
+ ],
198
+ sources: Object.entries((_facet_sources = facet['sources']) != null ? _facet_sources : {}).filter(([, on])=>on === true).map(([source])=>source).sort(),
199
+ campaigns,
200
+ notes: candidate.target === 'lead' ? text(lead['notes']) : '',
201
+ record: candidate.target,
202
+ siteName: (host == null ? void 0 : host.exists) ? text(host.get('name')) : '',
203
+ senderName: (mailbox == null ? void 0 : mailbox.displayName) || '',
204
+ personalLine: subject.personalLine
205
+ };
206
+ }
207
+ /** The email steps to draft, as the prompt states them: the step's words, or its template's. */ async function curationSteps(firestore, orgId, sequence, stepIndexes) {
208
+ const emails = emailStepIndexes(sequence);
209
+ const templates = firestore.collection('orgs').doc(orgId).collection(CRM_COLLECTIONS.emailTemplates);
210
+ return Promise.all(stepIndexes.map(async (stepIndex)=>{
211
+ const step = sequence.steps[stepIndex];
212
+ if ((step == null ? void 0 : step.kind) !== 'email') throw new Error(`step ${stepIndex} is not an email`);
213
+ let body = step.body;
214
+ if (step.templateId) {
215
+ const template = await templates.doc(step.templateId).get();
216
+ body = template.exists ? normalizeCrmEmailTemplate(template.data()).body : '';
217
+ }
218
+ return {
219
+ stepIndex,
220
+ position: emails.indexOf(stepIndex) + 1,
221
+ count: emails.length,
222
+ subject: step.subject,
223
+ body,
224
+ startsThread: !isInThreadEmailStep(sequence.steps, stepIndex)
225
+ };
226
+ }));
227
+ }
228
+ /** The member's name for the record's entry: as the roster lists them, else their address. */ async function memberName(firestore, caller) {
229
+ try {
230
+ const member = await firestore.collection('orgs').doc(caller.orgId).collection('members').doc(caller.uid).get();
231
+ return (member.exists ? text(member.get('displayName')) || text(member.get('email')) : '') || caller.email;
232
+ } catch (unused) {
233
+ return caller.email;
234
+ }
235
+ }
236
+ export function createOutreachCurateRoutes(deps) {
237
+ const draft = async (request)=>{
238
+ if (request.method !== 'POST') return outreachMethodNotAllowed('POST');
239
+ const body = await readOutreachJsonBody(request);
240
+ const caller = await outreachRouteGate(request, body['orgId'], deps.gate, [
241
+ OUTREACH_READS_PEOPLE
242
+ ]);
243
+ if (caller instanceof Response) return caller;
244
+ const generator = deps.textGenerator();
245
+ if (!generator) {
246
+ return outreachRefusal(503, 'curation-unavailable', 'AI drafting is not available in this workspace.');
247
+ }
248
+ const firestore = deps.firestore();
249
+ const subject = await draftSubject(firestore, caller, body);
250
+ if (subject instanceof Response) return subject;
251
+ const contactGroupId = consentGroupForHost(caller.org, subject.sequence.hostId).groupId;
252
+ const [candidate] = await readOutreachEnrollCandidates(firestore, {
253
+ orgId: caller.orgId,
254
+ hostId: subject.sequence.hostId,
255
+ contactGroupId,
256
+ people: [
257
+ subject.person
258
+ ]
259
+ });
260
+ if (!(candidate == null ? void 0 : candidate.contact) || !candidate.visible) {
261
+ return outreachRefusal(404, 'contact-not-found', "That person isn't in this sequence's site's CRM.");
262
+ }
263
+ const [facts, steps] = await Promise.all([
264
+ curationFacts(firestore, caller, subject, candidate, contactGroupId),
265
+ curationSteps(firestore, caller.orgId, subject.sequence, subject.stepIndexes)
266
+ ]);
267
+ const prompt = outreachCurationPrompt(facts, steps);
268
+ const answer = await generator.generate({
269
+ orgId: caller.orgId,
270
+ hostId: subject.sequence.hostId || null,
271
+ uid: caller.uid,
272
+ staff: caller.staff,
273
+ org: caller.org,
274
+ purpose: OUTREACH_CURATION_PURPOSE,
275
+ system: OUTREACH_CURATION_SYSTEM,
276
+ prompt,
277
+ maxTokens: CURATION_MAX_TOKENS,
278
+ now: new Date(deps.now())
279
+ });
280
+ // Compared to `false` rather than negated: the union narrows on the
281
+ // literal, and this tree compiles without strict null checks.
282
+ if (answer.ok === false) {
283
+ return outreachRefusal(answer.status, answer.reason === 'unavailable' ? 'curation-unavailable' : 'curation-refused', answer.error);
284
+ }
285
+ const parsed = parseOutreachCurationAnswer(answer.text, steps);
286
+ if (!parsed) {
287
+ return outreachRefusal(502, 'curation-refused', 'The draft came back in a shape that can’t be read. Try again.');
288
+ }
289
+ const drafts = parsed.map((entry)=>{
290
+ const step = steps.find((candidateStep)=>candidateStep.stepIndex === entry.stepIndex);
291
+ return _extends({}, entry, {
292
+ issues: validateOutreachCuratedStep({
293
+ subject: entry.subject,
294
+ body: entry.body,
295
+ startsThread: (step == null ? void 0 : step.startsThread) === true
296
+ }, `stepOverrides.${entry.stepIndex}`)
297
+ });
298
+ });
299
+ return outreachOk({
300
+ ok: true,
301
+ drafts,
302
+ prompt,
303
+ model: answer.model
304
+ });
305
+ };
306
+ const save = async (request)=>{
307
+ var _sequence_steps_stepIndex;
308
+ if (request.method !== 'POST') return outreachMethodNotAllowed('POST');
309
+ const body = await readOutreachJsonBody(request);
310
+ const caller = await outreachRouteGate(request, body['orgId'], deps.gate);
311
+ if (caller instanceof Response) return caller;
312
+ const enrollmentId = readOutreachDocumentId(body['enrollmentId']);
313
+ const stepIndex = Number(body['stepIndex']);
314
+ if (!enrollmentId || !Number.isInteger(stepIndex) || stepIndex < 0) {
315
+ return outreachRefusal(400, 'invalid-request', 'Name the enrollment and the step to curate.');
316
+ }
317
+ const firestore = deps.firestore();
318
+ const enrollments = outreachOrgCollection(firestore, caller.orgId, 'enrollments');
319
+ const current = await enrollments.doc(enrollmentId).get();
320
+ const stored = readStoredOutreachEnrollment(enrollmentId, current.exists ? current.data() : undefined);
321
+ if (!stored) return outreachRefusal(404, 'enrollment-not-found', 'That enrollment no longer exists.');
322
+ const sequenceSnapshot = await outreachOrgCollection(firestore, caller.orgId, 'sequences').doc(stored.sequenceId).get();
323
+ const sequence = readStoredOutreachSequence(stored.sequenceId, sequenceSnapshot.exists ? sequenceSnapshot.data() : undefined);
324
+ if (!sequence) return outreachRefusal(404, 'sequence-not-found', 'That sequence no longer exists.');
325
+ if (((_sequence_steps_stepIndex = sequence.steps[stepIndex]) == null ? void 0 : _sequence_steps_stepIndex.kind) !== 'email') {
326
+ return outreachRefusal(400, 'invalid-request', 'A curated step is one of the sequence’s email steps.');
327
+ }
328
+ const cleared = body['override'] === null;
329
+ let override = null;
330
+ if (!cleared) {
331
+ const read = readOutreachStepOverrideRequest(_extends({}, body['override'], {
332
+ stepIndex
333
+ }), sequence.steps);
334
+ if ('refusal' in read) return outreachRefusal(400, 'invalid-override', read.refusal);
335
+ if (hasOutreachValidationErrors(read.issues)) {
336
+ return outreachRefusal(400, 'invalid-override', 'This draft breaks a rule every sequence email keeps.', {
337
+ issues: read.issues
338
+ });
339
+ }
340
+ override = _extends({}, read.override, {
341
+ draftedAtMs: deps.now(),
342
+ draftedByUid: caller.uid
343
+ });
344
+ }
345
+ const nowMs = deps.now();
346
+ const written = await firestore.runTransaction(async (transaction)=>{
347
+ var _enrollment_stepOverrides;
348
+ const snapshot = await transaction.get(enrollments.doc(enrollmentId));
349
+ const enrollment = readStoredOutreachEnrollment(enrollmentId, snapshot.exists ? snapshot.data() : undefined);
350
+ if (!enrollment) return {
351
+ refusal: outreachRefusal(404, 'enrollment-not-found', 'That enrollment no longer exists.')
352
+ };
353
+ if (!OUTREACH_OPEN_ENROLLMENT_STATUSES.includes(enrollment.status)) {
354
+ return {
355
+ refusal: outreachRefusal(409, 'transition-refused', 'This enrollment has ended, so nothing more is sent to them.')
356
+ };
357
+ }
358
+ // A step that already went out, or is going out now, is not rewritten
359
+ // under the run: what was sent is what was sent.
360
+ if (stepIndex < enrollment.stepIndex || enrollment.sendClaim && enrollment.sendClaim.stepIndex === stepIndex) {
361
+ return {
362
+ refusal: outreachRefusal(409, 'transition-refused', 'That step already went out to this person.')
363
+ };
364
+ }
365
+ transaction.update(enrollments.doc(enrollmentId), {
366
+ [`stepOverrides.${stepIndex}`]: override != null ? override : FieldValue.delete(),
367
+ updatedAtMs: nowMs
368
+ });
369
+ const stepOverrides = _extends({}, (_enrollment_stepOverrides = enrollment.stepOverrides) != null ? _enrollment_stepOverrides : {});
370
+ if (override) stepOverrides[String(stepIndex)] = override;
371
+ else delete stepOverrides[String(stepIndex)];
372
+ // Absent rather than empty once the last copy is cleared, as the
373
+ // stored reader answers it.
374
+ const next = _extends({}, enrollment, {
375
+ updatedAtMs: nowMs
376
+ });
377
+ delete next.stepOverrides;
378
+ if (Object.keys(stepOverrides).length) next.stepOverrides = stepOverrides;
379
+ return {
380
+ enrollment: next
381
+ };
382
+ });
383
+ if ('refusal' in written) return written.refusal;
384
+ const { enrollment } = written;
385
+ if (override) {
386
+ // The person's record says so (AGL-3274, AGL-3324): who wrote this
387
+ // person's copy of the step, under the send it shapes.
388
+ const entry = outreachCuratedEntry({
389
+ enrollmentId,
390
+ stepIndex,
391
+ source: override.source,
392
+ edited: override.edited === true,
393
+ memberName: await memberName(firestore, caller),
394
+ atMs: nowMs
395
+ });
396
+ await fileOutreachNote(deps, {
397
+ orgId: caller.orgId,
398
+ hostId: enrollment.hostId,
399
+ link: outreachEnrollmentLink(enrollment),
400
+ dedupeKey: entry.dedupeKey,
401
+ body: entry.body,
402
+ atMs: nowMs
403
+ });
404
+ }
405
+ await deps.logOrgActivity(caller.orgId, {
406
+ uid: caller.uid,
407
+ email: caller.email
408
+ }, outreachCuratedActivity(stepIndex, cleared), {
409
+ type: OUTREACH_SEQUENCE_ACTIVITY_TARGET,
410
+ id: sequence.id,
411
+ name: sequence.name
412
+ });
413
+ return outreachOk({
414
+ ok: true,
415
+ enrollment
416
+ });
417
+ };
418
+ return {
419
+ draft,
420
+ save
421
+ };
422
+ }
423
+
424
+ //# sourceMappingURL=curate-routes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/curate-routes.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 { readCampaignIds, readContactCampaignIds } from '@aglyn/aglyn/app-utils/campaign-membership'\nimport { consentGroupForHost } from '@aglyn/aglyn/app-utils/consent-groups'\nimport { readContactFacet } from '@aglyn/aglyn/app-utils/contacts'\nimport { CRM_COLLECTIONS } from '@aglyn/aglyn/app-utils/crm'\nimport { normalizeCrmEmailTemplate } from '@aglyn/aglyn/app-utils/crm-email-templates'\nimport type { PluginTextGenerator } from '@aglyn/aglyn/plugin-manager/plugin-text-generation'\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n OUTREACH_CURATION_SYSTEM,\n outreachCurationPrompt,\n parseOutreachCurationAnswer,\n type OutreachCurationFacts,\n type OutreachCurationStep,\n} from '../engine/curation-prompt'\nimport { outreachCuratedEntry } from '../engine/enrollment-activity'\nimport { OUTREACH_OPEN_ENROLLMENT_STATUSES } from '../engine/gates'\nimport {\n hasOutreachValidationErrors,\n isInThreadEmailStep,\n validateOutreachCuratedStep,\n} from '../engine/sequence-validation'\nimport { readOutreachEnrollCandidates, type OutreachEnrollCandidate } from '../enrollment/enroll-people'\nimport { readOutreachStepOverrideRequest } from '../enrollment/step-overrides'\nimport type {\n OutreachCurateDraft,\n OutreachCurateDraftResponse,\n OutreachCurateSaveResponse,\n OutreachPersonRef,\n} from '../model/outreach-api'\nimport {\n OUTREACH_MAX_EMAIL_STEPS,\n type OutreachEnrollment,\n type OutreachSequence,\n type OutreachStepOverride,\n} from '../model/outreach.types'\nimport { fileOutreachNote } from '../runtime/timeline'\nimport {\n outreachEnrollmentLink,\n outreachEnrollmentPerson,\n outreachOrgCollection,\n readStoredOutreachEnrollment,\n readStoredOutreachMailbox,\n readStoredOutreachSequence,\n} from '../storage/outreach-records'\nimport { OUTREACH_READS_PEOPLE, type OutreachEnrollRouteDeps } from './enroll-routes'\nimport { OUTREACH_SEQUENCE_ACTIVITY_TARGET } from './route-deps'\nimport { outreachRouteGate, type OutreachRouteCaller } from './route-gate'\nimport {\n outreachMethodNotAllowed,\n outreachOk,\n outreachRefusal,\n readOutreachDocumentId,\n readOutreachJsonBody,\n} from './route-http'\n\n/**\n * CURATING ONE PERSON'S EMAILS (AGL-3324): `outreach/curate/draft` and\n * `outreach/curate/save`.\n *\n * A sequence's step is one template for everyone plus the personal line;\n * what an agent-run outbound tool does instead is write each send for each\n * prospect from what it knows about them, and let the seller approve or\n * edit. These two routes are that, with the member's confirmation kept as\n * the one thing that stores anything.\n *\n * DRAFT reads the person the way enrolling reads them — the contact, or the\n * lead as the contact it would be, with the company filed beside them — and\n * asks the workspace's text generator, on the core's seam, for this\n * person's copy of the email steps named: every email step for a person\n * about to be enrolled, the next one for an enrollment. The answer is\n * validated against the playbook's rules and returned with each draft's\n * issues, the prompt and the model. NOTHING IS STORED: the member reads it\n * in the dialog, edits it, ticks the confirmation, and then either enrolls\n * (the enroll route stores the copies with the enrollment) or saves.\n *\n * SAVE stores one confirmed copy on an open enrollment, for a step that\n * has not gone out, validated again — the request is not trusted to have\n * shown the member what it sends — and files \"Curated step N\" on the\n * person's record with who wrote the words. `override: null` clears a copy,\n * so the step goes back to the sequence's own words.\n *\n * Both read the CRM, so both ask for `data.manage` beside `outreach.use`;\n * the generator applies its own rules — the generation permission, the\n * plan, the switches, the allotments — to the member named, and meters the\n * draft on the member's month and the org's. Outreach never reaches the AI\n * plugin: the seam is the platform's, and a workspace where no plugin\n * generates text is told so in the route's words.\n */\n\n/** The purpose the generator's ledger files a draft under. */\nexport const OUTREACH_CURATION_PURPOSE = 'outreach-curate'\n\n/** The most tokens one drafting answer may run to: four short emails, with room. */\nconst CURATION_MAX_TOKENS = 2_400\n\n/** The org activity line a saved copy writes. */\nexport function outreachCuratedActivity(stepIndex: number, cleared: boolean): string {\n return cleared\n ? `Cleared the curated copy of step ${stepIndex + 1} of a sequence`\n : `Curated step ${stepIndex + 1} of a sequence for one person`\n}\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** The person's copies of steps a draft covers, and the enrollment they belong to when one exists. */\ninterface DraftSubject {\n sequence: OutreachSequence\n person: OutreachPersonRef\n personalLine: string\n enrollment: OutreachEnrollment | null\n /** The steps to draft, email steps only, unique, in order. */\n stepIndexes: number[]\n}\n\n/** Every email step's index, in order. */\nfunction emailStepIndexes(sequence: Pick<OutreachSequence, 'steps'>): number[] {\n return sequence.steps.map((step, index) => (step.kind === 'email' ? index : -1)).filter((index) => index >= 0)\n}\n\n/** The step indexes a request names, or the default for its subject; a refusal for anything else. */\nfunction readStepIndexes(\n raw: unknown,\n sequence: OutreachSequence,\n fallback: number[],\n): number[] | Response {\n if (raw === undefined) return fallback\n if (!Array.isArray(raw) || !raw.length) {\n return outreachRefusal(400, 'invalid-request', 'Name the email steps to draft.')\n }\n const wanted = [...new Set(raw.map((value) => Number(value)))]\n const emails = emailStepIndexes(sequence)\n if (wanted.some((index) => !emails.includes(index))) {\n return outreachRefusal(400, 'invalid-request', 'A curated step is one of the sequence’s email steps.')\n }\n if (wanted.length > OUTREACH_MAX_EMAIL_STEPS) {\n return outreachRefusal(400, 'invalid-request', `Draft at most ${OUTREACH_MAX_EMAIL_STEPS} emails at a time.`)\n }\n return wanted.sort((a, b) => a - b)\n}\n\n/** What the request is about: a person to enroll, or an enrollment's next step. */\nasync function draftSubject(\n firestore: Firestore,\n caller: OutreachRouteCaller,\n body: Record<string, unknown>,\n): Promise<DraftSubject | Response> {\n const enrollmentId = body['enrollmentId'] === undefined ? null : readOutreachDocumentId(body['enrollmentId'])\n if (body['enrollmentId'] !== undefined && !enrollmentId) {\n return outreachRefusal(400, 'invalid-request', 'Name the enrollment to curate.')\n }\n if (enrollmentId) {\n const snapshot = await outreachOrgCollection(firestore, caller.orgId, 'enrollments').doc(enrollmentId).get()\n const enrollment = readStoredOutreachEnrollment(enrollmentId, snapshot.exists ? snapshot.data() : undefined)\n if (!enrollment) return outreachRefusal(404, 'enrollment-not-found', 'That enrollment no longer exists.')\n if (!OUTREACH_OPEN_ENROLLMENT_STATUSES.includes(enrollment.status)) {\n return outreachRefusal(409, 'transition-refused', 'This enrollment has ended, so nothing more is sent to them.')\n }\n const sequenceSnapshot = await outreachOrgCollection(firestore, caller.orgId, 'sequences')\n .doc(enrollment.sequenceId)\n .get()\n const sequence = readStoredOutreachSequence(\n enrollment.sequenceId,\n sequenceSnapshot.exists ? sequenceSnapshot.data() : undefined,\n )\n if (!sequence) return outreachRefusal(404, 'sequence-not-found', 'That sequence no longer exists.')\n // The next email step that has not gone out: the one the enrollment is\n // on when it is an email, else the first email after it.\n const next = emailStepIndexes(sequence).find((index) => index >= enrollment.stepIndex)\n const stepIndexes = readStepIndexes(body['stepIndexes'], sequence, next === undefined ? [] : [next])\n if (stepIndexes instanceof Response) return stepIndexes\n if (stepIndexes.some((index) => index < enrollment.stepIndex)) {\n return outreachRefusal(409, 'transition-refused', 'That step already went out to this person.')\n }\n if (!stepIndexes.length) {\n return outreachRefusal(409, 'transition-refused', 'Every email in this sequence has gone out to this person.')\n }\n return {\n sequence,\n person: outreachEnrollmentPerson(enrollment),\n personalLine: enrollment.personalLine,\n enrollment,\n stepIndexes,\n }\n }\n const sequenceId = readOutreachDocumentId(body['sequenceId'])\n if (!sequenceId) return outreachRefusal(400, 'invalid-request', 'Name the sequence to draft for.')\n const contactId = body['contactId'] === undefined ? null : readOutreachDocumentId(body['contactId'])\n const leadId = body['leadId'] === undefined ? null : readOutreachDocumentId(body['leadId'])\n const person: OutreachPersonRef | null = contactId\n ? { kind: 'contact', id: contactId }\n : leadId\n ? { kind: 'lead', id: leadId }\n : null\n if (!person) return outreachRefusal(400, 'invalid-request', 'Name the contact or the lead to draft for.')\n const snapshot = await outreachOrgCollection(firestore, caller.orgId, 'sequences').doc(sequenceId).get()\n const sequence = readStoredOutreachSequence(sequenceId, snapshot.exists ? snapshot.data() : undefined)\n if (!sequence) return outreachRefusal(404, 'sequence-not-found', 'That sequence no longer exists.')\n const stepIndexes = readStepIndexes(body['stepIndexes'], sequence, emailStepIndexes(sequence))\n if (stepIndexes instanceof Response) return stepIndexes\n if (!stepIndexes.length) {\n return outreachRefusal(400, 'invalid-request', 'This sequence sends no email, so there is nothing to draft.')\n }\n return {\n sequence,\n person,\n personalLine: typeof body['personalLine'] === 'string' ? body['personalLine'] : '',\n enrollment: null,\n stepIndexes,\n }\n}\n\nconst text = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')\nconst texts = (value: unknown): string[] =>\n Array.isArray(value) ? value.map(text).filter(Boolean) : []\n\n/**\n * The names of the campaigns the person is filed under — the org's\n * containers, `orgs/{orgId}/emailCampaigns`. A container that is gone names\n * nothing; a read that fails names none, and the draft goes without.\n */\nasync function campaignNames(firestore: Firestore, orgId: string, campaignIds: readonly string[]): Promise<string[]> {\n if (!campaignIds.length) return []\n try {\n const containers = firestore.collection('orgs').doc(orgId).collection('emailCampaigns')\n const found = await firestore.getAll(...campaignIds.map((id) => containers.doc(id)))\n return found.map((snapshot) => text(snapshot.get('name'))).filter(Boolean)\n } catch (error) {\n console.error('[outreach] the person’s campaigns could not be named for the draft', error)\n return []\n }\n}\n\n/** What the CRM holds about the person, for the prompt. */\nasync function curationFacts(\n firestore: Firestore,\n caller: OutreachRouteCaller,\n subject: DraftSubject,\n candidate: OutreachEnrollCandidate,\n contactGroupId: string,\n): Promise<OutreachCurationFacts> {\n const contact = candidate.contact ?? {}\n const facet = readContactFacet(contact, contactGroupId) as unknown as Record<string, unknown>\n const lead = candidate.lead ?? {}\n const company = candidate.company ?? {}\n const campaignIds =\n candidate.target === 'lead' ? readCampaignIds(lead) : readContactCampaignIds(contact, contactGroupId)\n const [mailboxSnapshot, host, campaigns] = await Promise.all([\n subject.sequence.mailboxId\n ? outreachOrgCollection(firestore, caller.orgId, 'mailboxes').doc(subject.sequence.mailboxId).get()\n : null,\n subject.sequence.hostId ? firestore.collection('hosts').doc(subject.sequence.hostId).get() : null,\n campaignNames(firestore, caller.orgId, campaignIds),\n ])\n const mailbox = readStoredOutreachMailbox(\n subject.sequence.mailboxId,\n mailboxSnapshot?.exists ? mailboxSnapshot.data() : undefined,\n )\n return {\n name: candidate.name || text(contact['name']),\n company: text(facet['companyName']) || text(lead['company']) || text(company['name']),\n title: text(facet['jobTitle']) || text(lead['jobTitle']),\n website: text(company['website']) || text(company['domain']) || text(lead['website']) || text(lead['domain']),\n tags: [...new Set([...texts(facet['tags']), ...texts(company['tags'])])],\n sources: Object.entries((facet['sources'] ?? {}) as Record<string, unknown>)\n .filter(([, on]) => on === true)\n .map(([source]) => source)\n .sort(),\n campaigns,\n notes: candidate.target === 'lead' ? text(lead['notes']) : '',\n record: candidate.target,\n siteName: host?.exists ? text(host.get('name')) : '',\n senderName: mailbox?.displayName || '',\n personalLine: subject.personalLine,\n }\n}\n\n/** The email steps to draft, as the prompt states them: the step's words, or its template's. */\nasync function curationSteps(\n firestore: Firestore,\n orgId: string,\n sequence: OutreachSequence,\n stepIndexes: readonly number[],\n): Promise<OutreachCurationStep[]> {\n const emails = emailStepIndexes(sequence)\n const templates = firestore.collection('orgs').doc(orgId).collection(CRM_COLLECTIONS.emailTemplates)\n return Promise.all(\n stepIndexes.map(async (stepIndex) => {\n const step = sequence.steps[stepIndex]\n if (step?.kind !== 'email') throw new Error(`step ${stepIndex} is not an email`)\n let body = step.body\n if (step.templateId) {\n const template = await templates.doc(step.templateId).get()\n body = template.exists ? normalizeCrmEmailTemplate(template.data() as Record<string, unknown>).body : ''\n }\n return {\n stepIndex,\n position: emails.indexOf(stepIndex) + 1,\n count: emails.length,\n subject: step.subject,\n body,\n startsThread: !isInThreadEmailStep(sequence.steps, stepIndex),\n }\n }),\n )\n}\n\n/** The member's name for the record's entry: as the roster lists them, else their address. */\nasync function memberName(firestore: Firestore, caller: OutreachRouteCaller): Promise<string | null> {\n try {\n const member = await firestore.collection('orgs').doc(caller.orgId).collection('members').doc(caller.uid).get()\n return (member.exists ? text(member.get('displayName')) || text(member.get('email')) : '') || caller.email\n } catch {\n return caller.email\n }\n}\n\nexport interface OutreachCurateRoutes {\n draft: PluginWebApiHandler\n save: PluginWebApiHandler\n}\n\nexport function createOutreachCurateRoutes(deps: OutreachEnrollRouteDeps): OutreachCurateRoutes {\n const draft: PluginWebApiHandler = async (request) => {\n if (request.method !== 'POST') return outreachMethodNotAllowed('POST')\n const body = await readOutreachJsonBody(request)\n const caller = await outreachRouteGate(request, body['orgId'], deps.gate, [OUTREACH_READS_PEOPLE])\n if (caller instanceof Response) return caller\n const generator = deps.textGenerator()\n if (!generator) {\n return outreachRefusal(503, 'curation-unavailable', 'AI drafting is not available in this workspace.')\n }\n const firestore = deps.firestore()\n const subject = await draftSubject(firestore, caller, body)\n if (subject instanceof Response) return subject\n const contactGroupId = consentGroupForHost(caller.org, subject.sequence.hostId).groupId\n const [candidate] = await readOutreachEnrollCandidates(firestore, {\n orgId: caller.orgId,\n hostId: subject.sequence.hostId,\n contactGroupId,\n people: [subject.person],\n })\n if (!candidate?.contact || !candidate.visible) {\n return outreachRefusal(404, 'contact-not-found', \"That person isn't in this sequence's site's CRM.\")\n }\n const [facts, steps] = await Promise.all([\n curationFacts(firestore, caller, subject, candidate, contactGroupId),\n curationSteps(firestore, caller.orgId, subject.sequence, subject.stepIndexes),\n ])\n const prompt = outreachCurationPrompt(facts, steps)\n const answer = await generator.generate({\n orgId: caller.orgId,\n hostId: subject.sequence.hostId || null,\n uid: caller.uid,\n staff: caller.staff,\n org: caller.org,\n purpose: OUTREACH_CURATION_PURPOSE,\n system: OUTREACH_CURATION_SYSTEM,\n prompt,\n maxTokens: CURATION_MAX_TOKENS,\n now: new Date(deps.now()),\n })\n // Compared to `false` rather than negated: the union narrows on the\n // literal, and this tree compiles without strict null checks.\n if (answer.ok === false) {\n return outreachRefusal(\n answer.status,\n answer.reason === 'unavailable' ? 'curation-unavailable' : 'curation-refused',\n answer.error,\n )\n }\n const parsed = parseOutreachCurationAnswer(answer.text, steps)\n if (!parsed) {\n return outreachRefusal(\n 502,\n 'curation-refused',\n 'The draft came back in a shape that can’t be read. Try again.',\n )\n }\n const drafts: OutreachCurateDraft[] = parsed.map((entry) => {\n const step = steps.find((candidateStep) => candidateStep.stepIndex === entry.stepIndex)\n return {\n ...entry,\n issues: validateOutreachCuratedStep(\n { subject: entry.subject, body: entry.body, startsThread: step?.startsThread === true },\n `stepOverrides.${entry.stepIndex}`,\n ),\n }\n })\n return outreachOk({ ok: true, drafts, prompt, model: answer.model } satisfies OutreachCurateDraftResponse)\n }\n\n const save: PluginWebApiHandler = async (request) => {\n if (request.method !== 'POST') return outreachMethodNotAllowed('POST')\n const body = await readOutreachJsonBody(request)\n const caller = await outreachRouteGate(request, body['orgId'], deps.gate)\n if (caller instanceof Response) return caller\n const enrollmentId = readOutreachDocumentId(body['enrollmentId'])\n const stepIndex = Number(body['stepIndex'])\n if (!enrollmentId || !Number.isInteger(stepIndex) || stepIndex < 0) {\n return outreachRefusal(400, 'invalid-request', 'Name the enrollment and the step to curate.')\n }\n const firestore = deps.firestore()\n const enrollments = outreachOrgCollection(firestore, caller.orgId, 'enrollments')\n const current = await enrollments.doc(enrollmentId).get()\n const stored = readStoredOutreachEnrollment(enrollmentId, current.exists ? current.data() : undefined)\n if (!stored) return outreachRefusal(404, 'enrollment-not-found', 'That enrollment no longer exists.')\n const sequenceSnapshot = await outreachOrgCollection(firestore, caller.orgId, 'sequences').doc(stored.sequenceId).get()\n const sequence = readStoredOutreachSequence(stored.sequenceId, sequenceSnapshot.exists ? sequenceSnapshot.data() : undefined)\n if (!sequence) return outreachRefusal(404, 'sequence-not-found', 'That sequence no longer exists.')\n if (sequence.steps[stepIndex]?.kind !== 'email') {\n return outreachRefusal(400, 'invalid-request', 'A curated step is one of the sequence’s email steps.')\n }\n\n const cleared = body['override'] === null\n let override: OutreachStepOverride | null = null\n if (!cleared) {\n const read = readOutreachStepOverrideRequest({ ...(body['override'] as object), stepIndex }, sequence.steps)\n if ('refusal' in read) return outreachRefusal(400, 'invalid-override', read.refusal)\n if (hasOutreachValidationErrors(read.issues)) {\n return outreachRefusal(400, 'invalid-override', 'This draft breaks a rule every sequence email keeps.', {\n issues: read.issues,\n })\n }\n override = { ...read.override, draftedAtMs: deps.now(), draftedByUid: caller.uid }\n }\n\n const nowMs = deps.now()\n const written = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(enrollments.doc(enrollmentId))\n const enrollment = readStoredOutreachEnrollment(enrollmentId, snapshot.exists ? snapshot.data() : undefined)\n if (!enrollment) return { refusal: outreachRefusal(404, 'enrollment-not-found', 'That enrollment no longer exists.') }\n if (!OUTREACH_OPEN_ENROLLMENT_STATUSES.includes(enrollment.status)) {\n return {\n refusal: outreachRefusal(409, 'transition-refused', 'This enrollment has ended, so nothing more is sent to them.'),\n }\n }\n // A step that already went out, or is going out now, is not rewritten\n // under the run: what was sent is what was sent.\n if (stepIndex < enrollment.stepIndex || (enrollment.sendClaim && enrollment.sendClaim.stepIndex === stepIndex)) {\n return { refusal: outreachRefusal(409, 'transition-refused', 'That step already went out to this person.') }\n }\n transaction.update(enrollments.doc(enrollmentId), {\n [`stepOverrides.${stepIndex}`]: override ?? FieldValue.delete(),\n updatedAtMs: nowMs,\n })\n const stepOverrides = { ...(enrollment.stepOverrides ?? {}) }\n if (override) stepOverrides[String(stepIndex)] = override\n else delete stepOverrides[String(stepIndex)]\n // Absent rather than empty once the last copy is cleared, as the\n // stored reader answers it.\n const next: OutreachEnrollment = { ...enrollment, updatedAtMs: nowMs }\n delete next.stepOverrides\n if (Object.keys(stepOverrides).length) next.stepOverrides = stepOverrides\n return { enrollment: next }\n })\n if ('refusal' in written) return written.refusal\n const { enrollment } = written\n\n if (override) {\n // The person's record says so (AGL-3274, AGL-3324): who wrote this\n // person's copy of the step, under the send it shapes.\n const entry = outreachCuratedEntry({\n enrollmentId,\n stepIndex,\n source: override.source,\n edited: override.edited === true,\n memberName: await memberName(firestore, caller),\n atMs: nowMs,\n })\n await fileOutreachNote(deps, {\n orgId: caller.orgId,\n hostId: enrollment.hostId,\n link: outreachEnrollmentLink(enrollment),\n dedupeKey: entry.dedupeKey,\n body: entry.body,\n atMs: nowMs,\n })\n }\n await deps.logOrgActivity(\n caller.orgId,\n { uid: caller.uid, email: caller.email },\n outreachCuratedActivity(stepIndex, cleared),\n { type: OUTREACH_SEQUENCE_ACTIVITY_TARGET, id: sequence.id, name: sequence.name },\n )\n return outreachOk({ ok: true, enrollment } satisfies OutreachCurateSaveResponse)\n }\n\n return { draft, save }\n}\n\n/** The text generator on the core's seam, for the default route deps; `null` when no plugin generates. */\nexport type OutreachTextGeneratorReader = () => PluginTextGenerator | null\n"],"names":["readCampaignIds","readContactCampaignIds","consentGroupForHost","readContactFacet","CRM_COLLECTIONS","normalizeCrmEmailTemplate","FieldValue","OUTREACH_CURATION_SYSTEM","outreachCurationPrompt","parseOutreachCurationAnswer","outreachCuratedEntry","OUTREACH_OPEN_ENROLLMENT_STATUSES","hasOutreachValidationErrors","isInThreadEmailStep","validateOutreachCuratedStep","readOutreachEnrollCandidates","readOutreachStepOverrideRequest","OUTREACH_MAX_EMAIL_STEPS","fileOutreachNote","outreachEnrollmentLink","outreachEnrollmentPerson","outreachOrgCollection","readStoredOutreachEnrollment","readStoredOutreachMailbox","readStoredOutreachSequence","OUTREACH_READS_PEOPLE","OUTREACH_SEQUENCE_ACTIVITY_TARGET","outreachRouteGate","outreachMethodNotAllowed","outreachOk","outreachRefusal","readOutreachDocumentId","readOutreachJsonBody","OUTREACH_CURATION_PURPOSE","CURATION_MAX_TOKENS","outreachCuratedActivity","stepIndex","cleared","emailStepIndexes","sequence","steps","map","step","index","kind","filter","readStepIndexes","raw","fallback","undefined","Array","isArray","length","wanted","Set","value","Number","emails","some","includes","sort","a","b","draftSubject","firestore","caller","body","enrollmentId","snapshot","orgId","doc","get","enrollment","exists","data","status","sequenceSnapshot","sequenceId","next","find","stepIndexes","Response","person","personalLine","contactId","leadId","id","text","trim","texts","Boolean","campaignNames","campaignIds","containers","collection","found","getAll","error","console","curationFacts","subject","candidate","contactGroupId","facet","contact","lead","company","target","mailboxSnapshot","host","campaigns","Promise","all","mailboxId","hostId","mailbox","name","title","website","tags","sources","Object","entries","on","source","notes","record","siteName","senderName","displayName","curationSteps","templates","emailTemplates","Error","templateId","template","position","indexOf","count","startsThread","memberName","member","uid","email","createOutreachCurateRoutes","deps","draft","request","method","gate","generator","textGenerator","org","groupId","people","visible","facts","prompt","answer","generate","staff","purpose","system","maxTokens","now","Date","ok","reason","parsed","drafts","entry","candidateStep","issues","model","save","isInteger","enrollments","current","stored","override","read","refusal","draftedAtMs","draftedByUid","nowMs","written","runTransaction","transaction","sendClaim","update","delete","updatedAtMs","stepOverrides","String","keys","edited","atMs","link","dedupeKey","logOrgActivity","type"],"mappings":"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"}
@@ -15,6 +15,7 @@
15
15
  * limitations under the License.
16
16
  */
17
17
  import type { PluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline';
18
+ import type { PluginTextGenerator } from '@aglyn/aglyn/plugin-manager/plugin-text-generation';
18
19
  import type { PluginWebApiHandler } from '@aglyn/aglyn/server';
19
20
  import { type OutreachRouteDeps } from './route-deps';
20
21
  import { type OutreachRouteExtraPermission } from './route-gate';
@@ -61,6 +62,12 @@ export interface OutreachEnrollRouteDeps extends OutreachRouteDeps {
61
62
  * — the runtime's `timeline`, reached the same way.
62
63
  */
63
64
  timeline(): PluginRecordTimelineWriter | null;
65
+ /**
66
+ * The workspace's text generator on the core's text-generation seam
67
+ * (AGL-3324), which drafts one person's copies of the steps; `null` when
68
+ * no plugin generates text, and the curate route says so.
69
+ */
70
+ textGenerator(): PluginTextGenerator | null;
64
71
  }
65
72
  export interface OutreachEnrollRoutes {
66
73
  preview: PluginWebApiHandler;