@aglyn/plugins-outreach 1.0.0-beta.199 → 1.0.0-beta.201
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +14 -14
- package/src/lib/components/do-not-contact-domains.js +60 -42
- package/src/lib/components/do-not-contact-domains.js.map +1 -1
- package/src/lib/components/enrollment-actions.d.ts +33 -0
- package/src/lib/components/enrollment-actions.js +174 -0
- package/src/lib/components/enrollment-actions.js.map +1 -0
- package/src/lib/components/enrollment-detail.d.ts +21 -0
- package/src/lib/components/enrollment-detail.js +870 -0
- package/src/lib/components/enrollment-detail.js.map +1 -0
- package/src/lib/components/enrollment-filter-params.d.ts +58 -0
- package/src/lib/components/enrollment-filter-params.js +98 -0
- package/src/lib/components/enrollment-filter-params.js.map +1 -0
- package/src/lib/components/enrollments-table.d.ts +25 -2
- package/src/lib/components/enrollments-table.js +303 -283
- package/src/lib/components/enrollments-table.js.map +1 -1
- package/src/lib/components/outreach-console-page.js +10 -0
- package/src/lib/components/outreach-console-page.js.map +1 -1
- package/src/lib/components/sequence-detail.d.ts +2 -0
- package/src/lib/components/sequence-detail.js +73 -11
- package/src/lib/components/sequence-detail.js.map +1 -1
- package/src/lib/components/sequence-report-card.d.ts +17 -0
- package/src/lib/components/sequence-report-card.js +63 -15
- package/src/lib/components/sequence-report-card.js.map +1 -1
- package/src/lib/components/sequences-section.d.ts +13 -1
- package/src/lib/components/sequences-section.js +108 -83
- package/src/lib/components/sequences-section.js.map +1 -1
- package/src/lib/components/use-outreach-api.d.ts +3 -1
- package/src/lib/components/use-outreach-api.js +6 -0
- package/src/lib/components/use-outreach-api.js.map +1 -1
- package/src/lib/components/use-outreach-data.d.ts +30 -25
- package/src/lib/components/use-outreach-data.js +93 -104
- package/src/lib/components/use-outreach-data.js.map +1 -1
- package/src/lib/components/use-outreach-enrollment.d.ts +18 -0
- package/src/lib/components/use-outreach-enrollment.js +133 -0
- package/src/lib/components/use-outreach-enrollment.js.map +1 -0
- package/src/lib/constants/api-routes.d.ts +2 -0
- package/src/lib/constants/api-routes.js +1 -0
- package/src/lib/constants/api-routes.js.map +1 -1
- package/src/lib/engine/gates.d.ts +4 -3
- package/src/lib/engine/gates.js.map +1 -1
- package/src/lib/engine/gateway-block.d.ts +4 -8
- package/src/lib/engine/gateway-block.js +6 -26
- package/src/lib/engine/gateway-block.js.map +1 -1
- package/src/lib/engine/mail-gateway.d.ts +18 -73
- package/src/lib/engine/mail-gateway.js +32 -206
- package/src/lib/engine/mail-gateway.js.map +1 -1
- package/src/lib/enrollment/enrollment-search.d.ts +32 -0
- package/src/lib/enrollment/enrollment-search.js +40 -0
- package/src/lib/enrollment/enrollment-search.js.map +1 -0
- package/src/lib/model/do-not-contact-domain-list-query.d.ts +32 -0
- package/src/lib/model/do-not-contact-domain-list-query.js +107 -0
- package/src/lib/model/do-not-contact-domain-list-query.js.map +1 -0
- package/src/lib/model/enrollment-engagement.d.ts +26 -2
- package/src/lib/model/enrollment-engagement.js +40 -3
- package/src/lib/model/enrollment-engagement.js.map +1 -1
- package/src/lib/model/enrollment-history.d.ts +40 -0
- package/src/lib/model/enrollment-history.js +95 -0
- package/src/lib/model/enrollment-history.js.map +1 -0
- package/src/lib/model/enrollment-list-query.d.ts +28 -0
- package/src/lib/model/enrollment-list-query.js +151 -0
- package/src/lib/model/enrollment-list-query.js.map +1 -0
- package/src/lib/model/enrollment-timeline.d.ts +78 -0
- package/src/lib/model/enrollment-timeline.js +430 -0
- package/src/lib/model/enrollment-timeline.js.map +1 -0
- package/src/lib/model/outreach-api.d.ts +24 -0
- package/src/lib/model/outreach-api.js.map +1 -1
- package/src/lib/model/outreach.types.d.ts +90 -29
- package/src/lib/model/outreach.types.js +33 -8
- package/src/lib/model/outreach.types.js.map +1 -1
- package/src/lib/model/sequence-list-query.d.ts +22 -0
- package/src/lib/model/sequence-list-query.js +99 -0
- package/src/lib/model/sequence-list-query.js.map +1 -0
- package/src/lib/routes/enroll-routes.js +34 -3
- package/src/lib/routes/enroll-routes.js.map +1 -1
- package/src/lib/routes/enrollment-routes.d.ts +10 -0
- package/src/lib/routes/enrollment-routes.js +82 -1
- package/src/lib/routes/enrollment-routes.js.map +1 -1
- package/src/lib/routes/register-routes.js +14 -4
- package/src/lib/routes/register-routes.js.map +1 -1
- package/src/lib/routes/route-deps.d.ts +3 -1
- package/src/lib/routes/route-deps.js.map +1 -1
- package/src/lib/routes/sequence-routes.js +6 -1
- package/src/lib/routes/sequence-routes.js.map +1 -1
- package/src/lib/runtime/click-events.d.ts +10 -5
- package/src/lib/runtime/click-events.js +45 -6
- package/src/lib/runtime/click-events.js.map +1 -1
- package/src/lib/runtime/gateway-ledger.d.ts +6 -0
- package/src/lib/runtime/gateway-ledger.js +9 -3
- package/src/lib/runtime/gateway-ledger.js.map +1 -1
- package/src/lib/runtime/host-suspension.d.ts +30 -0
- package/src/lib/runtime/host-suspension.js +35 -0
- package/src/lib/runtime/host-suspension.js.map +1 -0
- package/src/lib/runtime/person-erasure.d.ts +4 -1
- package/src/lib/runtime/person-erasure.js +14 -3
- package/src/lib/runtime/person-erasure.js.map +1 -1
- package/src/lib/runtime/platform-runtime-deps.js +34 -3
- package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
- package/src/lib/runtime/runtime-deps.d.ts +27 -1
- package/src/lib/runtime/runtime-deps.js.map +1 -1
- package/src/lib/runtime/send-job.js +46 -6
- package/src/lib/runtime/send-job.js.map +1 -1
- package/src/lib/runtime/sync-job.js +4 -0
- package/src/lib/runtime/sync-job.js.map +1 -1
- package/src/lib/storage/do-not-contact-store.js +6 -1
- package/src/lib/storage/do-not-contact-store.js.map +1 -1
- package/src/lib/storage/domain-intel-store.d.ts +61 -43
- package/src/lib/storage/domain-intel-store.js +138 -226
- package/src/lib/storage/domain-intel-store.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/register-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 { pluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport { pluginTextGenerator } from '@aglyn/aglyn/plugin-manager/plugin-text-generation'\nimport { registerPluginApiRoute } from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { resolveMx } from 'node:dns/promises'\nimport { OUTREACH_API_ROUTES } from '../constants/api-routes'\nimport { openOutreachMailboxClient } from '../mailboxes/mailbox-transport'\nimport { outreachOrgSubject } from '../mailboxes/register-mailbox-routes'\nimport { outreachShortLinkUrl } from '../runtime/click-link'\nimport { createOutreachCurateRoutes } from './curate-routes'\nimport { createOutreachDoNotContactDomainsRoute } from './do-not-contact-routes'\nimport { createOutreachEnrollRoutes, type OutreachEnrollRouteDeps } from './enroll-routes'\nimport { createOutreachEnrollmentActionRoute } from './enrollment-routes'\nimport { canonicalConsoleOrigin } from '../mailboxes/oauth-redirect'\nimport { createOutreachLinkDomainsRoute, type OutreachLinkDomainRouteDeps } from './link-domain-routes'\nimport { createOutreachPreviewRoute } from './preview-routes'\nimport type { OutreachRouteGateDeps } from './route-gate'\nimport { createOutreachSequenceRoutes } from './sequence-routes'\nimport { createOutreachSettingsRoute } from './settings-routes'\nimport { createOutreachStepTestRoute, type OutreachStepTestDeps } from './step-test-routes'\n\n/**\n * Wires the settings, sequence and enrollment routes into the console\n * dispatcher (AGL-2980) with the platform's own dependencies. Specs build\n * their own.\n *\n * Every one names its organization (`orgId`, in the query or the JSON body)\n * and none names a site, so each declares the mailbox routes' subject reader:\n * the dispatcher's release gate then asks about that organization, and a\n * `release_outreach` override for one organization reaches these routes as it\n * reaches Mailboxes. Without it the gate reads the request as anonymous, which\n * only a fully enabled flag or a staff session passes.\n */\n\n/** The platform's heavier modules, loaded the first time a request needs one. */\nconst platform = () => import('./platform-deps')\n\n/** The gate's reach into the platform. */\nexport function defaultOutreachRouteGateDeps(): OutreachRouteGateDeps {\n return {\n verifyIdToken: (idToken) => firebaseAdmin.app().auth().verifyIdToken(idToken),\n resolveOrgPermissions: async (uid, context) => (await platform()).resolveOrgPermissions(uid, context),\n holdsOrgCatalogPermission: async (uid, orgId, key) =>\n (await platform()).holdsOrgCatalogPermission(uid, orgId, key),\n readOrg: async (orgId) => {\n const snapshot = await firebaseAdmin.app().firestore().collection('orgs').doc(orgId).get()\n return snapshot.exists ? (snapshot.data() ?? {}) : null\n },\n lockdownRefusal: async (options) => (await platform()).lockdownRefusal(options),\n }\n}\n\nexport function defaultOutreachRouteDeps(): OutreachEnrollRouteDeps {\n return {\n firestore: () => firebaseAdmin.app().firestore(),\n gate: defaultOutreachRouteGateDeps(),\n now: Date.now,\n random: Math.random,\n logOrgActivity: async (orgId, actor, action, target) =>\n (await platform()).logOrgActivity(orgId, actor, action, target),\n crmViewEmails: async (input) => (await platform()).crmViewEmails(input),\n stampRecordEmailState: async (stamp) => {\n await (await platform()).stampRecordEmailState(stamp)\n },\n creditCampaign: async ({ hostId, orgId, campaignIds, outcome, atMs }) => {\n await (await platform()).creditCampaignSequenceOutcome(\n { hostId, orgId, campaignIds, outcome, atMs },\n firebaseAdmin.app().firestore(),\n )\n },\n // The record system's writer (AGL-3274), resolved per call the way the\n // runtime resolves it: the CRM registers at boot, and a request that\n // arrives before it did files nothing rather than caching a `null`.\n timeline: () => pluginRecordTimelineWriter()?.writer ?? null,\n // The domain's MX (AGL-3326), from the console's own resolver.\n resolveMx: (domain) => resolveMx(domain),\n // The workspace's text generator (AGL-3324), resolved per call the same\n // way: the AI plugin registers at boot, and a request before it did is\n // told drafting is unavailable rather than caching a `null`.\n textGenerator: () => pluginTextGenerator()?.generator ?? null,\n }\n}\n\n/** The link domains route's reach (AGL-3306): the tracking-host store, loaded on first use. */\nexport function defaultOutreachLinkDomainDeps(): OutreachLinkDomainRouteDeps {\n return {\n hosts: async () => {\n const loaded = await platform()\n return {\n list: loaded.listTrackingHosts,\n setUp: loaded.setUpTrackingHost,\n verify: loaded.verifyTrackingHost,\n remove: loaded.removeTrackingHost,\n }\n },\n consoleOrigin: canonicalConsoleOrigin,\n }\n}\n\n/**\n * The test send's reach (AGL-3325): the mailbox's Gmail through the same\n * door the runtime and the Mailboxes test use, the platform's rate limit,\n * and the short links a real send would mint.\n */\nexport function defaultOutreachStepTestDeps(): OutreachStepTestDeps {\n return {\n openMailbox: (mailboxId) => openOutreachMailboxClient(firebaseAdmin.app().firestore(), { mailboxId }),\n consumeRateLimit: async (key, options) =>\n (await import('@aglyn/tenant-data-admin/server/rate-limit-store')).consumeRateLimit(key, options),\n clickLinkUrl: (linkId, trackingOrigin) =>\n outreachShortLinkUrl({ origin: canonicalConsoleOrigin(), linkId, trackingOrigin }),\n clickLinkOrigin: async ({ orgId, senderAddress }) =>\n (await platform()).resolveTrackingLinkOrigin(firebaseAdmin.app().firestore(), orgId, senderAddress),\n }\n}\n\nexport function registerOutreachRoutes(\n deps: OutreachEnrollRouteDeps = defaultOutreachRouteDeps(),\n): void {\n const sequences = createOutreachSequenceRoutes(deps)\n const enroll = createOutreachEnrollRoutes(deps)\n const curate = createOutreachCurateRoutes(deps)\n const orgSubject = { subject: outreachOrgSubject }\n registerPluginApiRoute(OUTREACH_API_ROUTES.settings, { web: createOutreachSettingsRoute(deps) }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesSave, { web: sequences.save }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesStatus, { web: sequences.status }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesDelete, { web: sequences.remove }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.enrollPreview, { web: enroll.preview }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.enroll, { web: enroll.confirm }, orgSubject)\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.enrollmentsAction,\n { web: createOutreachEnrollmentActionRoute(deps) },\n orgSubject,\n )\n registerPluginApiRoute(OUTREACH_API_ROUTES.preview, { web: createOutreachPreviewRoute(deps) }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.curateDraft, { web: curate.draft }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.curateSave, { web: curate.save }, orgSubject)\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.stepTest,\n { web: createOutreachStepTestRoute(deps, defaultOutreachStepTestDeps()) },\n orgSubject,\n )\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.doNotContactDomains,\n { web: createOutreachDoNotContactDomainsRoute(deps) },\n orgSubject,\n )\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.linkDomains,\n { web: createOutreachLinkDomainsRoute(deps, defaultOutreachLinkDomainDeps()) },\n orgSubject,\n )\n}\n"],"names":["pluginRecordTimelineWriter","pluginTextGenerator","registerPluginApiRoute","firebaseAdmin","resolveMx","OUTREACH_API_ROUTES","openOutreachMailboxClient","outreachOrgSubject","outreachShortLinkUrl","createOutreachCurateRoutes","createOutreachDoNotContactDomainsRoute","createOutreachEnrollRoutes","createOutreachEnrollmentActionRoute","canonicalConsoleOrigin","createOutreachLinkDomainsRoute","createOutreachPreviewRoute","createOutreachSequenceRoutes","createOutreachSettingsRoute","createOutreachStepTestRoute","platform","defaultOutreachRouteGateDeps","verifyIdToken","idToken","app","auth","resolveOrgPermissions","uid","context","holdsOrgCatalogPermission","orgId","key","readOrg","snapshot","firestore","collection","doc","get","exists","data","lockdownRefusal","options","defaultOutreachRouteDeps","gate","now","Date","random","Math","logOrgActivity","actor","action","target","crmViewEmails","input","stampRecordEmailState","stamp","creditCampaign","hostId","campaignIds","outcome","atMs","creditCampaignSequenceOutcome","timeline","writer","domain","textGenerator","generator","defaultOutreachLinkDomainDeps","hosts","loaded","list","listTrackingHosts","setUp","setUpTrackingHost","verify","verifyTrackingHost","remove","removeTrackingHost","consoleOrigin","defaultOutreachStepTestDeps","openMailbox","mailboxId","consumeRateLimit","clickLinkUrl","linkId","trackingOrigin","origin","clickLinkOrigin","senderAddress","resolveTrackingLinkOrigin","registerOutreachRoutes","deps","sequences","enroll","curate","orgSubject","subject","settings","web","sequencesSave","save","sequencesStatus","status","sequencesDelete","enrollPreview","preview","confirm","enrollmentsAction","curateDraft","draft","curateSave","stepTest","doNotContactDomains","linkDomains"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/register-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 { pluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport { pluginTextGenerator } from '@aglyn/aglyn/plugin-manager/plugin-text-generation'\nimport { registerPluginApiRoute } from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { platformMailDnsResolver } from '@aglyn/tenant-data-admin/server/email-deliverability'\nimport { OUTREACH_API_ROUTES } from '../constants/api-routes'\nimport { openOutreachMailboxClient } from '../mailboxes/mailbox-transport'\nimport { outreachOrgSubject } from '../mailboxes/register-mailbox-routes'\nimport { outreachShortLinkUrl } from '../runtime/click-link'\nimport { createOutreachCurateRoutes } from './curate-routes'\nimport { createOutreachDoNotContactDomainsRoute } from './do-not-contact-routes'\nimport { createOutreachEnrollRoutes, type OutreachEnrollRouteDeps } from './enroll-routes'\nimport {\n createOutreachEnrollmentActionRoute,\n createOutreachEnrollmentGatewayRoute,\n} from './enrollment-routes'\nimport { canonicalConsoleOrigin } from '../mailboxes/oauth-redirect'\nimport { createOutreachLinkDomainsRoute, type OutreachLinkDomainRouteDeps } from './link-domain-routes'\nimport { createOutreachPreviewRoute } from './preview-routes'\nimport type { OutreachRouteGateDeps } from './route-gate'\nimport { createOutreachSequenceRoutes } from './sequence-routes'\nimport { createOutreachSettingsRoute } from './settings-routes'\nimport { createOutreachStepTestRoute, type OutreachStepTestDeps } from './step-test-routes'\n\n/**\n * Wires the settings, sequence and enrollment routes into the console\n * dispatcher (AGL-2980) with the platform's own dependencies. Specs build\n * their own.\n *\n * Every one names its organization (`orgId`, in the query or the JSON body)\n * and none names a site, so each declares the mailbox routes' subject reader:\n * the dispatcher's release gate then asks about that organization, and a\n * `release_outreach` override for one organization reaches these routes as it\n * reaches Mailboxes. Without it the gate reads the request as anonymous, which\n * only a fully enabled flag or a staff session passes.\n */\n\n/** The platform's heavier modules, loaded the first time a request needs one. */\nconst platform = () => import('./platform-deps')\n\n/** The gate's reach into the platform. */\nexport function defaultOutreachRouteGateDeps(): OutreachRouteGateDeps {\n return {\n verifyIdToken: (idToken) => firebaseAdmin.app().auth().verifyIdToken(idToken),\n resolveOrgPermissions: async (uid, context) => (await platform()).resolveOrgPermissions(uid, context),\n holdsOrgCatalogPermission: async (uid, orgId, key) =>\n (await platform()).holdsOrgCatalogPermission(uid, orgId, key),\n readOrg: async (orgId) => {\n const snapshot = await firebaseAdmin.app().firestore().collection('orgs').doc(orgId).get()\n return snapshot.exists ? (snapshot.data() ?? {}) : null\n },\n lockdownRefusal: async (options) => (await platform()).lockdownRefusal(options),\n }\n}\n\nexport function defaultOutreachRouteDeps(): OutreachEnrollRouteDeps {\n return {\n firestore: () => firebaseAdmin.app().firestore(),\n gate: defaultOutreachRouteGateDeps(),\n now: Date.now,\n random: Math.random,\n logOrgActivity: async (orgId, actor, action, target) =>\n (await platform()).logOrgActivity(orgId, actor, action, target),\n crmViewEmails: async (input) => (await platform()).crmViewEmails(input),\n stampRecordEmailState: async (stamp) => {\n await (await platform()).stampRecordEmailState(stamp)\n },\n creditCampaign: async ({ hostId, orgId, campaignIds, outcome, atMs }) => {\n await (await platform()).creditCampaignSequenceOutcome(\n { hostId, orgId, campaignIds, outcome, atMs },\n firebaseAdmin.app().firestore(),\n )\n },\n // The record system's writer (AGL-3274), resolved per call the way the\n // runtime resolves it: the CRM registers at boot, and a request that\n // arrives before it did files nothing rather than caching a `null`.\n timeline: () => pluginRecordTimelineWriter()?.writer ?? null,\n // The domain's MX (AGL-3326) and, for a domain with none, its address\n // record — through the platform's pinned resolver (AGL-3328), which\n // asks twice before it says a domain takes no mail.\n resolveMx: (domain) => platformMailDnsResolver().resolveMx(domain),\n resolveAddress: (domain) => platformMailDnsResolver().resolveAddress?.(domain) ?? Promise.resolve(false),\n // The workspace's text generator (AGL-3324), resolved per call the same\n // way: the AI plugin registers at boot, and a request before it did is\n // told drafting is unavailable rather than caching a `null`.\n textGenerator: () => pluginTextGenerator()?.generator ?? null,\n }\n}\n\n/** The link domains route's reach (AGL-3306): the tracking-host store, loaded on first use. */\nexport function defaultOutreachLinkDomainDeps(): OutreachLinkDomainRouteDeps {\n return {\n hosts: async () => {\n const loaded = await platform()\n return {\n list: loaded.listTrackingHosts,\n setUp: loaded.setUpTrackingHost,\n verify: loaded.verifyTrackingHost,\n remove: loaded.removeTrackingHost,\n }\n },\n consoleOrigin: canonicalConsoleOrigin,\n }\n}\n\n/**\n * The test send's reach (AGL-3325): the mailbox's Gmail through the same\n * door the runtime and the Mailboxes test use, the platform's rate limit,\n * and the short links a real send would mint.\n */\nexport function defaultOutreachStepTestDeps(): OutreachStepTestDeps {\n return {\n openMailbox: (mailboxId) => openOutreachMailboxClient(firebaseAdmin.app().firestore(), { mailboxId }),\n consumeRateLimit: async (key, options) =>\n (await import('@aglyn/tenant-data-admin/server/rate-limit-store')).consumeRateLimit(key, options),\n clickLinkUrl: (linkId, trackingOrigin) =>\n outreachShortLinkUrl({ origin: canonicalConsoleOrigin(), linkId, trackingOrigin }),\n clickLinkOrigin: async ({ orgId, senderAddress }) =>\n (await platform()).resolveTrackingLinkOrigin(firebaseAdmin.app().firestore(), orgId, senderAddress),\n }\n}\n\nexport function registerOutreachRoutes(\n deps: OutreachEnrollRouteDeps = defaultOutreachRouteDeps(),\n): void {\n const sequences = createOutreachSequenceRoutes(deps)\n const enroll = createOutreachEnrollRoutes(deps)\n const curate = createOutreachCurateRoutes(deps)\n const orgSubject = { subject: outreachOrgSubject }\n registerPluginApiRoute(OUTREACH_API_ROUTES.settings, { web: createOutreachSettingsRoute(deps) }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesSave, { web: sequences.save }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesStatus, { web: sequences.status }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesDelete, { web: sequences.remove }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.enrollPreview, { web: enroll.preview }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.enroll, { web: enroll.confirm }, orgSubject)\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.enrollmentsAction,\n { web: createOutreachEnrollmentActionRoute(deps) },\n orgSubject,\n )\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.enrollmentsGateway,\n { web: createOutreachEnrollmentGatewayRoute(deps) },\n orgSubject,\n )\n registerPluginApiRoute(OUTREACH_API_ROUTES.preview, { web: createOutreachPreviewRoute(deps) }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.curateDraft, { web: curate.draft }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.curateSave, { web: curate.save }, orgSubject)\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.stepTest,\n { web: createOutreachStepTestRoute(deps, defaultOutreachStepTestDeps()) },\n orgSubject,\n )\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.doNotContactDomains,\n { web: createOutreachDoNotContactDomainsRoute(deps) },\n orgSubject,\n )\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.linkDomains,\n { web: createOutreachLinkDomainsRoute(deps, defaultOutreachLinkDomainDeps()) },\n orgSubject,\n )\n}\n"],"names":["pluginRecordTimelineWriter","pluginTextGenerator","registerPluginApiRoute","firebaseAdmin","platformMailDnsResolver","OUTREACH_API_ROUTES","openOutreachMailboxClient","outreachOrgSubject","outreachShortLinkUrl","createOutreachCurateRoutes","createOutreachDoNotContactDomainsRoute","createOutreachEnrollRoutes","createOutreachEnrollmentActionRoute","createOutreachEnrollmentGatewayRoute","canonicalConsoleOrigin","createOutreachLinkDomainsRoute","createOutreachPreviewRoute","createOutreachSequenceRoutes","createOutreachSettingsRoute","createOutreachStepTestRoute","platform","defaultOutreachRouteGateDeps","verifyIdToken","idToken","app","auth","resolveOrgPermissions","uid","context","holdsOrgCatalogPermission","orgId","key","readOrg","snapshot","firestore","collection","doc","get","exists","data","lockdownRefusal","options","defaultOutreachRouteDeps","gate","now","Date","random","Math","logOrgActivity","actor","action","target","crmViewEmails","input","stampRecordEmailState","stamp","creditCampaign","hostId","campaignIds","outcome","atMs","creditCampaignSequenceOutcome","timeline","writer","resolveMx","domain","resolveAddress","Promise","resolve","textGenerator","generator","defaultOutreachLinkDomainDeps","hosts","loaded","list","listTrackingHosts","setUp","setUpTrackingHost","verify","verifyTrackingHost","remove","removeTrackingHost","consoleOrigin","defaultOutreachStepTestDeps","openMailbox","mailboxId","consumeRateLimit","clickLinkUrl","linkId","trackingOrigin","origin","clickLinkOrigin","senderAddress","resolveTrackingLinkOrigin","registerOutreachRoutes","deps","sequences","enroll","curate","orgSubject","subject","settings","web","sequencesSave","save","sequencesStatus","status","sequencesDelete","enrollPreview","preview","confirm","enrollmentsAction","enrollmentsGateway","curateDraft","draft","curateSave","stepTest","doNotContactDomains","linkDomains"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,0BAA0B,QAAQ,qDAAoD;AAC/F,SAASC,mBAAmB,QAAQ,qDAAoD;AACxF,SAASC,sBAAsB,QAAQ,sBAAqB;AAC5D,SAASC,aAAa,QAAQ,iDAAgD;AAC9E,SAASC,uBAAuB,QAAQ,uDAAsD;AAC9F,SAASC,mBAAmB,QAAQ,6BAAyB;AAC7D,SAASC,yBAAyB,QAAQ,oCAAgC;AAC1E,SAASC,kBAAkB,QAAQ,0CAAsC;AACzE,SAASC,oBAAoB,QAAQ,2BAAuB;AAC5D,SAASC,0BAA0B,QAAQ,qBAAiB;AAC5D,SAASC,sCAAsC,QAAQ,6BAAyB;AAChF,SAASC,0BAA0B,QAAsC,qBAAiB;AAC1F,SACEC,mCAAmC,EACnCC,oCAAoC,QAC/B,yBAAqB;AAC5B,SAASC,sBAAsB,QAAQ,iCAA6B;AACpE,SAASC,8BAA8B,QAA0C,0BAAsB;AACvG,SAASC,0BAA0B,QAAQ,sBAAkB;AAE7D,SAASC,4BAA4B,QAAQ,uBAAmB;AAChE,SAASC,2BAA2B,QAAQ,uBAAmB;AAC/D,SAASC,2BAA2B,QAAmC,wBAAoB;AAE3F;;;;;;;;;;;CAWC,GAED,+EAA+E,GAC/E,MAAMC,WAAW,IAAM,MAAM,CAAC;AAE9B,wCAAwC,GACxC,OAAO,SAASC;IACd,OAAO;QACLC,eAAe,CAACC,UAAYpB,cAAcqB,GAAG,GAAGC,IAAI,GAAGH,aAAa,CAACC;QACrEG,uBAAuB,OAAOC,KAAKC,UAAY,AAAC,CAAA,MAAMR,UAAS,EAAGM,qBAAqB,CAACC,KAAKC;QAC7FC,2BAA2B,OAAOF,KAAKG,OAAOC,MAC5C,AAAC,CAAA,MAAMX,UAAS,EAAGS,yBAAyB,CAACF,KAAKG,OAAOC;QAC3DC,SAAS,OAAOF;gBAEYG;YAD1B,MAAMA,WAAW,MAAM9B,cAAcqB,GAAG,GAAGU,SAAS,GAAGC,UAAU,CAAC,QAAQC,GAAG,CAACN,OAAOO,GAAG;YACxF,OAAOJ,SAASK,MAAM,IAAIL,iBAAAA,SAASM,IAAI,cAAbN,iBAAmB,CAAC,IAAK;QACrD;QACAO,iBAAiB,OAAOC,UAAY,AAAC,CAAA,MAAMrB,UAAS,EAAGoB,eAAe,CAACC;IACzE;AACF;AAEA,OAAO,SAASC;IACd,OAAO;QACLR,WAAW,IAAM/B,cAAcqB,GAAG,GAAGU,SAAS;QAC9CS,MAAMtB;QACNuB,KAAKC,KAAKD,GAAG;QACbE,QAAQC,KAAKD,MAAM;QACnBE,gBAAgB,OAAOlB,OAAOmB,OAAOC,QAAQC,SAC3C,AAAC,CAAA,MAAM/B,UAAS,EAAG4B,cAAc,CAAClB,OAAOmB,OAAOC,QAAQC;QAC1DC,eAAe,OAAOC,QAAU,AAAC,CAAA,MAAMjC,UAAS,EAAGgC,aAAa,CAACC;QACjEC,uBAAuB,OAAOC;YAC5B,MAAM,AAAC,CAAA,MAAMnC,UAAS,EAAGkC,qBAAqB,CAACC;QACjD;QACAC,gBAAgB,OAAO,EAAEC,MAAM,EAAE3B,KAAK,EAAE4B,WAAW,EAAEC,OAAO,EAAEC,IAAI,EAAE;YAClE,MAAM,AAAC,CAAA,MAAMxC,UAAS,EAAGyC,6BAA6B,CACpD;gBAAEJ;gBAAQ3B;gBAAO4B;gBAAaC;gBAASC;YAAK,GAC5CzD,cAAcqB,GAAG,GAAGU,SAAS;QAEjC;QACA,uEAAuE;QACvE,qEAAqE;QACrE,oEAAoE;QACpE4B,UAAU;;gBAAM9D;4BAAAA,8BAAAA,iDAAAA,4BAA8B+D,MAAM,mBAAI;;QACxD,sEAAsE;QACtE,oEAAoE;QACpE,oDAAoD;QACpDC,WAAW,CAACC,SAAW7D,0BAA0B4D,SAAS,CAACC;QAC3DC,gBAAgB,CAACD;;gBAAW7D,yCAAAA;4BAAAA,0CAAAA,CAAAA,2BAAAA,2BAA0B8D,cAAc,qBAAxC9D,6CAAAA,0BAA2C6D,0BAAWE,QAAQC,OAAO,CAAC;;QAClG,wEAAwE;QACxE,uEAAuE;QACvE,6DAA6D;QAC7DC,eAAe;;gBAAMpE;4BAAAA,uBAAAA,0CAAAA,qBAAuBqE,SAAS,mBAAI;;IAC3D;AACF;AAEA,6FAA6F,GAC7F,OAAO,SAASC;IACd,OAAO;QACLC,OAAO;YACL,MAAMC,SAAS,MAAMrD;YACrB,OAAO;gBACLsD,MAAMD,OAAOE,iBAAiB;gBAC9BC,OAAOH,OAAOI,iBAAiB;gBAC/BC,QAAQL,OAAOM,kBAAkB;gBACjCC,QAAQP,OAAOQ,kBAAkB;YACnC;QACF;QACAC,eAAepE;IACjB;AACF;AAEA;;;;CAIC,GACD,OAAO,SAASqE;IACd,OAAO;QACLC,aAAa,CAACC,YAAc/E,0BAA0BH,cAAcqB,GAAG,GAAGU,SAAS,IAAI;gBAAEmD;YAAU;QACnGC,kBAAkB,OAAOvD,KAAKU,UAC5B,AAAC,CAAA,MAAM,MAAM,CAAC,mDAAkD,EAAG6C,gBAAgB,CAACvD,KAAKU;QAC3F8C,cAAc,CAACC,QAAQC,iBACrBjF,qBAAqB;gBAAEkF,QAAQ5E;gBAA0B0E;gBAAQC;YAAe;QAClFE,iBAAiB,OAAO,EAAE7D,KAAK,EAAE8D,aAAa,EAAE,GAC9C,AAAC,CAAA,MAAMxE,UAAS,EAAGyE,yBAAyB,CAAC1F,cAAcqB,GAAG,GAAGU,SAAS,IAAIJ,OAAO8D;IACzF;AACF;AAEA,OAAO,SAASE,uBACdC,OAAgCrD,0BAA0B;IAE1D,MAAMsD,YAAY/E,6BAA6B8E;IAC/C,MAAME,SAAStF,2BAA2BoF;IAC1C,MAAMG,SAASzF,2BAA2BsF;IAC1C,MAAMI,aAAa;QAAEC,SAAS7F;IAAmB;IACjDL,uBAAuBG,oBAAoBgG,QAAQ,EAAE;QAAEC,KAAKpF,4BAA4B6E;IAAM,GAAGI;IACjGjG,uBAAuBG,oBAAoBkG,aAAa,EAAE;QAAED,KAAKN,UAAUQ,IAAI;IAAC,GAAGL;IACnFjG,uBAAuBG,oBAAoBoG,eAAe,EAAE;QAAEH,KAAKN,UAAUU,MAAM;IAAC,GAAGP;IACvFjG,uBAAuBG,oBAAoBsG,eAAe,EAAE;QAAEL,KAAKN,UAAUhB,MAAM;IAAC,GAAGmB;IACvFjG,uBAAuBG,oBAAoBuG,aAAa,EAAE;QAAEN,KAAKL,OAAOY,OAAO;IAAC,GAAGV;IACnFjG,uBAAuBG,oBAAoB4F,MAAM,EAAE;QAAEK,KAAKL,OAAOa,OAAO;IAAC,GAAGX;IAC5EjG,uBACEG,oBAAoB0G,iBAAiB,EACrC;QAAET,KAAK1F,oCAAoCmF;IAAM,GACjDI;IAEFjG,uBACEG,oBAAoB2G,kBAAkB,EACtC;QAAEV,KAAKzF,qCAAqCkF;IAAM,GAClDI;IAEFjG,uBAAuBG,oBAAoBwG,OAAO,EAAE;QAAEP,KAAKtF,2BAA2B+E;IAAM,GAAGI;IAC/FjG,uBAAuBG,oBAAoB4G,WAAW,EAAE;QAAEX,KAAKJ,OAAOgB,KAAK;IAAC,GAAGf;IAC/EjG,uBAAuBG,oBAAoB8G,UAAU,EAAE;QAAEb,KAAKJ,OAAOM,IAAI;IAAC,GAAGL;IAC7EjG,uBACEG,oBAAoB+G,QAAQ,EAC5B;QAAEd,KAAKnF,4BAA4B4E,MAAMZ;IAA+B,GACxEgB;IAEFjG,uBACEG,oBAAoBgH,mBAAmB,EACvC;QAAEf,KAAK5F,uCAAuCqF;IAAM,GACpDI;IAEFjG,uBACEG,oBAAoBiH,WAAW,EAC/B;QAAEhB,KAAKvF,+BAA+BgF,MAAMxB;IAAiC,GAC7E4B;AAEJ"}
|
|
@@ -60,7 +60,9 @@ export interface OutreachRouteDeps {
|
|
|
60
60
|
creditCampaign: OutreachCampaignCredit['credit'];
|
|
61
61
|
/**
|
|
62
62
|
* The MX resolver the enroll routes classify a domain's mail gateway with
|
|
63
|
-
* (AGL-3326) —
|
|
63
|
+
* (AGL-3326) — the platform's pinned resolver (`platformMailDnsResolver`).
|
|
64
64
|
*/
|
|
65
65
|
resolveMx: OutreachResolveMx;
|
|
66
|
+
/** Whether a domain with no MX has an address record (AGL-3328); see the runtime's twin. */
|
|
67
|
+
resolveAddress?: (domain: string) => Promise<boolean>;
|
|
66
68
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/route-deps.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 { OutreachCampaignCredit, OutreachRecordEmailStamp } from '../runtime/runtime-deps'\nimport type { OutreachResolveMx } from '../storage/domain-intel-store'\nimport type { OutreachRouteGateDeps } from './route-gate'\n\n/**\n * What the settings, sequence and enrollment routes reach outside this\n * plugin (AGL-2980). The platform's own are `register-routes.ts`; specs\n * build their own.\n */\n\n/**\n * The org activity target a sequence's rows are filed under: Outreach's own\n * namespaced resource (`pluginId:noun`), which the feed reads as \"Sequence\".\n * Core's target list names only core's resources, so a plugin's never joins\n * it.\n */\nexport const OUTREACH_SEQUENCE_ACTIVITY_TARGET = 'outreach:sequence'\n\n/** What an org activity line written by these routes points at. */\nexport type OutreachActivityTarget =\n | { type: 'org'; id: string }\n | { type: typeof OUTREACH_SEQUENCE_ACTIVITY_TARGET; id: string; name: string }\n\nexport interface OutreachRouteDeps {\n firestore(): FirebaseFirestore.Firestore\n gate: OutreachRouteGateDeps\n now(): number\n /** A source of numbers in `[0, 1)` — the scheduler's jitter. */\n random(): number\n logOrgActivity(\n orgId: string,\n actor: { uid: string; email?: string | null },\n action: string,\n target: OutreachActivityTarget,\n ): Promise<void>\n /**\n * Stamps a member's do-not-contact mark on the lead and the contact that\n * carry the address (AGL-3245); the runtime's twin is on its own deps.\n */\n stampRecordEmailState(stamp: OutreachRecordEmailStamp): Promise<void>\n /**\n * Credits an enrollment to the sequence's campaigns (AGL-3254) — the\n * enroll route's `enrolled`; the runtime's twin credits the rest.\n */\n creditCampaign: OutreachCampaignCredit['credit']\n /**\n * The MX resolver the enroll routes classify a domain's mail gateway with\n * (AGL-3326) —
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/route-deps.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 { OutreachCampaignCredit, OutreachRecordEmailStamp } from '../runtime/runtime-deps'\nimport type { OutreachResolveMx } from '../storage/domain-intel-store'\nimport type { OutreachRouteGateDeps } from './route-gate'\n\n/**\n * What the settings, sequence and enrollment routes reach outside this\n * plugin (AGL-2980). The platform's own are `register-routes.ts`; specs\n * build their own.\n */\n\n/**\n * The org activity target a sequence's rows are filed under: Outreach's own\n * namespaced resource (`pluginId:noun`), which the feed reads as \"Sequence\".\n * Core's target list names only core's resources, so a plugin's never joins\n * it.\n */\nexport const OUTREACH_SEQUENCE_ACTIVITY_TARGET = 'outreach:sequence'\n\n/** What an org activity line written by these routes points at. */\nexport type OutreachActivityTarget =\n | { type: 'org'; id: string }\n | { type: typeof OUTREACH_SEQUENCE_ACTIVITY_TARGET; id: string; name: string }\n\nexport interface OutreachRouteDeps {\n firestore(): FirebaseFirestore.Firestore\n gate: OutreachRouteGateDeps\n now(): number\n /** A source of numbers in `[0, 1)` — the scheduler's jitter. */\n random(): number\n logOrgActivity(\n orgId: string,\n actor: { uid: string; email?: string | null },\n action: string,\n target: OutreachActivityTarget,\n ): Promise<void>\n /**\n * Stamps a member's do-not-contact mark on the lead and the contact that\n * carry the address (AGL-3245); the runtime's twin is on its own deps.\n */\n stampRecordEmailState(stamp: OutreachRecordEmailStamp): Promise<void>\n /**\n * Credits an enrollment to the sequence's campaigns (AGL-3254) — the\n * enroll route's `enrolled`; the runtime's twin credits the rest.\n */\n creditCampaign: OutreachCampaignCredit['credit']\n /**\n * The MX resolver the enroll routes classify a domain's mail gateway with\n * (AGL-3326) — the platform's pinned resolver (`platformMailDnsResolver`).\n */\n resolveMx: OutreachResolveMx\n /** Whether a domain with no MX has an address record (AGL-3328); see the runtime's twin. */\n resolveAddress?: (domain: string) => Promise<boolean>\n}\n"],"names":["OUTREACH_SEQUENCE_ACTIVITY_TARGET"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAMD;;;;CAIC,GAED;;;;;CAKC,GACD,OAAO,MAAMA,oCAAoC,oBAAmB"}
|
|
@@ -16,6 +16,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
16
16
|
* limitations under the License.
|
|
17
17
|
*/ import { normalizeCampaignIds } from "@aglyn/aglyn/app-utils/campaign-membership";
|
|
18
18
|
import { createResourceUid } from "@aglyn/aglyn/app-utils/create-resource-uid";
|
|
19
|
+
import { nameSearchFields } from "@aglyn/aglyn/app-utils/name-search";
|
|
19
20
|
import { applyOutreachEnrollmentEvent } from "../engine/enrollment-state.js";
|
|
20
21
|
import { validateOutreachSequence, validateOutreachSequenceActivation } from "../engine/sequence-validation.js";
|
|
21
22
|
import { OUTREACH_OPEN_ENROLLMENT_STATUSES } from "../engine/gates.js";
|
|
@@ -255,7 +256,11 @@ export function createOutreachSequenceRoutes(deps) {
|
|
|
255
256
|
createdAtMs: (existing == null ? void 0 : existing.createdAtMs) || nowMs,
|
|
256
257
|
updatedAtMs: nowMs
|
|
257
258
|
});
|
|
258
|
-
|
|
259
|
+
/*
|
|
260
|
+
* The list's search fields ride with every write of the name (AGL-3321):
|
|
261
|
+
* the sequences list asks Firestore for them, and a sequence missing
|
|
262
|
+
* them would list normally and never be found.
|
|
263
|
+
*/ await ref.set(_extends({}, sequence, nameSearchFields(sequence.name)));
|
|
259
264
|
if (!existing) await activity(caller, OUTREACH_SEQUENCE_ACTIVITY.create, sequence);
|
|
260
265
|
return outreachOk({
|
|
261
266
|
ok: true,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/sequence-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 { normalizeCampaignIds } from '@aglyn/aglyn/app-utils/campaign-membership'\nimport { createResourceUid } from '@aglyn/aglyn/app-utils/create-resource-uid'\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport { applyOutreachEnrollmentEvent } from '../engine/enrollment-state'\nimport {\n validateOutreachSequence,\n validateOutreachSequenceActivation,\n} from '../engine/sequence-validation'\nimport type {\n OutreachSequenceAction,\n OutreachSequenceSaveResponse,\n OutreachSequenceStatusResponse,\n} from '../model/outreach-api'\nimport { OUTREACH_OPEN_ENROLLMENT_STATUSES } from '../engine/gates'\nimport type { OutreachMailbox, OutreachSequence, OutreachSequenceStatus } from '../model/outreach.types'\nimport {\n outreachEnrolledSequenceIssues,\n outreachMailboxActivationIssue,\n readOutreachSequenceDraft,\n type OutreachSequenceDraft,\n type OutreachSequenceIssue,\n} from '../model/sequence-draft'\nimport { readOutreachComplianceSettingsDoc } from '../storage/compliance-settings-store'\nimport {\n outreachOrgCollection,\n readStoredOutreachEnrollment,\n readStoredOutreachMailbox,\n readStoredOutreachSequence,\n} from '../storage/outreach-records'\nimport { OUTREACH_SEQUENCE_ACTIVITY_TARGET, type OutreachRouteDeps } 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 * THE SEQUENCE ROUTES (AGL-2980): save, activate, pause, archive, delete.\n *\n * ## Who may change a sequence\n *\n * A sequence sends as the member whose mailbox it names, so it is theirs to\n * change: the member who connected the mailbox, or an organization owner or\n * admin, may save it, activate it and send from it — the same two the\n * mailbox's own routes let manage the mailbox. Anyone with `outreach.use`\n * may pause or archive any sequence, because stopping mail is never the act\n * that needs guarding.\n *\n * ## Saving\n *\n * The draft is read with the editor's reader and judged by the engine's\n * validator, so the save refuses exactly what the editor showed beside a\n * field. On top of that, the site must be one of the organization's, the\n * mailbox one of its connected mailboxes, the countries ones the\n * organization allows, and — once anyone is enrolled — the steps, site and\n * mailbox fixed (`outreachEnrolledSequenceIssues`). A new sequence is a\n * draft; an edit keeps the status it had, and an active sequence stays\n * active only because the edit passed the same validator activation does.\n *\n * ## Activating\n *\n * `validateOutreachSequenceActivation`: everything a save refuses on, and an\n * organization whose footer cannot be written — no legal name, or no postal\n * address. And its mailbox must be sending (`outreachMailboxActivationIssue`):\n * chosen, still connected, not paused, and not waiting to be reconnected —\n * the same words the sequence page shows beside its disabled Activate.\n *\n * ## Archiving\n *\n * Final. Every person still active or paused in the sequence is stopped\n * with `sequence_archived`, in batches, so no archived sequence goes on\n * sending.\n */\n\n/** A batch holds 500 writes; a page of enrollments stops this many. */\nconst ARCHIVE_PAGE = 400\n\n/** The activity line each lifecycle act writes. */\nexport const OUTREACH_SEQUENCE_ACTIVITY = {\n create: 'Created a sequence',\n activate: 'Activated a sequence',\n pause: 'Paused a sequence',\n archive: 'Archived a sequence',\n delete: 'Deleted a sequence',\n} as const\n\n/** Where each action may start, and where it ends. */\nconst TRANSITIONS: Record<\n OutreachSequenceAction,\n { from: readonly OutreachSequenceStatus[]; to: OutreachSequenceStatus }\n> = {\n activate: { from: ['draft', 'paused'], to: 'active' },\n pause: { from: ['active'], to: 'paused' },\n archive: { from: ['draft', 'active', 'paused'], to: 'archived' },\n}\n\nconst ACTION_REFUSALS: Record<OutreachSequenceAction, string> = {\n activate: 'Only a draft or paused sequence can be activated.',\n pause: 'Only an active sequence can be paused.',\n archive: 'This sequence is already archived.',\n}\n\nexport interface OutreachSequenceRoutes {\n save: PluginWebApiHandler\n status: PluginWebApiHandler\n remove: PluginWebApiHandler\n}\n\ntype Firestore = FirebaseFirestore.Firestore\n\nasync function loadSequence(\n firestore: Firestore,\n orgId: string,\n sequenceId: string,\n): Promise<OutreachSequence | null> {\n const snapshot = await outreachOrgCollection(firestore, orgId, 'sequences').doc(sequenceId).get()\n return readStoredOutreachSequence(sequenceId, snapshot.exists ? snapshot.data() : undefined)\n}\n\n/** Whether anyone was ever enrolled in the sequence. */\nasync function hasEnrollments(firestore: Firestore, orgId: string, sequenceId: string): Promise<boolean> {\n const found = await outreachOrgCollection(firestore, orgId, 'enrollments')\n .where('sequenceId', '==', sequenceId)\n .limit(1)\n .get()\n return !found.empty\n}\n\nconst issue = (\n path: string,\n code: OutreachSequenceIssue['code'],\n message: string,\n): OutreachSequenceIssue => ({ path, code, message, severity: 'error' })\n\n/**\n * The organization-level facts a draft has to agree with: its site is the\n * organization's, its mailbox is connected and the caller may send from it,\n * and its countries are ones the organization allows. `activating` adds\n * that the mailbox is sending now — chosen, and neither paused nor waiting\n * to be reconnected.\n */\nasync function draftPlacementIssues(\n firestore: Firestore,\n caller: OutreachRouteCaller,\n // A draft, or a stored sequence being activated, which may predate the\n // campaign field (AGL-3254).\n draft: Pick<OutreachSequenceDraft, 'hostId' | 'mailboxId' | 'settings'> & Pick<OutreachSequence, 'campaignIds'>,\n options: { activating?: boolean } = {},\n): Promise<OutreachSequenceIssue[]> {\n const issues: OutreachSequenceIssue[] = []\n const campaignIds = normalizeCampaignIds(draft.campaignIds)\n if (draft.hostId) {\n const host = readOutreachDocumentId(draft.hostId)\n ? await firestore.collection('hosts').doc(draft.hostId).get()\n : null\n if (!host?.exists || host.get('orgId') !== caller.orgId) {\n issues.push(issue('hostId', 'host_unknown', \"Choose one of this organization's sites.\"))\n }\n } else {\n issues.push(issue('hostId', 'host_unknown', \"Choose the site whose contacts this sequence emails.\"))\n }\n const mailbox = draft.mailboxId ? await loadMailbox(firestore, caller.orgId, draft.mailboxId) : null\n const activation = options.activating ? outreachMailboxActivationIssue(draft.mailboxId, mailbox) : null\n if (draft.mailboxId) {\n const placement = mailboxIssues(caller, mailbox)\n // A mailbox that is gone is refused once, in activation's words when\n // activating: they say what to do next.\n issues.push(\n ...(activation?.code === 'mailbox_unknown'\n ? placement.filter((entry) => entry.code !== 'mailbox_unknown')\n : placement),\n )\n }\n if (activation && !issues.some((entry) => entry.code === 'mailbox_not_yours')) issues.push(activation)\n const settings = await readOutreachComplianceSettingsDoc(firestore, caller.orgId)\n const outside = draft.settings.allowedCountries.filter(\n (code) => !settings.allowedCountries.includes(code),\n )\n if (outside.length) {\n issues.push(\n issue(\n 'settings.allowedCountries',\n 'country_not_in_org',\n `${outside.join(', ')} ${outside.length === 1 ? \"isn't\" : \"aren't\"} among the countries your organization allows. Add ${outside.length === 1 ? 'it' : 'them'} in Sequences compliance settings first.`,\n ),\n )\n }\n /*\n * The campaigns must be the ORGANIZATION's live containers\n * (`orgs/{orgId}/emailCampaigns`, AGL-3254): a request can claim any id,\n * and a sequence filed under another org's campaign, or under one the\n * console soft-deleted, would credit its outcomes to a page nobody here\n * can open. A sequence is an org record, so any live campaign of the org\n * qualifies, whichever sites it is placed on.\n */\n if (campaignIds.length) {\n const containers = firestore.collection('orgs').doc(caller.orgId).collection('emailCampaigns')\n const found = await firestore.getAll(\n ...campaignIds.map((campaignId) => containers.doc(readOutreachDocumentId(campaignId) ?? '-')),\n )\n const unknown = campaignIds.filter((campaignId, index) => {\n const snapshot = found[index]\n return !readOutreachDocumentId(campaignId) || !snapshot?.exists || Boolean(snapshot.get('deletedAt'))\n })\n if (unknown.length) {\n issues.push(\n issue(\n 'campaignIds',\n 'campaign_unknown',\n unknown.length === 1\n ? \"One of the campaigns picked isn't one of this organization's campaigns any more. Pick it again.\"\n : \"Some of the campaigns picked aren't this organization's campaigns any more. Pick them again.\",\n ),\n )\n }\n }\n return issues\n}\n\n/** A mailbox of this organization's, as stored; `null` when there is none by that id. */\nasync function loadMailbox(firestore: Firestore, orgId: string, mailboxId: string): Promise<OutreachMailbox | null> {\n const snapshot = readOutreachDocumentId(mailboxId)\n ? await outreachOrgCollection(firestore, orgId, 'mailboxes').doc(mailboxId).get()\n : null\n return readStoredOutreachMailbox(mailboxId, snapshot?.exists ? snapshot.data() : undefined)\n}\n\n/** Whether the mailbox is connected here, and the caller may send from it. */\nfunction mailboxIssues(caller: OutreachRouteCaller, mailbox: OutreachMailbox | null): OutreachSequenceIssue[] {\n if (!mailbox || mailbox.status === 'disconnected') {\n return [issue('mailboxId', 'mailbox_unknown', 'That mailbox is not connected to this organization.')]\n }\n if (mailbox.connectedByUid !== caller.uid && !caller.isOrgAdmin) {\n return [\n issue(\n 'mailboxId',\n 'mailbox_not_yours',\n 'Only the member who connected this mailbox, or an organization owner or admin, can send from it.',\n ),\n ]\n }\n return []\n}\n\n/** One issue per field and code: activation and the engine can both ask for a mailbox. */\nfunction distinctIssues(issues: readonly OutreachSequenceIssue[]): OutreachSequenceIssue[] {\n const seen = new Set<string>()\n return issues.filter((entry) => {\n const key = `${entry.path}|${entry.code}`\n if (seen.has(key)) return false\n seen.add(key)\n return true\n })\n}\n\nexport function createOutreachSequenceRoutes(deps: OutreachRouteDeps): OutreachSequenceRoutes {\n const activity = (caller: OutreachRouteCaller, action: string, sequence: Pick<OutreachSequence, 'id' | 'name'>) =>\n deps.logOrgActivity(caller.orgId, { uid: caller.uid, email: caller.email }, action, {\n type: OUTREACH_SEQUENCE_ACTIVITY_TARGET,\n id: sequence.id,\n name: sequence.name,\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 firestore = deps.firestore()\n\n const rawId = body['sequenceId']\n const sequenceId = rawId === undefined || rawId === null || rawId === '' ? null : readOutreachDocumentId(rawId)\n if (rawId && !sequenceId) return outreachRefusal(400, 'invalid-request', 'Name the sequence to save.')\n const existing = sequenceId ? await loadSequence(firestore, caller.orgId, sequenceId) : null\n if (sequenceId && !existing) {\n return outreachRefusal(404, 'sequence-not-found', 'That sequence no longer exists.')\n }\n if (existing?.status === 'archived') {\n return outreachRefusal(409, 'sequence-archived', \"An archived sequence can't be edited.\")\n }\n\n const draft = readOutreachSequenceDraft(body['sequence'])\n // A sequence sends as its mailbox's member, so it is theirs (or an\n // admin's) to change — the stored mailbox as well as a new one: moving\n // a colleague's sequence onto your own mailbox is still changing theirs.\n if (existing?.mailboxId && existing.mailboxId !== draft.mailboxId) {\n const stored = mailboxIssues(caller, await loadMailbox(firestore, caller.orgId, existing.mailboxId))\n const refused = stored.find((entry) => entry.code === 'mailbox_not_yours')\n if (refused) return outreachRefusal(403, 'permission', refused.message)\n }\n const judged: OutreachSequenceIssue[] = [\n ...validateOutreachSequence(draft),\n ...(await draftPlacementIssues(firestore, caller, draft)),\n ]\n const ownership = judged.find((entry) => entry.code === 'mailbox_not_yours')\n if (ownership) return outreachRefusal(403, 'permission', ownership.message)\n if (existing && (await hasEnrollments(firestore, caller.orgId, existing.id))) {\n judged.push(...outreachEnrolledSequenceIssues(existing, draft))\n }\n const errors = judged.filter((entry) => entry.severity === 'error')\n if (errors.length) {\n return outreachRefusal(400, 'invalid-sequence', errors[0].message, { issues: judged })\n }\n\n const nowMs = deps.now()\n const ref = existing\n ? outreachOrgCollection(firestore, caller.orgId, 'sequences').doc(existing.id)\n : outreachOrgCollection(firestore, caller.orgId, 'sequences').doc(createResourceUid())\n const sequence: OutreachSequence = {\n id: ref.id,\n ...draft,\n status: existing?.status ?? 'draft',\n /*\n * Carried through explicitly, because the write below REPLACES the\n * document (AGL-3239). The counters are the runtime's and the click\n * route's, never the editor's, and a member renaming a sequence must\n * not reset what it measured.\n */\n ...(existing?.stats ? { stats: existing.stats } : {}),\n createdAtMs: existing?.createdAtMs || nowMs,\n updatedAtMs: nowMs,\n }\n await ref.set(sequence)\n if (!existing) await activity(caller, OUTREACH_SEQUENCE_ACTIVITY.create, sequence)\n return outreachOk({\n ok: true,\n sequence,\n created: !existing,\n warnings: judged.filter((entry) => entry.severity === 'warning'),\n } satisfies OutreachSequenceSaveResponse)\n }\n\n const status: 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 action = body['action'] as OutreachSequenceAction\n const sequenceId = readOutreachDocumentId(body['sequenceId'])\n if (!sequenceId || !Object.prototype.hasOwnProperty.call(TRANSITIONS, action)) {\n return outreachRefusal(400, 'invalid-request', 'Name the sequence, and activate, pause or archive.')\n }\n const firestore = deps.firestore()\n const sequence = await loadSequence(firestore, caller.orgId, sequenceId)\n if (!sequence) return outreachRefusal(404, 'sequence-not-found', 'That sequence no longer exists.')\n const transition = TRANSITIONS[action]\n if (sequence.status === transition.to) {\n return outreachOk({ ok: true, sequence, stoppedEnrollments: 0 } satisfies OutreachSequenceStatusResponse)\n }\n if (!transition.from.includes(sequence.status)) {\n return outreachRefusal(409, 'transition-refused', ACTION_REFUSALS[action])\n }\n\n if (action === 'activate') {\n const orgSettings = await readOutreachComplianceSettingsDoc(firestore, caller.orgId)\n const judged: OutreachSequenceIssue[] = distinctIssues([\n ...validateOutreachSequenceActivation(sequence, orgSettings),\n ...(await draftPlacementIssues(firestore, caller, sequence, { activating: true })),\n ]).filter((entry) => entry.severity === 'error')\n if (judged.length) {\n const ownership = judged.find((entry) => entry.code === 'mailbox_not_yours')\n return ownership\n ? outreachRefusal(403, 'permission', ownership.message)\n : outreachRefusal(409, 'activation-refused', judged[0].message, { issues: judged })\n }\n }\n\n const nowMs = deps.now()\n const next: OutreachSequence = { ...sequence, status: transition.to, updatedAtMs: nowMs }\n await outreachOrgCollection(firestore, caller.orgId, 'sequences')\n .doc(sequence.id)\n .update({ status: next.status, updatedAtMs: nowMs })\n const stoppedEnrollments =\n action === 'archive' ? await stopOpenEnrollments(firestore, caller, sequence.id, nowMs) : 0\n await activity(caller, OUTREACH_SEQUENCE_ACTIVITY[action], sequence)\n return outreachOk({ ok: true, sequence: next, stoppedEnrollments } satisfies OutreachSequenceStatusResponse)\n }\n\n const remove: 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 sequenceId = readOutreachDocumentId(body['sequenceId'])\n if (!sequenceId) return outreachRefusal(400, 'invalid-request', 'Name the sequence to delete.')\n const firestore = deps.firestore()\n const sequence = await loadSequence(firestore, caller.orgId, sequenceId)\n if (!sequence) return outreachRefusal(404, 'sequence-not-found', 'That sequence no longer exists.')\n if (sequence.status !== 'draft' || (await hasEnrollments(firestore, caller.orgId, sequence.id))) {\n // What people were sent, and why they stopped, is the record of what\n // this organization did; only a draft nobody was enrolled in has none.\n return outreachRefusal(\n 409,\n 'delete-refused',\n 'Only a draft nobody was enrolled in can be deleted. Archive this sequence instead.',\n )\n }\n await outreachOrgCollection(firestore, caller.orgId, 'sequences').doc(sequence.id).delete()\n await activity(caller, OUTREACH_SEQUENCE_ACTIVITY.delete, sequence)\n return outreachOk({ ok: true })\n }\n\n return { save, status, remove }\n}\n\n/**\n * Stops everyone still active or paused in an archived sequence, a page at\n * a time, through the engine's own transition. Returns how many it stopped.\n */\nasync function stopOpenEnrollments(\n firestore: Firestore,\n caller: OutreachRouteCaller,\n sequenceId: string,\n nowMs: number,\n): Promise<number> {\n const enrollments = outreachOrgCollection(firestore, caller.orgId, 'enrollments')\n let stopped = 0\n let after: FirebaseFirestore.QueryDocumentSnapshot | null = null\n for (;;) {\n let page = enrollments.where('sequenceId', '==', sequenceId).orderBy('__name__').limit(ARCHIVE_PAGE)\n if (after) page = page.startAfter(after)\n const snapshot = await page.get()\n if (snapshot.empty) break\n const batch = firestore.batch()\n let writes = 0\n for (const doc of snapshot.docs) {\n const enrollment = readStoredOutreachEnrollment(doc.id, doc.data())\n if (!enrollment || !OUTREACH_OPEN_ENROLLMENT_STATUSES.includes(enrollment.status)) continue\n const { patch } = applyOutreachEnrollmentEvent(enrollment, {\n type: 'stop',\n reason: 'sequence_archived',\n atMs: nowMs,\n byUid: caller.uid,\n })\n if (!patch) continue\n batch.update(doc.ref, { ...patch, updatedAtMs: nowMs })\n writes += 1\n }\n if (writes) await batch.commit()\n stopped += writes\n after = snapshot.docs[snapshot.docs.length - 1]\n if (snapshot.size < ARCHIVE_PAGE) break\n }\n return stopped\n}\n"],"names":["normalizeCampaignIds","createResourceUid","applyOutreachEnrollmentEvent","validateOutreachSequence","validateOutreachSequenceActivation","OUTREACH_OPEN_ENROLLMENT_STATUSES","outreachEnrolledSequenceIssues","outreachMailboxActivationIssue","readOutreachSequenceDraft","readOutreachComplianceSettingsDoc","outreachOrgCollection","readStoredOutreachEnrollment","readStoredOutreachMailbox","readStoredOutreachSequence","OUTREACH_SEQUENCE_ACTIVITY_TARGET","outreachRouteGate","outreachMethodNotAllowed","outreachOk","outreachRefusal","readOutreachDocumentId","readOutreachJsonBody","ARCHIVE_PAGE","OUTREACH_SEQUENCE_ACTIVITY","create","activate","pause","archive","delete","TRANSITIONS","from","to","ACTION_REFUSALS","loadSequence","firestore","orgId","sequenceId","snapshot","doc","get","exists","data","undefined","hasEnrollments","found","where","limit","empty","issue","path","code","message","severity","draftPlacementIssues","caller","draft","options","issues","campaignIds","hostId","host","collection","push","mailbox","mailboxId","loadMailbox","activation","activating","placement","mailboxIssues","filter","entry","some","settings","outside","allowedCountries","includes","length","join","containers","getAll","map","campaignId","unknown","index","Boolean","status","connectedByUid","uid","isOrgAdmin","distinctIssues","seen","Set","key","has","add","createOutreachSequenceRoutes","deps","activity","action","sequence","logOrgActivity","email","type","id","name","save","request","method","body","gate","Response","rawId","existing","stored","refused","find","judged","ownership","errors","nowMs","now","ref","stats","createdAtMs","updatedAtMs","set","ok","created","warnings","Object","prototype","hasOwnProperty","call","transition","stoppedEnrollments","orgSettings","next","update","stopOpenEnrollments","remove","enrollments","stopped","after","page","orderBy","startAfter","batch","writes","docs","enrollment","patch","reason","atMs","byUid","commit","size"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,oBAAoB,QAAQ,6CAA4C;AACjF,SAASC,iBAAiB,QAAQ,6CAA4C;AAE9E,SAASC,4BAA4B,QAAQ,gCAA4B;AACzE,SACEC,wBAAwB,EACxBC,kCAAkC,QAC7B,mCAA+B;AAMtC,SAASC,iCAAiC,QAAQ,qBAAiB;AAEnE,SACEC,8BAA8B,EAC9BC,8BAA8B,EAC9BC,yBAAyB,QAGpB,6BAAyB;AAChC,SAASC,iCAAiC,QAAQ,0CAAsC;AACxF,SACEC,qBAAqB,EACrBC,4BAA4B,EAC5BC,yBAAyB,EACzBC,0BAA0B,QACrB,iCAA6B;AACpC,SAASC,iCAAiC,QAAgC,kBAAc;AACxF,SAASC,iBAAiB,QAAkC,kBAAc;AAC1E,SACEC,wBAAwB,EACxBC,UAAU,EACVC,eAAe,EACfC,sBAAsB,EACtBC,oBAAoB,QACf,kBAAc;AAErB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoCC,GAED,qEAAqE,GACrE,MAAMC,eAAe;AAErB,iDAAiD,GACjD,OAAO,MAAMC,6BAA6B;IACxCC,QAAQ;IACRC,UAAU;IACVC,OAAO;IACPC,SAAS;IACTC,QAAQ;AACV,EAAU;AAEV,oDAAoD,GACpD,MAAMC,cAGF;IACFJ,UAAU;QAAEK,MAAM;YAAC;YAAS;SAAS;QAAEC,IAAI;IAAS;IACpDL,OAAO;QAAEI,MAAM;YAAC;SAAS;QAAEC,IAAI;IAAS;IACxCJ,SAAS;QAAEG,MAAM;YAAC;YAAS;YAAU;SAAS;QAAEC,IAAI;IAAW;AACjE;AAEA,MAAMC,kBAA0D;IAC9DP,UAAU;IACVC,OAAO;IACPC,SAAS;AACX;AAUA,eAAeM,aACbC,SAAoB,EACpBC,KAAa,EACbC,UAAkB;IAElB,MAAMC,WAAW,MAAM1B,sBAAsBuB,WAAWC,OAAO,aAAaG,GAAG,CAACF,YAAYG,GAAG;IAC/F,OAAOzB,2BAA2BsB,YAAYC,SAASG,MAAM,GAAGH,SAASI,IAAI,KAAKC;AACpF;AAEA,sDAAsD,GACtD,eAAeC,eAAeT,SAAoB,EAAEC,KAAa,EAAEC,UAAkB;IACnF,MAAMQ,QAAQ,MAAMjC,sBAAsBuB,WAAWC,OAAO,eACzDU,KAAK,CAAC,cAAc,MAAMT,YAC1BU,KAAK,CAAC,GACNP,GAAG;IACN,OAAO,CAACK,MAAMG,KAAK;AACrB;AAEA,MAAMC,QAAQ,CACZC,MACAC,MACAC,UAC2B,CAAA;QAAEF;QAAMC;QAAMC;QAASC,UAAU;IAAQ,CAAA;AAEtE;;;;;;CAMC,GACD,eAAeC,qBACbnB,SAAoB,EACpBoB,MAA2B,EAC3B,uEAAuE;AACvE,6BAA6B;AAC7BC,KAA+G,EAC/GC,UAAoC,CAAC,CAAC;IAEtC,MAAMC,SAAkC,EAAE;IAC1C,MAAMC,cAAczD,qBAAqBsD,MAAMG,WAAW;IAC1D,IAAIH,MAAMI,MAAM,EAAE;QAChB,MAAMC,OAAOxC,uBAAuBmC,MAAMI,MAAM,IAC5C,MAAMzB,UAAU2B,UAAU,CAAC,SAASvB,GAAG,CAACiB,MAAMI,MAAM,EAAEpB,GAAG,KACzD;QACJ,IAAI,EAACqB,wBAAAA,KAAMpB,MAAM,KAAIoB,KAAKrB,GAAG,CAAC,aAAae,OAAOnB,KAAK,EAAE;YACvDsB,OAAOK,IAAI,CAACd,MAAM,UAAU,gBAAgB;QAC9C;IACF,OAAO;QACLS,OAAOK,IAAI,CAACd,MAAM,UAAU,gBAAgB;IAC9C;IACA,MAAMe,UAAUR,MAAMS,SAAS,GAAG,MAAMC,YAAY/B,WAAWoB,OAAOnB,KAAK,EAAEoB,MAAMS,SAAS,IAAI;IAChG,MAAME,aAAaV,QAAQW,UAAU,GAAG3D,+BAA+B+C,MAAMS,SAAS,EAAED,WAAW;IACnG,IAAIR,MAAMS,SAAS,EAAE;QACnB,MAAMI,YAAYC,cAAcf,QAAQS;QACxC,qEAAqE;QACrE,wCAAwC;QACxCN,OAAOK,IAAI,IACLI,CAAAA,8BAAAA,WAAYhB,IAAI,MAAK,oBACrBkB,UAAUE,MAAM,CAAC,CAACC,QAAUA,MAAMrB,IAAI,KAAK,qBAC3CkB;IAER;IACA,IAAIF,cAAc,CAACT,OAAOe,IAAI,CAAC,CAACD,QAAUA,MAAMrB,IAAI,KAAK,sBAAsBO,OAAOK,IAAI,CAACI;IAC3F,MAAMO,WAAW,MAAM/D,kCAAkCwB,WAAWoB,OAAOnB,KAAK;IAChF,MAAMuC,UAAUnB,MAAMkB,QAAQ,CAACE,gBAAgB,CAACL,MAAM,CACpD,CAACpB,OAAS,CAACuB,SAASE,gBAAgB,CAACC,QAAQ,CAAC1B;IAEhD,IAAIwB,QAAQG,MAAM,EAAE;QAClBpB,OAAOK,IAAI,CACTd,MACE,6BACA,sBACA,GAAG0B,QAAQI,IAAI,CAAC,MAAM,CAAC,EAAEJ,QAAQG,MAAM,KAAK,IAAI,UAAU,SAAS,mDAAmD,EAAEH,QAAQG,MAAM,KAAK,IAAI,OAAO,OAAO,wCAAwC,CAAC;IAG5M;IACA;;;;;;;GAOC,GACD,IAAInB,YAAYmB,MAAM,EAAE;QACtB,MAAME,aAAa7C,UAAU2B,UAAU,CAAC,QAAQvB,GAAG,CAACgB,OAAOnB,KAAK,EAAE0B,UAAU,CAAC;QAC7E,MAAMjB,QAAQ,MAAMV,UAAU8C,MAAM,IAC/BtB,YAAYuB,GAAG,CAAC,CAACC;gBAA8B9D;mBAAf2D,WAAWzC,GAAG,EAAClB,0BAAAA,uBAAuB8D,uBAAvB9D,0BAAsC;;QAE1F,MAAM+D,UAAUzB,YAAYY,MAAM,CAAC,CAACY,YAAYE;YAC9C,MAAM/C,WAAWO,KAAK,CAACwC,MAAM;YAC7B,OAAO,CAAChE,uBAAuB8D,eAAe,EAAC7C,4BAAAA,SAAUG,MAAM,KAAI6C,QAAQhD,SAASE,GAAG,CAAC;QAC1F;QACA,IAAI4C,QAAQN,MAAM,EAAE;YAClBpB,OAAOK,IAAI,CACTd,MACE,eACA,oBACAmC,QAAQN,MAAM,KAAK,IACf,oGACA;QAGV;IACF;IACA,OAAOpB;AACT;AAEA,uFAAuF,GACvF,eAAeQ,YAAY/B,SAAoB,EAAEC,KAAa,EAAE6B,SAAiB;IAC/E,MAAM3B,WAAWjB,uBAAuB4C,aACpC,MAAMrD,sBAAsBuB,WAAWC,OAAO,aAAaG,GAAG,CAAC0B,WAAWzB,GAAG,KAC7E;IACJ,OAAO1B,0BAA0BmD,WAAW3B,CAAAA,4BAAAA,SAAUG,MAAM,IAAGH,SAASI,IAAI,KAAKC;AACnF;AAEA,4EAA4E,GAC5E,SAAS2B,cAAcf,MAA2B,EAAES,OAA+B;IACjF,IAAI,CAACA,WAAWA,QAAQuB,MAAM,KAAK,gBAAgB;QACjD,OAAO;YAACtC,MAAM,aAAa,mBAAmB;SAAuD;IACvG;IACA,IAAIe,QAAQwB,cAAc,KAAKjC,OAAOkC,GAAG,IAAI,CAAClC,OAAOmC,UAAU,EAAE;QAC/D,OAAO;YACLzC,MACE,aACA,qBACA;SAEH;IACH;IACA,OAAO,EAAE;AACX;AAEA,wFAAwF,GACxF,SAAS0C,eAAejC,MAAwC;IAC9D,MAAMkC,OAAO,IAAIC;IACjB,OAAOnC,OAAOa,MAAM,CAAC,CAACC;QACpB,MAAMsB,MAAM,GAAGtB,MAAMtB,IAAI,CAAC,CAAC,EAAEsB,MAAMrB,IAAI,EAAE;QACzC,IAAIyC,KAAKG,GAAG,CAACD,MAAM,OAAO;QAC1BF,KAAKI,GAAG,CAACF;QACT,OAAO;IACT;AACF;AAEA,OAAO,SAASG,6BAA6BC,IAAuB;IAClE,MAAMC,WAAW,CAAC5C,QAA6B6C,QAAgBC,WAC7DH,KAAKI,cAAc,CAAC/C,OAAOnB,KAAK,EAAE;YAAEqD,KAAKlC,OAAOkC,GAAG;YAAEc,OAAOhD,OAAOgD,KAAK;QAAC,GAAGH,QAAQ;YAClFI,MAAMxF;YACNyF,IAAIJ,SAASI,EAAE;YACfC,MAAML,SAASK,IAAI;QACrB;IAEF,MAAMC,OAA4B,OAAOC;;QACvC,IAAIA,QAAQC,MAAM,KAAK,QAAQ,OAAO3F,yBAAyB;QAC/D,MAAM4F,OAAO,MAAMxF,qBAAqBsF;QACxC,MAAMrD,SAAS,MAAMtC,kBAAkB2F,SAASE,IAAI,CAAC,QAAQ,EAAEZ,KAAKa,IAAI;QACxE,IAAIxD,kBAAkByD,UAAU,OAAOzD;QACvC,MAAMpB,YAAY+D,KAAK/D,SAAS;QAEhC,MAAM8E,QAAQH,IAAI,CAAC,aAAa;QAChC,MAAMzE,aAAa4E,UAAUtE,aAAasE,UAAU,QAAQA,UAAU,KAAK,OAAO5F,uBAAuB4F;QACzG,IAAIA,SAAS,CAAC5E,YAAY,OAAOjB,gBAAgB,KAAK,mBAAmB;QACzE,MAAM8F,WAAW7E,aAAa,MAAMH,aAAaC,WAAWoB,OAAOnB,KAAK,EAAEC,cAAc;QACxF,IAAIA,cAAc,CAAC6E,UAAU;YAC3B,OAAO9F,gBAAgB,KAAK,sBAAsB;QACpD;QACA,IAAI8F,CAAAA,4BAAAA,SAAU3B,MAAM,MAAK,YAAY;YACnC,OAAOnE,gBAAgB,KAAK,qBAAqB;QACnD;QAEA,MAAMoC,QAAQ9C,0BAA0BoG,IAAI,CAAC,WAAW;QACxD,mEAAmE;QACnE,uEAAuE;QACvE,yEAAyE;QACzE,IAAII,CAAAA,4BAAAA,SAAUjD,SAAS,KAAIiD,SAASjD,SAAS,KAAKT,MAAMS,SAAS,EAAE;YACjE,MAAMkD,SAAS7C,cAAcf,QAAQ,MAAMW,YAAY/B,WAAWoB,OAAOnB,KAAK,EAAE8E,SAASjD,SAAS;YAClG,MAAMmD,UAAUD,OAAOE,IAAI,CAAC,CAAC7C,QAAUA,MAAMrB,IAAI,KAAK;YACtD,IAAIiE,SAAS,OAAOhG,gBAAgB,KAAK,cAAcgG,QAAQhE,OAAO;QACxE;QACA,MAAMkE,SAAkC;eACnCjH,yBAAyBmD;eACxB,MAAMF,qBAAqBnB,WAAWoB,QAAQC;SACnD;QACD,MAAM+D,YAAYD,OAAOD,IAAI,CAAC,CAAC7C,QAAUA,MAAMrB,IAAI,KAAK;QACxD,IAAIoE,WAAW,OAAOnG,gBAAgB,KAAK,cAAcmG,UAAUnE,OAAO;QAC1E,IAAI8D,YAAa,MAAMtE,eAAeT,WAAWoB,OAAOnB,KAAK,EAAE8E,SAAST,EAAE,GAAI;YAC5Ea,OAAOvD,IAAI,IAAIvD,+BAA+B0G,UAAU1D;QAC1D;QACA,MAAMgE,SAASF,OAAO/C,MAAM,CAAC,CAACC,QAAUA,MAAMnB,QAAQ,KAAK;QAC3D,IAAImE,OAAO1C,MAAM,EAAE;YACjB,OAAO1D,gBAAgB,KAAK,oBAAoBoG,MAAM,CAAC,EAAE,CAACpE,OAAO,EAAE;gBAAEM,QAAQ4D;YAAO;QACtF;QAEA,MAAMG,QAAQvB,KAAKwB,GAAG;QACtB,MAAMC,MAAMT,WACRtG,sBAAsBuB,WAAWoB,OAAOnB,KAAK,EAAE,aAAaG,GAAG,CAAC2E,SAAST,EAAE,IAC3E7F,sBAAsBuB,WAAWoB,OAAOnB,KAAK,EAAE,aAAaG,GAAG,CAACpC;QACpE,MAAMkG,WAA6B;YACjCI,IAAIkB,IAAIlB,EAAE;WACPjD;YACH+B,MAAM,UAAE2B,4BAAAA,SAAU3B,MAAM,mBAAI;WAOxB2B,CAAAA,4BAAAA,SAAUU,KAAK,IAAG;YAAEA,OAAOV,SAASU,KAAK;QAAC,IAAI,CAAC;YACnDC,aAAaX,CAAAA,4BAAAA,SAAUW,WAAW,KAAIJ;YACtCK,aAAaL;;QAEf,MAAME,IAAII,GAAG,CAAC1B;QACd,IAAI,CAACa,UAAU,MAAMf,SAAS5C,QAAQ/B,2BAA2BC,MAAM,EAAE4E;QACzE,OAAOlF,WAAW;YAChB6G,IAAI;YACJ3B;YACA4B,SAAS,CAACf;YACVgB,UAAUZ,OAAO/C,MAAM,CAAC,CAACC,QAAUA,MAAMnB,QAAQ,KAAK;QACxD;IACF;IAEA,MAAMkC,SAA8B,OAAOqB;QACzC,IAAIA,QAAQC,MAAM,KAAK,QAAQ,OAAO3F,yBAAyB;QAC/D,MAAM4F,OAAO,MAAMxF,qBAAqBsF;QACxC,MAAMrD,SAAS,MAAMtC,kBAAkB2F,SAASE,IAAI,CAAC,QAAQ,EAAEZ,KAAKa,IAAI;QACxE,IAAIxD,kBAAkByD,UAAU,OAAOzD;QACvC,MAAM6C,SAASU,IAAI,CAAC,SAAS;QAC7B,MAAMzE,aAAahB,uBAAuByF,IAAI,CAAC,aAAa;QAC5D,IAAI,CAACzE,cAAc,CAAC8F,OAAOC,SAAS,CAACC,cAAc,CAACC,IAAI,CAACxG,aAAasE,SAAS;YAC7E,OAAOhF,gBAAgB,KAAK,mBAAmB;QACjD;QACA,MAAMe,YAAY+D,KAAK/D,SAAS;QAChC,MAAMkE,WAAW,MAAMnE,aAAaC,WAAWoB,OAAOnB,KAAK,EAAEC;QAC7D,IAAI,CAACgE,UAAU,OAAOjF,gBAAgB,KAAK,sBAAsB;QACjE,MAAMmH,aAAazG,WAAW,CAACsE,OAAO;QACtC,IAAIC,SAASd,MAAM,KAAKgD,WAAWvG,EAAE,EAAE;YACrC,OAAOb,WAAW;gBAAE6G,IAAI;gBAAM3B;gBAAUmC,oBAAoB;YAAE;QAChE;QACA,IAAI,CAACD,WAAWxG,IAAI,CAAC8C,QAAQ,CAACwB,SAASd,MAAM,GAAG;YAC9C,OAAOnE,gBAAgB,KAAK,sBAAsBa,eAAe,CAACmE,OAAO;QAC3E;QAEA,IAAIA,WAAW,YAAY;YACzB,MAAMqC,cAAc,MAAM9H,kCAAkCwB,WAAWoB,OAAOnB,KAAK;YACnF,MAAMkF,SAAkC3B,eAAe;mBAClDrF,mCAAmC+F,UAAUoC;mBAC5C,MAAMnF,qBAAqBnB,WAAWoB,QAAQ8C,UAAU;oBAAEjC,YAAY;gBAAK;aAChF,EAAEG,MAAM,CAAC,CAACC,QAAUA,MAAMnB,QAAQ,KAAK;YACxC,IAAIiE,OAAOxC,MAAM,EAAE;gBACjB,MAAMyC,YAAYD,OAAOD,IAAI,CAAC,CAAC7C,QAAUA,MAAMrB,IAAI,KAAK;gBACxD,OAAOoE,YACHnG,gBAAgB,KAAK,cAAcmG,UAAUnE,OAAO,IACpDhC,gBAAgB,KAAK,sBAAsBkG,MAAM,CAAC,EAAE,CAAClE,OAAO,EAAE;oBAAEM,QAAQ4D;gBAAO;YACrF;QACF;QAEA,MAAMG,QAAQvB,KAAKwB,GAAG;QACtB,MAAMgB,OAAyB,aAAKrC;YAAUd,QAAQgD,WAAWvG,EAAE;YAAE8F,aAAaL;;QAClF,MAAM7G,sBAAsBuB,WAAWoB,OAAOnB,KAAK,EAAE,aAClDG,GAAG,CAAC8D,SAASI,EAAE,EACfkC,MAAM,CAAC;YAAEpD,QAAQmD,KAAKnD,MAAM;YAAEuC,aAAaL;QAAM;QACpD,MAAMe,qBACJpC,WAAW,YAAY,MAAMwC,oBAAoBzG,WAAWoB,QAAQ8C,SAASI,EAAE,EAAEgB,SAAS;QAC5F,MAAMtB,SAAS5C,QAAQ/B,0BAA0B,CAAC4E,OAAO,EAAEC;QAC3D,OAAOlF,WAAW;YAAE6G,IAAI;YAAM3B,UAAUqC;YAAMF;QAAmB;IACnE;IAEA,MAAMK,SAA8B,OAAOjC;QACzC,IAAIA,QAAQC,MAAM,KAAK,QAAQ,OAAO3F,yBAAyB;QAC/D,MAAM4F,OAAO,MAAMxF,qBAAqBsF;QACxC,MAAMrD,SAAS,MAAMtC,kBAAkB2F,SAASE,IAAI,CAAC,QAAQ,EAAEZ,KAAKa,IAAI;QACxE,IAAIxD,kBAAkByD,UAAU,OAAOzD;QACvC,MAAMlB,aAAahB,uBAAuByF,IAAI,CAAC,aAAa;QAC5D,IAAI,CAACzE,YAAY,OAAOjB,gBAAgB,KAAK,mBAAmB;QAChE,MAAMe,YAAY+D,KAAK/D,SAAS;QAChC,MAAMkE,WAAW,MAAMnE,aAAaC,WAAWoB,OAAOnB,KAAK,EAAEC;QAC7D,IAAI,CAACgE,UAAU,OAAOjF,gBAAgB,KAAK,sBAAsB;QACjE,IAAIiF,SAASd,MAAM,KAAK,WAAY,MAAM3C,eAAeT,WAAWoB,OAAOnB,KAAK,EAAEiE,SAASI,EAAE,GAAI;YAC/F,qEAAqE;YACrE,uEAAuE;YACvE,OAAOrF,gBACL,KACA,kBACA;QAEJ;QACA,MAAMR,sBAAsBuB,WAAWoB,OAAOnB,KAAK,EAAE,aAAaG,GAAG,CAAC8D,SAASI,EAAE,EAAE5E,MAAM;QACzF,MAAMsE,SAAS5C,QAAQ/B,2BAA2BK,MAAM,EAAEwE;QAC1D,OAAOlF,WAAW;YAAE6G,IAAI;QAAK;IAC/B;IAEA,OAAO;QAAErB;QAAMpB;QAAQsD;IAAO;AAChC;AAEA;;;CAGC,GACD,eAAeD,oBACbzG,SAAoB,EACpBoB,MAA2B,EAC3BlB,UAAkB,EAClBoF,KAAa;IAEb,MAAMqB,cAAclI,sBAAsBuB,WAAWoB,OAAOnB,KAAK,EAAE;IACnE,IAAI2G,UAAU;IACd,IAAIC,QAAwD;IAC5D,OAAS;QACP,IAAIC,OAAOH,YAAYhG,KAAK,CAAC,cAAc,MAAMT,YAAY6G,OAAO,CAAC,YAAYnG,KAAK,CAACxB;QACvF,IAAIyH,OAAOC,OAAOA,KAAKE,UAAU,CAACH;QAClC,MAAM1G,WAAW,MAAM2G,KAAKzG,GAAG;QAC/B,IAAIF,SAASU,KAAK,EAAE;QACpB,MAAMoG,QAAQjH,UAAUiH,KAAK;QAC7B,IAAIC,SAAS;QACb,KAAK,MAAM9G,OAAOD,SAASgH,IAAI,CAAE;YAC/B,MAAMC,aAAa1I,6BAA6B0B,IAAIkE,EAAE,EAAElE,IAAIG,IAAI;YAChE,IAAI,CAAC6G,cAAc,CAAChJ,kCAAkCsE,QAAQ,CAAC0E,WAAWhE,MAAM,GAAG;YACnF,MAAM,EAAEiE,KAAK,EAAE,GAAGpJ,6BAA6BmJ,YAAY;gBACzD/C,MAAM;gBACNiD,QAAQ;gBACRC,MAAMjC;gBACNkC,OAAOpG,OAAOkC,GAAG;YACnB;YACA,IAAI,CAAC+D,OAAO;YACZJ,MAAMT,MAAM,CAACpG,IAAIoF,GAAG,EAAE,aAAK6B;gBAAO1B,aAAaL;;YAC/C4B,UAAU;QACZ;QACA,IAAIA,QAAQ,MAAMD,MAAMQ,MAAM;QAC9Bb,WAAWM;QACXL,QAAQ1G,SAASgH,IAAI,CAAChH,SAASgH,IAAI,CAACxE,MAAM,GAAG,EAAE;QAC/C,IAAIxC,SAASuH,IAAI,GAAGtI,cAAc;IACpC;IACA,OAAOwH;AACT"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/sequence-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 { normalizeCampaignIds } from '@aglyn/aglyn/app-utils/campaign-membership'\nimport { createResourceUid } from '@aglyn/aglyn/app-utils/create-resource-uid'\nimport { nameSearchFields } from '@aglyn/aglyn/app-utils/name-search'\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport { applyOutreachEnrollmentEvent } from '../engine/enrollment-state'\nimport {\n validateOutreachSequence,\n validateOutreachSequenceActivation,\n} from '../engine/sequence-validation'\nimport type {\n OutreachSequenceAction,\n OutreachSequenceSaveResponse,\n OutreachSequenceStatusResponse,\n} from '../model/outreach-api'\nimport { OUTREACH_OPEN_ENROLLMENT_STATUSES } from '../engine/gates'\nimport type { OutreachMailbox, OutreachSequence, OutreachSequenceStatus } from '../model/outreach.types'\nimport {\n outreachEnrolledSequenceIssues,\n outreachMailboxActivationIssue,\n readOutreachSequenceDraft,\n type OutreachSequenceDraft,\n type OutreachSequenceIssue,\n} from '../model/sequence-draft'\nimport { readOutreachComplianceSettingsDoc } from '../storage/compliance-settings-store'\nimport {\n outreachOrgCollection,\n readStoredOutreachEnrollment,\n readStoredOutreachMailbox,\n readStoredOutreachSequence,\n} from '../storage/outreach-records'\nimport { OUTREACH_SEQUENCE_ACTIVITY_TARGET, type OutreachRouteDeps } 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 * THE SEQUENCE ROUTES (AGL-2980): save, activate, pause, archive, delete.\n *\n * ## Who may change a sequence\n *\n * A sequence sends as the member whose mailbox it names, so it is theirs to\n * change: the member who connected the mailbox, or an organization owner or\n * admin, may save it, activate it and send from it — the same two the\n * mailbox's own routes let manage the mailbox. Anyone with `outreach.use`\n * may pause or archive any sequence, because stopping mail is never the act\n * that needs guarding.\n *\n * ## Saving\n *\n * The draft is read with the editor's reader and judged by the engine's\n * validator, so the save refuses exactly what the editor showed beside a\n * field. On top of that, the site must be one of the organization's, the\n * mailbox one of its connected mailboxes, the countries ones the\n * organization allows, and — once anyone is enrolled — the steps, site and\n * mailbox fixed (`outreachEnrolledSequenceIssues`). A new sequence is a\n * draft; an edit keeps the status it had, and an active sequence stays\n * active only because the edit passed the same validator activation does.\n *\n * ## Activating\n *\n * `validateOutreachSequenceActivation`: everything a save refuses on, and an\n * organization whose footer cannot be written — no legal name, or no postal\n * address. And its mailbox must be sending (`outreachMailboxActivationIssue`):\n * chosen, still connected, not paused, and not waiting to be reconnected —\n * the same words the sequence page shows beside its disabled Activate.\n *\n * ## Archiving\n *\n * Final. Every person still active or paused in the sequence is stopped\n * with `sequence_archived`, in batches, so no archived sequence goes on\n * sending.\n */\n\n/** A batch holds 500 writes; a page of enrollments stops this many. */\nconst ARCHIVE_PAGE = 400\n\n/** The activity line each lifecycle act writes. */\nexport const OUTREACH_SEQUENCE_ACTIVITY = {\n create: 'Created a sequence',\n activate: 'Activated a sequence',\n pause: 'Paused a sequence',\n archive: 'Archived a sequence',\n delete: 'Deleted a sequence',\n} as const\n\n/** Where each action may start, and where it ends. */\nconst TRANSITIONS: Record<\n OutreachSequenceAction,\n { from: readonly OutreachSequenceStatus[]; to: OutreachSequenceStatus }\n> = {\n activate: { from: ['draft', 'paused'], to: 'active' },\n pause: { from: ['active'], to: 'paused' },\n archive: { from: ['draft', 'active', 'paused'], to: 'archived' },\n}\n\nconst ACTION_REFUSALS: Record<OutreachSequenceAction, string> = {\n activate: 'Only a draft or paused sequence can be activated.',\n pause: 'Only an active sequence can be paused.',\n archive: 'This sequence is already archived.',\n}\n\nexport interface OutreachSequenceRoutes {\n save: PluginWebApiHandler\n status: PluginWebApiHandler\n remove: PluginWebApiHandler\n}\n\ntype Firestore = FirebaseFirestore.Firestore\n\nasync function loadSequence(\n firestore: Firestore,\n orgId: string,\n sequenceId: string,\n): Promise<OutreachSequence | null> {\n const snapshot = await outreachOrgCollection(firestore, orgId, 'sequences').doc(sequenceId).get()\n return readStoredOutreachSequence(sequenceId, snapshot.exists ? snapshot.data() : undefined)\n}\n\n/** Whether anyone was ever enrolled in the sequence. */\nasync function hasEnrollments(firestore: Firestore, orgId: string, sequenceId: string): Promise<boolean> {\n const found = await outreachOrgCollection(firestore, orgId, 'enrollments')\n .where('sequenceId', '==', sequenceId)\n .limit(1)\n .get()\n return !found.empty\n}\n\nconst issue = (\n path: string,\n code: OutreachSequenceIssue['code'],\n message: string,\n): OutreachSequenceIssue => ({ path, code, message, severity: 'error' })\n\n/**\n * The organization-level facts a draft has to agree with: its site is the\n * organization's, its mailbox is connected and the caller may send from it,\n * and its countries are ones the organization allows. `activating` adds\n * that the mailbox is sending now — chosen, and neither paused nor waiting\n * to be reconnected.\n */\nasync function draftPlacementIssues(\n firestore: Firestore,\n caller: OutreachRouteCaller,\n // A draft, or a stored sequence being activated, which may predate the\n // campaign field (AGL-3254).\n draft: Pick<OutreachSequenceDraft, 'hostId' | 'mailboxId' | 'settings'> & Pick<OutreachSequence, 'campaignIds'>,\n options: { activating?: boolean } = {},\n): Promise<OutreachSequenceIssue[]> {\n const issues: OutreachSequenceIssue[] = []\n const campaignIds = normalizeCampaignIds(draft.campaignIds)\n if (draft.hostId) {\n const host = readOutreachDocumentId(draft.hostId)\n ? await firestore.collection('hosts').doc(draft.hostId).get()\n : null\n if (!host?.exists || host.get('orgId') !== caller.orgId) {\n issues.push(issue('hostId', 'host_unknown', \"Choose one of this organization's sites.\"))\n }\n } else {\n issues.push(issue('hostId', 'host_unknown', \"Choose the site whose contacts this sequence emails.\"))\n }\n const mailbox = draft.mailboxId ? await loadMailbox(firestore, caller.orgId, draft.mailboxId) : null\n const activation = options.activating ? outreachMailboxActivationIssue(draft.mailboxId, mailbox) : null\n if (draft.mailboxId) {\n const placement = mailboxIssues(caller, mailbox)\n // A mailbox that is gone is refused once, in activation's words when\n // activating: they say what to do next.\n issues.push(\n ...(activation?.code === 'mailbox_unknown'\n ? placement.filter((entry) => entry.code !== 'mailbox_unknown')\n : placement),\n )\n }\n if (activation && !issues.some((entry) => entry.code === 'mailbox_not_yours')) issues.push(activation)\n const settings = await readOutreachComplianceSettingsDoc(firestore, caller.orgId)\n const outside = draft.settings.allowedCountries.filter(\n (code) => !settings.allowedCountries.includes(code),\n )\n if (outside.length) {\n issues.push(\n issue(\n 'settings.allowedCountries',\n 'country_not_in_org',\n `${outside.join(', ')} ${outside.length === 1 ? \"isn't\" : \"aren't\"} among the countries your organization allows. Add ${outside.length === 1 ? 'it' : 'them'} in Sequences compliance settings first.`,\n ),\n )\n }\n /*\n * The campaigns must be the ORGANIZATION's live containers\n * (`orgs/{orgId}/emailCampaigns`, AGL-3254): a request can claim any id,\n * and a sequence filed under another org's campaign, or under one the\n * console soft-deleted, would credit its outcomes to a page nobody here\n * can open. A sequence is an org record, so any live campaign of the org\n * qualifies, whichever sites it is placed on.\n */\n if (campaignIds.length) {\n const containers = firestore.collection('orgs').doc(caller.orgId).collection('emailCampaigns')\n const found = await firestore.getAll(\n ...campaignIds.map((campaignId) => containers.doc(readOutreachDocumentId(campaignId) ?? '-')),\n )\n const unknown = campaignIds.filter((campaignId, index) => {\n const snapshot = found[index]\n return !readOutreachDocumentId(campaignId) || !snapshot?.exists || Boolean(snapshot.get('deletedAt'))\n })\n if (unknown.length) {\n issues.push(\n issue(\n 'campaignIds',\n 'campaign_unknown',\n unknown.length === 1\n ? \"One of the campaigns picked isn't one of this organization's campaigns any more. Pick it again.\"\n : \"Some of the campaigns picked aren't this organization's campaigns any more. Pick them again.\",\n ),\n )\n }\n }\n return issues\n}\n\n/** A mailbox of this organization's, as stored; `null` when there is none by that id. */\nasync function loadMailbox(firestore: Firestore, orgId: string, mailboxId: string): Promise<OutreachMailbox | null> {\n const snapshot = readOutreachDocumentId(mailboxId)\n ? await outreachOrgCollection(firestore, orgId, 'mailboxes').doc(mailboxId).get()\n : null\n return readStoredOutreachMailbox(mailboxId, snapshot?.exists ? snapshot.data() : undefined)\n}\n\n/** Whether the mailbox is connected here, and the caller may send from it. */\nfunction mailboxIssues(caller: OutreachRouteCaller, mailbox: OutreachMailbox | null): OutreachSequenceIssue[] {\n if (!mailbox || mailbox.status === 'disconnected') {\n return [issue('mailboxId', 'mailbox_unknown', 'That mailbox is not connected to this organization.')]\n }\n if (mailbox.connectedByUid !== caller.uid && !caller.isOrgAdmin) {\n return [\n issue(\n 'mailboxId',\n 'mailbox_not_yours',\n 'Only the member who connected this mailbox, or an organization owner or admin, can send from it.',\n ),\n ]\n }\n return []\n}\n\n/** One issue per field and code: activation and the engine can both ask for a mailbox. */\nfunction distinctIssues(issues: readonly OutreachSequenceIssue[]): OutreachSequenceIssue[] {\n const seen = new Set<string>()\n return issues.filter((entry) => {\n const key = `${entry.path}|${entry.code}`\n if (seen.has(key)) return false\n seen.add(key)\n return true\n })\n}\n\nexport function createOutreachSequenceRoutes(deps: OutreachRouteDeps): OutreachSequenceRoutes {\n const activity = (caller: OutreachRouteCaller, action: string, sequence: Pick<OutreachSequence, 'id' | 'name'>) =>\n deps.logOrgActivity(caller.orgId, { uid: caller.uid, email: caller.email }, action, {\n type: OUTREACH_SEQUENCE_ACTIVITY_TARGET,\n id: sequence.id,\n name: sequence.name,\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 firestore = deps.firestore()\n\n const rawId = body['sequenceId']\n const sequenceId = rawId === undefined || rawId === null || rawId === '' ? null : readOutreachDocumentId(rawId)\n if (rawId && !sequenceId) return outreachRefusal(400, 'invalid-request', 'Name the sequence to save.')\n const existing = sequenceId ? await loadSequence(firestore, caller.orgId, sequenceId) : null\n if (sequenceId && !existing) {\n return outreachRefusal(404, 'sequence-not-found', 'That sequence no longer exists.')\n }\n if (existing?.status === 'archived') {\n return outreachRefusal(409, 'sequence-archived', \"An archived sequence can't be edited.\")\n }\n\n const draft = readOutreachSequenceDraft(body['sequence'])\n // A sequence sends as its mailbox's member, so it is theirs (or an\n // admin's) to change — the stored mailbox as well as a new one: moving\n // a colleague's sequence onto your own mailbox is still changing theirs.\n if (existing?.mailboxId && existing.mailboxId !== draft.mailboxId) {\n const stored = mailboxIssues(caller, await loadMailbox(firestore, caller.orgId, existing.mailboxId))\n const refused = stored.find((entry) => entry.code === 'mailbox_not_yours')\n if (refused) return outreachRefusal(403, 'permission', refused.message)\n }\n const judged: OutreachSequenceIssue[] = [\n ...validateOutreachSequence(draft),\n ...(await draftPlacementIssues(firestore, caller, draft)),\n ]\n const ownership = judged.find((entry) => entry.code === 'mailbox_not_yours')\n if (ownership) return outreachRefusal(403, 'permission', ownership.message)\n if (existing && (await hasEnrollments(firestore, caller.orgId, existing.id))) {\n judged.push(...outreachEnrolledSequenceIssues(existing, draft))\n }\n const errors = judged.filter((entry) => entry.severity === 'error')\n if (errors.length) {\n return outreachRefusal(400, 'invalid-sequence', errors[0].message, { issues: judged })\n }\n\n const nowMs = deps.now()\n const ref = existing\n ? outreachOrgCollection(firestore, caller.orgId, 'sequences').doc(existing.id)\n : outreachOrgCollection(firestore, caller.orgId, 'sequences').doc(createResourceUid())\n const sequence: OutreachSequence = {\n id: ref.id,\n ...draft,\n status: existing?.status ?? 'draft',\n /*\n * Carried through explicitly, because the write below REPLACES the\n * document (AGL-3239). The counters are the runtime's and the click\n * route's, never the editor's, and a member renaming a sequence must\n * not reset what it measured.\n */\n ...(existing?.stats ? { stats: existing.stats } : {}),\n createdAtMs: existing?.createdAtMs || nowMs,\n updatedAtMs: nowMs,\n }\n /*\n * The list's search fields ride with every write of the name (AGL-3321):\n * the sequences list asks Firestore for them, and a sequence missing\n * them would list normally and never be found.\n */\n await ref.set({ ...sequence, ...nameSearchFields(sequence.name) })\n if (!existing) await activity(caller, OUTREACH_SEQUENCE_ACTIVITY.create, sequence)\n return outreachOk({\n ok: true,\n sequence,\n created: !existing,\n warnings: judged.filter((entry) => entry.severity === 'warning'),\n } satisfies OutreachSequenceSaveResponse)\n }\n\n const status: 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 action = body['action'] as OutreachSequenceAction\n const sequenceId = readOutreachDocumentId(body['sequenceId'])\n if (!sequenceId || !Object.prototype.hasOwnProperty.call(TRANSITIONS, action)) {\n return outreachRefusal(400, 'invalid-request', 'Name the sequence, and activate, pause or archive.')\n }\n const firestore = deps.firestore()\n const sequence = await loadSequence(firestore, caller.orgId, sequenceId)\n if (!sequence) return outreachRefusal(404, 'sequence-not-found', 'That sequence no longer exists.')\n const transition = TRANSITIONS[action]\n if (sequence.status === transition.to) {\n return outreachOk({ ok: true, sequence, stoppedEnrollments: 0 } satisfies OutreachSequenceStatusResponse)\n }\n if (!transition.from.includes(sequence.status)) {\n return outreachRefusal(409, 'transition-refused', ACTION_REFUSALS[action])\n }\n\n if (action === 'activate') {\n const orgSettings = await readOutreachComplianceSettingsDoc(firestore, caller.orgId)\n const judged: OutreachSequenceIssue[] = distinctIssues([\n ...validateOutreachSequenceActivation(sequence, orgSettings),\n ...(await draftPlacementIssues(firestore, caller, sequence, { activating: true })),\n ]).filter((entry) => entry.severity === 'error')\n if (judged.length) {\n const ownership = judged.find((entry) => entry.code === 'mailbox_not_yours')\n return ownership\n ? outreachRefusal(403, 'permission', ownership.message)\n : outreachRefusal(409, 'activation-refused', judged[0].message, { issues: judged })\n }\n }\n\n const nowMs = deps.now()\n const next: OutreachSequence = { ...sequence, status: transition.to, updatedAtMs: nowMs }\n await outreachOrgCollection(firestore, caller.orgId, 'sequences')\n .doc(sequence.id)\n .update({ status: next.status, updatedAtMs: nowMs })\n const stoppedEnrollments =\n action === 'archive' ? await stopOpenEnrollments(firestore, caller, sequence.id, nowMs) : 0\n await activity(caller, OUTREACH_SEQUENCE_ACTIVITY[action], sequence)\n return outreachOk({ ok: true, sequence: next, stoppedEnrollments } satisfies OutreachSequenceStatusResponse)\n }\n\n const remove: 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 sequenceId = readOutreachDocumentId(body['sequenceId'])\n if (!sequenceId) return outreachRefusal(400, 'invalid-request', 'Name the sequence to delete.')\n const firestore = deps.firestore()\n const sequence = await loadSequence(firestore, caller.orgId, sequenceId)\n if (!sequence) return outreachRefusal(404, 'sequence-not-found', 'That sequence no longer exists.')\n if (sequence.status !== 'draft' || (await hasEnrollments(firestore, caller.orgId, sequence.id))) {\n // What people were sent, and why they stopped, is the record of what\n // this organization did; only a draft nobody was enrolled in has none.\n return outreachRefusal(\n 409,\n 'delete-refused',\n 'Only a draft nobody was enrolled in can be deleted. Archive this sequence instead.',\n )\n }\n await outreachOrgCollection(firestore, caller.orgId, 'sequences').doc(sequence.id).delete()\n await activity(caller, OUTREACH_SEQUENCE_ACTIVITY.delete, sequence)\n return outreachOk({ ok: true })\n }\n\n return { save, status, remove }\n}\n\n/**\n * Stops everyone still active or paused in an archived sequence, a page at\n * a time, through the engine's own transition. Returns how many it stopped.\n */\nasync function stopOpenEnrollments(\n firestore: Firestore,\n caller: OutreachRouteCaller,\n sequenceId: string,\n nowMs: number,\n): Promise<number> {\n const enrollments = outreachOrgCollection(firestore, caller.orgId, 'enrollments')\n let stopped = 0\n let after: FirebaseFirestore.QueryDocumentSnapshot | null = null\n for (;;) {\n let page = enrollments.where('sequenceId', '==', sequenceId).orderBy('__name__').limit(ARCHIVE_PAGE)\n if (after) page = page.startAfter(after)\n const snapshot = await page.get()\n if (snapshot.empty) break\n const batch = firestore.batch()\n let writes = 0\n for (const doc of snapshot.docs) {\n const enrollment = readStoredOutreachEnrollment(doc.id, doc.data())\n if (!enrollment || !OUTREACH_OPEN_ENROLLMENT_STATUSES.includes(enrollment.status)) continue\n const { patch } = applyOutreachEnrollmentEvent(enrollment, {\n type: 'stop',\n reason: 'sequence_archived',\n atMs: nowMs,\n byUid: caller.uid,\n })\n if (!patch) continue\n batch.update(doc.ref, { ...patch, updatedAtMs: nowMs })\n writes += 1\n }\n if (writes) await batch.commit()\n stopped += writes\n after = snapshot.docs[snapshot.docs.length - 1]\n if (snapshot.size < ARCHIVE_PAGE) break\n }\n return stopped\n}\n"],"names":["normalizeCampaignIds","createResourceUid","nameSearchFields","applyOutreachEnrollmentEvent","validateOutreachSequence","validateOutreachSequenceActivation","OUTREACH_OPEN_ENROLLMENT_STATUSES","outreachEnrolledSequenceIssues","outreachMailboxActivationIssue","readOutreachSequenceDraft","readOutreachComplianceSettingsDoc","outreachOrgCollection","readStoredOutreachEnrollment","readStoredOutreachMailbox","readStoredOutreachSequence","OUTREACH_SEQUENCE_ACTIVITY_TARGET","outreachRouteGate","outreachMethodNotAllowed","outreachOk","outreachRefusal","readOutreachDocumentId","readOutreachJsonBody","ARCHIVE_PAGE","OUTREACH_SEQUENCE_ACTIVITY","create","activate","pause","archive","delete","TRANSITIONS","from","to","ACTION_REFUSALS","loadSequence","firestore","orgId","sequenceId","snapshot","doc","get","exists","data","undefined","hasEnrollments","found","where","limit","empty","issue","path","code","message","severity","draftPlacementIssues","caller","draft","options","issues","campaignIds","hostId","host","collection","push","mailbox","mailboxId","loadMailbox","activation","activating","placement","mailboxIssues","filter","entry","some","settings","outside","allowedCountries","includes","length","join","containers","getAll","map","campaignId","unknown","index","Boolean","status","connectedByUid","uid","isOrgAdmin","distinctIssues","seen","Set","key","has","add","createOutreachSequenceRoutes","deps","activity","action","sequence","logOrgActivity","email","type","id","name","save","request","method","body","gate","Response","rawId","existing","stored","refused","find","judged","ownership","errors","nowMs","now","ref","stats","createdAtMs","updatedAtMs","set","ok","created","warnings","Object","prototype","hasOwnProperty","call","transition","stoppedEnrollments","orgSettings","next","update","stopOpenEnrollments","remove","enrollments","stopped","after","page","orderBy","startAfter","batch","writes","docs","enrollment","patch","reason","atMs","byUid","commit","size"],"mappings":"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"}
|
|
@@ -27,15 +27,20 @@ import type { OutreachRuntimeDeps } from './runtime-deps';
|
|
|
27
27
|
* redirect needs nothing from Firestore to answer it, and a failure here
|
|
28
28
|
* costs a number rather than a visit.
|
|
29
29
|
*
|
|
30
|
-
*
|
|
31
|
-
* them and no one document answers all
|
|
30
|
+
* Four places are written, because four different questions are asked of
|
|
31
|
+
* them and no one document answers all of them:
|
|
32
32
|
*
|
|
33
33
|
* 1. **The enrollment** — "did THIS person act?", which the enrollments
|
|
34
|
-
* table shows per row and a rep reads before calling someone
|
|
35
|
-
*
|
|
34
|
+
* table shows per row and a rep reads before calling someone: totals,
|
|
35
|
+
* and the distinct destinations they followed.
|
|
36
|
+
* 2. **The enrollment's history** (AGL-3332) — "what exactly did they do,
|
|
37
|
+
* and when?", one row per visit: the destination, the step whose email
|
|
38
|
+
* carried it, and whether a person or a scanner made it. Read only when
|
|
39
|
+
* someone opens the person, so the table's listener never carries it.
|
|
40
|
+
* 3. **The sequence** — "is this sequence working?", which is the report
|
|
36
41
|
* card, and which cannot be derived from the enrollments because the
|
|
37
42
|
* console lists them a page at a time.
|
|
38
|
-
*
|
|
43
|
+
* 4. **The link rollup** — "which of our links did they follow?", one
|
|
39
44
|
* document holding a bounded map, keyed and capped by the campaign
|
|
40
45
|
* rollup's own rules so a link means the same thing in both reports.
|
|
41
46
|
*/
|
|
@@ -19,7 +19,8 @@ import { FieldValue } from "firebase-admin/firestore";
|
|
|
19
19
|
import { OUTREACH_PLUGIN_ID } from "../constants/bundle-common.js";
|
|
20
20
|
import { judgeOutreachClick } from "../engine/click-tracking.js";
|
|
21
21
|
import { readOutreachEngagement } from "../model/enrollment-engagement.js";
|
|
22
|
-
import {
|
|
22
|
+
import { outreachClickHistoryRow, outreachHistoryEntryId } from "../model/enrollment-history.js";
|
|
23
|
+
import { OUTREACH_ENGAGEMENT_LINKS_MAX, OUTREACH_ENROLLMENT_HISTORY, OUTREACH_ENROLLMENT_HISTORY_MAX, OUTREACH_LINK_ROLLUP_PATH } from "../model/outreach.types.js";
|
|
23
24
|
import { outreachEnrollmentLink, outreachOrgCollection, readStoredOutreachEnrollment } from "../storage/outreach-records.js";
|
|
24
25
|
/** When the step that carried the link was sent, or `null` when it cannot be found. */ function sentAtMs(enrollment, stepIndex) {
|
|
25
26
|
var _enrollment_stepRecords;
|
|
@@ -61,7 +62,7 @@ import { outreachEnrollmentLink, outreachOrgCollection, readStoredOutreachEnroll
|
|
|
61
62
|
const nowMs = deps.now();
|
|
62
63
|
const ref = outreachOrgCollection(firestore, orgId, 'enrollments').doc(enrollmentId);
|
|
63
64
|
const outcome = await firestore.runTransaction(async (transaction)=>{
|
|
64
|
-
var _held_firstClickAtMs
|
|
65
|
+
var _held_firstClickAtMs;
|
|
65
66
|
const snapshot = await transaction.get(ref);
|
|
66
67
|
const enrollment = readStoredOutreachEnrollment(enrollmentId, snapshot.exists ? snapshot.data() : undefined);
|
|
67
68
|
if (!enrollment) {
|
|
@@ -80,23 +81,61 @@ import { outreachEnrollmentLink, outreachOrgCollection, readStoredOutreachEnroll
|
|
|
80
81
|
});
|
|
81
82
|
const held = readOutreachEngagement(enrollment.engagement);
|
|
82
83
|
const first = judgement.human && held.firstClickAtMs === null;
|
|
84
|
+
const key = campaignLinkKey(url);
|
|
85
|
+
// A row for this visit, while the person's history has room for one.
|
|
86
|
+
const logged = held.loggedClicks + held.loggedMachineClicks < OUTREACH_ENROLLMENT_HISTORY_MAX;
|
|
87
|
+
/*
|
|
88
|
+
* The destinations known to have been followed. A click from before the
|
|
89
|
+
* per-click history (AGL-3332) is known only by `lastClickUrl`, which the
|
|
90
|
+
* next click replaces — so it joins the list first, or the person would
|
|
91
|
+
* stop being found by the "Link followed" filter for the link they did
|
|
92
|
+
* follow.
|
|
93
|
+
*/ const known = held.lastClickUrl && !held.links.includes(held.lastClickUrl) && held.links.length < OUTREACH_ENGAGEMENT_LINKS_MAX ? [
|
|
94
|
+
...held.links,
|
|
95
|
+
held.lastClickUrl
|
|
96
|
+
] : held.links;
|
|
83
97
|
const engagement = judgement.human ? {
|
|
84
98
|
clicks: held.clicks + 1,
|
|
85
99
|
firstClickAtMs: (_held_firstClickAtMs = held.firstClickAtMs) != null ? _held_firstClickAtMs : nowMs,
|
|
86
100
|
lastClickAtMs: nowMs,
|
|
87
|
-
lastClickUrl:
|
|
88
|
-
machineClicks: held.machineClicks
|
|
101
|
+
lastClickUrl: key != null ? key : held.lastClickUrl,
|
|
102
|
+
machineClicks: held.machineClicks,
|
|
103
|
+
links: key && !known.includes(key) && known.length < OUTREACH_ENGAGEMENT_LINKS_MAX ? [
|
|
104
|
+
...known,
|
|
105
|
+
key
|
|
106
|
+
] : known,
|
|
107
|
+
loggedClicks: held.loggedClicks + (logged ? 1 : 0),
|
|
108
|
+
loggedMachineClicks: held.loggedMachineClicks
|
|
89
109
|
} : _extends({}, held, {
|
|
90
|
-
machineClicks: held.machineClicks + 1
|
|
110
|
+
machineClicks: held.machineClicks + 1,
|
|
111
|
+
loggedMachineClicks: held.loggedMachineClicks + (logged ? 1 : 0)
|
|
91
112
|
});
|
|
92
113
|
/*
|
|
93
114
|
* `updatedAtMs` is deliberately NOT touched. It is what the enrollments
|
|
94
115
|
* table sorts "last activity" by and what a member reads as "something
|
|
95
116
|
* happened to this enrollment", and a scanner's fetch of a link is not
|
|
96
117
|
* that. The engagement carries its own timestamps for what a click is.
|
|
97
|
-
*/
|
|
118
|
+
*/ // A person's click is what the "Clicked" filter asks about (AGL-3332); a scanner's is not.
|
|
119
|
+
transaction.update(ref, judgement.human ? {
|
|
120
|
+
engagement,
|
|
121
|
+
clicked: true
|
|
122
|
+
} : {
|
|
98
123
|
engagement
|
|
99
124
|
});
|
|
125
|
+
/*
|
|
126
|
+
* The row is written in the same transaction as the count it itemizes,
|
|
127
|
+
* so `loggedClicks` is exactly the number of rows: the detail view says
|
|
128
|
+
* how many clicks have none — before the history began — from the two
|
|
129
|
+
* numbers alone, and never invents a row for one of them.
|
|
130
|
+
*/ if (logged) {
|
|
131
|
+
transaction.set(ref.collection(OUTREACH_ENROLLMENT_HISTORY).doc(outreachHistoryEntryId(nowMs)), outreachClickHistoryRow({
|
|
132
|
+
atMs: nowMs,
|
|
133
|
+
url: key,
|
|
134
|
+
stepIndex,
|
|
135
|
+
human: judgement.human,
|
|
136
|
+
machineReason: judgement.machineReason
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
100
139
|
return _extends({}, judgement, {
|
|
101
140
|
first,
|
|
102
141
|
enrollment: _extends({}, enrollment, {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/click-events.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 {\n CAMPAIGN_LINK_ROLLUP_MAX,\n campaignLinkKey,\n} from '@aglyn/shared-ui-email-campaigns/model'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { OUTREACH_PLUGIN_ID } from '../constants/bundle-common'\nimport {\n judgeOutreachClick,\n type OutreachClickJudgement,\n} from '../engine/click-tracking'\nimport { readOutreachEngagement } from '../model/enrollment-engagement'\nimport {\n OUTREACH_LINK_ROLLUP_PATH,\n type OutreachEnrollment,\n type OutreachEnrollmentEngagement,\n} from '../model/outreach.types'\nimport {\n outreachEnrollmentLink,\n outreachOrgCollection,\n readStoredOutreachEnrollment,\n} from '../storage/outreach-records'\nimport type { OutreachClickTarget } from './click-link'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n\n/**\n * RECORDING A CLICK (AGL-3239).\n *\n * Every write here happens AFTER the recipient has been sent on their way.\n * A click is bookkeeping beside the act, in the same order `timeline.ts`\n * puts an email and its filing: the destination is in the token, so the\n * redirect needs nothing from Firestore to answer it, and a failure here\n * costs a number rather than a visit.\n *\n * Three places are written, because three different questions are asked of\n * them and no one document answers all three:\n *\n * 1. **The enrollment** — \"did THIS person act?\", which the enrollments\n * table shows per row and a rep reads before calling someone.\n * 2. **The sequence** — \"is this sequence working?\", which is the report\n * card, and which cannot be derived from the enrollments because the\n * console lists them a page at a time.\n * 3. **The link rollup** — \"which of our links did they follow?\", one\n * document holding a bounded map, keyed and capped by the campaign\n * rollup's own rules so a link means the same thing in both reports.\n */\n\n/** What one recorded click turned out to be. */\nexport interface OutreachClickOutcome extends OutreachClickJudgement {\n /** Whether it was this person's first human click — the sequence's `uniqueClicks`. */\n first: boolean\n /** The enrollment it was recorded against, or `null` when there is none. */\n enrollment: OutreachEnrollment | null\n}\n\n/** When the step that carried the link was sent, or `null` when it cannot be found. */\nfunction sentAtMs(enrollment: OutreachEnrollment, stepIndex: number): number | null {\n const records = enrollment.stepRecords ?? []\n for (let index = records.length - 1; index >= 0; index -= 1) {\n const record = records[index]\n if (record?.stepIndex === stepIndex && record.kind === 'email') {\n return typeof record.atMs === 'number' && Number.isFinite(record.atMs) ? record.atMs : null\n }\n }\n return null\n}\n\n/**\n * Records one visit to a tracking link.\n *\n * The enrollment is read and written in a transaction, because two links\n * followed at once must not both read a `clicks` of nought and both write a\n * one — and because whether this is the person's FIRST click is decided from\n * the same read that writes it, which is what keeps `uniqueClicks` from\n * counting one person twice.\n *\n * An enrollment that no longer exists belongs to a person or a workspace\n * already erased. Nothing is recorded for them, anywhere — a deleted record\n * must not be reconstructed from a click on an old email — and the caller\n * has already answered the redirect either way.\n */\nexport async function recordOutreachClick(\n deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'timeline'>,\n input: {\n target: OutreachClickTarget\n method: string\n userAgent: string | null | undefined\n },\n): Promise<OutreachClickOutcome> {\n const firestore = deps.firestore()\n const { orgId, enrollmentId, stepIndex, url } = input.target\n // A test link (AGL-3325) names no enrollment and counts on nothing —\n // held here too, so no caller can record one by reaching past the route.\n if (input.target.test) {\n return { human: false, machineReason: null, first: false, enrollment: null }\n }\n const nowMs = deps.now()\n const ref = outreachOrgCollection(firestore, orgId, 'enrollments').doc(enrollmentId)\n\n const outcome = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const enrollment = readStoredOutreachEnrollment(\n enrollmentId,\n snapshot.exists ? snapshot.data() : undefined,\n )\n if (!enrollment) {\n return { human: false, machineReason: null, first: false, enrollment: null } as OutreachClickOutcome\n }\n const sent = sentAtMs(enrollment, stepIndex)\n const judgement = judgeOutreachClick({\n method: input.method,\n userAgent: input.userAgent,\n sinceSentMs: sent === null ? null : nowMs - sent,\n })\n const held = readOutreachEngagement(enrollment.engagement)\n const first = judgement.human && held.firstClickAtMs === null\n const engagement: OutreachEnrollmentEngagement = judgement.human\n ? {\n clicks: held.clicks + 1,\n firstClickAtMs: held.firstClickAtMs ?? nowMs,\n lastClickAtMs: nowMs,\n lastClickUrl: campaignLinkKey(url) ?? held.lastClickUrl,\n machineClicks: held.machineClicks,\n }\n : { ...held, machineClicks: held.machineClicks + 1 }\n /*\n * `updatedAtMs` is deliberately NOT touched. It is what the enrollments\n * table sorts \"last activity\" by and what a member reads as \"something\n * happened to this enrollment\", and a scanner's fetch of a link is not\n * that. The engagement carries its own timestamps for what a click is.\n */\n transaction.update(ref, { engagement })\n return { ...judgement, first, enrollment: { ...enrollment, engagement } } as OutreachClickOutcome\n })\n\n if (!outcome.enrollment) return outcome\n\n await Promise.all([\n bumpSequenceStats(firestore, outcome, {\n orgId,\n sequenceId: outcome.enrollment.sequenceId,\n nowMs,\n }),\n outcome.human ? bumpLinkRollup(firestore, { orgId, sequenceId: outcome.enrollment.sequenceId, url }) : null,\n outcome.first ? fileFirstClick(deps, { orgId, enrollment: outcome.enrollment, url, nowMs }) : null,\n ])\n return outcome\n}\n\n/**\n * The sequence's counters, incremented outside the enrollment's transaction.\n *\n * One sequence holds every enrollment's clicks, so a transaction over it\n * would serialise every recipient of a sequence against every other.\n * `FieldValue.increment` is a blind write with no read to contend on, which\n * is exactly what a counter wants.\n */\nasync function bumpSequenceStats(\n firestore: FirebaseFirestore.Firestore,\n outcome: OutreachClickOutcome,\n input: { orgId: string; sequenceId: string; nowMs: number },\n): Promise<void> {\n if (!input.sequenceId) return\n const patch: Record<string, unknown> = outcome.human\n ? {\n 'stats.clicks': FieldValue.increment(1),\n 'stats.lastClickAtMs': input.nowMs,\n ...(outcome.first ? { 'stats.uniqueClicks': FieldValue.increment(1) } : {}),\n }\n : { 'stats.machineClicks': FieldValue.increment(1) }\n await outreachOrgCollection(firestore, input.orgId, 'sequences')\n .doc(input.sequenceId)\n .update(patch)\n .catch((error: unknown) => {\n // A sequence deleted while its mail is still out there. The click is\n // already on the enrollment; the aggregate has nowhere to go.\n console.warn('[outreach] a click could not be counted on its sequence', error)\n })\n}\n\n/**\n * The per-destination rollup for one sequence.\n *\n * Keyed by `campaignLinkKey`, which drops the query string — a step body\n * goes through the merge resolver per recipient, so keying on the full URL\n * would mint one row per PERSON and turn the aggregate into the\n * per-recipient list it exists to summarise. Capped at\n * `CAMPAIGN_LINK_ROLLUP_MAX` for the same reason the campaign rollup is: it\n * is one document with a size ceiling. Past the cap a click is counted in\n * `overflowClicks` rather than dropped, so the table still reconciles.\n */\nasync function bumpLinkRollup(\n firestore: FirebaseFirestore.Firestore,\n input: { orgId: string; sequenceId: string; url: string },\n): Promise<void> {\n if (!input.sequenceId) return\n const key = campaignLinkKey(input.url)\n const ref = outreachOrgCollection(firestore, input.orgId, 'sequences')\n .doc(input.sequenceId)\n .collection(OUTREACH_LINK_ROLLUP_PATH[0])\n .doc(OUTREACH_LINK_ROLLUP_PATH[1])\n if (!key) {\n await ref\n .set({ unattributedClicks: FieldValue.increment(1) }, { merge: true })\n .catch(() => undefined)\n return\n }\n try {\n await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const links = (snapshot.exists ? snapshot.data()?.['links'] : null) as\n | Record<string, { url?: string; clicks?: number }>\n | undefined\n const held = links?.[campaignRollupField(key)]\n if (!held && Object.keys(links ?? {}).length >= CAMPAIGN_LINK_ROLLUP_MAX) {\n transaction.set(ref, { overflowClicks: FieldValue.increment(1) }, { merge: true })\n return\n }\n transaction.set(\n ref,\n {\n links: {\n [campaignRollupField(key)]: {\n url: key,\n clicks: (Number(held?.clicks) || 0) + 1,\n },\n },\n },\n { merge: true },\n )\n })\n } catch (error) {\n console.warn('[outreach] a click could not be added to the link rollup', error)\n }\n}\n\n/**\n * The map key one destination is held under.\n *\n * A Firestore map key may not contain `/`, `.` or `~`, and a URL contains\n * all three, so the URL itself cannot be the key. It is base64url of the\n * key, with the readable form kept in the row's own `url` — which is what\n * the report renders, so nothing downstream ever decodes this.\n */\nfunction campaignRollupField(key: string): string {\n return Buffer.from(key, 'utf8').toString('base64url')\n}\n\n/**\n * A person's FIRST click on a sequence's link, on their record.\n *\n * Only the first. A note per click would bury the contact's timeline under\n * the same fact repeated, and the one a rep is reading for is that this\n * person went from ignoring the mail to acting on it.\n */\nasync function fileFirstClick(\n deps: Pick<OutreachRuntimeDeps, 'timeline'>,\n input: { orgId: string; enrollment: OutreachEnrollment; url: string; nowMs: number },\n): Promise<void> {\n const writer = deps.timeline()\n if (!writer) return\n try {\n const written = await writer.logActivity({\n orgId: input.orgId,\n hostId: input.enrollment.hostId,\n link: outreachEnrollmentLink(input.enrollment),\n sourcePluginId: OUTREACH_PLUGIN_ID,\n kind: 'note',\n atMs: input.nowMs,\n body: `Followed a link in a sequence email: ${campaignLinkKey(input.url) ?? input.url}`,\n byUid: '',\n byName: null,\n })\n if (written.ok === false) {\n console.warn(`[outreach] the record system did not file a click: ${written.error}`)\n }\n } catch (error) {\n console.error('[outreach] filing a click on the record failed', error)\n }\n}\n"],"names":["CAMPAIGN_LINK_ROLLUP_MAX","campaignLinkKey","FieldValue","OUTREACH_PLUGIN_ID","judgeOutreachClick","readOutreachEngagement","OUTREACH_LINK_ROLLUP_PATH","outreachEnrollmentLink","outreachOrgCollection","readStoredOutreachEnrollment","sentAtMs","enrollment","stepIndex","records","stepRecords","index","length","record","kind","atMs","Number","isFinite","recordOutreachClick","deps","input","firestore","orgId","enrollmentId","url","target","test","human","machineReason","first","nowMs","now","ref","doc","outcome","runTransaction","transaction","held","snapshot","get","exists","data","undefined","sent","judgement","method","userAgent","sinceSentMs","engagement","firstClickAtMs","clicks","lastClickAtMs","lastClickUrl","machineClicks","update","Promise","all","bumpSequenceStats","sequenceId","bumpLinkRollup","fileFirstClick","patch","increment","catch","error","console","warn","key","collection","set","unattributedClicks","merge","links","campaignRollupField","Object","keys","overflowClicks","Buffer","from","toString","writer","timeline","written","logActivity","hostId","link","sourcePluginId","body","byUid","byName","ok"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/click-events.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 {\n CAMPAIGN_LINK_ROLLUP_MAX,\n campaignLinkKey,\n} from '@aglyn/shared-ui-email-campaigns/model'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { OUTREACH_PLUGIN_ID } from '../constants/bundle-common'\nimport {\n judgeOutreachClick,\n type OutreachClickJudgement,\n} from '../engine/click-tracking'\nimport { readOutreachEngagement } from '../model/enrollment-engagement'\nimport { outreachClickHistoryRow, outreachHistoryEntryId } from '../model/enrollment-history'\nimport {\n OUTREACH_ENGAGEMENT_LINKS_MAX,\n OUTREACH_ENROLLMENT_HISTORY,\n OUTREACH_ENROLLMENT_HISTORY_MAX,\n OUTREACH_LINK_ROLLUP_PATH,\n type OutreachEnrollment,\n type OutreachEnrollmentEngagement,\n} from '../model/outreach.types'\nimport {\n outreachEnrollmentLink,\n outreachOrgCollection,\n readStoredOutreachEnrollment,\n} from '../storage/outreach-records'\nimport type { OutreachClickTarget } from './click-link'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n\n/**\n * RECORDING A CLICK (AGL-3239).\n *\n * Every write here happens AFTER the recipient has been sent on their way.\n * A click is bookkeeping beside the act, in the same order `timeline.ts`\n * puts an email and its filing: the destination is in the token, so the\n * redirect needs nothing from Firestore to answer it, and a failure here\n * costs a number rather than a visit.\n *\n * Four places are written, because four different questions are asked of\n * them and no one document answers all of them:\n *\n * 1. **The enrollment** — \"did THIS person act?\", which the enrollments\n * table shows per row and a rep reads before calling someone: totals,\n * and the distinct destinations they followed.\n * 2. **The enrollment's history** (AGL-3332) — \"what exactly did they do,\n * and when?\", one row per visit: the destination, the step whose email\n * carried it, and whether a person or a scanner made it. Read only when\n * someone opens the person, so the table's listener never carries it.\n * 3. **The sequence** — \"is this sequence working?\", which is the report\n * card, and which cannot be derived from the enrollments because the\n * console lists them a page at a time.\n * 4. **The link rollup** — \"which of our links did they follow?\", one\n * document holding a bounded map, keyed and capped by the campaign\n * rollup's own rules so a link means the same thing in both reports.\n */\n\n/** What one recorded click turned out to be. */\nexport interface OutreachClickOutcome extends OutreachClickJudgement {\n /** Whether it was this person's first human click — the sequence's `uniqueClicks`. */\n first: boolean\n /** The enrollment it was recorded against, or `null` when there is none. */\n enrollment: OutreachEnrollment | null\n}\n\n/** When the step that carried the link was sent, or `null` when it cannot be found. */\nfunction sentAtMs(enrollment: OutreachEnrollment, stepIndex: number): number | null {\n const records = enrollment.stepRecords ?? []\n for (let index = records.length - 1; index >= 0; index -= 1) {\n const record = records[index]\n if (record?.stepIndex === stepIndex && record.kind === 'email') {\n return typeof record.atMs === 'number' && Number.isFinite(record.atMs) ? record.atMs : null\n }\n }\n return null\n}\n\n/**\n * Records one visit to a tracking link.\n *\n * The enrollment is read and written in a transaction, because two links\n * followed at once must not both read a `clicks` of nought and both write a\n * one — and because whether this is the person's FIRST click is decided from\n * the same read that writes it, which is what keeps `uniqueClicks` from\n * counting one person twice.\n *\n * An enrollment that no longer exists belongs to a person or a workspace\n * already erased. Nothing is recorded for them, anywhere — a deleted record\n * must not be reconstructed from a click on an old email — and the caller\n * has already answered the redirect either way.\n */\nexport async function recordOutreachClick(\n deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'timeline'>,\n input: {\n target: OutreachClickTarget\n method: string\n userAgent: string | null | undefined\n },\n): Promise<OutreachClickOutcome> {\n const firestore = deps.firestore()\n const { orgId, enrollmentId, stepIndex, url } = input.target\n // A test link (AGL-3325) names no enrollment and counts on nothing —\n // held here too, so no caller can record one by reaching past the route.\n if (input.target.test) {\n return { human: false, machineReason: null, first: false, enrollment: null }\n }\n const nowMs = deps.now()\n const ref = outreachOrgCollection(firestore, orgId, 'enrollments').doc(enrollmentId)\n\n const outcome = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const enrollment = readStoredOutreachEnrollment(\n enrollmentId,\n snapshot.exists ? snapshot.data() : undefined,\n )\n if (!enrollment) {\n return { human: false, machineReason: null, first: false, enrollment: null } as OutreachClickOutcome\n }\n const sent = sentAtMs(enrollment, stepIndex)\n const judgement = judgeOutreachClick({\n method: input.method,\n userAgent: input.userAgent,\n sinceSentMs: sent === null ? null : nowMs - sent,\n })\n const held = readOutreachEngagement(enrollment.engagement)\n const first = judgement.human && held.firstClickAtMs === null\n const key = campaignLinkKey(url)\n // A row for this visit, while the person's history has room for one.\n const logged = held.loggedClicks + held.loggedMachineClicks < OUTREACH_ENROLLMENT_HISTORY_MAX\n /*\n * The destinations known to have been followed. A click from before the\n * per-click history (AGL-3332) is known only by `lastClickUrl`, which the\n * next click replaces — so it joins the list first, or the person would\n * stop being found by the \"Link followed\" filter for the link they did\n * follow.\n */\n const known =\n held.lastClickUrl &&\n !held.links.includes(held.lastClickUrl) &&\n held.links.length < OUTREACH_ENGAGEMENT_LINKS_MAX\n ? [...held.links, held.lastClickUrl]\n : held.links\n const engagement: OutreachEnrollmentEngagement = judgement.human\n ? {\n clicks: held.clicks + 1,\n firstClickAtMs: held.firstClickAtMs ?? nowMs,\n lastClickAtMs: nowMs,\n lastClickUrl: key ?? held.lastClickUrl,\n machineClicks: held.machineClicks,\n links:\n key && !known.includes(key) && known.length < OUTREACH_ENGAGEMENT_LINKS_MAX\n ? [...known, key]\n : known,\n loggedClicks: held.loggedClicks + (logged ? 1 : 0),\n loggedMachineClicks: held.loggedMachineClicks,\n }\n : {\n ...held,\n machineClicks: held.machineClicks + 1,\n loggedMachineClicks: held.loggedMachineClicks + (logged ? 1 : 0),\n }\n /*\n * `updatedAtMs` is deliberately NOT touched. It is what the enrollments\n * table sorts \"last activity\" by and what a member reads as \"something\n * happened to this enrollment\", and a scanner's fetch of a link is not\n * that. The engagement carries its own timestamps for what a click is.\n */\n // A person's click is what the \"Clicked\" filter asks about (AGL-3332); a scanner's is not.\n transaction.update(ref, judgement.human ? { engagement, clicked: true } : { engagement })\n /*\n * The row is written in the same transaction as the count it itemizes,\n * so `loggedClicks` is exactly the number of rows: the detail view says\n * how many clicks have none — before the history began — from the two\n * numbers alone, and never invents a row for one of them.\n */\n if (logged) {\n transaction.set(\n ref.collection(OUTREACH_ENROLLMENT_HISTORY).doc(outreachHistoryEntryId(nowMs)),\n outreachClickHistoryRow({\n atMs: nowMs,\n url: key,\n stepIndex,\n human: judgement.human,\n machineReason: judgement.machineReason,\n }),\n )\n }\n return { ...judgement, first, enrollment: { ...enrollment, engagement } } as OutreachClickOutcome\n })\n\n if (!outcome.enrollment) return outcome\n\n await Promise.all([\n bumpSequenceStats(firestore, outcome, {\n orgId,\n sequenceId: outcome.enrollment.sequenceId,\n nowMs,\n }),\n outcome.human ? bumpLinkRollup(firestore, { orgId, sequenceId: outcome.enrollment.sequenceId, url }) : null,\n outcome.first ? fileFirstClick(deps, { orgId, enrollment: outcome.enrollment, url, nowMs }) : null,\n ])\n return outcome\n}\n\n/**\n * The sequence's counters, incremented outside the enrollment's transaction.\n *\n * One sequence holds every enrollment's clicks, so a transaction over it\n * would serialise every recipient of a sequence against every other.\n * `FieldValue.increment` is a blind write with no read to contend on, which\n * is exactly what a counter wants.\n */\nasync function bumpSequenceStats(\n firestore: FirebaseFirestore.Firestore,\n outcome: OutreachClickOutcome,\n input: { orgId: string; sequenceId: string; nowMs: number },\n): Promise<void> {\n if (!input.sequenceId) return\n const patch: Record<string, unknown> = outcome.human\n ? {\n 'stats.clicks': FieldValue.increment(1),\n 'stats.lastClickAtMs': input.nowMs,\n ...(outcome.first ? { 'stats.uniqueClicks': FieldValue.increment(1) } : {}),\n }\n : { 'stats.machineClicks': FieldValue.increment(1) }\n await outreachOrgCollection(firestore, input.orgId, 'sequences')\n .doc(input.sequenceId)\n .update(patch)\n .catch((error: unknown) => {\n // A sequence deleted while its mail is still out there. The click is\n // already on the enrollment; the aggregate has nowhere to go.\n console.warn('[outreach] a click could not be counted on its sequence', error)\n })\n}\n\n/**\n * The per-destination rollup for one sequence.\n *\n * Keyed by `campaignLinkKey`, which drops the query string — a step body\n * goes through the merge resolver per recipient, so keying on the full URL\n * would mint one row per PERSON and turn the aggregate into the\n * per-recipient list it exists to summarise. Capped at\n * `CAMPAIGN_LINK_ROLLUP_MAX` for the same reason the campaign rollup is: it\n * is one document with a size ceiling. Past the cap a click is counted in\n * `overflowClicks` rather than dropped, so the table still reconciles.\n */\nasync function bumpLinkRollup(\n firestore: FirebaseFirestore.Firestore,\n input: { orgId: string; sequenceId: string; url: string },\n): Promise<void> {\n if (!input.sequenceId) return\n const key = campaignLinkKey(input.url)\n const ref = outreachOrgCollection(firestore, input.orgId, 'sequences')\n .doc(input.sequenceId)\n .collection(OUTREACH_LINK_ROLLUP_PATH[0])\n .doc(OUTREACH_LINK_ROLLUP_PATH[1])\n if (!key) {\n await ref\n .set({ unattributedClicks: FieldValue.increment(1) }, { merge: true })\n .catch(() => undefined)\n return\n }\n try {\n await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const links = (snapshot.exists ? snapshot.data()?.['links'] : null) as\n | Record<string, { url?: string; clicks?: number }>\n | undefined\n const held = links?.[campaignRollupField(key)]\n if (!held && Object.keys(links ?? {}).length >= CAMPAIGN_LINK_ROLLUP_MAX) {\n transaction.set(ref, { overflowClicks: FieldValue.increment(1) }, { merge: true })\n return\n }\n transaction.set(\n ref,\n {\n links: {\n [campaignRollupField(key)]: {\n url: key,\n clicks: (Number(held?.clicks) || 0) + 1,\n },\n },\n },\n { merge: true },\n )\n })\n } catch (error) {\n console.warn('[outreach] a click could not be added to the link rollup', error)\n }\n}\n\n/**\n * The map key one destination is held under.\n *\n * A Firestore map key may not contain `/`, `.` or `~`, and a URL contains\n * all three, so the URL itself cannot be the key. It is base64url of the\n * key, with the readable form kept in the row's own `url` — which is what\n * the report renders, so nothing downstream ever decodes this.\n */\nfunction campaignRollupField(key: string): string {\n return Buffer.from(key, 'utf8').toString('base64url')\n}\n\n/**\n * A person's FIRST click on a sequence's link, on their record.\n *\n * Only the first. A note per click would bury the contact's timeline under\n * the same fact repeated, and the one a rep is reading for is that this\n * person went from ignoring the mail to acting on it.\n */\nasync function fileFirstClick(\n deps: Pick<OutreachRuntimeDeps, 'timeline'>,\n input: { orgId: string; enrollment: OutreachEnrollment; url: string; nowMs: number },\n): Promise<void> {\n const writer = deps.timeline()\n if (!writer) return\n try {\n const written = await writer.logActivity({\n orgId: input.orgId,\n hostId: input.enrollment.hostId,\n link: outreachEnrollmentLink(input.enrollment),\n sourcePluginId: OUTREACH_PLUGIN_ID,\n kind: 'note',\n atMs: input.nowMs,\n body: `Followed a link in a sequence email: ${campaignLinkKey(input.url) ?? input.url}`,\n byUid: '',\n byName: null,\n })\n if (written.ok === false) {\n console.warn(`[outreach] the record system did not file a click: ${written.error}`)\n }\n } catch (error) {\n console.error('[outreach] filing a click on the record failed', error)\n }\n}\n"],"names":["CAMPAIGN_LINK_ROLLUP_MAX","campaignLinkKey","FieldValue","OUTREACH_PLUGIN_ID","judgeOutreachClick","readOutreachEngagement","outreachClickHistoryRow","outreachHistoryEntryId","OUTREACH_ENGAGEMENT_LINKS_MAX","OUTREACH_ENROLLMENT_HISTORY","OUTREACH_ENROLLMENT_HISTORY_MAX","OUTREACH_LINK_ROLLUP_PATH","outreachEnrollmentLink","outreachOrgCollection","readStoredOutreachEnrollment","sentAtMs","enrollment","stepIndex","records","stepRecords","index","length","record","kind","atMs","Number","isFinite","recordOutreachClick","deps","input","firestore","orgId","enrollmentId","url","target","test","human","machineReason","first","nowMs","now","ref","doc","outcome","runTransaction","transaction","held","snapshot","get","exists","data","undefined","sent","judgement","method","userAgent","sinceSentMs","engagement","firstClickAtMs","key","logged","loggedClicks","loggedMachineClicks","known","lastClickUrl","links","includes","clicks","lastClickAtMs","machineClicks","update","clicked","set","collection","Promise","all","bumpSequenceStats","sequenceId","bumpLinkRollup","fileFirstClick","patch","increment","catch","error","console","warn","unattributedClicks","merge","campaignRollupField","Object","keys","overflowClicks","Buffer","from","toString","writer","timeline","written","logActivity","hostId","link","sourcePluginId","body","byUid","byName","ok"],"mappings":"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"}
|
|
@@ -22,6 +22,10 @@ export declare function recordOutreachGatewayBlock(firestore: Firestore, input:
|
|
|
22
22
|
orgId: string;
|
|
23
23
|
email: string;
|
|
24
24
|
remoteMta: string | null;
|
|
25
|
+
/** The domain the mailbox sends from, whose ledger the block is counted on (AGL-3328). */
|
|
26
|
+
sendingDomain: string | null;
|
|
27
|
+
/** The bounce's diagnostic, kept scrubbed of addresses for the ledger. */
|
|
28
|
+
diagnostic?: string | null;
|
|
25
29
|
resolveMx: OutreachResolveMx;
|
|
26
30
|
nowMs: number;
|
|
27
31
|
}): Promise<void>;
|
|
@@ -40,6 +44,8 @@ export declare function outreachDeliveredStepCount(enrollment: Pick<OutreachEnro
|
|
|
40
44
|
export declare function creditOutreachGatewayDeliveries(firestore: Firestore, input: {
|
|
41
45
|
orgId: string;
|
|
42
46
|
enrollments: readonly OutreachEnrollment[];
|
|
47
|
+
/** The domain the mailbox sends from, whose ledger the deliveries are credited to (AGL-3328). */
|
|
48
|
+
sendingDomain: string | null;
|
|
43
49
|
resolveMx: OutreachResolveMx;
|
|
44
50
|
nowMs: number;
|
|
45
51
|
}): Promise<number>;
|
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
* hears in words, so it is inferred from silence, and only after
|
|
25
25
|
* {@link OUTREACH_GATEWAY_DELIVERED_AFTER_MS} of it.
|
|
26
26
|
*
|
|
27
|
+
* Every count is filed against the MAILBOX's sending domain (AGL-3328):
|
|
28
|
+
* a gateway refuses a sender, so the ledger that holds the next send is the
|
|
29
|
+
* one keyed `sendingDomain × gateway`.
|
|
30
|
+
*
|
|
27
31
|
* Which gateway a block is filed under is read from the bounce itself
|
|
28
32
|
* where it can be: a DSN's `Remote-MTA` names the server that refused,
|
|
29
33
|
* and `*.ess.barracudanetworks.com` is Barracuda whatever the MX says
|
|
@@ -34,6 +38,7 @@ import { OUTREACH_GATEWAY_DELIVERED_AFTER_MS, outreachMailGatewayOfHost } from "
|
|
|
34
38
|
import { readOutreachDomainIntel, recordOutreachGatewayOutcome } from "../storage/domain-intel-store.js";
|
|
35
39
|
import { outreachOrgCollection } from "../storage/outreach-records.js";
|
|
36
40
|
/** A gateway block, counted against the gateway the bounce names — see the module note. */ export async function recordOutreachGatewayBlock(firestore, input) {
|
|
41
|
+
var _input_diagnostic;
|
|
37
42
|
const domain = outreachEmailDomain(input.email);
|
|
38
43
|
if (!domain) return;
|
|
39
44
|
let gateway = outreachMailGatewayOfHost(input.remoteMta);
|
|
@@ -50,10 +55,11 @@ import { outreachOrgCollection } from "../storage/outreach-records.js";
|
|
|
50
55
|
}
|
|
51
56
|
if (!gateway || gateway === 'none') return;
|
|
52
57
|
await recordOutreachGatewayOutcome(firestore, input.orgId, {
|
|
53
|
-
|
|
58
|
+
sendingDomain: input.sendingDomain,
|
|
54
59
|
gateway,
|
|
55
60
|
outcome: 'blocked',
|
|
56
|
-
atMs: input.nowMs
|
|
61
|
+
atMs: input.nowMs,
|
|
62
|
+
detail: (_input_diagnostic = input.diagnostic) != null ? _input_diagnostic : null
|
|
57
63
|
});
|
|
58
64
|
}
|
|
59
65
|
/**
|
|
@@ -92,7 +98,7 @@ import { outreachOrgCollection } from "../storage/outreach-records.js";
|
|
|
92
98
|
if (!domain || !gateway || gateway === 'none') continue;
|
|
93
99
|
const fresh = delivered - Math.max(0, Number(enrollment.gatewayDeliveredSteps) || 0);
|
|
94
100
|
await recordOutreachGatewayOutcome(firestore, input.orgId, {
|
|
95
|
-
|
|
101
|
+
sendingDomain: input.sendingDomain,
|
|
96
102
|
gateway,
|
|
97
103
|
outcome: 'delivered',
|
|
98
104
|
count: fresh,
|