@aglyn/plugins-outreach 1.0.0-beta.143
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +93 -0
- package/package.json +52 -0
- package/src/index.d.ts +22 -0
- package/src/index.js +29 -0
- package/src/index.js.map +1 -0
- package/src/lib/components/compliance-section.d.ts +20 -0
- package/src/lib/components/compliance-section.js +291 -0
- package/src/lib/components/compliance-section.js.map +1 -0
- package/src/lib/components/enroll-dialog.d.ts +36 -0
- package/src/lib/components/enroll-dialog.js +656 -0
- package/src/lib/components/enroll-dialog.js.map +1 -0
- package/src/lib/components/enrollments-table.d.ts +28 -0
- package/src/lib/components/enrollments-table.js +375 -0
- package/src/lib/components/enrollments-table.js.map +1 -0
- package/src/lib/components/mailbox-card.d.ts +46 -0
- package/src/lib/components/mailbox-card.js +517 -0
- package/src/lib/components/mailbox-card.js.map +1 -0
- package/src/lib/components/mailboxes-section.d.ts +24 -0
- package/src/lib/components/mailboxes-section.js +258 -0
- package/src/lib/components/mailboxes-section.js.map +1 -0
- package/src/lib/components/outreach-console-page.d.ts +17 -0
- package/src/lib/components/outreach-console-page.js +81 -0
- package/src/lib/components/outreach-console-page.js.map +1 -0
- package/src/lib/components/outreach-console-sections.d.ts +43 -0
- package/src/lib/components/outreach-console-sections.js +55 -0
- package/src/lib/components/outreach-console-sections.js.map +1 -0
- package/src/lib/components/outreach-ui.d.ts +51 -0
- package/src/lib/components/outreach-ui.js +171 -0
- package/src/lib/components/outreach-ui.js.map +1 -0
- package/src/lib/components/send-window-fields.d.ts +25 -0
- package/src/lib/components/send-window-fields.js +178 -0
- package/src/lib/components/send-window-fields.js.map +1 -0
- package/src/lib/components/sequence-detail.d.ts +54 -0
- package/src/lib/components/sequence-detail.js +435 -0
- package/src/lib/components/sequence-detail.js.map +1 -0
- package/src/lib/components/sequence-editor.d.ts +37 -0
- package/src/lib/components/sequence-editor.js +485 -0
- package/src/lib/components/sequence-editor.js.map +1 -0
- package/src/lib/components/sequence-mailbox-picker.d.ts +44 -0
- package/src/lib/components/sequence-mailbox-picker.js +137 -0
- package/src/lib/components/sequence-mailbox-picker.js.map +1 -0
- package/src/lib/components/sequence-preview.d.ts +27 -0
- package/src/lib/components/sequence-preview.js +146 -0
- package/src/lib/components/sequence-preview.js.map +1 -0
- package/src/lib/components/sequence-step-card.d.ts +31 -0
- package/src/lib/components/sequence-step-card.js +326 -0
- package/src/lib/components/sequence-step-card.js.map +1 -0
- package/src/lib/components/sequences-section.d.ts +40 -0
- package/src/lib/components/sequences-section.js +310 -0
- package/src/lib/components/sequences-section.js.map +1 -0
- package/src/lib/components/use-outreach-api.d.ts +38 -0
- package/src/lib/components/use-outreach-api.js +139 -0
- package/src/lib/components/use-outreach-api.js.map +1 -0
- package/src/lib/components/use-outreach-crm.d.ts +50 -0
- package/src/lib/components/use-outreach-crm.js +200 -0
- package/src/lib/components/use-outreach-crm.js.map +1 -0
- package/src/lib/components/use-outreach-data.d.ts +55 -0
- package/src/lib/components/use-outreach-data.js +199 -0
- package/src/lib/components/use-outreach-data.js.map +1 -0
- package/src/lib/components/use-outreach-mailbox-api.d.ts +30 -0
- package/src/lib/components/use-outreach-mailbox-api.js +101 -0
- package/src/lib/components/use-outreach-mailbox-api.js.map +1 -0
- package/src/lib/components/use-outreach-mailboxes.d.ts +22 -0
- package/src/lib/components/use-outreach-mailboxes.js +68 -0
- package/src/lib/components/use-outreach-mailboxes.js.map +1 -0
- package/src/lib/components/use-outreach-settings.d.ts +17 -0
- package/src/lib/components/use-outreach-settings.js +64 -0
- package/src/lib/components/use-outreach-settings.js.map +1 -0
- package/src/lib/constants/api-routes.d.ts +68 -0
- package/src/lib/constants/api-routes.js +57 -0
- package/src/lib/constants/api-routes.js.map +1 -0
- package/src/lib/constants/bundle-common.d.ts +46 -0
- package/src/lib/constants/bundle-common.js +55 -0
- package/src/lib/constants/bundle-common.js.map +1 -0
- package/src/lib/constants/runtime-jobs.d.ts +24 -0
- package/src/lib/constants/runtime-jobs.js +24 -0
- package/src/lib/constants/runtime-jobs.js.map +1 -0
- package/src/lib/declarations.console-server.d.ts +38 -0
- package/src/lib/declarations.console-server.js +99 -0
- package/src/lib/declarations.console-server.js.map +1 -0
- package/src/lib/engine/compose.d.ts +86 -0
- package/src/lib/engine/compose.js +212 -0
- package/src/lib/engine/compose.js.map +1 -0
- package/src/lib/engine/delivery-status.d.ts +53 -0
- package/src/lib/engine/delivery-status.js +184 -0
- package/src/lib/engine/delivery-status.js.map +1 -0
- package/src/lib/engine/do-not-contact.d.ts +18 -0
- package/src/lib/engine/do-not-contact.js +34 -0
- package/src/lib/engine/do-not-contact.js.map +1 -0
- package/src/lib/engine/enrollment-state.d.ts +156 -0
- package/src/lib/engine/enrollment-state.js +303 -0
- package/src/lib/engine/enrollment-state.js.map +1 -0
- package/src/lib/engine/gates.d.ts +125 -0
- package/src/lib/engine/gates.js +232 -0
- package/src/lib/engine/gates.js.map +1 -0
- package/src/lib/engine/index.d.ts +28 -0
- package/src/lib/engine/index.js +33 -0
- package/src/lib/engine/index.js.map +1 -0
- package/src/lib/engine/mailbox-health.d.ts +49 -0
- package/src/lib/engine/mailbox-health.js +83 -0
- package/src/lib/engine/mailbox-health.js.map +1 -0
- package/src/lib/engine/opt-out-intent.d.ts +41 -0
- package/src/lib/engine/opt-out-intent.js +220 -0
- package/src/lib/engine/opt-out-intent.js.map +1 -0
- package/src/lib/engine/recipient-country.d.ts +41 -0
- package/src/lib/engine/recipient-country.js +171 -0
- package/src/lib/engine/recipient-country.js.map +1 -0
- package/src/lib/engine/schedule.d.ts +70 -0
- package/src/lib/engine/schedule.js +115 -0
- package/src/lib/engine/schedule.js.map +1 -0
- package/src/lib/engine/sending-capacity.d.ts +96 -0
- package/src/lib/engine/sending-capacity.js +172 -0
- package/src/lib/engine/sending-capacity.js.map +1 -0
- package/src/lib/engine/sequence-validation.d.ts +90 -0
- package/src/lib/engine/sequence-validation.js +291 -0
- package/src/lib/engine/sequence-validation.js.map +1 -0
- package/src/lib/engine/thread-classification.d.ts +78 -0
- package/src/lib/engine/thread-classification.js +224 -0
- package/src/lib/engine/thread-classification.js.map +1 -0
- package/src/lib/engine/thread-message.d.ts +80 -0
- package/src/lib/engine/thread-message.js +143 -0
- package/src/lib/engine/thread-message.js.map +1 -0
- package/src/lib/enrollment/enroll-people.d.ts +81 -0
- package/src/lib/enrollment/enroll-people.js +250 -0
- package/src/lib/enrollment/enroll-people.js.map +1 -0
- package/src/lib/enrollment/gate-lookups.d.ts +32 -0
- package/src/lib/enrollment/gate-lookups.js +154 -0
- package/src/lib/enrollment/gate-lookups.js.map +1 -0
- package/src/lib/mailboxes/mailbox-api.d.ts +126 -0
- package/src/lib/mailboxes/mailbox-api.js +66 -0
- package/src/lib/mailboxes/mailbox-api.js.map +1 -0
- package/src/lib/mailboxes/mailbox-credentials.d.ts +95 -0
- package/src/lib/mailboxes/mailbox-credentials.js +102 -0
- package/src/lib/mailboxes/mailbox-credentials.js.map +1 -0
- package/src/lib/mailboxes/mailbox-erasure.d.ts +69 -0
- package/src/lib/mailboxes/mailbox-erasure.js +148 -0
- package/src/lib/mailboxes/mailbox-erasure.js.map +1 -0
- package/src/lib/mailboxes/mailbox-gate.d.ts +75 -0
- package/src/lib/mailboxes/mailbox-gate.js +98 -0
- package/src/lib/mailboxes/mailbox-gate.js.map +1 -0
- package/src/lib/mailboxes/mailbox-revoke.d.ts +59 -0
- package/src/lib/mailboxes/mailbox-revoke.js +41 -0
- package/src/lib/mailboxes/mailbox-revoke.js.map +1 -0
- package/src/lib/mailboxes/mailbox-routes.d.ts +119 -0
- package/src/lib/mailboxes/mailbox-routes.js +605 -0
- package/src/lib/mailboxes/mailbox-routes.js.map +1 -0
- package/src/lib/mailboxes/mailbox-settings.d.ts +110 -0
- package/src/lib/mailboxes/mailbox-settings.js +212 -0
- package/src/lib/mailboxes/mailbox-settings.js.map +1 -0
- package/src/lib/mailboxes/mailbox-transport.d.ts +64 -0
- package/src/lib/mailboxes/mailbox-transport.js +94 -0
- package/src/lib/mailboxes/mailbox-transport.js.map +1 -0
- package/src/lib/mailboxes/oauth-redirect.d.ts +41 -0
- package/src/lib/mailboxes/oauth-redirect.js +63 -0
- package/src/lib/mailboxes/oauth-redirect.js.map +1 -0
- package/src/lib/mailboxes/oauth-state.d.ts +152 -0
- package/src/lib/mailboxes/oauth-state.js +212 -0
- package/src/lib/mailboxes/oauth-state.js.map +1 -0
- package/src/lib/mailboxes/outreach-config.d.ts +64 -0
- package/src/lib/mailboxes/outreach-config.js +77 -0
- package/src/lib/mailboxes/outreach-config.js.map +1 -0
- package/src/lib/mailboxes/register-mailbox-routes.d.ts +47 -0
- package/src/lib/mailboxes/register-mailbox-routes.js +135 -0
- package/src/lib/mailboxes/register-mailbox-routes.js.map +1 -0
- package/src/lib/model/compliance-settings.d.ts +78 -0
- package/src/lib/model/compliance-settings.js +155 -0
- package/src/lib/model/compliance-settings.js.map +1 -0
- package/src/lib/model/outreach-api.d.ts +210 -0
- package/src/lib/model/outreach-api.js +28 -0
- package/src/lib/model/outreach-api.js.map +1 -0
- package/src/lib/model/outreach.types.d.ts +563 -0
- package/src/lib/model/outreach.types.js +225 -0
- package/src/lib/model/outreach.types.js.map +1 -0
- package/src/lib/model/sequence-draft.d.ts +84 -0
- package/src/lib/model/sequence-draft.js +239 -0
- package/src/lib/model/sequence-draft.js.map +1 -0
- package/src/lib/model/step-preview.d.ts +45 -0
- package/src/lib/model/step-preview.js +98 -0
- package/src/lib/model/step-preview.js.map +1 -0
- package/src/lib/model/stored-records.d.ts +23 -0
- package/src/lib/model/stored-records.js +84 -0
- package/src/lib/model/stored-records.js.map +1 -0
- package/src/lib/plugin.d.ts +46 -0
- package/src/lib/plugin.js +86 -0
- package/src/lib/plugin.js.map +1 -0
- package/src/lib/routes/enroll-routes.d.ts +64 -0
- package/src/lib/routes/enroll-routes.js +350 -0
- package/src/lib/routes/enroll-routes.js.map +1 -0
- package/src/lib/routes/enrollment-routes.d.ts +19 -0
- package/src/lib/routes/enrollment-routes.js +171 -0
- package/src/lib/routes/enrollment-routes.js.map +1 -0
- package/src/lib/routes/platform-deps.d.ts +51 -0
- package/src/lib/routes/platform-deps.js +66 -0
- package/src/lib/routes/platform-deps.js.map +1 -0
- package/src/lib/routes/preview-routes.d.ts +33 -0
- package/src/lib/routes/preview-routes.js +117 -0
- package/src/lib/routes/preview-routes.js.map +1 -0
- package/src/lib/routes/register-routes.d.ts +22 -0
- package/src/lib/routes/register-routes.js +92 -0
- package/src/lib/routes/register-routes.js.map +1 -0
- package/src/lib/routes/route-deps.d.ts +49 -0
- package/src/lib/routes/route-deps.js +27 -0
- package/src/lib/routes/route-deps.js.map +1 -0
- package/src/lib/routes/route-gate.d.ts +94 -0
- package/src/lib/routes/route-gate.js +87 -0
- package/src/lib/routes/route-gate.js.map +1 -0
- package/src/lib/routes/route-http.d.ts +32 -0
- package/src/lib/routes/route-http.js +62 -0
- package/src/lib/routes/route-http.js.map +1 -0
- package/src/lib/routes/sequence-routes.d.ts +32 -0
- package/src/lib/routes/sequence-routes.js +359 -0
- package/src/lib/routes/sequence-routes.js.map +1 -0
- package/src/lib/routes/settings-routes.d.ts +30 -0
- package/src/lib/routes/settings-routes.js +73 -0
- package/src/lib/routes/settings-routes.js.map +1 -0
- package/src/lib/runtime/enrollment-events.d.ts +86 -0
- package/src/lib/runtime/enrollment-events.js +141 -0
- package/src/lib/runtime/enrollment-events.js.map +1 -0
- package/src/lib/runtime/fixtures/fake-gmail.d.ts +121 -0
- package/src/lib/runtime/fixtures/fake-gmail.js +312 -0
- package/src/lib/runtime/fixtures/fake-gmail.js.map +1 -0
- package/src/lib/runtime/mailbox-health-store.d.ts +47 -0
- package/src/lib/runtime/mailbox-health-store.js +168 -0
- package/src/lib/runtime/mailbox-health-store.js.map +1 -0
- package/src/lib/runtime/person-erasure.d.ts +39 -0
- package/src/lib/runtime/person-erasure.js +57 -0
- package/src/lib/runtime/person-erasure.js.map +1 -0
- package/src/lib/runtime/platform-runtime-deps.d.ts +29 -0
- package/src/lib/runtime/platform-runtime-deps.js +98 -0
- package/src/lib/runtime/platform-runtime-deps.js.map +1 -0
- package/src/lib/runtime/runtime-deps.d.ts +77 -0
- package/src/lib/runtime/runtime-deps.js +18 -0
- package/src/lib/runtime/runtime-deps.js.map +1 -0
- package/src/lib/runtime/send-job.d.ts +42 -0
- package/src/lib/runtime/send-job.js +780 -0
- package/src/lib/runtime/send-job.js.map +1 -0
- package/src/lib/runtime/sync-job.d.ts +41 -0
- package/src/lib/runtime/sync-job.js +480 -0
- package/src/lib/runtime/sync-job.js.map +1 -0
- package/src/lib/runtime/timeline.d.ts +67 -0
- package/src/lib/runtime/timeline.js +93 -0
- package/src/lib/runtime/timeline.js.map +1 -0
- package/src/lib/runtime/unsubscribe-link.d.ts +75 -0
- package/src/lib/runtime/unsubscribe-link.js +121 -0
- package/src/lib/runtime/unsubscribe-link.js.map +1 -0
- package/src/lib/runtime/unsubscribe-route.d.ts +24 -0
- package/src/lib/runtime/unsubscribe-route.js +103 -0
- package/src/lib/runtime/unsubscribe-route.js.map +1 -0
- package/src/lib/server.d.ts +53 -0
- package/src/lib/server.js +94 -0
- package/src/lib/server.js.map +1 -0
- package/src/lib/storage/compliance-settings-store.d.ts +44 -0
- package/src/lib/storage/compliance-settings-store.js +78 -0
- package/src/lib/storage/compliance-settings-store.js.map +1 -0
- package/src/lib/storage/do-not-contact-store.d.ts +60 -0
- package/src/lib/storage/do-not-contact-store.js +168 -0
- package/src/lib/storage/do-not-contact-store.js.map +1 -0
- package/src/lib/storage/outreach-records.d.ts +33 -0
- package/src/lib/storage/outreach-records.js +35 -0
- package/src/lib/storage/outreach-records.js.map +1 -0
- package/src/lib/subprocessors.d.ts +48 -0
- package/src/lib/subprocessors.js +70 -0
- package/src/lib/subprocessors.js.map +1 -0
- package/src/lib/transport/gmail-client.d.ts +145 -0
- package/src/lib/transport/gmail-client.js +229 -0
- package/src/lib/transport/gmail-client.js.map +1 -0
- package/src/lib/transport/gmail-errors.d.ts +84 -0
- package/src/lib/transport/gmail-errors.js +138 -0
- package/src/lib/transport/gmail-errors.js.map +1 -0
- package/src/lib/transport/google-oauth.d.ts +138 -0
- package/src/lib/transport/google-oauth.js +247 -0
- package/src/lib/transport/google-oauth.js.map +1 -0
- package/src/lib/transport/http.d.ts +53 -0
- package/src/lib/transport/http.js +79 -0
- package/src/lib/transport/http.js.map +1 -0
- package/src/lib/transport/rfc5322.d.ts +116 -0
- package/src/lib/transport/rfc5322.js +330 -0
- package/src/lib/transport/rfc5322.js.map +1 -0
- package/src/lib/transport/send-message.d.ts +69 -0
- package/src/lib/transport/send-message.js +77 -0
- package/src/lib/transport/send-message.js.map +1 -0
|
@@ -0,0 +1,350 @@
|
|
|
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 { consentGroupForHost } from "@aglyn/aglyn/app-utils/consent-groups";
|
|
18
|
+
import { CRM_COLLECTIONS, crmViewIsListed, normalizeCrmViewFilters } from "@aglyn/aglyn/app-utils/crm";
|
|
19
|
+
import { dynamicListDimensionsForCrmView } from "@aglyn/aglyn/app-utils/dynamic-list-rule";
|
|
20
|
+
import { findContactByEmail } from "@aglyn/tenant-data-admin/server/contact-email-index";
|
|
21
|
+
import { buildOutreachEnrollment, planOutreachFirstDue } from "../engine/enrollment-state.js";
|
|
22
|
+
import { decideOutreachEnrollment, previewOutreachPerson, readOutreachEnrollCandidates } from "../enrollment/enroll-people.js";
|
|
23
|
+
import { readOutreachGateLookups } from "../enrollment/gate-lookups.js";
|
|
24
|
+
import { effectiveOutreachAllowedCountries } from "../model/compliance-settings.js";
|
|
25
|
+
import { OUTREACH_ENROLL_BATCH_MAX } from "../model/outreach-api.js";
|
|
26
|
+
import { readOutreachComplianceSettingsDoc } from "../storage/compliance-settings-store.js";
|
|
27
|
+
import { outreachEnrollmentId, outreachOrgCollection, readStoredOutreachMailbox, readStoredOutreachSequence } from "../storage/outreach-records.js";
|
|
28
|
+
import { OUTREACH_SEQUENCE_ACTIVITY_TARGET } from "./route-deps.js";
|
|
29
|
+
import { outreachRouteGate } from "./route-gate.js";
|
|
30
|
+
import { outreachMethodNotAllowed, outreachOk, outreachRefusal, readOutreachDocumentId, readOutreachJsonBody } from "./route-http.js";
|
|
31
|
+
/**
|
|
32
|
+
* ENROLLING PEOPLE IN A SEQUENCE (AGL-2980): `outreach/enroll/preview` and
|
|
33
|
+
* `outreach/enroll`.
|
|
34
|
+
*
|
|
35
|
+
* The preview reads the people a saved Contacts view or a search named —
|
|
36
|
+
* at most {@link OUTREACH_ENROLL_BATCH_MAX} — and says where each stands:
|
|
37
|
+
* eligible, blocked with the gate's own sentence, or waiting on the rep for
|
|
38
|
+
* a personal line and the attestations only they can make. Confirm reads
|
|
39
|
+
* everything again and asks the gates again with what the rep supplied,
|
|
40
|
+
* because a preview is a picture of a moment: someone can unsubscribe
|
|
41
|
+
* between the two, and a request can claim anything. Nothing the preview
|
|
42
|
+
* answered is trusted by the enroll.
|
|
43
|
+
*
|
|
44
|
+
* Both read the CRM's people, so both ask for `data.manage` beside
|
|
45
|
+
* `outreach.use` — the permission the contacts' own rules read with, so
|
|
46
|
+
* Outreach is no side door into the address book.
|
|
47
|
+
*
|
|
48
|
+
* Only an ACTIVE sequence takes people: enrolling schedules the first step
|
|
49
|
+
* in the mailbox's hours, and a draft's steps and a paused sequence's timing
|
|
50
|
+
* are not yet what will be sent.
|
|
51
|
+
*/ /** The permission enrolling reads the CRM with. */ export const OUTREACH_READS_PEOPLE = {
|
|
52
|
+
key: 'data.manage',
|
|
53
|
+
refusal: "Enrolling reads your CRM's contacts, and your role does not include Manage data."
|
|
54
|
+
};
|
|
55
|
+
/** The activity line an enroll writes. */ export function outreachEnrolledActivity(count) {
|
|
56
|
+
return count === 1 ? 'Enrolled 1 person in a sequence' : `Enrolled ${count} people in a sequence`;
|
|
57
|
+
}
|
|
58
|
+
/** Lookups for a person the gates will never be asked about: every one unchecked. */ const UNCHECKED = {
|
|
59
|
+
platformSuppressed: null,
|
|
60
|
+
hostSuppressed: null,
|
|
61
|
+
salesTopicState: null,
|
|
62
|
+
doNotContact: null,
|
|
63
|
+
workspaceMembers: null,
|
|
64
|
+
openEnrollments: null,
|
|
65
|
+
hasInboundEmail: null
|
|
66
|
+
};
|
|
67
|
+
/** gRPC `ALREADY_EXISTS`, which `create()` rejects with when the document is there. */ const ALREADY_EXISTS = 6;
|
|
68
|
+
async function loadActiveSequence(firestore, orgId, rawSequenceId) {
|
|
69
|
+
const sequenceId = readOutreachDocumentId(rawSequenceId);
|
|
70
|
+
if (!sequenceId) return outreachRefusal(400, 'invalid-request', 'Name the sequence to enroll people in.');
|
|
71
|
+
const snapshot = await outreachOrgCollection(firestore, orgId, 'sequences').doc(sequenceId).get();
|
|
72
|
+
const sequence = readStoredOutreachSequence(sequenceId, snapshot.exists ? snapshot.data() : undefined);
|
|
73
|
+
if (!sequence) return outreachRefusal(404, 'sequence-not-found', 'That sequence no longer exists.');
|
|
74
|
+
if (sequence.status === 'archived') {
|
|
75
|
+
return outreachRefusal(409, 'sequence-archived', 'This sequence is archived and takes no one new.');
|
|
76
|
+
}
|
|
77
|
+
if (sequence.status !== 'active') {
|
|
78
|
+
return outreachRefusal(409, 'sequence-not-active', 'Activate this sequence before enrolling people.');
|
|
79
|
+
}
|
|
80
|
+
return sequence;
|
|
81
|
+
}
|
|
82
|
+
async function enrollContext(firestore, caller, sequence) {
|
|
83
|
+
var _host_get;
|
|
84
|
+
const [orgSettings, host] = await Promise.all([
|
|
85
|
+
readOutreachComplianceSettingsDoc(firestore, caller.orgId),
|
|
86
|
+
firestore.collection('hosts').doc(sequence.hostId).get()
|
|
87
|
+
]);
|
|
88
|
+
return {
|
|
89
|
+
// The organization's countries are a ceiling over the sequence's.
|
|
90
|
+
settings: _extends({}, sequence.settings, {
|
|
91
|
+
allowedCountries: effectiveOutreachAllowedCountries(sequence.settings.allowedCountries, orgSettings.allowedCountries)
|
|
92
|
+
}),
|
|
93
|
+
contactGroupId: consentGroupForHost(caller.org, sequence.hostId).groupId,
|
|
94
|
+
siteName: host.exists ? String((_host_get = host.get('name')) != null ? _host_get : '') : ''
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** The candidates, the enrollments they already have here, and the gates' lookups. */ async function readPeople(firestore, caller, sequence, context, contactIds) {
|
|
98
|
+
const candidates = await readOutreachEnrollCandidates(firestore, {
|
|
99
|
+
orgId: caller.orgId,
|
|
100
|
+
hostId: sequence.hostId,
|
|
101
|
+
contactGroupId: context.contactGroupId,
|
|
102
|
+
contactIds
|
|
103
|
+
});
|
|
104
|
+
const enrollments = outreachOrgCollection(firestore, caller.orgId, 'enrollments');
|
|
105
|
+
const existing = candidates.length ? await firestore.getAll(...candidates.map((candidate)=>enrollments.doc(outreachEnrollmentId(sequence.id, candidate.contactId)))) : [];
|
|
106
|
+
const enrolledHere = new Set(candidates.filter((_candidate, index)=>{
|
|
107
|
+
var _existing_index;
|
|
108
|
+
return (_existing_index = existing[index]) == null ? void 0 : _existing_index.exists;
|
|
109
|
+
}).map((candidate)=>candidate.contactId));
|
|
110
|
+
const askable = candidates.filter((candidate)=>candidate.contact && candidate.visible && candidate.email);
|
|
111
|
+
const lookups = askable.length ? await readOutreachGateLookups(firestore, {
|
|
112
|
+
orgId: caller.orgId,
|
|
113
|
+
hostId: sequence.hostId,
|
|
114
|
+
people: askable.map((candidate)=>({
|
|
115
|
+
contactId: candidate.contactId,
|
|
116
|
+
email: String(candidate.email)
|
|
117
|
+
}))
|
|
118
|
+
}) : new Map();
|
|
119
|
+
return {
|
|
120
|
+
candidates,
|
|
121
|
+
enrolledHere,
|
|
122
|
+
lookups
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const uniqueIds = (values)=>Array.isArray(values) ? [
|
|
126
|
+
...new Set(values.map(readOutreachDocumentId).filter((id)=>id !== null))
|
|
127
|
+
] : [];
|
|
128
|
+
export function createOutreachEnrollRoutes(deps) {
|
|
129
|
+
/**
|
|
130
|
+
* The contacts a source names, the batch limit applied: every contact a
|
|
131
|
+
* search picked, or the people a saved Contacts view selects at the
|
|
132
|
+
* sequence's site, found by address.
|
|
133
|
+
*/ async function sourceContactIds(firestore, caller, sequence, source) {
|
|
134
|
+
var _data_ownerUid;
|
|
135
|
+
if (source['kind'] === 'contacts') {
|
|
136
|
+
const ids = uniqueIds(source['contactIds']);
|
|
137
|
+
if (!ids.length) return outreachRefusal(400, 'invalid-request', 'Pick the people to enroll.');
|
|
138
|
+
return {
|
|
139
|
+
contactIds: ids.slice(0, OUTREACH_ENROLL_BATCH_MAX),
|
|
140
|
+
total: ids.length,
|
|
141
|
+
truncated: ids.length > OUTREACH_ENROLL_BATCH_MAX
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
if (source['kind'] !== 'view') {
|
|
145
|
+
return outreachRefusal(400, 'invalid-request', 'Enroll people from a saved view or a search.');
|
|
146
|
+
}
|
|
147
|
+
const viewId = readOutreachDocumentId(source['viewId']);
|
|
148
|
+
const view = viewId ? await firestore.collection('orgs').doc(caller.orgId).collection(CRM_COLLECTIONS.views).doc(viewId).get() : null;
|
|
149
|
+
const data = (view == null ? void 0 : view.exists) ? view.data() : null;
|
|
150
|
+
// A colleague's private view is theirs: it is not listed for this
|
|
151
|
+
// reader, so it does not exist for them here either.
|
|
152
|
+
if (!viewId || !data || !crmViewIsListed({
|
|
153
|
+
shared: data['shared'] === true,
|
|
154
|
+
ownerUid: String((_data_ownerUid = data['ownerUid']) != null ? _data_ownerUid : '')
|
|
155
|
+
}, caller.uid)) {
|
|
156
|
+
return outreachRefusal(404, 'view-not-found', 'That saved view no longer exists.');
|
|
157
|
+
}
|
|
158
|
+
if (data['section'] !== 'contacts') {
|
|
159
|
+
return outreachRefusal(400, 'view-unsupported', 'Enroll from a saved view of Contacts.');
|
|
160
|
+
}
|
|
161
|
+
const { unsupported } = dynamicListDimensionsForCrmView(normalizeCrmViewFilters(data['filters']));
|
|
162
|
+
if (unsupported.length) {
|
|
163
|
+
// Dropping the filter enrolling cannot read would enroll more people
|
|
164
|
+
// than the view shows, so the view is refused whole.
|
|
165
|
+
return outreachRefusal(400, 'view-unsupported', `This view filters on ${unsupported.map((clause)=>clause.label || clause.field).join(', ')}, which enrolling can't apply. Pick another view, or search.`);
|
|
166
|
+
}
|
|
167
|
+
const { emails, complete } = await deps.crmViewEmails({
|
|
168
|
+
hostId: sequence.hostId,
|
|
169
|
+
viewId
|
|
170
|
+
});
|
|
171
|
+
const ordered = [
|
|
172
|
+
...new Set(emails)
|
|
173
|
+
].sort();
|
|
174
|
+
const contacts = firestore.collection('orgs').doc(caller.orgId).collection('contacts');
|
|
175
|
+
const found = await Promise.all(ordered.slice(0, OUTREACH_ENROLL_BATCH_MAX).map((email)=>findContactByEmail(contacts, email, {
|
|
176
|
+
hostId: sequence.hostId
|
|
177
|
+
})));
|
|
178
|
+
return {
|
|
179
|
+
contactIds: [
|
|
180
|
+
...new Set(found.filter((snapshot)=>snapshot).map((snapshot)=>String(snapshot == null ? void 0 : snapshot.id)))
|
|
181
|
+
],
|
|
182
|
+
total: ordered.length,
|
|
183
|
+
truncated: ordered.length > OUTREACH_ENROLL_BATCH_MAX || !complete
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
const preview = async (request)=>{
|
|
187
|
+
if (request.method !== 'POST') return outreachMethodNotAllowed('POST');
|
|
188
|
+
const body = await readOutreachJsonBody(request);
|
|
189
|
+
const caller = await outreachRouteGate(request, body['orgId'], deps.gate, [
|
|
190
|
+
OUTREACH_READS_PEOPLE
|
|
191
|
+
]);
|
|
192
|
+
if (caller instanceof Response) return caller;
|
|
193
|
+
const firestore = deps.firestore();
|
|
194
|
+
const sequence = await loadActiveSequence(firestore, caller.orgId, body['sequenceId']);
|
|
195
|
+
if (sequence instanceof Response) return sequence;
|
|
196
|
+
const source = body['source'] && typeof body['source'] === 'object' ? body['source'] : {};
|
|
197
|
+
const named = await sourceContactIds(firestore, caller, sequence, source);
|
|
198
|
+
if (named instanceof Response) return named;
|
|
199
|
+
const context = await enrollContext(firestore, caller, sequence);
|
|
200
|
+
const { candidates, enrolledHere, lookups } = await readPeople(firestore, caller, sequence, context, named.contactIds);
|
|
201
|
+
const people = candidates.map((candidate)=>{
|
|
202
|
+
var _lookups_get;
|
|
203
|
+
return previewOutreachPerson({
|
|
204
|
+
candidate,
|
|
205
|
+
lookups: (_lookups_get = lookups.get(candidate.contactId)) != null ? _lookups_get : UNCHECKED,
|
|
206
|
+
settings: context.settings,
|
|
207
|
+
contactGroupId: context.contactGroupId
|
|
208
|
+
}, {
|
|
209
|
+
siteName: context.siteName,
|
|
210
|
+
alreadyInSequence: enrolledHere.has(candidate.contactId)
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
return outreachOk({
|
|
214
|
+
ok: true,
|
|
215
|
+
people,
|
|
216
|
+
total: named.total,
|
|
217
|
+
truncated: named.truncated
|
|
218
|
+
});
|
|
219
|
+
};
|
|
220
|
+
const confirm = async (request)=>{
|
|
221
|
+
if (request.method !== 'POST') return outreachMethodNotAllowed('POST');
|
|
222
|
+
const body = await readOutreachJsonBody(request);
|
|
223
|
+
const caller = await outreachRouteGate(request, body['orgId'], deps.gate, [
|
|
224
|
+
OUTREACH_READS_PEOPLE
|
|
225
|
+
]);
|
|
226
|
+
if (caller instanceof Response) return caller;
|
|
227
|
+
const firestore = deps.firestore();
|
|
228
|
+
const sequence = await loadActiveSequence(firestore, caller.orgId, body['sequenceId']);
|
|
229
|
+
if (sequence instanceof Response) return sequence;
|
|
230
|
+
const requested = new Map();
|
|
231
|
+
for (const entry of Array.isArray(body['people']) ? body['people'] : []){
|
|
232
|
+
const person = entry && typeof entry === 'object' ? entry : {};
|
|
233
|
+
const contactId = readOutreachDocumentId(person['contactId']);
|
|
234
|
+
if (!contactId || requested.has(contactId)) continue;
|
|
235
|
+
requested.set(contactId, {
|
|
236
|
+
personalLine: typeof person['personalLine'] === 'string' ? person['personalLine'] : '',
|
|
237
|
+
attestations: Array.isArray(person['attestations']) ? person['attestations'] : []
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
if (!requested.size) return outreachRefusal(400, 'invalid-request', 'Pick the people to enroll.');
|
|
241
|
+
if (requested.size > OUTREACH_ENROLL_BATCH_MAX) {
|
|
242
|
+
return outreachRefusal(400, 'invalid-request', `Enroll at most ${OUTREACH_ENROLL_BATCH_MAX} people at a time.`);
|
|
243
|
+
}
|
|
244
|
+
const mailboxSnapshot = sequence.mailboxId ? await outreachOrgCollection(firestore, caller.orgId, 'mailboxes').doc(sequence.mailboxId).get() : null;
|
|
245
|
+
const mailbox = readStoredOutreachMailbox(sequence.mailboxId, (mailboxSnapshot == null ? void 0 : mailboxSnapshot.exists) ? mailboxSnapshot.data() : undefined);
|
|
246
|
+
if (!mailbox || mailbox.status === 'disconnected') {
|
|
247
|
+
return outreachRefusal(409, 'mailbox-unavailable', "This sequence's mailbox is no longer connected. Choose another mailbox for it before enrolling people.");
|
|
248
|
+
}
|
|
249
|
+
const nowMs = deps.now();
|
|
250
|
+
if (planOutreachFirstDue({
|
|
251
|
+
sequence,
|
|
252
|
+
mailbox,
|
|
253
|
+
enrolledAtMs: nowMs,
|
|
254
|
+
random: deps.random
|
|
255
|
+
}) === null) {
|
|
256
|
+
return outreachRefusal(409, 'mailbox-unavailable', "This sequence's sending hours never open in its mailbox's timezone. Fix the hours before enrolling people.");
|
|
257
|
+
}
|
|
258
|
+
const context = await enrollContext(firestore, caller, sequence);
|
|
259
|
+
const { candidates, enrolledHere, lookups } = await readPeople(firestore, caller, sequence, context, [
|
|
260
|
+
...requested.keys()
|
|
261
|
+
]);
|
|
262
|
+
const enrollments = outreachOrgCollection(firestore, caller.orgId, 'enrollments');
|
|
263
|
+
const results = await Promise.all(candidates.map(async (candidate)=>{
|
|
264
|
+
var _requested_get, _lookups_get;
|
|
265
|
+
const supplied = (_requested_get = requested.get(candidate.contactId)) != null ? _requested_get : {
|
|
266
|
+
personalLine: '',
|
|
267
|
+
attestations: []
|
|
268
|
+
};
|
|
269
|
+
const decision = decideOutreachEnrollment({
|
|
270
|
+
candidate,
|
|
271
|
+
lookups: (_lookups_get = lookups.get(candidate.contactId)) != null ? _lookups_get : UNCHECKED,
|
|
272
|
+
settings: context.settings,
|
|
273
|
+
contactGroupId: context.contactGroupId
|
|
274
|
+
}, {
|
|
275
|
+
siteName: context.siteName,
|
|
276
|
+
alreadyInSequence: enrolledHere.has(candidate.contactId)
|
|
277
|
+
}, _extends({}, supplied, {
|
|
278
|
+
uid: caller.uid,
|
|
279
|
+
nowMs
|
|
280
|
+
}));
|
|
281
|
+
if (!decision.allowed || !decision.email) {
|
|
282
|
+
return {
|
|
283
|
+
contactId: candidate.contactId,
|
|
284
|
+
email: candidate.email,
|
|
285
|
+
outcome: 'blocked',
|
|
286
|
+
blocks: decision.blocks
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
const id = outreachEnrollmentId(sequence.id, candidate.contactId);
|
|
290
|
+
const enrollment = buildOutreachEnrollment({
|
|
291
|
+
id,
|
|
292
|
+
sequence,
|
|
293
|
+
mailbox,
|
|
294
|
+
contactId: candidate.contactId,
|
|
295
|
+
contactName: candidate.name,
|
|
296
|
+
email: decision.email,
|
|
297
|
+
cold: decision.cold,
|
|
298
|
+
personalLine: supplied.personalLine,
|
|
299
|
+
attestations: decision.attestations,
|
|
300
|
+
enrolledByUid: caller.uid,
|
|
301
|
+
nowMs,
|
|
302
|
+
random: deps.random
|
|
303
|
+
});
|
|
304
|
+
try {
|
|
305
|
+
await enrollments.doc(id).create(enrollment);
|
|
306
|
+
} catch (error) {
|
|
307
|
+
if ((error == null ? void 0 : error.code) !== ALREADY_EXISTS) throw error;
|
|
308
|
+
return {
|
|
309
|
+
contactId: candidate.contactId,
|
|
310
|
+
email: candidate.email,
|
|
311
|
+
outcome: 'blocked',
|
|
312
|
+
blocks: [
|
|
313
|
+
{
|
|
314
|
+
code: 'already_in_sequence',
|
|
315
|
+
reason: "This person has already been in this sequence, so they won't be sent its emails again."
|
|
316
|
+
}
|
|
317
|
+
]
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
return {
|
|
321
|
+
contactId: candidate.contactId,
|
|
322
|
+
email: decision.email,
|
|
323
|
+
outcome: 'enrolled',
|
|
324
|
+
enrollmentId: id
|
|
325
|
+
};
|
|
326
|
+
}));
|
|
327
|
+
const enrolled = results.filter((result)=>result.outcome === 'enrolled').length;
|
|
328
|
+
if (enrolled) {
|
|
329
|
+
await deps.logOrgActivity(caller.orgId, {
|
|
330
|
+
uid: caller.uid,
|
|
331
|
+
email: caller.email
|
|
332
|
+
}, outreachEnrolledActivity(enrolled), {
|
|
333
|
+
type: OUTREACH_SEQUENCE_ACTIVITY_TARGET,
|
|
334
|
+
id: sequence.id,
|
|
335
|
+
name: sequence.name
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
return outreachOk({
|
|
339
|
+
ok: true,
|
|
340
|
+
results,
|
|
341
|
+
enrolled
|
|
342
|
+
});
|
|
343
|
+
};
|
|
344
|
+
return {
|
|
345
|
+
preview,
|
|
346
|
+
confirm
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
//# sourceMappingURL=enroll-routes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/enroll-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 { consentGroupForHost } from '@aglyn/aglyn/app-utils/consent-groups'\nimport {\n CRM_COLLECTIONS,\n crmViewIsListed,\n normalizeCrmViewFilters,\n} from '@aglyn/aglyn/app-utils/crm'\nimport { dynamicListDimensionsForCrmView } from '@aglyn/aglyn/app-utils/dynamic-list-rule'\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport { findContactByEmail } from '@aglyn/tenant-data-admin/server/contact-email-index'\nimport { buildOutreachEnrollment, planOutreachFirstDue } from '../engine/enrollment-state'\nimport type { OutreachGateLookups } from '../engine/gates'\nimport {\n decideOutreachEnrollment,\n previewOutreachPerson,\n readOutreachEnrollCandidates,\n type OutreachEnrollCandidate,\n type OutreachGateQuestion,\n} from '../enrollment/enroll-people'\nimport { readOutreachGateLookups } from '../enrollment/gate-lookups'\nimport { effectiveOutreachAllowedCountries } from '../model/compliance-settings'\nimport {\n OUTREACH_ENROLL_BATCH_MAX,\n type OutreachEnrollOutcome,\n type OutreachEnrollPreviewResponse,\n type OutreachEnrollResponse,\n} from '../model/outreach-api'\nimport type { OutreachSequence } from '../model/outreach.types'\nimport { readOutreachComplianceSettingsDoc } from '../storage/compliance-settings-store'\nimport {\n outreachEnrollmentId,\n outreachOrgCollection,\n readStoredOutreachMailbox,\n readStoredOutreachSequence,\n} from '../storage/outreach-records'\nimport { OUTREACH_SEQUENCE_ACTIVITY_TARGET, type OutreachRouteDeps } from './route-deps'\nimport { outreachRouteGate, type OutreachRouteCaller, type OutreachRouteExtraPermission } from './route-gate'\nimport {\n outreachMethodNotAllowed,\n outreachOk,\n outreachRefusal,\n readOutreachDocumentId,\n readOutreachJsonBody,\n} from './route-http'\n\n/**\n * ENROLLING PEOPLE IN A SEQUENCE (AGL-2980): `outreach/enroll/preview` and\n * `outreach/enroll`.\n *\n * The preview reads the people a saved Contacts view or a search named —\n * at most {@link OUTREACH_ENROLL_BATCH_MAX} — and says where each stands:\n * eligible, blocked with the gate's own sentence, or waiting on the rep for\n * a personal line and the attestations only they can make. Confirm reads\n * everything again and asks the gates again with what the rep supplied,\n * because a preview is a picture of a moment: someone can unsubscribe\n * between the two, and a request can claim anything. Nothing the preview\n * answered is trusted by the enroll.\n *\n * Both read the CRM's people, so both ask for `data.manage` beside\n * `outreach.use` — the permission the contacts' own rules read with, so\n * Outreach is no side door into the address book.\n *\n * Only an ACTIVE sequence takes people: enrolling schedules the first step\n * in the mailbox's hours, and a draft's steps and a paused sequence's timing\n * are not yet what will be sent.\n */\n\n/** The permission enrolling reads the CRM with. */\nexport const OUTREACH_READS_PEOPLE: OutreachRouteExtraPermission = {\n key: 'data.manage',\n refusal: \"Enrolling reads your CRM's contacts, and your role does not include Manage data.\",\n}\n\n/** Everything enrolling reaches beyond the routes' own dependencies. */\nexport interface OutreachEnrollRouteDeps extends OutreachRouteDeps {\n /**\n * The addresses a saved Contacts view selects among one site's contacts,\n * read the way the dynamic-list sweep reads one, and whether the read\n * reached the whole view before its budget.\n */\n crmViewEmails(input: { hostId: string; viewId: string }): Promise<{ emails: string[]; complete: boolean }>\n}\n\nexport interface OutreachEnrollRoutes {\n preview: PluginWebApiHandler\n confirm: PluginWebApiHandler\n}\n\n/** The activity line an enroll writes. */\nexport function outreachEnrolledActivity(count: number): string {\n return count === 1\n ? 'Enrolled 1 person in a sequence'\n : `Enrolled ${count} people in a sequence`\n}\n\n/** Lookups for a person the gates will never be asked about: every one unchecked. */\nconst UNCHECKED: OutreachGateLookups = {\n platformSuppressed: null,\n hostSuppressed: null,\n salesTopicState: null,\n doNotContact: null,\n workspaceMembers: null,\n openEnrollments: null,\n hasInboundEmail: null,\n}\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** gRPC `ALREADY_EXISTS`, which `create()` rejects with when the document is there. */\nconst ALREADY_EXISTS = 6\n\nasync function loadActiveSequence(\n firestore: Firestore,\n orgId: string,\n rawSequenceId: unknown,\n): Promise<Response | OutreachSequence> {\n const sequenceId = readOutreachDocumentId(rawSequenceId)\n if (!sequenceId) return outreachRefusal(400, 'invalid-request', 'Name the sequence to enroll people in.')\n const snapshot = await outreachOrgCollection(firestore, 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 if (sequence.status === 'archived') {\n return outreachRefusal(409, 'sequence-archived', 'This sequence is archived and takes no one new.')\n }\n if (sequence.status !== 'active') {\n return outreachRefusal(409, 'sequence-not-active', 'Activate this sequence before enrolling people.')\n }\n return sequence\n}\n\n/** What every person in one request is judged against. */\ninterface EnrollContext {\n settings: OutreachGateQuestion['settings']\n contactGroupId: string\n siteName: string\n}\n\nasync function enrollContext(\n firestore: Firestore,\n caller: OutreachRouteCaller,\n sequence: OutreachSequence,\n): Promise<EnrollContext> {\n const [orgSettings, host] = await Promise.all([\n readOutreachComplianceSettingsDoc(firestore, caller.orgId),\n firestore.collection('hosts').doc(sequence.hostId).get(),\n ])\n return {\n // The organization's countries are a ceiling over the sequence's.\n settings: {\n ...sequence.settings,\n allowedCountries: effectiveOutreachAllowedCountries(\n sequence.settings.allowedCountries,\n orgSettings.allowedCountries,\n ),\n },\n contactGroupId: consentGroupForHost(caller.org, sequence.hostId).groupId,\n siteName: host.exists ? String(host.get('name') ?? '') : '',\n }\n}\n\n/** The candidates, the enrollments they already have here, and the gates' lookups. */\nasync function readPeople(\n firestore: Firestore,\n caller: OutreachRouteCaller,\n sequence: OutreachSequence,\n context: EnrollContext,\n contactIds: readonly string[],\n): Promise<{\n candidates: OutreachEnrollCandidate[]\n enrolledHere: Set<string>\n lookups: Map<string, OutreachGateLookups>\n}> {\n const candidates = await readOutreachEnrollCandidates(firestore, {\n orgId: caller.orgId,\n hostId: sequence.hostId,\n contactGroupId: context.contactGroupId,\n contactIds,\n })\n const enrollments = outreachOrgCollection(firestore, caller.orgId, 'enrollments')\n const existing = candidates.length\n ? await firestore.getAll(\n ...candidates.map((candidate) => enrollments.doc(outreachEnrollmentId(sequence.id, candidate.contactId))),\n )\n : []\n const enrolledHere = new Set(\n candidates.filter((_candidate, index) => existing[index]?.exists).map((candidate) => candidate.contactId),\n )\n const askable = candidates.filter((candidate) => candidate.contact && candidate.visible && candidate.email)\n const lookups = askable.length\n ? await readOutreachGateLookups(firestore, {\n orgId: caller.orgId,\n hostId: sequence.hostId,\n people: askable.map((candidate) => ({ contactId: candidate.contactId, email: String(candidate.email) })),\n })\n : new Map<string, OutreachGateLookups>()\n return { candidates, enrolledHere, lookups }\n}\n\nconst uniqueIds = (values: unknown): string[] =>\n Array.isArray(values)\n ? [...new Set(values.map(readOutreachDocumentId).filter((id): id is string => id !== null))]\n : []\n\nexport function createOutreachEnrollRoutes(deps: OutreachEnrollRouteDeps): OutreachEnrollRoutes {\n /**\n * The contacts a source names, the batch limit applied: every contact a\n * search picked, or the people a saved Contacts view selects at the\n * sequence's site, found by address.\n */\n async function sourceContactIds(\n firestore: Firestore,\n caller: OutreachRouteCaller,\n sequence: OutreachSequence,\n source: Record<string, unknown>,\n ): Promise<Response | { contactIds: string[]; total: number; truncated: boolean }> {\n if (source['kind'] === 'contacts') {\n const ids = uniqueIds(source['contactIds'])\n if (!ids.length) return outreachRefusal(400, 'invalid-request', 'Pick the people to enroll.')\n return {\n contactIds: ids.slice(0, OUTREACH_ENROLL_BATCH_MAX),\n total: ids.length,\n truncated: ids.length > OUTREACH_ENROLL_BATCH_MAX,\n }\n }\n if (source['kind'] !== 'view') {\n return outreachRefusal(400, 'invalid-request', 'Enroll people from a saved view or a search.')\n }\n const viewId = readOutreachDocumentId(source['viewId'])\n const view = viewId\n ? await firestore.collection('orgs').doc(caller.orgId).collection(CRM_COLLECTIONS.views).doc(viewId).get()\n : null\n const data = view?.exists ? (view.data() as Record<string, unknown>) : null\n // A colleague's private view is theirs: it is not listed for this\n // reader, so it does not exist for them here either.\n if (\n !viewId ||\n !data ||\n !crmViewIsListed({ shared: data['shared'] === true, ownerUid: String(data['ownerUid'] ?? '') }, caller.uid)\n ) {\n return outreachRefusal(404, 'view-not-found', 'That saved view no longer exists.')\n }\n if (data['section'] !== 'contacts') {\n return outreachRefusal(400, 'view-unsupported', 'Enroll from a saved view of Contacts.')\n }\n const { unsupported } = dynamicListDimensionsForCrmView(normalizeCrmViewFilters(data['filters']))\n if (unsupported.length) {\n // Dropping the filter enrolling cannot read would enroll more people\n // than the view shows, so the view is refused whole.\n return outreachRefusal(\n 400,\n 'view-unsupported',\n `This view filters on ${unsupported.map((clause) => clause.label || clause.field).join(', ')}, which enrolling can't apply. Pick another view, or search.`,\n )\n }\n const { emails, complete } = await deps.crmViewEmails({ hostId: sequence.hostId, viewId })\n const ordered = [...new Set(emails)].sort()\n const contacts = firestore.collection('orgs').doc(caller.orgId).collection('contacts')\n const found = await Promise.all(\n ordered\n .slice(0, OUTREACH_ENROLL_BATCH_MAX)\n .map((email) => findContactByEmail(contacts, email, { hostId: sequence.hostId })),\n )\n return {\n contactIds: [...new Set(found.filter((snapshot) => snapshot).map((snapshot) => String(snapshot?.id)))],\n total: ordered.length,\n truncated: ordered.length > OUTREACH_ENROLL_BATCH_MAX || !complete,\n }\n }\n\n const preview: 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 firestore = deps.firestore()\n const sequence = await loadActiveSequence(firestore, caller.orgId, body['sequenceId'])\n if (sequence instanceof Response) return sequence\n const source = body['source'] && typeof body['source'] === 'object' ? (body['source'] as Record<string, unknown>) : {}\n const named = await sourceContactIds(firestore, caller, sequence, source)\n if (named instanceof Response) return named\n const context = await enrollContext(firestore, caller, sequence)\n const { candidates, enrolledHere, lookups } = await readPeople(\n firestore,\n caller,\n sequence,\n context,\n named.contactIds,\n )\n const people = candidates.map((candidate) =>\n previewOutreachPerson(\n {\n candidate,\n lookups: lookups.get(candidate.contactId) ?? UNCHECKED,\n settings: context.settings,\n contactGroupId: context.contactGroupId,\n },\n { siteName: context.siteName, alreadyInSequence: enrolledHere.has(candidate.contactId) },\n ),\n )\n return outreachOk({\n ok: true,\n people,\n total: named.total,\n truncated: named.truncated,\n } satisfies OutreachEnrollPreviewResponse)\n }\n\n const confirm: 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 firestore = deps.firestore()\n const sequence = await loadActiveSequence(firestore, caller.orgId, body['sequenceId'])\n if (sequence instanceof Response) return sequence\n\n const requested = new Map<string, { personalLine: string; attestations: unknown[] }>()\n for (const entry of Array.isArray(body['people']) ? body['people'] : []) {\n const person = (entry && typeof entry === 'object' ? entry : {}) as Record<string, unknown>\n const contactId = readOutreachDocumentId(person['contactId'])\n if (!contactId || requested.has(contactId)) continue\n requested.set(contactId, {\n personalLine: typeof person['personalLine'] === 'string' ? person['personalLine'] : '',\n attestations: Array.isArray(person['attestations']) ? person['attestations'] : [],\n })\n }\n if (!requested.size) return outreachRefusal(400, 'invalid-request', 'Pick the people to enroll.')\n if (requested.size > OUTREACH_ENROLL_BATCH_MAX) {\n return outreachRefusal(\n 400,\n 'invalid-request',\n `Enroll at most ${OUTREACH_ENROLL_BATCH_MAX} people at a time.`,\n )\n }\n\n const mailboxSnapshot = sequence.mailboxId\n ? await outreachOrgCollection(firestore, caller.orgId, 'mailboxes').doc(sequence.mailboxId).get()\n : null\n const mailbox = readStoredOutreachMailbox(\n sequence.mailboxId,\n mailboxSnapshot?.exists ? mailboxSnapshot.data() : undefined,\n )\n if (!mailbox || mailbox.status === 'disconnected') {\n return outreachRefusal(\n 409,\n 'mailbox-unavailable',\n \"This sequence's mailbox is no longer connected. Choose another mailbox for it before enrolling people.\",\n )\n }\n const nowMs = deps.now()\n if (planOutreachFirstDue({ sequence, mailbox, enrolledAtMs: nowMs, random: deps.random }) === null) {\n return outreachRefusal(\n 409,\n 'mailbox-unavailable',\n \"This sequence's sending hours never open in its mailbox's timezone. Fix the hours before enrolling people.\",\n )\n }\n\n const context = await enrollContext(firestore, caller, sequence)\n const { candidates, enrolledHere, lookups } = await readPeople(\n firestore,\n caller,\n sequence,\n context,\n [...requested.keys()],\n )\n const enrollments = outreachOrgCollection(firestore, caller.orgId, 'enrollments')\n const results: OutreachEnrollOutcome[] = await Promise.all(\n candidates.map(async (candidate): Promise<OutreachEnrollOutcome> => {\n const supplied = requested.get(candidate.contactId) ?? { personalLine: '', attestations: [] }\n const decision = decideOutreachEnrollment(\n {\n candidate,\n lookups: lookups.get(candidate.contactId) ?? UNCHECKED,\n settings: context.settings,\n contactGroupId: context.contactGroupId,\n },\n { siteName: context.siteName, alreadyInSequence: enrolledHere.has(candidate.contactId) },\n { ...supplied, uid: caller.uid, nowMs },\n )\n if (!decision.allowed || !decision.email) {\n return { contactId: candidate.contactId, email: candidate.email, outcome: 'blocked', blocks: decision.blocks }\n }\n const id = outreachEnrollmentId(sequence.id, candidate.contactId)\n const enrollment = buildOutreachEnrollment({\n id,\n sequence,\n mailbox,\n contactId: candidate.contactId,\n contactName: candidate.name,\n email: decision.email,\n cold: decision.cold,\n personalLine: supplied.personalLine,\n attestations: decision.attestations,\n enrolledByUid: caller.uid,\n nowMs,\n random: deps.random,\n })\n try {\n await enrollments.doc(id).create(enrollment)\n } catch (error) {\n if ((error as { code?: unknown })?.code !== ALREADY_EXISTS) throw error\n return {\n contactId: candidate.contactId,\n email: candidate.email,\n outcome: 'blocked',\n blocks: [\n {\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 }\n }\n return { contactId: candidate.contactId, email: decision.email, outcome: 'enrolled', enrollmentId: id }\n }),\n )\n const enrolled = results.filter((result) => result.outcome === 'enrolled').length\n if (enrolled) {\n await deps.logOrgActivity(\n caller.orgId,\n { uid: caller.uid, email: caller.email },\n outreachEnrolledActivity(enrolled),\n { type: OUTREACH_SEQUENCE_ACTIVITY_TARGET, id: sequence.id, name: sequence.name },\n )\n }\n return outreachOk({ ok: true, results, enrolled } satisfies OutreachEnrollResponse)\n }\n\n return { preview, confirm }\n}\n"],"names":["consentGroupForHost","CRM_COLLECTIONS","crmViewIsListed","normalizeCrmViewFilters","dynamicListDimensionsForCrmView","findContactByEmail","buildOutreachEnrollment","planOutreachFirstDue","decideOutreachEnrollment","previewOutreachPerson","readOutreachEnrollCandidates","readOutreachGateLookups","effectiveOutreachAllowedCountries","OUTREACH_ENROLL_BATCH_MAX","readOutreachComplianceSettingsDoc","outreachEnrollmentId","outreachOrgCollection","readStoredOutreachMailbox","readStoredOutreachSequence","OUTREACH_SEQUENCE_ACTIVITY_TARGET","outreachRouteGate","outreachMethodNotAllowed","outreachOk","outreachRefusal","readOutreachDocumentId","readOutreachJsonBody","OUTREACH_READS_PEOPLE","key","refusal","outreachEnrolledActivity","count","UNCHECKED","platformSuppressed","hostSuppressed","salesTopicState","doNotContact","workspaceMembers","openEnrollments","hasInboundEmail","ALREADY_EXISTS","loadActiveSequence","firestore","orgId","rawSequenceId","sequenceId","snapshot","doc","get","sequence","exists","data","undefined","status","enrollContext","caller","host","orgSettings","Promise","all","collection","hostId","settings","allowedCountries","contactGroupId","org","groupId","siteName","String","readPeople","context","contactIds","candidates","enrollments","existing","length","getAll","map","candidate","id","contactId","enrolledHere","Set","filter","_candidate","index","askable","contact","visible","email","lookups","people","Map","uniqueIds","values","Array","isArray","createOutreachEnrollRoutes","deps","sourceContactIds","source","ids","slice","total","truncated","viewId","view","views","shared","ownerUid","uid","unsupported","clause","label","field","join","emails","complete","crmViewEmails","ordered","sort","contacts","found","preview","request","method","body","gate","Response","named","alreadyInSequence","has","ok","confirm","requested","entry","person","set","personalLine","attestations","size","mailboxSnapshot","mailboxId","mailbox","nowMs","now","enrolledAtMs","random","keys","results","supplied","decision","allowed","outcome","blocks","enrollment","contactName","name","cold","enrolledByUid","create","error","code","reason","enrollmentId","enrolled","result","logOrgActivity","type"],"mappings":"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"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import type { PluginWebApiHandler } from '@aglyn/aglyn/server';
|
|
18
|
+
import type { OutreachRouteDeps } from './route-deps';
|
|
19
|
+
export declare function createOutreachEnrollmentActionRoute(deps: OutreachRouteDeps): PluginWebApiHandler;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
17
|
+
import { applyOutreachEnrollmentEvent } from "../engine/enrollment-state.js";
|
|
18
|
+
import { OUTREACH_OPEN_ENROLLMENT_STATUSES } from "../engine/gates.js";
|
|
19
|
+
import { addOutreachDoNotContact } from "../storage/do-not-contact-store.js";
|
|
20
|
+
import { outreachOrgCollection, readStoredOutreachEnrollment } from "../storage/outreach-records.js";
|
|
21
|
+
import { outreachRouteGate } from "./route-gate.js";
|
|
22
|
+
import { outreachMethodNotAllowed, outreachOk, outreachRefusal, readOutreachDocumentId, readOutreachJsonBody } from "./route-http.js";
|
|
23
|
+
/**
|
|
24
|
+
* WHAT A MEMBER DOES TO ONE ENROLLMENT (AGL-2980):
|
|
25
|
+
* `outreach/enrollments/action` — pause, resume, stop, or mark the person
|
|
26
|
+
* do-not-contact.
|
|
27
|
+
*
|
|
28
|
+
* Every transition is the engine's (`applyOutreachEnrollmentEvent`): the
|
|
29
|
+
* route decides nothing about which status may follow which. Each write is
|
|
30
|
+
* a transaction over the one document, so a member's pause and the sending
|
|
31
|
+
* runtime's step completion cannot interleave and leave a paused enrollment
|
|
32
|
+
* with a step it should not have run.
|
|
33
|
+
*
|
|
34
|
+
* MARK DO-NOT-CONTACT puts the address on the organization's list, with
|
|
35
|
+
* the member as its author, and then opts out every enrollment that address
|
|
36
|
+
* has in the organization — this one wherever it stood, and any other that
|
|
37
|
+
* is still active or paused — so no sequence emails it again, whichever one
|
|
38
|
+
* would have been next.
|
|
39
|
+
*/ const ACTIONS = [
|
|
40
|
+
'pause',
|
|
41
|
+
'resume',
|
|
42
|
+
'stop',
|
|
43
|
+
'do_not_contact'
|
|
44
|
+
];
|
|
45
|
+
function eventFor(action, atMs, byUid, detail) {
|
|
46
|
+
switch(action){
|
|
47
|
+
case 'pause':
|
|
48
|
+
return {
|
|
49
|
+
type: 'pause',
|
|
50
|
+
atMs,
|
|
51
|
+
byUid,
|
|
52
|
+
detail
|
|
53
|
+
};
|
|
54
|
+
case 'resume':
|
|
55
|
+
return {
|
|
56
|
+
type: 'resume',
|
|
57
|
+
atMs,
|
|
58
|
+
byUid
|
|
59
|
+
};
|
|
60
|
+
case 'stop':
|
|
61
|
+
return {
|
|
62
|
+
type: 'stop',
|
|
63
|
+
atMs,
|
|
64
|
+
byUid,
|
|
65
|
+
reason: 'manual',
|
|
66
|
+
detail
|
|
67
|
+
};
|
|
68
|
+
case 'do_not_contact':
|
|
69
|
+
return {
|
|
70
|
+
type: 'opt_out',
|
|
71
|
+
atMs,
|
|
72
|
+
reason: 'do_not_contact',
|
|
73
|
+
detail
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function createOutreachEnrollmentActionRoute(deps) {
|
|
78
|
+
return async (request)=>{
|
|
79
|
+
if (request.method !== 'POST') return outreachMethodNotAllowed('POST');
|
|
80
|
+
const body = await readOutreachJsonBody(request);
|
|
81
|
+
const caller = await outreachRouteGate(request, body['orgId'], deps.gate);
|
|
82
|
+
if (caller instanceof Response) return caller;
|
|
83
|
+
const enrollmentId = readOutreachDocumentId(body['enrollmentId']);
|
|
84
|
+
const action = body['action'];
|
|
85
|
+
if (!enrollmentId || !ACTIONS.includes(action)) {
|
|
86
|
+
return outreachRefusal(400, 'invalid-request', 'Name the enrollment, and pause, resume, stop or mark it do-not-contact.');
|
|
87
|
+
}
|
|
88
|
+
const detail = typeof body['detail'] === 'string' ? body['detail'] : null;
|
|
89
|
+
const firestore = deps.firestore();
|
|
90
|
+
const enrollments = outreachOrgCollection(firestore, caller.orgId, 'enrollments');
|
|
91
|
+
const ref = enrollments.doc(enrollmentId);
|
|
92
|
+
const nowMs = deps.now();
|
|
93
|
+
const applied = await firestore.runTransaction(async (transaction)=>{
|
|
94
|
+
const snapshot = await transaction.get(ref);
|
|
95
|
+
const enrollment = readStoredOutreachEnrollment(enrollmentId, snapshot.exists ? snapshot.data() : undefined);
|
|
96
|
+
if (!enrollment) return null;
|
|
97
|
+
const { patch, error } = applyOutreachEnrollmentEvent(enrollment, eventFor(action, nowMs, caller.uid, detail));
|
|
98
|
+
if (!patch) return {
|
|
99
|
+
enrollment,
|
|
100
|
+
error,
|
|
101
|
+
changed: false
|
|
102
|
+
};
|
|
103
|
+
// The opt-out event names no member; the person who marked it is
|
|
104
|
+
// recorded here, beside the reason, and on the list entry.
|
|
105
|
+
const written = _extends({}, patch, {
|
|
106
|
+
stoppedByUid: action === 'do_not_contact' ? caller.uid : patch.stoppedByUid,
|
|
107
|
+
updatedAtMs: nowMs
|
|
108
|
+
});
|
|
109
|
+
transaction.update(ref, written);
|
|
110
|
+
return {
|
|
111
|
+
enrollment: _extends({}, enrollment, written),
|
|
112
|
+
error: null,
|
|
113
|
+
changed: true
|
|
114
|
+
};
|
|
115
|
+
});
|
|
116
|
+
if (!applied) return outreachRefusal(404, 'enrollment-not-found', 'That enrollment no longer exists.');
|
|
117
|
+
// An address that bounced still goes on the list when a member asks:
|
|
118
|
+
// the enrollment's own status stays final, the list entry is what they
|
|
119
|
+
// asked for.
|
|
120
|
+
if (applied.error && action !== 'do_not_contact') {
|
|
121
|
+
return outreachRefusal(409, 'transition-refused', applied.error);
|
|
122
|
+
}
|
|
123
|
+
let stoppedOthers = 0;
|
|
124
|
+
if (action === 'do_not_contact') {
|
|
125
|
+
await addOutreachDoNotContact(firestore, {
|
|
126
|
+
orgId: caller.orgId,
|
|
127
|
+
email: applied.enrollment.email,
|
|
128
|
+
reason: 'manual',
|
|
129
|
+
source: 'member',
|
|
130
|
+
addedByUid: caller.uid,
|
|
131
|
+
nowMs,
|
|
132
|
+
enrollmentId,
|
|
133
|
+
sequenceId: applied.enrollment.sequenceId,
|
|
134
|
+
detail
|
|
135
|
+
});
|
|
136
|
+
stoppedOthers = await optOutOtherEnrollments(firestore, enrollments, applied.enrollment, caller.uid, nowMs);
|
|
137
|
+
}
|
|
138
|
+
return outreachOk({
|
|
139
|
+
ok: true,
|
|
140
|
+
changed: applied.changed,
|
|
141
|
+
enrollment: applied.enrollment,
|
|
142
|
+
stoppedOthers
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/** Opts out every OTHER open enrollment of the same address; returns how many. */ async function optOutOtherEnrollments(firestore, enrollments, marked, byUid, nowMs) {
|
|
147
|
+
const found = await enrollments.where('email', '==', marked.email).get();
|
|
148
|
+
const batch = firestore.batch();
|
|
149
|
+
let writes = 0;
|
|
150
|
+
for (const doc of found.docs){
|
|
151
|
+
if (doc.id === marked.id) continue;
|
|
152
|
+
const other = readStoredOutreachEnrollment(doc.id, doc.data());
|
|
153
|
+
if (!other || !OUTREACH_OPEN_ENROLLMENT_STATUSES.includes(other.status)) continue;
|
|
154
|
+
const { patch } = applyOutreachEnrollmentEvent(other, {
|
|
155
|
+
type: 'opt_out',
|
|
156
|
+
atMs: nowMs,
|
|
157
|
+
reason: 'do_not_contact',
|
|
158
|
+
detail: 'Marked do-not-contact from another sequence.'
|
|
159
|
+
});
|
|
160
|
+
if (!patch) continue;
|
|
161
|
+
batch.update(doc.ref, _extends({}, patch, {
|
|
162
|
+
stoppedByUid: byUid,
|
|
163
|
+
updatedAtMs: nowMs
|
|
164
|
+
}));
|
|
165
|
+
writes += 1;
|
|
166
|
+
}
|
|
167
|
+
if (writes) await batch.commit();
|
|
168
|
+
return writes;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
//# sourceMappingURL=enrollment-routes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/enrollment-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 type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport {\n applyOutreachEnrollmentEvent,\n type OutreachEnrollmentEvent,\n} from '../engine/enrollment-state'\nimport { OUTREACH_OPEN_ENROLLMENT_STATUSES } from '../engine/gates'\nimport type {\n OutreachEnrollmentAction,\n OutreachEnrollmentActionResponse,\n} from '../model/outreach-api'\nimport type { OutreachEnrollment } from '../model/outreach.types'\nimport { addOutreachDoNotContact } from '../storage/do-not-contact-store'\nimport { outreachOrgCollection, readStoredOutreachEnrollment } from '../storage/outreach-records'\nimport type { OutreachRouteDeps } from './route-deps'\nimport { outreachRouteGate } from './route-gate'\nimport {\n outreachMethodNotAllowed,\n outreachOk,\n outreachRefusal,\n readOutreachDocumentId,\n readOutreachJsonBody,\n} from './route-http'\n\n/**\n * WHAT A MEMBER DOES TO ONE ENROLLMENT (AGL-2980):\n * `outreach/enrollments/action` — pause, resume, stop, or mark the person\n * do-not-contact.\n *\n * Every transition is the engine's (`applyOutreachEnrollmentEvent`): the\n * route decides nothing about which status may follow which. Each write is\n * a transaction over the one document, so a member's pause and the sending\n * runtime's step completion cannot interleave and leave a paused enrollment\n * with a step it should not have run.\n *\n * MARK DO-NOT-CONTACT puts the address on the organization's list, with\n * the member as its author, and then opts out every enrollment that address\n * has in the organization — this one wherever it stood, and any other that\n * is still active or paused — so no sequence emails it again, whichever one\n * would have been next.\n */\n\nconst ACTIONS: readonly OutreachEnrollmentAction[] = ['pause', 'resume', 'stop', 'do_not_contact']\n\nfunction eventFor(\n action: OutreachEnrollmentAction,\n atMs: number,\n byUid: string,\n detail: string | null,\n): OutreachEnrollmentEvent {\n switch (action) {\n case 'pause':\n return { type: 'pause', atMs, byUid, detail }\n case 'resume':\n return { type: 'resume', atMs, byUid }\n case 'stop':\n return { type: 'stop', atMs, byUid, reason: 'manual', detail }\n case 'do_not_contact':\n return { type: 'opt_out', atMs, reason: 'do_not_contact', detail }\n }\n}\n\nexport function createOutreachEnrollmentActionRoute(deps: OutreachRouteDeps): PluginWebApiHandler {\n return 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 action = body['action'] as OutreachEnrollmentAction\n if (!enrollmentId || !ACTIONS.includes(action)) {\n return outreachRefusal(400, 'invalid-request', 'Name the enrollment, and pause, resume, stop or mark it do-not-contact.')\n }\n const detail = typeof body['detail'] === 'string' ? body['detail'] : null\n const firestore = deps.firestore()\n const enrollments = outreachOrgCollection(firestore, caller.orgId, 'enrollments')\n const ref = enrollments.doc(enrollmentId)\n const nowMs = deps.now()\n\n const applied = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const enrollment = readStoredOutreachEnrollment(enrollmentId, snapshot.exists ? snapshot.data() : undefined)\n if (!enrollment) return null\n const { patch, error } = applyOutreachEnrollmentEvent(\n enrollment,\n eventFor(action, nowMs, caller.uid, detail),\n )\n if (!patch) return { enrollment, error, changed: false }\n // The opt-out event names no member; the person who marked it is\n // recorded here, beside the reason, and on the list entry.\n const written = {\n ...patch,\n stoppedByUid: action === 'do_not_contact' ? caller.uid : patch.stoppedByUid,\n updatedAtMs: nowMs,\n }\n transaction.update(ref, written)\n return { enrollment: { ...enrollment, ...written } as OutreachEnrollment, error: null, changed: true }\n })\n if (!applied) return outreachRefusal(404, 'enrollment-not-found', 'That enrollment no longer exists.')\n // An address that bounced still goes on the list when a member asks:\n // the enrollment's own status stays final, the list entry is what they\n // asked for.\n if (applied.error && action !== 'do_not_contact') {\n return outreachRefusal(409, 'transition-refused', applied.error)\n }\n\n let stoppedOthers = 0\n if (action === 'do_not_contact') {\n await addOutreachDoNotContact(firestore, {\n orgId: caller.orgId,\n email: applied.enrollment.email,\n reason: 'manual',\n source: 'member',\n addedByUid: caller.uid,\n nowMs,\n enrollmentId,\n sequenceId: applied.enrollment.sequenceId,\n detail,\n })\n stoppedOthers = await optOutOtherEnrollments(firestore, enrollments, applied.enrollment, caller.uid, nowMs)\n }\n return outreachOk({\n ok: true,\n changed: applied.changed,\n enrollment: applied.enrollment,\n stoppedOthers,\n } satisfies OutreachEnrollmentActionResponse)\n }\n}\n\n/** Opts out every OTHER open enrollment of the same address; returns how many. */\nasync function optOutOtherEnrollments(\n firestore: FirebaseFirestore.Firestore,\n enrollments: FirebaseFirestore.CollectionReference,\n marked: OutreachEnrollment,\n byUid: string,\n nowMs: number,\n): Promise<number> {\n const found = await enrollments.where('email', '==', marked.email).get()\n const batch = firestore.batch()\n let writes = 0\n for (const doc of found.docs) {\n if (doc.id === marked.id) continue\n const other = readStoredOutreachEnrollment(doc.id, doc.data())\n if (!other || !OUTREACH_OPEN_ENROLLMENT_STATUSES.includes(other.status)) continue\n const { patch } = applyOutreachEnrollmentEvent(other, {\n type: 'opt_out',\n atMs: nowMs,\n reason: 'do_not_contact',\n detail: 'Marked do-not-contact from another sequence.',\n })\n if (!patch) continue\n batch.update(doc.ref, { ...patch, stoppedByUid: byUid, updatedAtMs: nowMs })\n writes += 1\n }\n if (writes) await batch.commit()\n return writes\n}\n"],"names":["applyOutreachEnrollmentEvent","OUTREACH_OPEN_ENROLLMENT_STATUSES","addOutreachDoNotContact","outreachOrgCollection","readStoredOutreachEnrollment","outreachRouteGate","outreachMethodNotAllowed","outreachOk","outreachRefusal","readOutreachDocumentId","readOutreachJsonBody","ACTIONS","eventFor","action","atMs","byUid","detail","type","reason","createOutreachEnrollmentActionRoute","deps","request","method","body","caller","gate","Response","enrollmentId","includes","firestore","enrollments","orgId","ref","doc","nowMs","now","applied","runTransaction","transaction","snapshot","get","enrollment","exists","data","undefined","patch","error","uid","changed","written","stoppedByUid","updatedAtMs","update","stoppedOthers","email","source","addedByUid","sequenceId","optOutOtherEnrollments","ok","marked","found","where","batch","writes","docs","id","other","status","commit"],"mappings":"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"}
|