@aglyn/plugins-crm 1.0.0-beta.219 → 1.0.0-beta.221
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/README.md +2 -0
- package/package.json +14 -15
- package/src/lib/components/company-import-drawer.d.ts +1 -1
- package/src/lib/components/contact-associations-card.js +4 -3
- package/src/lib/components/contact-associations-card.js.map +1 -1
- package/src/lib/components/crm-booking-zone.d.ts +8 -2
- package/src/lib/components/crm-booking-zone.js.map +1 -1
- package/src/lib/components/crm-glance-card.js +2 -1
- package/src/lib/components/crm-glance-card.js.map +1 -1
- package/src/lib/components/deal-products-card.js +27 -16
- package/src/lib/components/deal-products-card.js.map +1 -1
- package/src/lib/components/lead-campaigns-card.d.ts +4 -4
- package/src/lib/components/lead-campaigns-card.js +9 -8
- package/src/lib/components/lead-campaigns-card.js.map +1 -1
- package/src/lib/components/lead-properties-card.js +4 -0
- package/src/lib/components/lead-properties-card.js.map +1 -1
- package/src/lib/components/lead-status-chip.js +3 -1
- package/src/lib/components/lead-status-chip.js.map +1 -1
- package/src/lib/components/leads-bulk-bar.js +7 -9
- package/src/lib/components/leads-bulk-bar.js.map +1 -1
- package/src/lib/components/leads-section.js +5 -1
- package/src/lib/components/leads-section.js.map +1 -1
- package/src/lib/components/new-lead-drawer.js +5 -4
- package/src/lib/components/new-lead-drawer.js.map +1 -1
- package/src/lib/components/reports/activity-card.js +4 -3
- package/src/lib/components/reports/activity-card.js.map +1 -1
- package/src/lib/components/reports/closed-deals-card.js +4 -3
- package/src/lib/components/reports/closed-deals-card.js.map +1 -1
- package/src/lib/components/reports/contacts-mix-card.js +3 -2
- package/src/lib/components/reports/contacts-mix-card.js.map +1 -1
- package/src/lib/components/reports/contacts-trend-card.js +2 -1
- package/src/lib/components/reports/contacts-trend-card.js.map +1 -1
- package/src/lib/components/reports/forecast-card.js +3 -2
- package/src/lib/components/reports/forecast-card.js.map +1 -1
- package/src/lib/components/reports/lead-funnel-card.js +8 -6
- package/src/lib/components/reports/lead-funnel-card.js.map +1 -1
- package/src/lib/components/reports/pipeline-card.js +4 -3
- package/src/lib/components/reports/pipeline-card.js.map +1 -1
- package/src/lib/components/reports/report-format.d.ts +1 -1
- package/src/lib/components/reports/report-format.js.map +1 -1
- package/src/lib/components/reports/report-scope.d.ts +1 -1
- package/src/lib/components/reports/report-scope.js.map +1 -1
- package/src/lib/components/reports/source-conversion-card.js +2 -1
- package/src/lib/components/reports/source-conversion-card.js.map +1 -1
- package/src/lib/components/reports/tasks-card.js +2 -1
- package/src/lib/components/reports/tasks-card.js.map +1 -1
- package/src/lib/components/reports/won-lost-by-owner-card.js +3 -2
- package/src/lib/components/reports/won-lost-by-owner-card.js.map +1 -1
- package/src/lib/components/reports-section.js +4 -4
- package/src/lib/components/reports-section.js.map +1 -1
- package/src/lib/constants/bundle-common.d.ts +2 -0
- package/src/lib/constants/bundle-common.js +1 -0
- package/src/lib/constants/bundle-common.js.map +1 -1
- package/src/lib/declarations.console-server.d.ts +4 -0
- package/src/lib/declarations.console-server.js +12 -0
- package/src/lib/declarations.console-server.js.map +1 -1
- package/src/lib/declarations.server.d.ts +6 -0
- package/src/lib/declarations.server.js +51 -1
- package/src/lib/declarations.server.js.map +1 -1
- package/src/lib/hooks/use-crm-campaigns.d.ts +2 -2
- package/src/lib/hooks/use-crm-campaigns.js +3 -3
- package/src/lib/hooks/use-crm-campaigns.js.map +1 -1
- package/src/lib/model/campaign-filing-activity.d.ts +1 -1
- package/src/lib/model/campaign-filing-activity.js +1 -1
- package/src/lib/model/campaign-filing-activity.js.map +1 -1
- package/src/lib/model/contact-record.js +1 -1
- package/src/lib/model/contact-record.js.map +1 -1
- package/src/lib/model/crm-digest.d.ts +45 -3
- package/src/lib/model/crm-digest.js +75 -11
- package/src/lib/model/crm-digest.js.map +1 -1
- package/src/lib/model/crm-inbound.d.ts +162 -0
- package/src/lib/model/crm-inbound.js +208 -0
- package/src/lib/model/crm-inbound.js.map +1 -0
- package/src/lib/model/crm-lead-import.d.ts +1 -1
- package/src/lib/model/crm-lead-import.js +4 -4
- package/src/lib/model/crm-lead-import.js.map +1 -1
- package/src/lib/model/crm-reports.d.ts +467 -0
- package/src/lib/model/crm-reports.js +667 -0
- package/src/lib/model/crm-reports.js.map +1 -0
- package/src/lib/model/lead-filters.d.ts +4 -3
- package/src/lib/model/lead-filters.js +7 -5
- package/src/lib/model/lead-filters.js.map +1 -1
- package/src/lib/plugin.js +36 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/server/api-v1/crm-leads.js +1 -0
- package/src/lib/server/api-v1/crm-leads.js.map +1 -1
- package/src/lib/server/api-v1/openapi.js +1 -1
- package/src/lib/server/api-v1/openapi.js.map +1 -1
- package/src/lib/server/campaign-filing-activity.d.ts +1 -1
- package/src/lib/server/campaign-filing-activity.js +7 -11
- package/src/lib/server/campaign-filing-activity.js.map +1 -1
- package/src/lib/server/capture-contact.js +19 -5
- package/src/lib/server/capture-contact.js.map +1 -1
- package/src/lib/server/contact-email-engagement.d.ts +33 -0
- package/src/lib/server/contact-email-engagement.js +146 -0
- package/src/lib/server/contact-email-engagement.js.map +1 -0
- package/src/lib/server/contact-update.js +3 -3
- package/src/lib/server/contact-update.js.map +1 -1
- package/src/lib/server/container-detach.d.ts +31 -0
- package/src/lib/server/container-detach.js +115 -0
- package/src/lib/server/container-detach.js.map +1 -0
- package/src/lib/server/crm-inbound-email.d.ts +162 -0
- package/src/lib/server/crm-inbound-email.js +259 -0
- package/src/lib/server/crm-inbound-email.js.map +1 -0
- package/src/lib/server/crm-records-meter.d.ts +50 -0
- package/src/lib/server/crm-records-meter.js +126 -0
- package/src/lib/server/crm-records-meter.js.map +1 -0
- package/src/lib/server/daily-digest-route.d.ts +2 -8
- package/src/lib/server/daily-digest-route.js +15 -25
- package/src/lib/server/daily-digest-route.js.map +1 -1
- package/src/lib/server/inbound-address.js +5 -3
- package/src/lib/server/inbound-address.js.map +1 -1
- package/src/lib/server/inbound-route.js +7 -4
- package/src/lib/server/inbound-route.js.map +1 -1
- package/src/lib/server/lead-campaign-carry.js +3 -3
- package/src/lib/server/lead-campaign-carry.js.map +1 -1
- package/src/lib/server/lead-create.d.ts +1 -1
- package/src/lib/server/lead-create.js +10 -15
- package/src/lib/server/lead-create.js.map +1 -1
- package/src/lib/server/lead-nurturing.d.ts +23 -0
- package/src/lib/server/lead-nurturing.js +82 -0
- package/src/lib/server/lead-nurturing.js.map +1 -0
- package/src/lib/server/leads-import.js +7 -7
- package/src/lib/server/leads-import.js.map +1 -1
- package/src/lib/server/record-email-state.js +23 -0
- package/src/lib/server/record-email-state.js.map +1 -1
- package/src/lib/server/record-timeline.d.ts +12 -0
- package/src/lib/server/record-timeline.js +127 -15
- package/src/lib/server/record-timeline.js.map +1 -1
- package/src/lib/server/task-reminders-route.js +4 -2
- package/src/lib/server/task-reminders-route.js.map +1 -1
- package/src/lib/server/task-routes.d.ts +2 -0
- package/src/lib/server/task-routes.js +22 -3
- package/src/lib/server/task-routes.js.map +1 -1
- package/src/lib/usage-axes.js +8 -1
- package/src/lib/usage-axes.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/daily-digest-route.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 type AglynOrgBilling,\n type AglynOrgMember,\n buildRoute,\n CRM_COLLECTIONS,\n DIGEST_PREFS_FIELD,\n digestEnabled,\n hostRoleFor,\n isReleaseFlagOnForOrg,\n notificationMuted,\n parseOrgReleaseFlagOverrides,\n pluginRequestFromWeb,\n resolveBrandingProfile,\n resolveEffectivePlan,\n resolveOrgEntitlements,\n Route,\n} from '@aglyn/aglyn/server'\nimport {\n buildMemberDigests,\n composeCrmDigestEmailText,\n composeCrmDigestSubject,\n composeCrmDigestSummary,\n CRM_DIGEST_DEFAULT_TIME_ZONE,\n CRM_DIGEST_LEAD_AGE_MS,\n CRM_DIGEST_LEAD_WINDOW,\n CRM_DIGEST_TASK_CEILING,\n crmDigestCounts,\n crmDigestEntitled,\n type CrmDigestLead,\n type CrmDigestTask,\n crmDigestWindow,\n type CrmMemberDigest,\n isUnworkedLead,\n utcDayKey,\n} from '../model/crm-digest'\nimport { rateLimitedRetryAtMs, sendEmail } from '@aglyn/shared-util-email'\nimport { withoutMailWithheldAccounts } from '@aglyn/tenant-data-admin/server/account-mail'\nimport {\n findUserByUidAcrossPools,\n firebaseAdmin,\n getServerReleaseFlagValues,\n listOrgMembers,\n memberHasOrgPermission,\n meterPlatformEmail,\n notifyUsers,\n} from '@aglyn/tenant-data-admin'\n// From the LEAF, not the barrel (AGL-2407): the cron specs mock\n// `@aglyn/tenant-data-admin` wholesale, and a suppression gate imported\n// through the barrel would be silently replaced by whatever the factory\n// lists. Same reasoning as `usage-alert-email.ts`.\nimport { filterSuppressedEmails } from '@aglyn/tenant-data-admin/server/email-suppression'\nimport {\n loadSystemEmail,\n renderLoadedSystemEmail,\n systemEmailBrand,\n systemEmailContent,\n type LoadedSystemEmail,\n} from '@aglyn/tenant-data-admin/server/render-system-email'\nimport { isCronAuthorized, isCronDryRun } from '@aglyn/tenant-data-admin/server/cron-auth'\nimport { writeCronBeat } from '@aglyn/aglyn/app-utils/health-report'\nimport { brandSupportLine } from '@aglyn/aglyn/app-utils/brand-support-line'\nimport { platformConsoleOrigin as consoleOrigin } from '@aglyn/aglyn/app-utils/platform-brand'\nimport { scopeTokensForHost } from '@aglyn/aglyn/app-utils/scope-tokens'\n\n/**\n * The mark a scheduled run leaves for `/api/health/crons` (AGL-1955), on the\n * INVOCATION and before the work — the console's rule for every cron. Never\n * throws: reaching the admin app is inside the try, because a monitor that\n * can take down the job it describes is worse than the failure it watches.\n */\nasync function recordCronBeat(jobId: string): Promise<void> {\n try {\n await writeCronBeat(firebaseAdmin.app().firestore(), jobId)\n } catch {\n // Deliberately swallowed: a job that stops stamping reds its own row.\n }\n}\n\n// lockdown-423: exempt — server-internal cron (x-cron-secret), no user caller; it reads an org's CRM to remind its members and writes nothing a locked org could lose.\n\n/**\n * The daily CRM digest (AGL-2619): `POST /api/crm/daily-digest`.\n *\n * Overdue and due-today are computed at read time by design (AGL-2599), so\n * until this route nothing ever told anybody a task was late — overdue work\n * was found by opening the Tasks section. Once a morning, per org and per\n * member with open work, this counts their overdue and due-today tasks and\n * the leads nobody has worked, and says so ONCE: a console notification\n * (`content.crmDailyDigest`) and an email through the platform's own\n * sending path. The words and the arithmetic live in `crm-digest.ts`; this\n * file is the reads, the sends and the sweep.\n *\n * ## Who is told, and who is not\n *\n * A task's assignee, a lead's owner, and — for a lead nobody owns — every\n * member who reaches the site it was captured on. Only members who may open\n * the CRM (`data.manage`, resolved through their custom role the way the task\n * routes resolve it), and only in an org whose plan carries the suite and\n * whose `release_crm` flag is on: a digest about a surface the reader\n * cannot open is a reminder to pay, not a reminder to call.\n *\n * Two switches on the recipient's own account are honored. The **Daily CRM\n * digest** preference (`users/{uid}.digestPrefs.crmDaily`, on until turned\n * off) governs the whole digest, mail included. The operational category\n * mute (`notificationPrefs.content`) governs the console notification alone,\n * as it does for every other `content.` type — the mute is a fact about the\n * console feed, and the digest switch is a fact about the digest.\n *\n * ## Once a day, and resumable\n *\n * `orgs/{orgId}/crmDigests/{YYYY-MM-DD}` records, per member, that today's\n * digest went out. It is stamped AFTER the sends, so a run that dies\n * mid-org re-sends only to the members it never reached, and a second run\n * on the same UTC day sends nothing. Orgs are swept in id order behind a\n * cursor the way `usage-alerts` sweeps them, so the workflow's `done:false`\n * loop carries a platform of any size across invocations.\n *\n * The email is a `bulk` send under the platform send-rate governor\n * (AGL-2409): a cron fan-out on the shared sending domain, answering nothing\n * anybody just did. A refusal STOPS the run and reports `done: true` — the\n * window is full, and re-POSTing into it would only red the workflow — and\n * because the refused member was never stamped, tomorrow's run digests them\n * afresh. Nothing about today's list is worth mailing tomorrow anyway.\n *\n * Metered as PLATFORM mail, not the org's: this is the platform reminding a\n * member, and a reminder that pushed an org toward its email overage would\n * be its own small absurdity. It is not a one-to-one CRM send and counts\n * against no per-member sending allowance.\n */\n\n/** The beat `/api/health/crons` would read, were this job in its inventory. */\nexport const CRM_DIGEST_JOB_ID = 'crm-daily-digest'\n\n/**\n * Orgs per invocation.\n *\n * Fewer than `usage-alerts`' hundred: an org costs a task query, a paged\n * host list, a leads query per site, the roster, and a send per member with\n * work, under `maxDuration = 60`. A chunk that times out never advances its\n * cursor, so the safe size is the one that finishes.\n */\nexport const CRM_DIGEST_ORG_CHUNK = 25\n\n/** Hosts read per page inside one org, ordered by id like the orgs sweep. */\nconst CRM_DIGEST_HOST_PAGE = 100\n\n/**\n * The most sites one org's digest reads leads from, after which the sweep\n * stops and SAYS SO in the response rather than reading without bound.\n */\nexport const CRM_DIGEST_HOST_CEILING = 200\n\n/**\n * The key a person's switch for this digest is stored under in\n * `users/{uid}.digestPrefs` — the one the CRM declares under\n * `notificationDigests` in `plugins.config.json`, which is the one the\n * settings page writes. `daily-digest-route.spec.ts` holds the two to one\n * spelling.\n */\nexport const CRM_DAILY_DIGEST_KEY = 'crmDaily'\n\n/** `orgs/{orgId}/crmDigests/{day}` — server-written, matched by no rule. */\nexport const CRM_DIGEST_MARKER_COLLECTION = 'crmDigests'\n\n/**\n * The most members per org whose address is chased through the auth pools\n * when their member document carries none. Bounds WORK, not audience — the\n * denormalized copy is normally present, and `orgAdminEmails` draws the same\n * line for the same reason.\n */\nexport const CRM_DIGEST_EMAIL_MAX_UID_LOOKUPS = 5\n\n/**\n * The zone the digest's \"today\" is drawn in.\n *\n * `CRM_DIGEST_TIME_ZONE` overrides the default for a self-hosted console\n * whose team wakes up elsewhere; a value `Intl` does not know falls back to\n * the default with a warning rather than throwing the whole run away.\n */\nexport function digestTimeZone(env: NodeJS.ProcessEnv = process.env): string {\n const configured = String(env['CRM_DIGEST_TIME_ZONE'] ?? '').trim()\n if (!configured) return CRM_DIGEST_DEFAULT_TIME_ZONE\n try {\n new Intl.DateTimeFormat('en-US', { timeZone: configured })\n return configured\n } catch {\n console.warn(\n `crm-daily-digest: CRM_DIGEST_TIME_ZONE \"${configured}\" is not a zone — ` +\n `using ${CRM_DIGEST_DEFAULT_TIME_ZONE}`,\n )\n return CRM_DIGEST_DEFAULT_TIME_ZONE\n }\n}\n\ntype Firestore = FirebaseFirestore.Firestore\ntype Snapshot = FirebaseFirestore.QueryDocumentSnapshot\n\n/**\n * Every host owned by an org, paged by document id — the shape\n * `usage-alerts` reads hosts with, at a lower ceiling because each host here\n * costs a leads query.\n */\nasync function hostsOfOrg(\n firestore: Firestore,\n orgId: string,\n): Promise<{ docs: Snapshot[]; truncated: boolean }> {\n const docs: Snapshot[] = []\n let cursor: string | null = null\n for (;;) {\n let query = firestore\n .collection('hosts')\n .where('orgId', '==', orgId)\n .orderBy(firebaseAdmin.firestore.FieldPath.documentId())\n .limit(CRM_DIGEST_HOST_PAGE)\n if (cursor) query = query.startAfter(firestore.collection('hosts').doc(cursor))\n const page = await query.get()\n for (const doc of page.docs) docs.push(doc)\n if (page.docs.length < CRM_DIGEST_HOST_PAGE) return { docs, truncated: false }\n if (docs.length >= CRM_DIGEST_HOST_CEILING) {\n console.warn(\n `crm-daily-digest: org ${orgId} has more than ${CRM_DIGEST_HOST_CEILING} ` +\n 'hosts — leads on the rest are not digested',\n )\n return { docs, truncated: true }\n }\n cursor = page.docs[page.docs.length - 1].id\n }\n}\n\n/** What one member's row in the response says. */\ninterface MemberReport {\n skipped?: string\n overdue?: number\n today?: number\n leads?: number\n notified?: boolean\n emailed?: boolean\n emailReason?: string\n deferred?: boolean\n}\n\ninterface OrgReport {\n skipped?: string\n digests: number\n notified: number\n emailed: number\n members?: Record<string, MemberReport>\n hostsTruncated?: boolean\n tasksTruncated?: boolean\n /** The send-rate governor refused; the sweep stops here. */\n deferred?: boolean\n}\n\ninterface SweepContext {\n firestore: Firestore\n nowMs: number\n day: string\n timeZone: string\n dryRun: boolean\n flagValues: Awaited<ReturnType<typeof getServerReleaseFlagValues>>\n origin: string\n}\n\n/**\n * The console path a notification opens — the legacy `/{hostDocId}/rest`\n * shape every host notification uses, or the `/org/rest` shape the console\n * rewrites onto the organization's own hub when nothing owed names a site:\n * an organization task (AGL-2637) has none.\n */\nfunction notificationLink(hostId: string, section: 'tasks' | 'leads'): string {\n return hostId ? `/${hostId}/crm/${section}` : `/org/crm/${section}`\n}\n\nasync function digestOrg(ctx: SweepContext, orgDoc: Snapshot): Promise<OrgReport> {\n const { firestore, nowMs, day, timeZone, dryRun } = ctx\n const orgId = orgDoc.id\n const org = (orgDoc.data() ?? {}) as Partial<AglynOrgBilling> & Record<string, unknown>\n const quiet: OrgReport = { digests: 0, notified: 0, emailed: 0 }\n\n if (!crmDigestEntitled(resolveOrgEntitlements(org).features)) {\n return { ...quiet, skipped: 'not-entitled' }\n }\n const flagOn = isReleaseFlagOnForOrg(\n 'release_crm',\n ctx.flagValues['release_crm'],\n orgId,\n parseOrgReleaseFlagOverrides(org['releaseFlags']),\n resolveEffectivePlan(org),\n )\n if (!flagOn) return { ...quiet, skipped: 'release-flag' }\n\n const window = crmDigestWindow(nowMs, timeZone)\n\n /*\n * Every open task due before tomorrow, soonest first — overdue and today\n * in one query on `(status, dueAtMs)`, bucketed afterwards. Undated tasks\n * carry `dueAtMs: null`, which a range excludes.\n */\n const taskPage = await orgDoc.ref\n .collection(CRM_COLLECTIONS.tasks)\n .where('status', '==', 'open')\n .where('dueAtMs', '<', window.endMs)\n .orderBy('dueAtMs', 'asc')\n .limit(CRM_DIGEST_TASK_CEILING)\n .get()\n const tasks: CrmDigestTask[] = taskPage.docs.map((doc) => ({\n id: doc.id,\n title: String(doc.get('title') ?? ''),\n kind: doc.get('kind') ?? 'todo',\n dueAtMs: Number(doc.get('dueAtMs')),\n assigneeUid: String(doc.get('assigneeUid') ?? ''),\n hostId: String(doc.get('hostId') ?? ''),\n contactId: doc.get('contactId') || undefined,\n companyId: doc.get('companyId') || undefined,\n dealId: doc.get('dealId') || undefined,\n }))\n const tasksTruncated = taskPage.docs.length >= CRM_DIGEST_TASK_CEILING\n\n /*\n * The leads nobody has worked, per site. A window of the newest old leads\n * by first sighting, filtered in memory, for the reason the Leads section\n * gives: the capture door stamps no `status`, and a query cannot select on\n * a field's absence.\n */\n const hosts = await hostsOfOrg(firestore, orgId)\n const hostById = new Map(hosts.docs.map((doc) => [doc.id, doc]))\n const leadCutoff = nowMs - CRM_DIGEST_LEAD_AGE_MS\n const leads: CrmDigestLead[] = []\n for (const host of hosts.docs) {\n /*\n * Still per site, but over the ORG collection narrowed to what that site\n * may see (AGL-3275). The digest is a per-site message, so the loop stays;\n * what changed is that a site's leads are selected by `visibleTo` rather\n * than by the collection's parent, and a lead two brands share is in both\n * of their digests — which is the honest answer, since both hold it.\n */\n const page = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('leads')\n .where('visibleTo', 'array-contains-any', scopeTokensForHost(host.id))\n .where('firstSeenAtMs', '<=', leadCutoff)\n .orderBy('firstSeenAtMs', 'desc')\n .limit(CRM_DIGEST_LEAD_WINDOW)\n .get()\n for (const doc of page.docs) {\n const data = doc.data() as Record<string, unknown>\n if (!isUnworkedLead(data as Parameters<typeof isUnworkedLead>[0], nowMs)) continue\n leads.push({\n id: doc.id,\n hostId: host.id,\n email: String(data['email'] ?? ''),\n name: String(data['name'] ?? '') || undefined,\n ownerUid: String(data['ownerUid'] ?? '') || undefined,\n firstSeenAtMs: Number(data['firstSeenAtMs']),\n })\n }\n }\n\n if (!tasks.length && !leads.length) return { ...quiet, skipped: 'nothing-owed' }\n\n const listed = (await listOrgMembers(orgId)).filter(\n (member) => member.orgSuspended !== true,\n )\n // A locked or disabled account is not told (AGL-3418): it cannot sign in\n // to act on it.\n const reachable = new Set(\n await withoutMailWithheldAccounts(listed.map((member) => member.$id)),\n )\n const members = listed.filter((member) => reachable.has(member.$id))\n const memberById = new Map(members.map((member) => [member.$id, member]))\n const digests = buildMemberDigests({\n tasks,\n leads,\n window,\n members: members.map((member) => ({\n uid: member.$id,\n reachesHost: (hostId: string) => hostRoleFor(member, hostId) !== null,\n })),\n })\n if (!digests.size) return { ...quiet, skipped: 'nobody-to-tell' }\n\n const markerRef = orgDoc.ref.collection(CRM_DIGEST_MARKER_COLLECTION).doc(day)\n const marker = await markerRef.get()\n const sentToday = (marker.get('members') ?? {}) as Record<string, unknown>\n\n // Sorted so the order members are told in — and the member a refusal\n // lands on — is the same on every run.\n const uids = [...digests.keys()].sort()\n const userDocs = await firestore.getAll(\n ...uids.map((uid) => firestore.collection('users').doc(uid)),\n )\n const userById = new Map(userDocs.map((doc) => [doc.id, doc]))\n\n const branding = resolveBrandingProfile(org)\n const orgSlug = String(org['slug'] ?? '')\n const hostName = (hostId: string): string => {\n const host = hostById.get(hostId)\n return String(host?.get('name') || host?.get('subdomain') || hostId)\n }\n const hubUrl = (hostId: string): string | null => {\n const subdomain = String(hostById.get(hostId)?.get('subdomain') ?? '')\n if (!orgSlug || !subdomain) return null\n return `${ctx.origin}${buildRoute(Route.HOST_DASHBOARD, { orgSlug, host: subdomain })}/crm`\n }\n // The organization's own hub, for a member whose owed tasks name no site.\n const orgHubUrl = orgSlug\n ? `${ctx.origin}${buildRoute(Route.ORG_HOME, { orgSlug })}/crm`\n : null\n const settingsUrl = `${ctx.origin}${buildRoute(Route.MANAGE_NOTIFICATION_SETTINGS)}`\n const supportLine = brandSupportLine(branding)\n const brand = systemEmailBrand(org)\n let digestEmail: Promise<LoadedSystemEmail | null> | undefined\n\n const report: OrgReport = { digests: 0, notified: 0, emailed: 0, members: {} }\n if (hosts.truncated) report.hostsTruncated = true\n if (tasksTruncated) report.tasksTruncated = true\n const rows = report.members as Record<string, MemberReport>\n let poolLookups = 0\n\n for (const uid of uids) {\n const digest = digests.get(uid) as CrmMemberDigest\n if (sentToday[uid]) {\n rows[uid] = { skipped: 'already-sent' }\n continue\n }\n const member = memberById.get(uid) as AglynOrgMember\n if (!(await memberHasOrgPermission(orgId, member, 'data.manage'))) {\n rows[uid] = { skipped: 'no-data-manage' }\n continue\n }\n const userDoc = userById.get(uid)\n if (!digestEnabled(userDoc?.get(DIGEST_PREFS_FIELD), CRM_DAILY_DIGEST_KEY)) {\n rows[uid] = { skipped: 'digest-off' }\n continue\n }\n const muted = notificationMuted(\n userDoc?.get('notificationPrefs'),\n 'content.crmDailyDigest',\n )\n const counts = crmDigestCounts(digest)\n const row: MemberReport = { ...counts }\n rows[uid] = row\n report.digests += 1\n if (dryRun) {\n row.notified = !muted\n continue\n }\n\n // The site the links open on: where the first task THAT HAS A SITE\n // lives, else where the first lead was captured. An organization task\n // (AGL-2637) names no site, and a digest owed nothing but those opens\n // on the organization's hub.\n const anchorHostId =\n [...digest.overdue, ...digest.today].find((task) => task.hostId)?.hostId ||\n digest.leads[0]?.hostId ||\n ''\n const section = counts.overdue + counts.today > 0 ? 'tasks' : 'leads'\n const hub = anchorHostId ? hubUrl(anchorHostId) : orgHubUrl\n\n /*\n * THE EMAIL FIRST. A refusal from the send-rate governor stops the run\n * with nothing else done for this member — no notification to double\n * tomorrow, no marker to skip them on — so the order here is the\n * idempotence, not a preference.\n */\n let emailed = false\n let emailReason: string | undefined\n let address = String(member.email ?? '').trim().toLowerCase()\n if (!address.includes('@') && poolLookups < CRM_DIGEST_EMAIL_MAX_UID_LOOKUPS) {\n poolLookups += 1\n const pooled = await findUserByUidAcrossPools(uid).catch(() => null)\n address = String(pooled?.record?.email ?? '').trim().toLowerCase()\n }\n const recipients = address.includes('@')\n ? await filterSuppressedEmails([address], firestore)\n : []\n if (!recipients.length) {\n emailReason = address.includes('@') ? 'suppressed' : 'no-recipient'\n } else {\n const digestInput = {\n digest,\n nowMs,\n timeZone,\n productName: branding.productName,\n tasksUrl: hub ? `${hub}/tasks` : ctx.origin,\n leadsUrl: (hostId: string) => `${hubUrl(hostId) ?? ctx.origin}/leads`,\n settingsUrl,\n hostName,\n supportLine,\n }\n const subject = composeCrmDigestSubject(counts)\n // The `crm-daily-digest` system email (AGL-3367), in the org's brand,\n // read once per org and rendered per member.\n digestEmail ??= loadSystemEmail('crm-daily-digest').catch(() => null)\n const loaded = await digestEmail\n const rendered = loaded\n ? renderLoadedSystemEmail(\n loaded,\n {\n ...brand.merge,\n 'digest.subject': subject,\n 'digest.body': composeCrmDigestEmailText({ ...digestInput, omitClosing: true }),\n settingsUrl,\n },\n brand.options,\n )\n : null\n const result = await sendEmail({\n to: recipients,\n ...systemEmailContent(rendered, {\n subject,\n text: composeCrmDigestEmailText(digestInput),\n }),\n fromName: branding.fromName,\n context: 'crm-daily-digest',\n priority: 'bulk',\n })\n if (result.sent) {\n emailed = true\n await meterPlatformEmail().catch(() => undefined)\n } else if (rateLimitedRetryAtMs(result) !== null) {\n row.deferred = true\n report.deferred = true\n return report\n } else {\n emailReason = (result as { reason?: string }).reason ?? 'failed'\n }\n }\n\n let notified = false\n if (!muted) {\n await notifyUsers([uid], {\n type: 'content.crmDailyDigest',\n title: 'Your CRM today',\n body: composeCrmDigestSummary(counts),\n link: notificationLink(anchorHostId, section),\n orgId,\n ...(anchorHostId ? { hostId: anchorHostId } : {}),\n })\n notified = true\n }\n\n await markerRef.set(\n {\n day,\n members: {\n [uid]: { atMs: nowMs, ...counts, notified, emailed },\n },\n updatedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n row.notified = notified\n row.emailed = emailed\n if (emailReason) row.emailReason = emailReason\n if (notified) report.notified += 1\n if (emailed) report.emailed += 1\n }\n return report\n}\n\nexport async function crmDailyDigestRoute(request: Request): Promise<Response> {\n const { method, body, headers: rawHeaders, query } = await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'POST' && method !== 'GET') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n if (!process.env.CRON_SECRET) {\n return Response.json(\n { error: 'The CRM digest is not configured (CRON_SECRET).' },\n { status: 501 },\n )\n }\n if (!isCronAuthorized(headers)) {\n return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n }\n // A GET reports the plan and sends nothing; a bodyless POST — what the\n // scheduled workflow sends — is a real run. `isCronDryRun` keys the\n // default on the METHOD for exactly that reason.\n const dryRun = isCronDryRun({ method, body, query })\n // Stamped on the invocation, not on the work (AGL-1955), so the beat is\n // already there the day this job joins `SCHEDULED_JOBS`.\n if (method === 'POST') await recordCronBeat(CRM_DIGEST_JOB_ID)\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n const nowMs = Date.now()\n const ctx: SweepContext = {\n firestore,\n nowMs,\n day: utcDayKey(nowMs),\n timeZone: digestTimeZone(),\n dryRun,\n flagValues: await getServerReleaseFlagValues(),\n origin: consoleOrigin(),\n }\n\n // The resumable sweep, in the shape `usage-alerts` reads orgs (AGL-2220):\n // ordered by id, `limit + 1` to learn whether there is more, the cursor\n // validated as an org id before it becomes a reference.\n const requestedLimit = Number((body as { limit?: unknown } | undefined)?.limit)\n const pageSize =\n Number.isFinite(requestedLimit) && requestedLimit > 0\n ? Math.min(Math.floor(requestedLimit), CRM_DIGEST_ORG_CHUNK)\n : CRM_DIGEST_ORG_CHUNK\n const rawCursor = (body as { cursor?: unknown } | undefined)?.cursor\n const cursor =\n typeof rawCursor === 'string' && rawCursor.length > 0 && !rawCursor.includes('/')\n ? rawCursor\n : null\n let orgQuery = firestore\n .collection('orgs')\n .orderBy(firebaseAdmin.firestore.FieldPath.documentId())\n .limit(pageSize + 1)\n if (cursor) orgQuery = orgQuery.startAfter(firestore.collection('orgs').doc(cursor))\n const page = await orgQuery.get()\n const hasMore = page.docs.length > pageSize\n const orgDocs = hasMore ? page.docs.slice(0, pageSize) : page.docs\n\n const orgs: Record<string, OrgReport> = {}\n const failures: Record<string, string> = {}\n let digests = 0\n let notified = 0\n let emailed = 0\n let deferred = false\n for (const orgDoc of orgDocs) {\n try {\n const report = await digestOrg(ctx, orgDoc)\n orgs[orgDoc.id] = report\n digests += report.digests\n notified += report.notified\n emailed += report.emailed\n if (report.deferred) {\n deferred = true\n break\n }\n } catch (error) {\n console.error('[crm] daily digest failed for org', orgDoc.id, error)\n failures[orgDoc.id] = (error as Error)?.message ?? 'unknown'\n }\n }\n const failed = Object.keys(failures)\n return Response.json(\n {\n day: ctx.day,\n timeZone: ctx.timeZone,\n dryRun,\n swept: orgDocs.length,\n digests,\n notified,\n emailed,\n orgs,\n ...(deferred ? { deferred: true } : {}),\n ...(failed.length ? { failures } : {}),\n // A deferred run reports `done: true` — see the module comment —\n // so the workflow's loop does not re-POST into a full window.\n nextCursor: deferred || !hasMore ? null : (orgDocs[orgDocs.length - 1]?.id ?? null),\n done: deferred || !hasMore,\n },\n // 207: this chunk finished and some org in it needs a person, which is\n // what fails the workflow (its one alerting channel).\n { status: failed.length ? 207 : 200 },\n )\n } catch (error) {\n console.error('[crm] daily digest run failed', error)\n return Response.json({ error: 'CRM digest run failed' }, { status: 500 })\n }\n}\n"],"names":["buildRoute","CRM_COLLECTIONS","DIGEST_PREFS_FIELD","digestEnabled","hostRoleFor","isReleaseFlagOnForOrg","notificationMuted","parseOrgReleaseFlagOverrides","pluginRequestFromWeb","resolveBrandingProfile","resolveEffectivePlan","resolveOrgEntitlements","Route","buildMemberDigests","composeCrmDigestEmailText","composeCrmDigestSubject","composeCrmDigestSummary","CRM_DIGEST_DEFAULT_TIME_ZONE","CRM_DIGEST_LEAD_AGE_MS","CRM_DIGEST_LEAD_WINDOW","CRM_DIGEST_TASK_CEILING","crmDigestCounts","crmDigestEntitled","crmDigestWindow","isUnworkedLead","utcDayKey","rateLimitedRetryAtMs","sendEmail","withoutMailWithheldAccounts","findUserByUidAcrossPools","firebaseAdmin","getServerReleaseFlagValues","listOrgMembers","memberHasOrgPermission","meterPlatformEmail","notifyUsers","filterSuppressedEmails","loadSystemEmail","renderLoadedSystemEmail","systemEmailBrand","systemEmailContent","isCronAuthorized","isCronDryRun","writeCronBeat","brandSupportLine","platformConsoleOrigin","consoleOrigin","scopeTokensForHost","recordCronBeat","jobId","app","firestore","CRM_DIGEST_JOB_ID","CRM_DIGEST_ORG_CHUNK","CRM_DIGEST_HOST_PAGE","CRM_DIGEST_HOST_CEILING","CRM_DAILY_DIGEST_KEY","CRM_DIGEST_MARKER_COLLECTION","CRM_DIGEST_EMAIL_MAX_UID_LOOKUPS","digestTimeZone","env","process","configured","String","trim","Intl","DateTimeFormat","timeZone","console","warn","hostsOfOrg","orgId","docs","cursor","query","collection","where","orderBy","FieldPath","documentId","limit","startAfter","doc","page","get","push","length","truncated","id","notificationLink","hostId","section","digestOrg","ctx","orgDoc","marker","org","nowMs","day","dryRun","data","quiet","digests","notified","emailed","features","skipped","flagOn","flagValues","window","taskPage","ref","tasks","endMs","map","title","kind","dueAtMs","Number","assigneeUid","contactId","undefined","companyId","dealId","tasksTruncated","hosts","hostById","Map","leadCutoff","leads","host","email","name","ownerUid","firstSeenAtMs","listed","filter","member","orgSuspended","reachable","Set","$id","members","has","memberById","uid","reachesHost","size","markerRef","sentToday","uids","keys","sort","userDocs","getAll","userById","branding","orgSlug","hostName","hubUrl","subdomain","origin","HOST_DASHBOARD","orgHubUrl","ORG_HOME","settingsUrl","MANAGE_NOTIFICATION_SETTINGS","supportLine","brand","digestEmail","report","hostsTruncated","rows","poolLookups","digest","userDoc","muted","counts","row","anchorHostId","overdue","today","find","task","hub","emailReason","address","toLowerCase","includes","pooled","catch","record","recipients","digestInput","productName","tasksUrl","leadsUrl","subject","loaded","rendered","merge","omitClosing","options","result","to","text","fromName","context","priority","sent","deferred","reason","type","body","link","set","atMs","updatedAt","FieldValue","serverTimestamp","crmDailyDigestRoute","request","method","headers","rawHeaders","Response","json","error","status","CRON_SECRET","orgDocs","Date","now","requestedLimit","pageSize","isFinite","Math","min","floor","rawCursor","orgQuery","hasMore","slice","orgs","failures","message","failed","Object","swept","nextCursor","done"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/daily-digest-route.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 type AglynOrgBilling,\n type AglynOrgMember,\n buildRoute,\n CRM_COLLECTIONS,\n DIGEST_PREFS_FIELD,\n digestEnabled,\n hostRoleFor,\n isReleaseFlagOnForOrg,\n notificationMuted,\n parseOrgReleaseFlagOverrides,\n pluginRequestFromWeb,\n resolveBrandingProfile,\n resolveEffectivePlan,\n resolveOrgEntitlements,\n Route,\n} from '@aglyn/aglyn/server'\nimport {\n buildMemberDigests,\n composeCrmDigestEmailText,\n composeCrmDigestSubject,\n composeCrmDigestSummary,\n CRM_DIGEST_LEAD_AGE_MS,\n CRM_DIGEST_LEAD_WINDOW,\n CRM_DIGEST_TASK_CEILING,\n crmDigestCounts,\n crmDigestEntitled,\n type CrmDigestLead,\n type CrmDigestTask,\n crmDigestWindow,\n type CrmMemberDigest,\n digestTimeZone,\n isNurturingLead,\n isUnworkedLead,\n utcDayKey,\n} from '../model/crm-digest'\nimport { rateLimitedRetryAtMs, sendEmail } from '@aglyn/shared-util-email'\nimport { withoutMailWithheldAccounts } from '@aglyn/tenant-data-admin/server/account-mail'\nimport {\n findUserByUidAcrossPools,\n firebaseAdmin,\n getServerReleaseFlagValues,\n listOrgMembers,\n memberHasOrgPermission,\n meterPlatformEmail,\n notifyUsers,\n} from '@aglyn/tenant-data-admin'\n// From the LEAF, not the barrel (AGL-2407): the cron specs mock\n// `@aglyn/tenant-data-admin` wholesale, and a suppression gate imported\n// through the barrel would be silently replaced by whatever the factory\n// lists. Same reasoning as `usage-alert-email.ts`.\nimport { filterSuppressedEmails } from '@aglyn/tenant-data-admin/server/email-suppression'\nimport {\n loadSystemEmail,\n renderLoadedSystemEmail,\n systemEmailBrand,\n systemEmailContent,\n type LoadedSystemEmail,\n} from '@aglyn/tenant-data-admin/server/render-system-email'\nimport { isCronAuthorized, isCronDryRun } from '@aglyn/tenant-data-admin/server/cron-auth'\nimport { writeCronBeat } from '@aglyn/aglyn/app-utils/health-report'\nimport { brandSupportLine } from '@aglyn/aglyn/app-utils/brand-support-line'\nimport { platformConsoleOrigin as consoleOrigin } from '@aglyn/aglyn/app-utils/platform-brand'\nimport { scopeTokensForHost } from '@aglyn/aglyn/app-utils/scope-tokens'\n\n/**\n * The mark a scheduled run leaves for `/api/health/crons` (AGL-1955), on the\n * INVOCATION and before the work — the console's rule for every cron. Never\n * throws: reaching the admin app is inside the try, because a monitor that\n * can take down the job it describes is worse than the failure it watches.\n */\nasync function recordCronBeat(jobId: string): Promise<void> {\n try {\n await writeCronBeat(firebaseAdmin.app().firestore(), jobId)\n } catch {\n // Deliberately swallowed: a job that stops stamping reds its own row.\n }\n}\n\n// lockdown-423: exempt — server-internal cron (x-cron-secret), no user caller; it reads an org's CRM to remind its members and writes nothing a locked org could lose.\n\n/**\n * The daily CRM digest (AGL-2619): `POST /api/crm/daily-digest`.\n *\n * Overdue and due-today are computed at read time by design (AGL-2599), so\n * until this route nothing ever told anybody a task was late — overdue work\n * was found by opening the Tasks section. Once a morning, per org and per\n * member with open work, this counts their overdue and due-today tasks and\n * the leads nobody has worked, and says so ONCE: a console notification\n * (`content.crmDailyDigest`) and an email through the platform's own\n * sending path. The words and the arithmetic live in `crm-digest.ts`; this\n * file is the reads, the sends and the sweep.\n *\n * ## Who is told, and who is not\n *\n * A task's assignee, a lead's owner, and — for a lead nobody owns — every\n * member who reaches the site it was captured on. Only members who may open\n * the CRM (`data.manage`, resolved through their custom role the way the task\n * routes resolve it), and only in an org whose plan carries the suite and\n * whose `release_crm` flag is on: a digest about a surface the reader\n * cannot open is a reminder to pay, not a reminder to call.\n *\n * Two switches on the recipient's own account are honored. The **Daily CRM\n * digest** preference (`users/{uid}.digestPrefs.crmDaily`, on until turned\n * off) governs the whole digest, mail included. The operational category\n * mute (`notificationPrefs.content`) governs the console notification alone,\n * as it does for every other `content.` type — the mute is a fact about the\n * console feed, and the digest switch is a fact about the digest.\n *\n * ## Once a day, and resumable\n *\n * `orgs/{orgId}/crmDigests/{YYYY-MM-DD}` records, per member, that today's\n * digest went out. It is stamped AFTER the sends, so a run that dies\n * mid-org re-sends only to the members it never reached, and a second run\n * on the same UTC day sends nothing. Orgs are swept in id order behind a\n * cursor the way `usage-alerts` sweeps them, so the workflow's `done:false`\n * loop carries a platform of any size across invocations.\n *\n * The email is a `bulk` send under the platform send-rate governor\n * (AGL-2409): a cron fan-out on the shared sending domain, answering nothing\n * anybody just did. A refusal STOPS the run and reports `done: true` — the\n * window is full, and re-POSTing into it would only red the workflow — and\n * because the refused member was never stamped, tomorrow's run digests them\n * afresh. Nothing about today's list is worth mailing tomorrow anyway.\n *\n * Metered as PLATFORM mail, not the org's: this is the platform reminding a\n * member, and a reminder that pushed an org toward its email overage would\n * be its own small absurdity. It is not a one-to-one CRM send and counts\n * against no per-member sending allowance.\n */\n\n/** The beat `/api/health/crons` would read, were this job in its inventory. */\nexport const CRM_DIGEST_JOB_ID = 'crm-daily-digest'\n\n/**\n * Orgs per invocation.\n *\n * Fewer than `usage-alerts`' hundred: an org costs a task query, a paged\n * host list, a leads query per site, the roster, and a send per member with\n * work, under `maxDuration = 60`. A chunk that times out never advances its\n * cursor, so the safe size is the one that finishes.\n */\nexport const CRM_DIGEST_ORG_CHUNK = 25\n\n/** Hosts read per page inside one org, ordered by id like the orgs sweep. */\nconst CRM_DIGEST_HOST_PAGE = 100\n\n/**\n * The most sites one org's digest reads leads from, after which the sweep\n * stops and SAYS SO in the response rather than reading without bound.\n */\nexport const CRM_DIGEST_HOST_CEILING = 200\n\n/**\n * The key a person's switch for this digest is stored under in\n * `users/{uid}.digestPrefs` — the one the CRM declares under\n * `notificationDigests` in `plugins.config.json`, which is the one the\n * settings page writes. `daily-digest-route.spec.ts` holds the two to one\n * spelling.\n */\nexport const CRM_DAILY_DIGEST_KEY = 'crmDaily'\n\n/** `orgs/{orgId}/crmDigests/{day}` — server-written, matched by no rule. */\nexport const CRM_DIGEST_MARKER_COLLECTION = 'crmDigests'\n\n/**\n * The most members per org whose address is chased through the auth pools\n * when their member document carries none. Bounds WORK, not audience — the\n * denormalized copy is normally present, and `orgAdminEmails` draws the same\n * line for the same reason.\n */\nexport const CRM_DIGEST_EMAIL_MAX_UID_LOOKUPS = 5\n\n// The zone lives with the digest's other pure judgments, where the task\n// routes read it too; re-exported for the callers that name it here.\nexport { digestTimeZone }\n\ntype Firestore = FirebaseFirestore.Firestore\ntype Snapshot = FirebaseFirestore.QueryDocumentSnapshot\n\n/**\n * Every host owned by an org, paged by document id — the shape\n * `usage-alerts` reads hosts with, at a lower ceiling because each host here\n * costs a leads query.\n */\nasync function hostsOfOrg(\n firestore: Firestore,\n orgId: string,\n): Promise<{ docs: Snapshot[]; truncated: boolean }> {\n const docs: Snapshot[] = []\n let cursor: string | null = null\n for (;;) {\n let query = firestore\n .collection('hosts')\n .where('orgId', '==', orgId)\n .orderBy(firebaseAdmin.firestore.FieldPath.documentId())\n .limit(CRM_DIGEST_HOST_PAGE)\n if (cursor) query = query.startAfter(firestore.collection('hosts').doc(cursor))\n const page = await query.get()\n for (const doc of page.docs) docs.push(doc)\n if (page.docs.length < CRM_DIGEST_HOST_PAGE) return { docs, truncated: false }\n if (docs.length >= CRM_DIGEST_HOST_CEILING) {\n console.warn(\n `crm-daily-digest: org ${orgId} has more than ${CRM_DIGEST_HOST_CEILING} ` +\n 'hosts — leads on the rest are not digested',\n )\n return { docs, truncated: true }\n }\n cursor = page.docs[page.docs.length - 1].id\n }\n}\n\n/** What one member's row in the response says. */\ninterface MemberReport {\n skipped?: string\n overdue?: number\n today?: number\n leads?: number\n nurturing?: number\n notified?: boolean\n emailed?: boolean\n emailReason?: string\n deferred?: boolean\n}\n\ninterface OrgReport {\n skipped?: string\n digests: number\n notified: number\n emailed: number\n members?: Record<string, MemberReport>\n hostsTruncated?: boolean\n tasksTruncated?: boolean\n /** The send-rate governor refused; the sweep stops here. */\n deferred?: boolean\n}\n\ninterface SweepContext {\n firestore: Firestore\n nowMs: number\n day: string\n timeZone: string\n dryRun: boolean\n flagValues: Awaited<ReturnType<typeof getServerReleaseFlagValues>>\n origin: string\n}\n\n/**\n * The console path a notification opens — the legacy `/{hostDocId}/rest`\n * shape every host notification uses, or the `/org/rest` shape the console\n * rewrites onto the organization's own hub when nothing owed names a site:\n * an organization task (AGL-2637) has none.\n */\nfunction notificationLink(hostId: string, section: 'tasks' | 'leads'): string {\n return hostId ? `/${hostId}/crm/${section}` : `/org/crm/${section}`\n}\n\nasync function digestOrg(ctx: SweepContext, orgDoc: Snapshot): Promise<OrgReport> {\n const { firestore, nowMs, day, timeZone, dryRun } = ctx\n const orgId = orgDoc.id\n const org = (orgDoc.data() ?? {}) as Partial<AglynOrgBilling> & Record<string, unknown>\n const quiet: OrgReport = { digests: 0, notified: 0, emailed: 0 }\n\n if (!crmDigestEntitled(resolveOrgEntitlements(org).features)) {\n return { ...quiet, skipped: 'not-entitled' }\n }\n const flagOn = isReleaseFlagOnForOrg(\n 'release_crm',\n ctx.flagValues['release_crm'],\n orgId,\n parseOrgReleaseFlagOverrides(org['releaseFlags']),\n resolveEffectivePlan(org),\n )\n if (!flagOn) return { ...quiet, skipped: 'release-flag' }\n\n const window = crmDigestWindow(nowMs, timeZone)\n\n /*\n * Every open task due before tomorrow, soonest first — overdue and today\n * in one query on `(status, dueAtMs)`, bucketed afterwards. Undated tasks\n * carry `dueAtMs: null`, which a range excludes.\n */\n const taskPage = await orgDoc.ref\n .collection(CRM_COLLECTIONS.tasks)\n .where('status', '==', 'open')\n .where('dueAtMs', '<', window.endMs)\n .orderBy('dueAtMs', 'asc')\n .limit(CRM_DIGEST_TASK_CEILING)\n .get()\n const tasks: CrmDigestTask[] = taskPage.docs.map((doc) => ({\n id: doc.id,\n title: String(doc.get('title') ?? ''),\n kind: doc.get('kind') ?? 'todo',\n dueAtMs: Number(doc.get('dueAtMs')),\n assigneeUid: String(doc.get('assigneeUid') ?? ''),\n hostId: String(doc.get('hostId') ?? ''),\n contactId: doc.get('contactId') || undefined,\n companyId: doc.get('companyId') || undefined,\n dealId: doc.get('dealId') || undefined,\n }))\n const tasksTruncated = taskPage.docs.length >= CRM_DIGEST_TASK_CEILING\n\n /*\n * The leads nobody has worked, per site. A window of the newest old leads\n * by first sighting, filtered in memory, for the reason the Leads section\n * gives: the capture door stamps no `status`, and a query cannot select on\n * a field's absence.\n */\n const hosts = await hostsOfOrg(firestore, orgId)\n const hostById = new Map(hosts.docs.map((doc) => [doc.id, doc]))\n const leadCutoff = nowMs - CRM_DIGEST_LEAD_AGE_MS\n const leads: CrmDigestLead[] = []\n // Read from the same window, and counted, not listed (AGL-3446).\n const nurturing: CrmDigestLead[] = []\n for (const host of hosts.docs) {\n /*\n * Still per site, but over the ORG collection narrowed to what that site\n * may see (AGL-3275). The digest is a per-site message, so the loop stays;\n * what changed is that a site's leads are selected by `visibleTo` rather\n * than by the collection's parent, and a lead two brands share is in both\n * of their digests — which is the honest answer, since both hold it.\n */\n const page = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('leads')\n .where('visibleTo', 'array-contains-any', scopeTokensForHost(host.id))\n .where('firstSeenAtMs', '<=', leadCutoff)\n .orderBy('firstSeenAtMs', 'desc')\n .limit(CRM_DIGEST_LEAD_WINDOW)\n .get()\n for (const doc of page.docs) {\n const data = doc.data() as Record<string, unknown>\n const fields = data as Parameters<typeof isUnworkedLead>[0]\n const into = isUnworkedLead(fields, nowMs)\n ? leads\n : isNurturingLead(fields, nowMs)\n ? nurturing\n : null\n if (!into) continue\n into.push({\n id: doc.id,\n hostId: host.id,\n email: String(data['email'] ?? ''),\n name: String(data['name'] ?? '') || undefined,\n ownerUid: String(data['ownerUid'] ?? '') || undefined,\n firstSeenAtMs: Number(data['firstSeenAtMs']),\n })\n }\n }\n\n if (!tasks.length && !leads.length) return { ...quiet, skipped: 'nothing-owed' }\n\n const listed = (await listOrgMembers(orgId)).filter(\n (member) => member.orgSuspended !== true,\n )\n // A locked or disabled account is not told (AGL-3418): it cannot sign in\n // to act on it.\n const reachable = new Set(\n await withoutMailWithheldAccounts(listed.map((member) => member.$id)),\n )\n const members = listed.filter((member) => reachable.has(member.$id))\n const memberById = new Map(members.map((member) => [member.$id, member]))\n const digests = buildMemberDigests({\n tasks,\n leads,\n nurturing,\n window,\n members: members.map((member) => ({\n uid: member.$id,\n reachesHost: (hostId: string) => hostRoleFor(member, hostId) !== null,\n })),\n })\n if (!digests.size) return { ...quiet, skipped: 'nobody-to-tell' }\n\n const markerRef = orgDoc.ref.collection(CRM_DIGEST_MARKER_COLLECTION).doc(day)\n const marker = await markerRef.get()\n const sentToday = (marker.get('members') ?? {}) as Record<string, unknown>\n\n // Sorted so the order members are told in — and the member a refusal\n // lands on — is the same on every run.\n const uids = [...digests.keys()].sort()\n const userDocs = await firestore.getAll(\n ...uids.map((uid) => firestore.collection('users').doc(uid)),\n )\n const userById = new Map(userDocs.map((doc) => [doc.id, doc]))\n\n const branding = resolveBrandingProfile(org)\n const orgSlug = String(org['slug'] ?? '')\n const workspaceName = String(org['name'] ?? '').trim() || orgSlug\n const hostName = (hostId: string): string => {\n const host = hostById.get(hostId)\n return String(host?.get('name') || host?.get('subdomain') || hostId)\n }\n const hubUrl = (hostId: string): string | null => {\n const subdomain = String(hostById.get(hostId)?.get('subdomain') ?? '')\n if (!orgSlug || !subdomain) return null\n return `${ctx.origin}${buildRoute(Route.HOST_DASHBOARD, { orgSlug, host: subdomain })}/crm`\n }\n // The organization's own hub, for a member whose owed tasks name no site.\n const orgHubUrl = orgSlug\n ? `${ctx.origin}${buildRoute(Route.ORG_HOME, { orgSlug })}/crm`\n : null\n const settingsUrl = `${ctx.origin}${buildRoute(Route.MANAGE_NOTIFICATION_SETTINGS)}`\n const supportLine = brandSupportLine(branding)\n const brand = systemEmailBrand(org)\n let digestEmail: Promise<LoadedSystemEmail | null> | undefined\n\n const report: OrgReport = { digests: 0, notified: 0, emailed: 0, members: {} }\n if (hosts.truncated) report.hostsTruncated = true\n if (tasksTruncated) report.tasksTruncated = true\n const rows = report.members as Record<string, MemberReport>\n let poolLookups = 0\n\n for (const uid of uids) {\n const digest = digests.get(uid) as CrmMemberDigest\n if (sentToday[uid]) {\n rows[uid] = { skipped: 'already-sent' }\n continue\n }\n const member = memberById.get(uid) as AglynOrgMember\n if (!(await memberHasOrgPermission(orgId, member, 'data.manage'))) {\n rows[uid] = { skipped: 'no-data-manage' }\n continue\n }\n const userDoc = userById.get(uid)\n if (!digestEnabled(userDoc?.get(DIGEST_PREFS_FIELD), CRM_DAILY_DIGEST_KEY)) {\n rows[uid] = { skipped: 'digest-off' }\n continue\n }\n const muted = notificationMuted(\n userDoc?.get('notificationPrefs'),\n 'content.crmDailyDigest',\n )\n const counts = crmDigestCounts(digest)\n const row: MemberReport = { ...counts }\n rows[uid] = row\n report.digests += 1\n if (dryRun) {\n row.notified = !muted\n continue\n }\n\n // The site the links open on: where the first task THAT HAS A SITE\n // lives, else where the first lead was captured. An organization task\n // (AGL-2637) names no site, and a digest owed nothing but those opens\n // on the organization's hub.\n const anchorHostId =\n [...digest.overdue, ...digest.today].find((task) => task.hostId)?.hostId ||\n digest.leads[0]?.hostId ||\n ''\n const section = counts.overdue + counts.today > 0 ? 'tasks' : 'leads'\n const hub = anchorHostId ? hubUrl(anchorHostId) : orgHubUrl\n\n /*\n * THE EMAIL FIRST. A refusal from the send-rate governor stops the run\n * with nothing else done for this member — no notification to double\n * tomorrow, no marker to skip them on — so the order here is the\n * idempotence, not a preference.\n */\n let emailed = false\n let emailReason: string | undefined\n let address = String(member.email ?? '').trim().toLowerCase()\n if (!address.includes('@') && poolLookups < CRM_DIGEST_EMAIL_MAX_UID_LOOKUPS) {\n poolLookups += 1\n const pooled = await findUserByUidAcrossPools(uid).catch(() => null)\n address = String(pooled?.record?.email ?? '').trim().toLowerCase()\n }\n const recipients = address.includes('@')\n ? await filterSuppressedEmails([address], firestore)\n : []\n if (!recipients.length) {\n emailReason = address.includes('@') ? 'suppressed' : 'no-recipient'\n } else {\n const digestInput = {\n digest,\n nowMs,\n timeZone,\n productName: branding.productName,\n workspaceName,\n tasksUrl: hub ? `${hub}/tasks` : ctx.origin,\n leadsUrl: (hostId: string) => `${hubUrl(hostId) ?? ctx.origin}/leads`,\n settingsUrl,\n hostName,\n supportLine,\n }\n const subject = composeCrmDigestSubject(counts, workspaceName)\n // The `crm-daily-digest` system email (AGL-3367), in the org's brand,\n // read once per org and rendered per member.\n digestEmail ??= loadSystemEmail('crm-daily-digest').catch(() => null)\n const loaded = await digestEmail\n const rendered = loaded\n ? renderLoadedSystemEmail(\n loaded,\n {\n ...brand.merge,\n 'digest.subject': subject,\n 'digest.body': composeCrmDigestEmailText({ ...digestInput, omitClosing: true }),\n settingsUrl,\n },\n brand.options,\n )\n : null\n const result = await sendEmail({\n to: recipients,\n ...systemEmailContent(rendered, {\n subject,\n text: composeCrmDigestEmailText(digestInput),\n }),\n fromName: branding.fromName,\n context: 'crm-daily-digest',\n priority: 'bulk',\n })\n if (result.sent) {\n emailed = true\n await meterPlatformEmail().catch(() => undefined)\n } else if (rateLimitedRetryAtMs(result) !== null) {\n row.deferred = true\n report.deferred = true\n return report\n } else {\n emailReason = (result as { reason?: string }).reason ?? 'failed'\n }\n }\n\n let notified = false\n if (!muted) {\n await notifyUsers([uid], {\n type: 'content.crmDailyDigest',\n title: 'Your CRM today',\n body: composeCrmDigestSummary(counts),\n link: notificationLink(anchorHostId, section),\n orgId,\n ...(anchorHostId ? { hostId: anchorHostId } : {}),\n })\n notified = true\n }\n\n await markerRef.set(\n {\n day,\n members: {\n [uid]: { atMs: nowMs, ...counts, notified, emailed },\n },\n updatedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n row.notified = notified\n row.emailed = emailed\n if (emailReason) row.emailReason = emailReason\n if (notified) report.notified += 1\n if (emailed) report.emailed += 1\n }\n return report\n}\n\nexport async function crmDailyDigestRoute(request: Request): Promise<Response> {\n const { method, body, headers: rawHeaders, query } = await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'POST' && method !== 'GET') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n if (!process.env.CRON_SECRET) {\n return Response.json(\n { error: 'The CRM digest is not configured (CRON_SECRET).' },\n { status: 501 },\n )\n }\n if (!isCronAuthorized(headers)) {\n return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n }\n // A GET reports the plan and sends nothing; a bodyless POST — what the\n // scheduled workflow sends — is a real run. `isCronDryRun` keys the\n // default on the METHOD for exactly that reason.\n const dryRun = isCronDryRun({ method, body, query })\n // Stamped on the invocation, not on the work (AGL-1955), so the beat is\n // already there the day this job joins `SCHEDULED_JOBS`.\n if (method === 'POST') await recordCronBeat(CRM_DIGEST_JOB_ID)\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n const nowMs = Date.now()\n const ctx: SweepContext = {\n firestore,\n nowMs,\n day: utcDayKey(nowMs),\n timeZone: digestTimeZone(),\n dryRun,\n flagValues: await getServerReleaseFlagValues(),\n origin: consoleOrigin(),\n }\n\n // The resumable sweep, in the shape `usage-alerts` reads orgs (AGL-2220):\n // ordered by id, `limit + 1` to learn whether there is more, the cursor\n // validated as an org id before it becomes a reference.\n const requestedLimit = Number((body as { limit?: unknown } | undefined)?.limit)\n const pageSize =\n Number.isFinite(requestedLimit) && requestedLimit > 0\n ? Math.min(Math.floor(requestedLimit), CRM_DIGEST_ORG_CHUNK)\n : CRM_DIGEST_ORG_CHUNK\n const rawCursor = (body as { cursor?: unknown } | undefined)?.cursor\n const cursor =\n typeof rawCursor === 'string' && rawCursor.length > 0 && !rawCursor.includes('/')\n ? rawCursor\n : null\n let orgQuery = firestore\n .collection('orgs')\n .orderBy(firebaseAdmin.firestore.FieldPath.documentId())\n .limit(pageSize + 1)\n if (cursor) orgQuery = orgQuery.startAfter(firestore.collection('orgs').doc(cursor))\n const page = await orgQuery.get()\n const hasMore = page.docs.length > pageSize\n const orgDocs = hasMore ? page.docs.slice(0, pageSize) : page.docs\n\n const orgs: Record<string, OrgReport> = {}\n const failures: Record<string, string> = {}\n let digests = 0\n let notified = 0\n let emailed = 0\n let deferred = false\n for (const orgDoc of orgDocs) {\n try {\n const report = await digestOrg(ctx, orgDoc)\n orgs[orgDoc.id] = report\n digests += report.digests\n notified += report.notified\n emailed += report.emailed\n if (report.deferred) {\n deferred = true\n break\n }\n } catch (error) {\n console.error('[crm] daily digest failed for org', orgDoc.id, error)\n failures[orgDoc.id] = (error as Error)?.message ?? 'unknown'\n }\n }\n const failed = Object.keys(failures)\n return Response.json(\n {\n day: ctx.day,\n timeZone: ctx.timeZone,\n dryRun,\n swept: orgDocs.length,\n digests,\n notified,\n emailed,\n orgs,\n ...(deferred ? { deferred: true } : {}),\n ...(failed.length ? { failures } : {}),\n // A deferred run reports `done: true` — see the module comment —\n // so the workflow's loop does not re-POST into a full window.\n nextCursor: deferred || !hasMore ? null : (orgDocs[orgDocs.length - 1]?.id ?? null),\n done: deferred || !hasMore,\n },\n // 207: this chunk finished and some org in it needs a person, which is\n // what fails the workflow (its one alerting channel).\n { status: failed.length ? 207 : 200 },\n )\n } catch (error) {\n console.error('[crm] daily digest run failed', error)\n return Response.json({ error: 'CRM digest run failed' }, { status: 500 })\n }\n}\n"],"names":["buildRoute","CRM_COLLECTIONS","DIGEST_PREFS_FIELD","digestEnabled","hostRoleFor","isReleaseFlagOnForOrg","notificationMuted","parseOrgReleaseFlagOverrides","pluginRequestFromWeb","resolveBrandingProfile","resolveEffectivePlan","resolveOrgEntitlements","Route","buildMemberDigests","composeCrmDigestEmailText","composeCrmDigestSubject","composeCrmDigestSummary","CRM_DIGEST_LEAD_AGE_MS","CRM_DIGEST_LEAD_WINDOW","CRM_DIGEST_TASK_CEILING","crmDigestCounts","crmDigestEntitled","crmDigestWindow","digestTimeZone","isNurturingLead","isUnworkedLead","utcDayKey","rateLimitedRetryAtMs","sendEmail","withoutMailWithheldAccounts","findUserByUidAcrossPools","firebaseAdmin","getServerReleaseFlagValues","listOrgMembers","memberHasOrgPermission","meterPlatformEmail","notifyUsers","filterSuppressedEmails","loadSystemEmail","renderLoadedSystemEmail","systemEmailBrand","systemEmailContent","isCronAuthorized","isCronDryRun","writeCronBeat","brandSupportLine","platformConsoleOrigin","consoleOrigin","scopeTokensForHost","recordCronBeat","jobId","app","firestore","CRM_DIGEST_JOB_ID","CRM_DIGEST_ORG_CHUNK","CRM_DIGEST_HOST_PAGE","CRM_DIGEST_HOST_CEILING","CRM_DAILY_DIGEST_KEY","CRM_DIGEST_MARKER_COLLECTION","CRM_DIGEST_EMAIL_MAX_UID_LOOKUPS","hostsOfOrg","orgId","docs","cursor","query","collection","where","orderBy","FieldPath","documentId","limit","startAfter","doc","page","get","push","length","truncated","console","warn","id","notificationLink","hostId","section","digestOrg","ctx","orgDoc","marker","org","nowMs","day","timeZone","dryRun","data","quiet","digests","notified","emailed","features","skipped","flagOn","flagValues","window","taskPage","ref","tasks","endMs","map","title","String","kind","dueAtMs","Number","assigneeUid","contactId","undefined","companyId","dealId","tasksTruncated","hosts","hostById","Map","leadCutoff","leads","nurturing","host","fields","into","email","name","ownerUid","firstSeenAtMs","listed","filter","member","orgSuspended","reachable","Set","$id","members","has","memberById","uid","reachesHost","size","markerRef","sentToday","uids","keys","sort","userDocs","getAll","userById","branding","orgSlug","workspaceName","trim","hostName","hubUrl","subdomain","origin","HOST_DASHBOARD","orgHubUrl","ORG_HOME","settingsUrl","MANAGE_NOTIFICATION_SETTINGS","supportLine","brand","digestEmail","report","hostsTruncated","rows","poolLookups","digest","userDoc","muted","counts","row","anchorHostId","overdue","today","find","task","hub","emailReason","address","toLowerCase","includes","pooled","catch","record","recipients","digestInput","productName","tasksUrl","leadsUrl","subject","loaded","rendered","merge","omitClosing","options","result","to","text","fromName","context","priority","sent","deferred","reason","type","body","link","set","atMs","updatedAt","FieldValue","serverTimestamp","crmDailyDigestRoute","request","method","headers","rawHeaders","Response","json","error","status","process","env","CRON_SECRET","orgDocs","Date","now","requestedLimit","pageSize","isFinite","Math","min","floor","rawCursor","orgQuery","hasMore","slice","orgs","failures","message","failed","Object","swept","nextCursor","done"],"mappings":"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"}
|
|
@@ -14,10 +14,12 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
14
14
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
15
|
* See the License for the specific language governing permissions and
|
|
16
16
|
* limitations under the License.
|
|
17
|
-
*/ import { canManageOrg,
|
|
18
|
-
import {
|
|
17
|
+
*/ import { canManageOrg, inboundMailDomain, isOrgWideMember } from "@aglyn/aglyn/server";
|
|
18
|
+
import { firebaseAdmin, getOrgForHost, logOrgActivity, memberHasOrgPermission, resolveOrgMembership } from "@aglyn/tenant-data-admin";
|
|
19
19
|
import { isRefusedIdToken } from "@aglyn/tenant-data-admin/server/id-token-refusal";
|
|
20
20
|
import { CRM_API_ROUTES } from "../constants/api-routes.js";
|
|
21
|
+
import { crmInboundAddress } from "../model/crm-inbound.js";
|
|
22
|
+
import { ensureCrmInboundToken } from "./crm-inbound-email.js";
|
|
21
23
|
import { authorizeOrgCaller, readCrmRouteScope } from "./org-caller.js";
|
|
22
24
|
import { crmSuiteRefusal } from "./suite-gate.js";
|
|
23
25
|
/**
|
|
@@ -168,7 +170,7 @@ export const crmInboundAddressHandler = async (req, res)=>{
|
|
|
168
170
|
}
|
|
169
171
|
const answer = _extends({
|
|
170
172
|
ok: true,
|
|
171
|
-
address: crmInboundAddress(result.token,
|
|
173
|
+
address: crmInboundAddress(result.token, inboundMailDomain()),
|
|
172
174
|
createdAtMs: result.createdAtMs
|
|
173
175
|
}, typeof result.rotatedAtMs === 'number' ? {
|
|
174
176
|
rotatedAtMs: result.rotatedAtMs
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/inbound-address.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 type AglynOrgMember,\n canManageOrg,\n crmInboundAddress,\n crmInboundDomain,\n isOrgWideMember,\n type PluginApiHandler,\n type PluginApiRequest,\n} from '@aglyn/aglyn/server'\nimport {\n ensureCrmInboundToken,\n firebaseAdmin,\n getOrgForHost,\n logOrgActivity,\n memberHasOrgPermission,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { CRM_API_ROUTES } from '../constants/api-routes'\nimport { authorizeOrgCaller, readCrmRouteScope } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/**\n * `POST /api/crm/inbound-address` — the workspace's email capture address\n * (AGL-2657).\n *\n * Body: `{ hostId }` under a site, `{ orgId, hostId? }` at the organization\n * level, and `rotate: true` to replace the token. Answers\n * `{ ok: true, address, createdAtMs, rotatedAtMs?, rotated }`.\n *\n * ## Why a route and not a read of the org document\n *\n * The token is on the org document, which every member may read, and the\n * address is the token in a fixed shape — but the shape's domain is the\n * deployment's, `CRM_INBOUND_DOMAIN`, which a browser does not know, and\n * the token does not exist until somebody asks for the address. So the\n * first ask mints it, inside a transaction, and every ask answers the\n * whole address. The org document is the read side for nothing but the\n * rules, which deny the key to every client.\n *\n * ## Who may ask, and who may rotate\n *\n * Asking takes what opening the CRM takes: `data.manage` on the site's\n * org, for a member who reaches the site — the sender's own gate in\n * `email-send.ts`, restated — or, at the organization level, org-wide\n * reach with the same permission. Rotating is a workspace OWNER or ADMIN's\n * act, because the old address stops filing the moment the new token is\n * written and every mailbox rule that carried it goes quiet: the same bar\n * every CRM setting has, and the erasure route's. Staff are admitted to\n * read as they are on every CRM route, and refused the rotation as the\n * erasure route refuses them — it is the workspace's address to retire.\n */\n\nexport const CRM_INBOUND_ADDRESS_ROUTE = CRM_API_ROUTES.inboundAddress\n\n/** What a successful call answers with. */\nexport interface CrmInboundAddressResponse {\n ok: true\n /** `crm+<token>@<capture domain>`. */\n address: string\n createdAtMs: number\n rotatedAtMs?: number\n /** True when this call replaced the token. */\n rotated: boolean\n}\n\nexport const CRM_INBOUND_ROTATE_REFUSAL =\n 'Rotating the capture address requires a workspace owner or admin.'\n\ntype Refusal = { ok: false; status: number; error: string }\n\ninterface Caller {\n ok: true\n uid: string\n email: string | null\n orgId: string\n /** The org document, for the plan the address belongs to. */\n org: unknown\n /** Whether this caller may replace the token. */\n canRotate: boolean\n}\n\nconst refuse = (status: number, error: string): Refusal => ({ ok: false, status, error })\n\n/**\n * The site variant's caller: a verified token, then `data.manage` on the\n * site's org for a member who reaches the site, staff passing as they do\n * on every CRM route. Rotation needs the org role itself.\n */\nasync function authorizeSiteCaller(\n req: PluginApiRequest,\n hostId: string,\n): Promise<Caller | Refusal> {\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return refuse(401, 'Unauthenticated')\n let decoded: { uid: string; email?: string; staff?: unknown }\n try {\n decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n } catch (error) {\n // A refused credential is the caller's 401; a failure to check one is\n // ours and keeps a 5xx (AGL-2852).\n if (isRefusedIdToken(error)) return refuse(401, 'Unauthenticated')\n console.error('[crm] inbound-address could not verify the caller', error)\n return refuse(500, 'The sign-in could not be checked. Try again.')\n }\n const resolved = await getOrgForHost(hostId).catch(() => null)\n if (!resolved) return refuse(404, 'Unknown site')\n const { orgId } = resolved\n const membership = await resolveOrgMembership(decoded.uid, orgId).catch(() => null)\n const member = (membership?.member ?? null) as Partial<AglynOrgMember> | null\n if (decoded.staff !== true) {\n const reaches =\n isOrgWideMember(member) || Boolean(member?.hostAccess?.[hostId])\n const allowed =\n member && reaches && (await memberHasOrgPermission(orgId, member, 'data.manage'))\n if (!allowed) {\n return refuse(\n 403,\n 'Reading the capture address requires the data.manage permission on this site',\n )\n }\n }\n return {\n ok: true,\n uid: decoded.uid,\n email: decoded.email ?? null,\n orgId,\n org: resolved.org,\n canRotate: canManageOrg(member?.role ?? null),\n }\n}\n\n/** The organization variant's caller, by the org the body names (AGL-2634). */\nasync function authorizeOrgLevelCaller(\n req: PluginApiRequest,\n orgId: string,\n rotate: boolean,\n): Promise<Caller | Refusal> {\n const caller = await authorizeOrgCaller(req, orgId, {\n needs: rotate ? 'manage-org' : 'data.manage',\n refusal: rotate\n ? CRM_INBOUND_ROTATE_REFUSAL\n : 'Reading the capture address at the organization level requires the ' +\n 'data.manage permission across the whole workspace',\n })\n if (caller.ok === false) return refuse(caller.status, caller.error)\n return {\n ok: true,\n uid: caller.uid,\n email: caller.email,\n orgId,\n org: caller.org,\n // `manage-org` admitted them when they asked to rotate; a reader who\n // did not ask is not told whether they could.\n canRotate: rotate,\n }\n}\n\nexport const crmInboundAddressHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const body = (\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n ) as Record<string, unknown>\n const scope = readCrmRouteScope(body)\n if (!scope) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const rotate = body['rotate'] === true\n try {\n const caller =\n scope.level === 'org'\n ? await authorizeOrgLevelCaller(req, scope.orgId, rotate)\n : await authorizeSiteCaller(req, scope.hostId)\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n if (rotate && !caller.canRotate) {\n res.status(403).json({ error: CRM_INBOUND_ROTATE_REFUSAL })\n return\n }\n // The first ask mints the token, so reading the address is a write on\n // a plan without the suite; the webhook refuses such a plan's mail too.\n const suite = crmSuiteRefusal(caller.org, 'The email capture address')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n const firestore = firebaseAdmin.app().firestore()\n const result = await ensureCrmInboundToken(firestore, caller.orgId, { rotate })\n if (result.rotated) {\n // The old address went quiet with this write; the feed says who did it.\n await logOrgActivity(\n caller.orgId,\n { uid: caller.uid, email: caller.email },\n 'Rotated the email capture address',\n { type: 'org' },\n )\n }\n const answer: CrmInboundAddressResponse = {\n ok: true,\n address: crmInboundAddress(result.token, crmInboundDomain()),\n createdAtMs: result.createdAtMs,\n ...(typeof result.rotatedAtMs === 'number' ? { rotatedAtMs: result.rotatedAtMs } : {}),\n rotated: result.rotated,\n }\n res.status(200).json(answer)\n } catch (error) {\n console.error('[crm] inbound-address failed', scope, error)\n res.status(500).json({ error: 'The capture address could not be read.' })\n }\n}\n\nexport default crmInboundAddressHandler\n"],"names":["canManageOrg","crmInboundAddress","crmInboundDomain","isOrgWideMember","ensureCrmInboundToken","firebaseAdmin","getOrgForHost","logOrgActivity","memberHasOrgPermission","resolveOrgMembership","isRefusedIdToken","CRM_API_ROUTES","authorizeOrgCaller","readCrmRouteScope","crmSuiteRefusal","CRM_INBOUND_ADDRESS_ROUTE","inboundAddress","CRM_INBOUND_ROTATE_REFUSAL","refuse","status","error","ok","authorizeSiteCaller","req","hostId","decoded","authorization","String","headers","idToken","startsWith","slice","length","undefined","app","auth","verifyIdToken","console","resolved","catch","orgId","membership","uid","member","staff","reaches","Boolean","hostAccess","allowed","email","org","canRotate","role","authorizeOrgLevelCaller","rotate","caller","needs","refusal","crmInboundAddressHandler","res","method","setHeader","json","body","JSON","parse","scope","level","suite","firestore","result","rotated","type","answer","address","token","createdAtMs","rotatedAtMs"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/inbound-address.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 type AglynOrgMember,\n canManageOrg,\n inboundMailDomain,\n isOrgWideMember,\n type PluginApiHandler,\n type PluginApiRequest,\n} from '@aglyn/aglyn/server'\nimport {\n firebaseAdmin,\n getOrgForHost,\n logOrgActivity,\n memberHasOrgPermission,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { CRM_API_ROUTES } from '../constants/api-routes'\nimport { crmInboundAddress } from '../model/crm-inbound'\nimport { ensureCrmInboundToken } from './crm-inbound-email'\nimport { authorizeOrgCaller, readCrmRouteScope } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/**\n * `POST /api/crm/inbound-address` — the workspace's email capture address\n * (AGL-2657).\n *\n * Body: `{ hostId }` under a site, `{ orgId, hostId? }` at the organization\n * level, and `rotate: true` to replace the token. Answers\n * `{ ok: true, address, createdAtMs, rotatedAtMs?, rotated }`.\n *\n * ## Why a route and not a read of the org document\n *\n * The token is on the org document, which every member may read, and the\n * address is the token in a fixed shape — but the shape's domain is the\n * deployment's, `CRM_INBOUND_DOMAIN`, which a browser does not know, and\n * the token does not exist until somebody asks for the address. So the\n * first ask mints it, inside a transaction, and every ask answers the\n * whole address. The org document is the read side for nothing but the\n * rules, which deny the key to every client.\n *\n * ## Who may ask, and who may rotate\n *\n * Asking takes what opening the CRM takes: `data.manage` on the site's\n * org, for a member who reaches the site — the sender's own gate in\n * `email-send.ts`, restated — or, at the organization level, org-wide\n * reach with the same permission. Rotating is a workspace OWNER or ADMIN's\n * act, because the old address stops filing the moment the new token is\n * written and every mailbox rule that carried it goes quiet: the same bar\n * every CRM setting has, and the erasure route's. Staff are admitted to\n * read as they are on every CRM route, and refused the rotation as the\n * erasure route refuses them — it is the workspace's address to retire.\n */\n\nexport const CRM_INBOUND_ADDRESS_ROUTE = CRM_API_ROUTES.inboundAddress\n\n/** What a successful call answers with. */\nexport interface CrmInboundAddressResponse {\n ok: true\n /** `crm+<token>@<capture domain>`. */\n address: string\n createdAtMs: number\n rotatedAtMs?: number\n /** True when this call replaced the token. */\n rotated: boolean\n}\n\nexport const CRM_INBOUND_ROTATE_REFUSAL =\n 'Rotating the capture address requires a workspace owner or admin.'\n\ntype Refusal = { ok: false; status: number; error: string }\n\ninterface Caller {\n ok: true\n uid: string\n email: string | null\n orgId: string\n /** The org document, for the plan the address belongs to. */\n org: unknown\n /** Whether this caller may replace the token. */\n canRotate: boolean\n}\n\nconst refuse = (status: number, error: string): Refusal => ({ ok: false, status, error })\n\n/**\n * The site variant's caller: a verified token, then `data.manage` on the\n * site's org for a member who reaches the site, staff passing as they do\n * on every CRM route. Rotation needs the org role itself.\n */\nasync function authorizeSiteCaller(\n req: PluginApiRequest,\n hostId: string,\n): Promise<Caller | Refusal> {\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return refuse(401, 'Unauthenticated')\n let decoded: { uid: string; email?: string; staff?: unknown }\n try {\n decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n } catch (error) {\n // A refused credential is the caller's 401; a failure to check one is\n // ours and keeps a 5xx (AGL-2852).\n if (isRefusedIdToken(error)) return refuse(401, 'Unauthenticated')\n console.error('[crm] inbound-address could not verify the caller', error)\n return refuse(500, 'The sign-in could not be checked. Try again.')\n }\n const resolved = await getOrgForHost(hostId).catch(() => null)\n if (!resolved) return refuse(404, 'Unknown site')\n const { orgId } = resolved\n const membership = await resolveOrgMembership(decoded.uid, orgId).catch(() => null)\n const member = (membership?.member ?? null) as Partial<AglynOrgMember> | null\n if (decoded.staff !== true) {\n const reaches =\n isOrgWideMember(member) || Boolean(member?.hostAccess?.[hostId])\n const allowed =\n member && reaches && (await memberHasOrgPermission(orgId, member, 'data.manage'))\n if (!allowed) {\n return refuse(\n 403,\n 'Reading the capture address requires the data.manage permission on this site',\n )\n }\n }\n return {\n ok: true,\n uid: decoded.uid,\n email: decoded.email ?? null,\n orgId,\n org: resolved.org,\n canRotate: canManageOrg(member?.role ?? null),\n }\n}\n\n/** The organization variant's caller, by the org the body names (AGL-2634). */\nasync function authorizeOrgLevelCaller(\n req: PluginApiRequest,\n orgId: string,\n rotate: boolean,\n): Promise<Caller | Refusal> {\n const caller = await authorizeOrgCaller(req, orgId, {\n needs: rotate ? 'manage-org' : 'data.manage',\n refusal: rotate\n ? CRM_INBOUND_ROTATE_REFUSAL\n : 'Reading the capture address at the organization level requires the ' +\n 'data.manage permission across the whole workspace',\n })\n if (caller.ok === false) return refuse(caller.status, caller.error)\n return {\n ok: true,\n uid: caller.uid,\n email: caller.email,\n orgId,\n org: caller.org,\n // `manage-org` admitted them when they asked to rotate; a reader who\n // did not ask is not told whether they could.\n canRotate: rotate,\n }\n}\n\nexport const crmInboundAddressHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const body = (\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n ) as Record<string, unknown>\n const scope = readCrmRouteScope(body)\n if (!scope) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const rotate = body['rotate'] === true\n try {\n const caller =\n scope.level === 'org'\n ? await authorizeOrgLevelCaller(req, scope.orgId, rotate)\n : await authorizeSiteCaller(req, scope.hostId)\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n if (rotate && !caller.canRotate) {\n res.status(403).json({ error: CRM_INBOUND_ROTATE_REFUSAL })\n return\n }\n // The first ask mints the token, so reading the address is a write on\n // a plan without the suite; the webhook refuses such a plan's mail too.\n const suite = crmSuiteRefusal(caller.org, 'The email capture address')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n const firestore = firebaseAdmin.app().firestore()\n const result = await ensureCrmInboundToken(firestore, caller.orgId, { rotate })\n if (result.rotated) {\n // The old address went quiet with this write; the feed says who did it.\n await logOrgActivity(\n caller.orgId,\n { uid: caller.uid, email: caller.email },\n 'Rotated the email capture address',\n { type: 'org' },\n )\n }\n const answer: CrmInboundAddressResponse = {\n ok: true,\n address: crmInboundAddress(result.token, inboundMailDomain()),\n createdAtMs: result.createdAtMs,\n ...(typeof result.rotatedAtMs === 'number' ? { rotatedAtMs: result.rotatedAtMs } : {}),\n rotated: result.rotated,\n }\n res.status(200).json(answer)\n } catch (error) {\n console.error('[crm] inbound-address failed', scope, error)\n res.status(500).json({ error: 'The capture address could not be read.' })\n }\n}\n\nexport default crmInboundAddressHandler\n"],"names":["canManageOrg","inboundMailDomain","isOrgWideMember","firebaseAdmin","getOrgForHost","logOrgActivity","memberHasOrgPermission","resolveOrgMembership","isRefusedIdToken","CRM_API_ROUTES","crmInboundAddress","ensureCrmInboundToken","authorizeOrgCaller","readCrmRouteScope","crmSuiteRefusal","CRM_INBOUND_ADDRESS_ROUTE","inboundAddress","CRM_INBOUND_ROTATE_REFUSAL","refuse","status","error","ok","authorizeSiteCaller","req","hostId","decoded","authorization","String","headers","idToken","startsWith","slice","length","undefined","app","auth","verifyIdToken","console","resolved","catch","orgId","membership","uid","member","staff","reaches","Boolean","hostAccess","allowed","email","org","canRotate","role","authorizeOrgLevelCaller","rotate","caller","needs","refusal","crmInboundAddressHandler","res","method","setHeader","json","body","JSON","parse","scope","level","suite","firestore","result","rotated","type","answer","address","token","createdAtMs","rotatedAtMs"],"mappings":"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"}
|
|
@@ -14,12 +14,15 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
14
14
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
15
|
* See the License for the specific language governing permissions and
|
|
16
16
|
* limitations under the License.
|
|
17
|
-
*/ import {
|
|
17
|
+
*/ import { inboundMailDomain, isReleaseFlagOnForOrg, parseOrgReleaseFlagOverrides, pluginRequestFromWeb, resolveEffectivePlan, resolveOrgEntitlements } from "@aglyn/aglyn/server";
|
|
18
18
|
import { mailProviderReads, readInboundMailEvent } from "@aglyn/shared-util-email";
|
|
19
19
|
// By its own path, not the barrel: the check holds a `crypto` HMAC, and the
|
|
20
20
|
// barrel is reached from the browser through the campaign model.
|
|
21
21
|
import { verifySvixSignature } from "@aglyn/shared-util-email/svix-signature";
|
|
22
|
-
import {
|
|
22
|
+
import { firebaseAdmin, getServerReleaseFlagValues, logOrgActivity } from "@aglyn/tenant-data-admin";
|
|
23
|
+
import { loadMemberAddressRoster } from "@aglyn/tenant-data-admin/server/member-email-aliases";
|
|
24
|
+
import { CRM_INBOUND_CEILING_ACTION, CRM_INBOUND_UNMATCHED_ACTION, crmInboundTokensIn } from "../model/crm-inbound.js";
|
|
25
|
+
import { crmInboundHostIds, fileCrmInboundEmail, findOrgByCrmInboundToken } from "./crm-inbound-email.js";
|
|
23
26
|
// lockdown-423: exempt — a provider webhook (Svix-signed), no user caller; it files one received message on the record it was with and reads nothing a locked org could lose.
|
|
24
27
|
/**
|
|
25
28
|
* THE CAPTURE WEBHOOK (AGL-2657): `POST /api/crm/inbound`.
|
|
@@ -134,7 +137,7 @@ export async function crmInboundRoute(request) {
|
|
|
134
137
|
ignored: true,
|
|
135
138
|
reason: 'not-received-event'
|
|
136
139
|
});
|
|
137
|
-
const domain =
|
|
140
|
+
const domain = inboundMailDomain();
|
|
138
141
|
const tokens = crmInboundTokensIn(inbound.recipients, domain);
|
|
139
142
|
if (!tokens.length) return acknowledge(202, {
|
|
140
143
|
filed: false,
|
|
@@ -189,7 +192,7 @@ export async function crmInboundRoute(request) {
|
|
|
189
192
|
// they sign in with (AGL-2975), so a send from an outbound-domain alias
|
|
190
193
|
// is read as the member's and filed on the person it was written to.
|
|
191
194
|
const [members, hostIds] = await Promise.all([
|
|
192
|
-
|
|
195
|
+
loadMemberAddressRoster(firestore, orgId),
|
|
193
196
|
crmInboundHostIds(firestore, orgId)
|
|
194
197
|
]);
|
|
195
198
|
const result = await fileCrmInboundEmail(firestore, {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/inbound-route.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 CRM_INBOUND_CEILING_ACTION,\n CRM_INBOUND_UNMATCHED_ACTION,\n crmInboundDomain,\n crmInboundTokensIn,\n isReleaseFlagOnForOrg,\n parseOrgReleaseFlagOverrides,\n pluginRequestFromWeb,\n resolveEffectivePlan,\n resolveOrgEntitlements,\n} from '@aglyn/aglyn/server'\nimport {\n type ReceivedEmailSource,\n mailProviderReads,\n readInboundMailEvent,\n} from '@aglyn/shared-util-email'\n// By its own path, not the barrel: the check holds a `crypto` HMAC, and the\n// barrel is reached from the browser through the campaign model.\nimport { verifySvixSignature } from '@aglyn/shared-util-email/svix-signature'\nimport {\n crmInboundHostIds,\n fileCrmInboundEmail,\n findOrgByCrmInboundToken,\n firebaseAdmin,\n getServerReleaseFlagValues,\n loadCrmInboundRoster,\n logOrgActivity,\n} from '@aglyn/tenant-data-admin'\n\n// lockdown-423: exempt — a provider webhook (Svix-signed), no user caller; it files one received message on the record it was with and reads nothing a locked org could lose.\n\n/**\n * THE CAPTURE WEBHOOK (AGL-2657): `POST /api/crm/inbound`.\n *\n * The mail provider receives mail for the capture domain and announces each\n * message with a notification, signed the Svix way. This route verifies the\n * signature, asks the provider which message the notification announces and\n * who it was for (`readInboundMailEvent`), finds the capture token among\n * those recipients, resolves the workspace the token names, has the\n * provider read the message, and files it on the record of whoever it was\n * with — see\n * `fileCrmInboundEmail` for the matching and `crm-inbound.ts` for the\n * rules. A message nobody in the workspace knows is noted in the org's\n * feed by its sender's domain and dropped.\n *\n * ## A machine's route\n *\n * The plugin dispatcher gates a path on the org's release flag, and\n * resolves the org from a `hostId` the caller names. A webhook names no\n * site — the workspace is inside the address — so gated that way every\n * delivery would be a subject-less request, which a partial rollout\n * refuses. So the CRM registers it as a MACHINE's route (`machine: true`,\n * AGL-3080): the dispatcher leaves those gates to it, the org is resolved\n * from the token first, and the plan and the flag are judged for THAT org,\n * which is the gate the CRM actually ships behind.\n *\n * ## What the provider is told\n *\n * A signed event is never answered with an error the provider would retry\n * forever: an event that is not a received message, a token nobody holds,\n * a workspace whose CRM is off, a message that matched no record — each\n * is acknowledged, `200` or `202`, with a `reason` in the body for a\n * person reading the delivery log. Only a bad signature (`401`), a missing\n * secret or a provider that cannot read received mail (`501`), and a\n * failure of ours (`500`) are refused, and a `500` is the one the provider\n * SHOULD retry.\n *\n * ## The body never reaches a log\n *\n * The message is read into memory, reduced to the row's bounded excerpt,\n * and released. Nothing here prints it, and the feed line for an\n * unmatched message carries the sender's domain and nothing else: a\n * stranger's mail to a stale address is not the workspace's to keep.\n *\n * ## Two secrets\n *\n * A provider's webhook endpoint carries its own signing secret. The capture\n * endpoint is its own subscription, so `CRM_INBOUND_WEBHOOK_SECRET` is\n * tried first; `RESEND_WEBHOOK_SECRET` — the delivery-events endpoint's —\n * is accepted too, for a deployment that subscribed received mail on the\n * endpoint it already had. The check itself is the one the\n * delivery-events webhook runs.\n */\n\n/** The signing secrets a delivery may carry, dedicated first. */\nexport function inboundWebhookSecrets(\n env: Record<string, string | undefined> = process.env,\n): string[] {\n const secrets = [env['CRM_INBOUND_WEBHOOK_SECRET'], env['RESEND_WEBHOOK_SECRET']]\n .map((value) => String(value ?? '').trim())\n .filter((value) => value !== '')\n return [...new Set(secrets)]\n}\n\n/**\n * A reader a spec hands in through the module's seam, in place of the\n * provider's. `null` — always, outside a spec — reads through the provider.\n */\nlet readerOverride: ReceivedEmailSource | null = null\n\n/** The spec's seam: reads without the provider. */\nexport function setInboundReaderForTesting(reader: ReceivedEmailSource | null): void {\n readerOverride = reader\n}\n\nconst acknowledge = (status: number, body: Record<string, unknown>): Response =>\n Response.json(body, { status })\n\nexport async function crmInboundRoute(request: Request): Promise<Response> {\n const req = await pluginRequestFromWeb(request)\n if (req.method !== 'POST') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n const secrets = inboundWebhookSecrets()\n if (!secrets.length) {\n return Response.json({ error: 'Email capture is not configured' }, { status: 501 })\n }\n const payload = Buffer.from(req.rawBody ?? '', 'utf8')\n const headers = req.headers as Partial<Record<string, string>>\n const svixId = String(headers['svix-id'] ?? '')\n const svixTimestamp = String(headers['svix-timestamp'] ?? '')\n const svixSignature = String(headers['svix-signature'] ?? '')\n const signed =\n svixId !== '' &&\n svixTimestamp !== '' &&\n secrets.some((secret) =>\n verifySvixSignature(secret, svixId, svixTimestamp, payload, svixSignature),\n )\n if (!signed) return Response.json({ error: 'Bad signature' }, { status: 401 })\n\n let event: unknown\n try {\n event = JSON.parse(payload.toString('utf8'))\n } catch {\n return acknowledge(200, { ignored: true, reason: 'not-json' })\n }\n const inbound = readInboundMailEvent(event)\n if (!inbound) return acknowledge(200, { ignored: true, reason: 'not-received-event' })\n\n const domain = crmInboundDomain()\n const tokens = crmInboundTokensIn(inbound.recipients, domain)\n if (!tokens.length) return acknowledge(202, { filed: false, reason: 'no-token' })\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n // The first token that names a workspace: a message copied to two\n // workspaces' addresses is filed on the first, which is the only\n // reading that keeps one message one row.\n let resolved: Awaited<ReturnType<typeof findOrgByCrmInboundToken>> = null\n for (const token of tokens) {\n resolved = await findOrgByCrmInboundToken(firestore, token)\n if (resolved) break\n }\n if (!resolved) return acknowledge(202, { filed: false, reason: 'unknown-token' })\n const { orgId, org } = resolved\n\n // The gate the CRM ships behind, judged for the org the token named:\n // the plan carries the CRM and the release flag is on for it.\n if (resolveOrgEntitlements(org as never).features?.['crm'] !== true) {\n return acknowledge(202, { filed: false, reason: 'not-entitled' })\n }\n const flagValues = await getServerReleaseFlagValues()\n const flagOn = isReleaseFlagOnForOrg(\n 'release_crm',\n flagValues['release_crm'],\n orgId,\n parseOrgReleaseFlagOverrides(org['releaseFlags']),\n resolveEffectivePlan(org as never),\n )\n if (!flagOn) return acknowledge(202, { filed: false, reason: 'release-flag' })\n\n const reads = mailProviderReads()\n const unmet = reads.unmet()\n if (unmet) {\n console.warn(`[crm] inbound: received mail cannot be read — ${unmet}`)\n return Response.json(\n { error: `Email capture cannot read received mail. ${unmet}` },\n { status: 501 },\n )\n }\n const message = await (readerOverride ?? reads.received)(inbound.id)\n if (!message) return acknowledge(202, { filed: false, reason: 'message-gone' })\n\n // The roster carries each member's confirmed aliases beside the address\n // they sign in with (AGL-2975), so a send from an outbound-domain alias\n // is read as the member's and filed on the person it was written to.\n const [members, hostIds] = await Promise.all([\n loadCrmInboundRoster(firestore, orgId),\n crmInboundHostIds(firestore, orgId),\n ])\n const result = await fileCrmInboundEmail(firestore, {\n orgId,\n org,\n message,\n domain,\n members,\n hostIds,\n })\n\n switch (result.outcome) {\n case 'filed':\n return acknowledge(200, {\n filed: true,\n activityId: result.activityId,\n direction: result.match.direction,\n kind: result.match.kind,\n })\n case 'duplicate':\n return acknowledge(200, { filed: false, reason: 'duplicate', activityId: result.activityId })\n case 'ceiling':\n await logOrgActivity(orgId, { uid: null }, CRM_INBOUND_CEILING_ACTION, {\n type: result.match.kind === 'lead' ? 'lead' : 'contact',\n ...(result.match.link.contactId\n ? { id: String(result.match.link.contactId) }\n : result.match.link.leadId\n ? { id: String(result.match.link.leadId) }\n : {}),\n })\n return acknowledge(202, { filed: false, reason: 'ceiling' })\n case 'unmatched':\n default:\n // The sender's domain and nothing else: the feed says a message\n // arrived that nobody could be matched to, never what it said.\n await logOrgActivity(orgId, { uid: null }, CRM_INBOUND_UNMATCHED_ACTION, {\n type: 'contact',\n ...(result.senderDomain ? { name: result.senderDomain } : {}),\n })\n return acknowledge(202, { filed: false, reason: 'unmatched' })\n }\n } catch (error) {\n console.error('[crm] inbound capture failed', svixId, (error as Error)?.message)\n return Response.json({ error: 'The message could not be filed' }, { status: 500 })\n }\n}\n"],"names":["CRM_INBOUND_CEILING_ACTION","CRM_INBOUND_UNMATCHED_ACTION","crmInboundDomain","crmInboundTokensIn","isReleaseFlagOnForOrg","parseOrgReleaseFlagOverrides","pluginRequestFromWeb","resolveEffectivePlan","resolveOrgEntitlements","mailProviderReads","readInboundMailEvent","verifySvixSignature","crmInboundHostIds","fileCrmInboundEmail","findOrgByCrmInboundToken","firebaseAdmin","getServerReleaseFlagValues","loadCrmInboundRoster","logOrgActivity","inboundWebhookSecrets","env","process","secrets","map","value","String","trim","filter","Set","readerOverride","setInboundReaderForTesting","reader","acknowledge","status","body","Response","json","crmInboundRoute","request","req","headers","method","error","length","payload","Buffer","from","rawBody","svixId","svixTimestamp","svixSignature","signed","some","secret","event","JSON","parse","toString","ignored","reason","inbound","domain","tokens","recipients","filed","firestore","app","resolved","token","orgId","org","features","flagValues","flagOn","reads","unmet","console","warn","message","received","id","members","hostIds","Promise","all","result","outcome","activityId","direction","match","kind","uid","type","link","contactId","leadId","senderDomain","name"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/inbound-route.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 inboundMailDomain,\n isReleaseFlagOnForOrg,\n parseOrgReleaseFlagOverrides,\n pluginRequestFromWeb,\n resolveEffectivePlan,\n resolveOrgEntitlements,\n} from '@aglyn/aglyn/server'\nimport {\n type ReceivedEmailSource,\n mailProviderReads,\n readInboundMailEvent,\n} from '@aglyn/shared-util-email'\n// By its own path, not the barrel: the check holds a `crypto` HMAC, and the\n// barrel is reached from the browser through the campaign model.\nimport { verifySvixSignature } from '@aglyn/shared-util-email/svix-signature'\nimport {\n firebaseAdmin,\n getServerReleaseFlagValues,\n logOrgActivity,\n} from '@aglyn/tenant-data-admin'\nimport { loadMemberAddressRoster } from '@aglyn/tenant-data-admin/server/member-email-aliases'\nimport {\n CRM_INBOUND_CEILING_ACTION,\n CRM_INBOUND_UNMATCHED_ACTION,\n crmInboundTokensIn,\n} from '../model/crm-inbound'\nimport {\n crmInboundHostIds,\n fileCrmInboundEmail,\n findOrgByCrmInboundToken,\n} from './crm-inbound-email'\n\n// lockdown-423: exempt — a provider webhook (Svix-signed), no user caller; it files one received message on the record it was with and reads nothing a locked org could lose.\n\n/**\n * THE CAPTURE WEBHOOK (AGL-2657): `POST /api/crm/inbound`.\n *\n * The mail provider receives mail for the capture domain and announces each\n * message with a notification, signed the Svix way. This route verifies the\n * signature, asks the provider which message the notification announces and\n * who it was for (`readInboundMailEvent`), finds the capture token among\n * those recipients, resolves the workspace the token names, has the\n * provider read the message, and files it on the record of whoever it was\n * with — see\n * `fileCrmInboundEmail` for the matching and `crm-inbound.ts` for the\n * rules. A message nobody in the workspace knows is noted in the org's\n * feed by its sender's domain and dropped.\n *\n * ## A machine's route\n *\n * The plugin dispatcher gates a path on the org's release flag, and\n * resolves the org from a `hostId` the caller names. A webhook names no\n * site — the workspace is inside the address — so gated that way every\n * delivery would be a subject-less request, which a partial rollout\n * refuses. So the CRM registers it as a MACHINE's route (`machine: true`,\n * AGL-3080): the dispatcher leaves those gates to it, the org is resolved\n * from the token first, and the plan and the flag are judged for THAT org,\n * which is the gate the CRM actually ships behind.\n *\n * ## What the provider is told\n *\n * A signed event is never answered with an error the provider would retry\n * forever: an event that is not a received message, a token nobody holds,\n * a workspace whose CRM is off, a message that matched no record — each\n * is acknowledged, `200` or `202`, with a `reason` in the body for a\n * person reading the delivery log. Only a bad signature (`401`), a missing\n * secret or a provider that cannot read received mail (`501`), and a\n * failure of ours (`500`) are refused, and a `500` is the one the provider\n * SHOULD retry.\n *\n * ## The body never reaches a log\n *\n * The message is read into memory, reduced to the row's bounded excerpt,\n * and released. Nothing here prints it, and the feed line for an\n * unmatched message carries the sender's domain and nothing else: a\n * stranger's mail to a stale address is not the workspace's to keep.\n *\n * ## Two secrets\n *\n * A provider's webhook endpoint carries its own signing secret. The capture\n * endpoint is its own subscription, so `CRM_INBOUND_WEBHOOK_SECRET` is\n * tried first; `RESEND_WEBHOOK_SECRET` — the delivery-events endpoint's —\n * is accepted too, for a deployment that subscribed received mail on the\n * endpoint it already had. The check itself is the one the\n * delivery-events webhook runs.\n */\n\n/** The signing secrets a delivery may carry, dedicated first. */\nexport function inboundWebhookSecrets(\n env: Record<string, string | undefined> = process.env,\n): string[] {\n const secrets = [env['CRM_INBOUND_WEBHOOK_SECRET'], env['RESEND_WEBHOOK_SECRET']]\n .map((value) => String(value ?? '').trim())\n .filter((value) => value !== '')\n return [...new Set(secrets)]\n}\n\n/**\n * A reader a spec hands in through the module's seam, in place of the\n * provider's. `null` — always, outside a spec — reads through the provider.\n */\nlet readerOverride: ReceivedEmailSource | null = null\n\n/** The spec's seam: reads without the provider. */\nexport function setInboundReaderForTesting(reader: ReceivedEmailSource | null): void {\n readerOverride = reader\n}\n\nconst acknowledge = (status: number, body: Record<string, unknown>): Response =>\n Response.json(body, { status })\n\nexport async function crmInboundRoute(request: Request): Promise<Response> {\n const req = await pluginRequestFromWeb(request)\n if (req.method !== 'POST') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n const secrets = inboundWebhookSecrets()\n if (!secrets.length) {\n return Response.json({ error: 'Email capture is not configured' }, { status: 501 })\n }\n const payload = Buffer.from(req.rawBody ?? '', 'utf8')\n const headers = req.headers as Partial<Record<string, string>>\n const svixId = String(headers['svix-id'] ?? '')\n const svixTimestamp = String(headers['svix-timestamp'] ?? '')\n const svixSignature = String(headers['svix-signature'] ?? '')\n const signed =\n svixId !== '' &&\n svixTimestamp !== '' &&\n secrets.some((secret) =>\n verifySvixSignature(secret, svixId, svixTimestamp, payload, svixSignature),\n )\n if (!signed) return Response.json({ error: 'Bad signature' }, { status: 401 })\n\n let event: unknown\n try {\n event = JSON.parse(payload.toString('utf8'))\n } catch {\n return acknowledge(200, { ignored: true, reason: 'not-json' })\n }\n const inbound = readInboundMailEvent(event)\n if (!inbound) return acknowledge(200, { ignored: true, reason: 'not-received-event' })\n\n const domain = inboundMailDomain()\n const tokens = crmInboundTokensIn(inbound.recipients, domain)\n if (!tokens.length) return acknowledge(202, { filed: false, reason: 'no-token' })\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n // The first token that names a workspace: a message copied to two\n // workspaces' addresses is filed on the first, which is the only\n // reading that keeps one message one row.\n let resolved: Awaited<ReturnType<typeof findOrgByCrmInboundToken>> = null\n for (const token of tokens) {\n resolved = await findOrgByCrmInboundToken(firestore, token)\n if (resolved) break\n }\n if (!resolved) return acknowledge(202, { filed: false, reason: 'unknown-token' })\n const { orgId, org } = resolved\n\n // The gate the CRM ships behind, judged for the org the token named:\n // the plan carries the CRM and the release flag is on for it.\n if (resolveOrgEntitlements(org as never).features?.['crm'] !== true) {\n return acknowledge(202, { filed: false, reason: 'not-entitled' })\n }\n const flagValues = await getServerReleaseFlagValues()\n const flagOn = isReleaseFlagOnForOrg(\n 'release_crm',\n flagValues['release_crm'],\n orgId,\n parseOrgReleaseFlagOverrides(org['releaseFlags']),\n resolveEffectivePlan(org as never),\n )\n if (!flagOn) return acknowledge(202, { filed: false, reason: 'release-flag' })\n\n const reads = mailProviderReads()\n const unmet = reads.unmet()\n if (unmet) {\n console.warn(`[crm] inbound: received mail cannot be read — ${unmet}`)\n return Response.json(\n { error: `Email capture cannot read received mail. ${unmet}` },\n { status: 501 },\n )\n }\n const message = await (readerOverride ?? reads.received)(inbound.id)\n if (!message) return acknowledge(202, { filed: false, reason: 'message-gone' })\n\n // The roster carries each member's confirmed aliases beside the address\n // they sign in with (AGL-2975), so a send from an outbound-domain alias\n // is read as the member's and filed on the person it was written to.\n const [members, hostIds] = await Promise.all([\n loadMemberAddressRoster(firestore, orgId),\n crmInboundHostIds(firestore, orgId),\n ])\n const result = await fileCrmInboundEmail(firestore, {\n orgId,\n org,\n message,\n domain,\n members,\n hostIds,\n })\n\n switch (result.outcome) {\n case 'filed':\n return acknowledge(200, {\n filed: true,\n activityId: result.activityId,\n direction: result.match.direction,\n kind: result.match.kind,\n })\n case 'duplicate':\n return acknowledge(200, { filed: false, reason: 'duplicate', activityId: result.activityId })\n case 'ceiling':\n await logOrgActivity(orgId, { uid: null }, CRM_INBOUND_CEILING_ACTION, {\n type: result.match.kind === 'lead' ? 'lead' : 'contact',\n ...(result.match.link.contactId\n ? { id: String(result.match.link.contactId) }\n : result.match.link.leadId\n ? { id: String(result.match.link.leadId) }\n : {}),\n })\n return acknowledge(202, { filed: false, reason: 'ceiling' })\n case 'unmatched':\n default:\n // The sender's domain and nothing else: the feed says a message\n // arrived that nobody could be matched to, never what it said.\n await logOrgActivity(orgId, { uid: null }, CRM_INBOUND_UNMATCHED_ACTION, {\n type: 'contact',\n ...(result.senderDomain ? { name: result.senderDomain } : {}),\n })\n return acknowledge(202, { filed: false, reason: 'unmatched' })\n }\n } catch (error) {\n console.error('[crm] inbound capture failed', svixId, (error as Error)?.message)\n return Response.json({ error: 'The message could not be filed' }, { status: 500 })\n }\n}\n"],"names":["inboundMailDomain","isReleaseFlagOnForOrg","parseOrgReleaseFlagOverrides","pluginRequestFromWeb","resolveEffectivePlan","resolveOrgEntitlements","mailProviderReads","readInboundMailEvent","verifySvixSignature","firebaseAdmin","getServerReleaseFlagValues","logOrgActivity","loadMemberAddressRoster","CRM_INBOUND_CEILING_ACTION","CRM_INBOUND_UNMATCHED_ACTION","crmInboundTokensIn","crmInboundHostIds","fileCrmInboundEmail","findOrgByCrmInboundToken","inboundWebhookSecrets","env","process","secrets","map","value","String","trim","filter","Set","readerOverride","setInboundReaderForTesting","reader","acknowledge","status","body","Response","json","crmInboundRoute","request","req","headers","method","error","length","payload","Buffer","from","rawBody","svixId","svixTimestamp","svixSignature","signed","some","secret","event","JSON","parse","toString","ignored","reason","inbound","domain","tokens","recipients","filed","firestore","app","resolved","token","orgId","org","features","flagValues","flagOn","reads","unmet","console","warn","message","received","id","members","hostIds","Promise","all","result","outcome","activityId","direction","match","kind","uid","type","link","contactId","leadId","senderDomain","name"],"mappings":"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"}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
|
-
*/ import {
|
|
16
|
+
*/ import { contactContainerFieldPath, readContainerIds } from "@aglyn/aglyn/app-utils/container-membership";
|
|
17
17
|
import { firebaseAdmin } from "@aglyn/tenant-data-admin/server/firebase-admin";
|
|
18
18
|
import { consentGroupForSite } from "@aglyn/tenant-data-admin/server/organizations";
|
|
19
19
|
import { readLeadForHost } from "@aglyn/tenant-data-admin/server/host-visitor-records";
|
|
@@ -42,13 +42,13 @@ import { fileCampaignFilingActivities, orgCampaignRefs } from "./campaign-filing
|
|
|
42
42
|
*/ export async function carryLeadCampaignsToContact(firestore, request) {
|
|
43
43
|
try {
|
|
44
44
|
const lead = await readLeadForHost(request.hostId, request.leadId);
|
|
45
|
-
const campaignIds =
|
|
45
|
+
const campaignIds = readContainerIds(lead ? lead.data() : null, 'campaign');
|
|
46
46
|
if (!campaignIds.length) return {
|
|
47
47
|
campaigns: 0
|
|
48
48
|
};
|
|
49
49
|
const group = await consentGroupForSite(request.hostId);
|
|
50
50
|
await firestore.collection('orgs').doc(request.orgId).collection('contacts').doc(request.contactId).update({
|
|
51
|
-
[
|
|
51
|
+
[contactContainerFieldPath(group.groupId, 'campaign')]: FieldValue.arrayUnion(...campaignIds),
|
|
52
52
|
updatedAt: FieldValue.serverTimestamp()
|
|
53
53
|
});
|
|
54
54
|
/*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/lead-campaign-carry.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
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/lead-campaign-carry.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 contactContainerFieldPath,\n readContainerIds,\n} from '@aglyn/aglyn/app-utils/container-membership'\nimport type {\n PluginLeadConversionReport,\n PluginLeadConversionRequest,\n} from '@aglyn/aglyn/plugin-manager/plugin-lead-conversion'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { consentGroupForSite } from '@aglyn/tenant-data-admin/server/organizations'\nimport { readLeadForHost } from '@aglyn/tenant-data-admin/server/host-visitor-records'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { CAMPAIGN_FILING_CARRY_BY_NAME } from '../model/campaign-filing-activity'\nimport { fileCampaignFilingActivities, orgCampaignRefs } from './campaign-filing-activity'\n\n/**\n * WHAT A LEAD'S CAMPAIGNS BECOME WHEN IT CONVERTS (AGL-3254).\n *\n * A lead is filed under a site's campaigns at the top of its own document;\n * a contact holds the same membership inside the site's consent-group\n * facet, because a contact is shared across the org and a campaign is one\n * site's. When the lead becomes a contact, the campaigns go with it — added\n * to what the contact already carries there with `arrayUnion`, never in\n * place of it — so the campaign's page goes on listing the person after\n * they became a contact, and a lead a sequence enrolled under a campaign\n * does not leave the campaign by converting.\n *\n * The CRM's own module, because a lead and a contact are the CRM's records\n * (docs/PACKAGES.md rule 3): it runs as the CRM's share of the platform's\n * lead-conversion seam, which every door that converts a lead — the\n * dialog, the REST API, a sign-up, a purchase — reaches through\n * `handOffLeadRecords`. Never throws: the conversion has happened, and a\n * membership that could not be carried is one the contact's page can pick\n * by hand.\n */\nexport async function carryLeadCampaignsToContact(\n firestore: FirebaseFirestore.Firestore,\n request: Pick<\n PluginLeadConversionRequest,\n 'orgId' | 'hostId' | 'leadId' | 'contactId'\n >,\n): Promise<PluginLeadConversionReport> {\n try {\n const lead = await readLeadForHost(request.hostId, request.leadId)\n const campaignIds = readContainerIds(\n lead ? (lead.data() as Record<string, unknown>) : null,\n 'campaign',\n )\n if (!campaignIds.length) return { campaigns: 0 }\n const group = await consentGroupForSite(request.hostId)\n await firestore\n .collection('orgs')\n .doc(request.orgId)\n .collection('contacts')\n .doc(request.contactId)\n .update({\n [contactContainerFieldPath(group.groupId, 'campaign')]: FieldValue.arrayUnion(\n ...campaignIds,\n ),\n updatedAt: FieldValue.serverTimestamp(),\n })\n /*\n * The carry on the contact's Activity (AGL-3274): one \"Filed under\"\n * per campaign, by the conversion rather than a member, keyed once per\n * lead and contact so a door that converts twice files once. After the\n * membership has landed, and never failing the carry that is done.\n */\n await fileCampaignFilingActivities(firestore, {\n orgId: request.orgId,\n hostId: request.hostId,\n link: { contactId: request.contactId },\n action: 'filed',\n // Named where the containers answer; by id where a read fails, so a\n // name lookup can never unmake a carry that has landed.\n campaigns: await orgCampaignRefs(firestore, request.orgId, campaignIds).catch(() =>\n campaignIds.map((id) => ({ id, name: id })),\n ),\n atMs: Date.now(),\n byUid: '',\n byName: CAMPAIGN_FILING_CARRY_BY_NAME,\n dedupeKey: `carry:${request.leadId}:${request.contactId}`,\n })\n return { campaigns: campaignIds.length }\n } catch (error) {\n console.error(\n '[crm] the lead’s campaigns could not be carried to the contact',\n request.hostId,\n request.leadId,\n error,\n )\n return { campaigns: null }\n }\n}\n\n/**\n * The carry as the lead-conversion seam calls it: on the Admin SDK's own\n * Firestore. The SDK is resolved HERE, not in `declarations.server.ts`, for\n * two reasons that point the same way. That file must stay light — it runs\n * at the boot of every server process — so it defers this module and must\n * not import the data layer itself. And a library the plugin imports\n * statically in thirty files cannot also be lazy-loaded in one:\n * `enforce-module-boundaries` refuses every static import of a library the\n * project lazy-loads anywhere. This module is the deferred one; the SDK\n * comes with it, the way `capture-contact` brings its own.\n */\nexport function carryLeadCampaignsOnConversion(\n request: Pick<\n PluginLeadConversionRequest,\n 'orgId' | 'hostId' | 'leadId' | 'contactId'\n >,\n): Promise<PluginLeadConversionReport> {\n return carryLeadCampaignsToContact(firebaseAdmin.app().firestore(), request)\n}\n"],"names":["contactContainerFieldPath","readContainerIds","firebaseAdmin","consentGroupForSite","readLeadForHost","FieldValue","CAMPAIGN_FILING_CARRY_BY_NAME","fileCampaignFilingActivities","orgCampaignRefs","carryLeadCampaignsToContact","firestore","request","lead","hostId","leadId","campaignIds","data","length","campaigns","group","collection","doc","orgId","contactId","update","groupId","arrayUnion","updatedAt","serverTimestamp","link","action","catch","map","id","name","atMs","Date","now","byUid","byName","dedupeKey","error","console","carryLeadCampaignsOnConversion","app"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,yBAAyB,EACzBC,gBAAgB,QACX,8CAA6C;AAKpD,SAASC,aAAa,QAAQ,iDAAgD;AAC9E,SAASC,mBAAmB,QAAQ,gDAA+C;AACnF,SAASC,eAAe,QAAQ,uDAAsD;AACtF,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,6BAA6B,QAAQ,uCAAmC;AACjF,SAASC,4BAA4B,EAAEC,eAAe,QAAQ,gCAA4B;AAE1F;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,eAAeC,4BACpBC,SAAsC,EACtCC,OAGC;IAED,IAAI;QACF,MAAMC,OAAO,MAAMR,gBAAgBO,QAAQE,MAAM,EAAEF,QAAQG,MAAM;QACjE,MAAMC,cAAcd,iBAClBW,OAAQA,KAAKI,IAAI,KAAiC,MAClD;QAEF,IAAI,CAACD,YAAYE,MAAM,EAAE,OAAO;YAAEC,WAAW;QAAE;QAC/C,MAAMC,QAAQ,MAAMhB,oBAAoBQ,QAAQE,MAAM;QACtD,MAAMH,UACHU,UAAU,CAAC,QACXC,GAAG,CAACV,QAAQW,KAAK,EACjBF,UAAU,CAAC,YACXC,GAAG,CAACV,QAAQY,SAAS,EACrBC,MAAM,CAAC;YACN,CAACxB,0BAA0BmB,MAAMM,OAAO,EAAE,YAAY,EAAEpB,WAAWqB,UAAU,IACxEX;YAELY,WAAWtB,WAAWuB,eAAe;QACvC;QACF;;;;;KAKC,GACD,MAAMrB,6BAA6BG,WAAW;YAC5CY,OAAOX,QAAQW,KAAK;YACpBT,QAAQF,QAAQE,MAAM;YACtBgB,MAAM;gBAAEN,WAAWZ,QAAQY,SAAS;YAAC;YACrCO,QAAQ;YACR,oEAAoE;YACpE,wDAAwD;YACxDZ,WAAW,MAAMV,gBAAgBE,WAAWC,QAAQW,KAAK,EAAEP,aAAagB,KAAK,CAAC,IAC5EhB,YAAYiB,GAAG,CAAC,CAACC,KAAQ,CAAA;wBAAEA;wBAAIC,MAAMD;oBAAG,CAAA;YAE1CE,MAAMC,KAAKC,GAAG;YACdC,OAAO;YACPC,QAAQjC;YACRkC,WAAW,CAAC,MAAM,EAAE7B,QAAQG,MAAM,CAAC,CAAC,EAAEH,QAAQY,SAAS,EAAE;QAC3D;QACA,OAAO;YAAEL,WAAWH,YAAYE,MAAM;QAAC;IACzC,EAAE,OAAOwB,OAAO;QACdC,QAAQD,KAAK,CACX,kEACA9B,QAAQE,MAAM,EACdF,QAAQG,MAAM,EACd2B;QAEF,OAAO;YAAEvB,WAAW;QAAK;IAC3B;AACF;AAEA;;;;;;;;;;CAUC,GACD,OAAO,SAASyB,+BACdhC,OAGC;IAED,OAAOF,4BAA4BP,cAAc0C,GAAG,GAAGlC,SAAS,IAAIC;AACtE"}
|
|
@@ -77,7 +77,7 @@ export interface LeadCreateRequest {
|
|
|
77
77
|
export declare const LEAD_CAMPAIGN_REFUSAL = "One of the campaigns picked doesn't exist any more. Pick it again.";
|
|
78
78
|
/**
|
|
79
79
|
* The campaigns a request may file a lead under: the organization's own
|
|
80
|
-
* live containers
|
|
80
|
+
* live containers of the `campaign` kind, and nothing else. A
|
|
81
81
|
* request can claim any id, and a lead filed under another org's campaign
|
|
82
82
|
* — or one the console soft-deleted — would sit on a page nobody in this
|
|
83
83
|
* org can open. A lead is an org record, so any live campaign of the org
|
|
@@ -47,7 +47,8 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
47
47
|
*/ import { checkVisitorRecordCeiling, CONTACT_FIELDS_MAX_PER_ORG, CRM_COLLECTIONS, LEADS_MAX_PER_HOST, normalizeContactEmail, normalizeCrmLeadProfile, personKey, readCrmCustomInput } from "@aglyn/aglyn/server";
|
|
48
48
|
import { addHostLead, firebaseAdmin, getOrgForHost, logHostActivity, orgLeadsForHost, restampCrmListFieldsAt, readLeadForHost, scopedToHost } from "@aglyn/tenant-data-admin";
|
|
49
49
|
import { resolveOrgPermissions } from "@aglyn/tenant-runtime/org-permissions";
|
|
50
|
-
import {
|
|
50
|
+
import { normalizeContainerIds, readContainerIds } from "@aglyn/aglyn/app-utils/container-membership";
|
|
51
|
+
import { readOrgContainers } from "@aglyn/tenant-data-admin/server/org-containers";
|
|
51
52
|
import { FieldValue } from "firebase-admin/firestore";
|
|
52
53
|
import { fileCampaignFilingActivities } from "./campaign-filing-activity.js";
|
|
53
54
|
import { readLeadSourcePicklist, resolveLeadSourceWrite } from "./lead-source-picklist.js";
|
|
@@ -63,7 +64,7 @@ const NOTES_MAX = 4000;
|
|
|
63
64
|
/** The sentence a campaign that is not the org's live one is refused with, under the field. */ export const LEAD_CAMPAIGN_REFUSAL = "One of the campaigns picked doesn't exist any more. Pick it again.";
|
|
64
65
|
/**
|
|
65
66
|
* The campaigns a request may file a lead under: the organization's own
|
|
66
|
-
* live containers
|
|
67
|
+
* live containers of the `campaign` kind, and nothing else. A
|
|
67
68
|
* request can claim any id, and a lead filed under another org's campaign
|
|
68
69
|
* — or one the console soft-deleted — would sit on a page nobody in this
|
|
69
70
|
* org can open. A lead is an org record, so any live campaign of the org
|
|
@@ -72,20 +73,14 @@ const NOTES_MAX = 4000;
|
|
|
72
73
|
*
|
|
73
74
|
* @returns the clean ids with their names, or `null` when one is not the org's.
|
|
74
75
|
*/ export async function orgCampaigns(firestore, orgId, raw) {
|
|
75
|
-
const ids =
|
|
76
|
+
const ids = normalizeContainerIds(raw);
|
|
76
77
|
if (!ids.length) return [];
|
|
77
78
|
if (!orgId || ids.some((id)=>id.includes('/'))) return null;
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
var _found_index;
|
|
84
|
-
return {
|
|
85
|
-
id,
|
|
86
|
-
name: String((_ref = (_found_index = found[index]) == null ? void 0 : _found_index.get('name')) != null ? _ref : '').trim() || id
|
|
87
|
-
};
|
|
88
|
-
}) : null;
|
|
79
|
+
const found = await readOrgContainers(firestore, 'campaign', orgId, ids);
|
|
80
|
+
return found.every((container)=>container.live) ? found.map((container)=>({
|
|
81
|
+
id: container.id,
|
|
82
|
+
name: container.name || container.id
|
|
83
|
+
})) : null;
|
|
89
84
|
}
|
|
90
85
|
/** The ids alone — see {@link orgCampaigns}. */ export async function orgCampaignIds(firestore, orgId, raw) {
|
|
91
86
|
const campaigns = await orgCampaigns(firestore, orgId, raw);
|
|
@@ -297,7 +292,7 @@ export const leadCreateHandler = async (req, res)=>{
|
|
|
297
292
|
* campaign the lead was not already in, by the member who added it.
|
|
298
293
|
* After the write and never before, and never failing it — the
|
|
299
294
|
* membership is on the document; the entry records the act.
|
|
300
|
-
*/ const already =
|
|
295
|
+
*/ const already = readContainerIds(before ? before.data() : null, 'campaign');
|
|
301
296
|
await fileCampaignFilingActivities(firestore, {
|
|
302
297
|
orgId: resolved.orgId,
|
|
303
298
|
org: resolved.org,
|