@aglyn/plugins-crm 1.0.0-beta.217 → 1.0.0-beta.219
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +14 -13
- package/src/index.d.ts +1 -0
- package/src/index.js.map +1 -1
- package/src/lib/components/companies-bulk-bar.js +12 -4
- package/src/lib/components/companies-bulk-bar.js.map +1 -1
- package/src/lib/components/company-detail-page.js +11 -0
- package/src/lib/components/company-detail-page.js.map +1 -1
- package/src/lib/components/company-edit-drawer.js +12 -2
- package/src/lib/components/company-edit-drawer.js.map +1 -1
- package/src/lib/components/company-import-drawer.d.ts +1 -1
- package/src/lib/components/company-picker.js +10 -3
- package/src/lib/components/company-picker.js.map +1 -1
- package/src/lib/components/contact-detail-page.d.ts +4 -4
- package/src/lib/components/contact-detail-page.js +50 -53
- package/src/lib/components/contact-detail-page.js.map +1 -1
- package/src/lib/components/contact-list-columns.d.ts +3 -0
- package/src/lib/components/contact-list-columns.js +21 -5
- package/src/lib/components/contact-list-columns.js.map +1 -1
- package/src/lib/components/contact-merge-dialog.js +2 -1
- package/src/lib/components/contact-merge-dialog.js.map +1 -1
- package/src/lib/components/contact-timeline-card.js +4 -3
- package/src/lib/components/contact-timeline-card.js.map +1 -1
- package/src/lib/components/contacts-bulk-bar.d.ts +3 -3
- package/src/lib/components/contacts-bulk-bar.js +24 -51
- package/src/lib/components/contacts-bulk-bar.js.map +1 -1
- package/src/lib/components/contacts-section.js +9 -3
- package/src/lib/components/contacts-section.js.map +1 -1
- package/src/lib/components/crm-export-all-button.d.ts +1 -1
- package/src/lib/components/crm-export-all-button.js.map +1 -1
- package/src/lib/components/crm-send-email-dialog.js +9 -6
- package/src/lib/components/crm-send-email-dialog.js.map +1 -1
- package/src/lib/components/csv-import-drawer.js +2 -2
- package/src/lib/components/csv-import-drawer.js.map +1 -1
- package/src/lib/components/deal-detail-page.js +18 -2
- package/src/lib/components/deal-detail-page.js.map +1 -1
- package/src/lib/components/deal-edit-drawer.js +10 -0
- package/src/lib/components/deal-edit-drawer.js.map +1 -1
- package/src/lib/components/deals-bulk-bar.js +13 -5
- package/src/lib/components/deals-bulk-bar.js.map +1 -1
- package/src/lib/components/lead-campaigns-card.js +6 -0
- package/src/lib/components/lead-campaigns-card.js.map +1 -1
- package/src/lib/components/lead-detail-page.js +25 -5
- package/src/lib/components/lead-detail-page.js.map +1 -1
- package/src/lib/components/lead-properties-card.js +6 -0
- package/src/lib/components/lead-properties-card.js.map +1 -1
- package/src/lib/components/lead-surfaces-note.js +4 -5
- package/src/lib/components/lead-surfaces-note.js.map +1 -1
- package/src/lib/components/lead-unqualify-dialog.js +6 -0
- package/src/lib/components/lead-unqualify-dialog.js.map +1 -1
- package/src/lib/components/leads-bulk-bar.js +28 -3
- package/src/lib/components/leads-bulk-bar.js.map +1 -1
- package/src/lib/components/leads-section.js +36 -7
- package/src/lib/components/leads-section.js.map +1 -1
- package/src/lib/components/org-lead-surfaces-note.js +6 -2
- package/src/lib/components/org-lead-surfaces-note.js.map +1 -1
- package/src/lib/components/recipes-card.js +9 -4
- package/src/lib/components/recipes-card.js.map +1 -1
- package/src/lib/components/record-sharing-card.d.ts +57 -0
- package/src/lib/components/record-sharing-card.js +244 -0
- package/src/lib/components/record-sharing-card.js.map +1 -0
- package/src/lib/components/settings-section.js +7 -0
- package/src/lib/components/settings-section.js.map +1 -1
- package/src/lib/components/share-records-dialog.d.ts +28 -0
- package/src/lib/components/share-records-dialog.js +184 -0
- package/src/lib/components/share-records-dialog.js.map +1 -0
- package/src/lib/components/sharing-rule-drawer.d.ts +37 -0
- package/src/lib/components/sharing-rule-drawer.js +374 -0
- package/src/lib/components/sharing-rule-drawer.js.map +1 -0
- package/src/lib/components/sharing-rules-card.d.ts +24 -0
- package/src/lib/components/sharing-rules-card.js +287 -0
- package/src/lib/components/sharing-rules-card.js.map +1 -0
- package/src/lib/components/use-crm-api.d.ts +1 -1
- package/src/lib/components/use-crm-api.js.map +1 -1
- package/src/lib/components/use-site-route-context.d.ts +25 -0
- package/src/lib/components/use-site-route-context.js +58 -0
- package/src/lib/components/use-site-route-context.js.map +1 -0
- package/src/lib/constants/api-routes.d.ts +31 -0
- package/src/lib/constants/api-routes.js +27 -1
- package/src/lib/constants/api-routes.js.map +1 -1
- package/src/lib/declarations.console-server.d.ts +37 -0
- package/src/lib/declarations.console-server.js +103 -0
- package/src/lib/declarations.console-server.js.map +1 -0
- package/src/lib/declarations.server.d.ts +6 -0
- package/src/lib/declarations.server.js +33 -0
- package/src/lib/declarations.server.js.map +1 -1
- package/src/lib/hooks/use-contact-remove.d.ts +35 -0
- package/src/lib/hooks/use-contact-remove.js +77 -0
- package/src/lib/hooks/use-contact-remove.js.map +1 -0
- package/src/lib/hooks/use-crm-sharing.d.ts +30 -0
- package/src/lib/hooks/use-crm-sharing.js +109 -0
- package/src/lib/hooks/use-crm-sharing.js.map +1 -0
- package/src/lib/model/companies-csv.d.ts +1 -1
- package/src/lib/model/companies-csv.js +2 -2
- package/src/lib/model/companies-csv.js.map +1 -1
- package/src/lib/model/contact-holder.d.ts +68 -0
- package/src/lib/model/contact-holder.js +129 -0
- package/src/lib/model/contact-holder.js.map +1 -0
- package/src/lib/model/contact-interaction-links.d.ts +46 -0
- package/src/lib/model/contact-interaction-links.js +65 -0
- package/src/lib/model/contact-interaction-links.js.map +1 -0
- package/src/lib/model/contact-merge.d.ts +136 -0
- package/src/lib/model/contact-merge.js +412 -0
- package/src/lib/model/contact-merge.js.map +1 -0
- package/src/lib/model/contact-record.d.ts +7 -1
- package/src/lib/model/contact-record.js +6 -5
- package/src/lib/model/contact-record.js.map +1 -1
- package/src/lib/model/contact-remove.d.ts +48 -0
- package/src/lib/model/contact-remove.js +22 -0
- package/src/lib/model/contact-remove.js.map +1 -0
- package/src/lib/model/contacts-bulk-writes.d.ts +2 -71
- package/src/lib/model/contacts-bulk-writes.js +2 -82
- package/src/lib/model/contacts-bulk-writes.js.map +1 -1
- package/src/lib/model/contacts-csv.d.ts +2 -2
- package/src/lib/model/contacts-csv.js +2 -2
- package/src/lib/model/contacts-csv.js.map +1 -1
- package/src/lib/model/crm-csv.d.ts +239 -0
- package/src/lib/model/crm-csv.js +467 -0
- package/src/lib/model/crm-csv.js.map +1 -0
- package/src/lib/model/crm-digest.d.ts +185 -0
- package/src/lib/model/crm-digest.js +272 -0
- package/src/lib/model/crm-digest.js.map +1 -0
- package/src/lib/model/crm-import.d.ts +7 -1
- package/src/lib/model/crm-import.js +2 -1
- package/src/lib/model/crm-import.js.map +1 -1
- package/src/lib/model/crm-record-routes.d.ts +5 -0
- package/src/lib/model/crm-record-routes.js +7 -0
- package/src/lib/model/crm-record-routes.js.map +1 -1
- package/src/lib/model/crm-routes.d.ts +6 -5
- package/src/lib/model/crm-routes.js +7 -7
- package/src/lib/model/crm-routes.js.map +1 -1
- package/src/lib/model/crm-search-sources.d.ts +15 -0
- package/src/lib/model/crm-search-sources.js +162 -0
- package/src/lib/model/crm-search-sources.js.map +1 -0
- package/src/lib/model/crm-sharing-api.d.ts +32 -0
- package/src/lib/model/crm-sharing-api.js +59 -0
- package/src/lib/model/crm-sharing-api.js.map +1 -0
- package/src/lib/model/crm-sharing.d.ts +333 -0
- package/src/lib/model/crm-sharing.js +603 -0
- package/src/lib/model/crm-sharing.js.map +1 -0
- package/src/lib/model/deals-csv.d.ts +1 -1
- package/src/lib/model/deals-csv.js +4 -5
- package/src/lib/model/deals-csv.js.map +1 -1
- package/src/lib/model/lead-filters.d.ts +1 -1
- package/src/lib/model/leads-csv.d.ts +1 -1
- package/src/lib/model/leads-csv.js +1 -1
- package/src/lib/model/leads-csv.js.map +1 -1
- package/src/lib/model/tasks-csv.d.ts +1 -1
- package/src/lib/model/tasks-csv.js +1 -1
- package/src/lib/model/tasks-csv.js.map +1 -1
- package/src/lib/plan-entitlements.d.ts +75 -0
- package/src/lib/plan-entitlements.js +56 -0
- package/src/lib/plan-entitlements.js.map +1 -0
- package/src/lib/plugin.js +3 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/server/api-v1/contacts-merge.d.ts +20 -0
- package/src/lib/server/api-v1/contacts-merge.js +120 -0
- package/src/lib/server/api-v1/contacts-merge.js.map +1 -0
- package/src/lib/server/api-v1/contacts.d.ts +18 -0
- package/src/lib/server/api-v1/contacts.js +1076 -0
- package/src/lib/server/api-v1/contacts.js.map +1 -0
- package/src/lib/server/api-v1/crm-activities.d.ts +18 -0
- package/src/lib/server/api-v1/crm-activities.js +269 -0
- package/src/lib/server/api-v1/crm-activities.js.map +1 -0
- package/src/lib/server/api-v1/crm-companies.d.ts +18 -0
- package/src/lib/server/api-v1/crm-companies.js +391 -0
- package/src/lib/server/api-v1/crm-companies.js.map +1 -0
- package/src/lib/server/api-v1/crm-deals.d.ts +18 -0
- package/src/lib/server/api-v1/crm-deals.js +631 -0
- package/src/lib/server/api-v1/crm-deals.js.map +1 -0
- package/src/lib/server/api-v1/crm-email-templates.d.ts +18 -0
- package/src/lib/server/api-v1/crm-email-templates.js +336 -0
- package/src/lib/server/api-v1/crm-email-templates.js.map +1 -0
- package/src/lib/server/api-v1/crm-leads.d.ts +18 -0
- package/src/lib/server/api-v1/crm-leads.js +832 -0
- package/src/lib/server/api-v1/crm-leads.js.map +1 -0
- package/src/lib/server/api-v1/crm-pipelines.d.ts +76 -0
- package/src/lib/server/api-v1/crm-pipelines.js +146 -0
- package/src/lib/server/api-v1/crm-pipelines.js.map +1 -0
- package/src/lib/server/api-v1/crm-shared.d.ts +271 -0
- package/src/lib/server/api-v1/crm-shared.js +436 -0
- package/src/lib/server/api-v1/crm-shared.js.map +1 -0
- package/src/lib/server/api-v1/crm-tasks.d.ts +18 -0
- package/src/lib/server/api-v1/crm-tasks.js +398 -0
- package/src/lib/server/api-v1/crm-tasks.js.map +1 -0
- package/src/lib/server/api-v1/openapi.d.ts +29 -0
- package/src/lib/server/api-v1/openapi.js +985 -0
- package/src/lib/server/api-v1/openapi.js.map +1 -0
- package/src/lib/server/api-v1/usage.d.ts +26 -0
- package/src/lib/server/api-v1/usage.js +73 -0
- package/src/lib/server/api-v1/usage.js.map +1 -0
- package/src/lib/server/consent-group-participant.js +1 -1
- package/src/lib/server/consent-group-participant.js.map +1 -1
- package/src/lib/server/contact-lifecycle-floor.d.ts +89 -0
- package/src/lib/server/contact-lifecycle-floor.js +91 -0
- package/src/lib/server/contact-lifecycle-floor.js.map +1 -0
- package/src/lib/server/contact-remove.d.ts +75 -0
- package/src/lib/server/contact-remove.js +244 -0
- package/src/lib/server/contact-remove.js.map +1 -0
- package/src/lib/server/contact-update.js +2 -1
- package/src/lib/server/contact-update.js.map +1 -1
- package/src/lib/server/contacts-import.d.ts +47 -11
- package/src/lib/server/contacts-import.js +171 -71
- package/src/lib/server/contacts-import.js.map +1 -1
- package/src/lib/server/contacts-merge.d.ts +1 -1
- package/src/lib/server/contacts-merge.js +3 -2
- package/src/lib/server/contacts-merge.js.map +1 -1
- package/src/lib/server/convert-host-lead.d.ts +189 -0
- package/src/lib/server/convert-host-lead.js +518 -0
- package/src/lib/server/convert-host-lead.js.map +1 -0
- package/src/lib/server/crm-sharing.d.ts +108 -0
- package/src/lib/server/crm-sharing.js +790 -0
- package/src/lib/server/crm-sharing.js.map +1 -0
- package/src/lib/server/daily-digest-route.d.ts +107 -0
- package/src/lib/server/daily-digest-route.js +588 -0
- package/src/lib/server/daily-digest-route.js.map +1 -0
- package/src/lib/server/export-route.d.ts +60 -0
- package/src/lib/server/export-route.js +447 -0
- package/src/lib/server/export-route.js.map +1 -0
- package/src/lib/server/inbound-route.d.ts +74 -0
- package/src/lib/server/inbound-route.js +257 -0
- package/src/lib/server/inbound-route.js.map +1 -0
- package/src/lib/server/lead-convert.d.ts +1 -1
- package/src/lib/server/lead-convert.js +2 -2
- package/src/lib/server/lead-convert.js.map +1 -1
- package/src/lib/server/merge-contacts.d.ts +55 -0
- package/src/lib/server/merge-contacts.js +274 -0
- package/src/lib/server/merge-contacts.js.map +1 -0
- package/src/lib/server/pipeline-record-index.d.ts +36 -0
- package/src/lib/server/pipeline-record-index.js +87 -0
- package/src/lib/server/pipeline-record-index.js.map +1 -0
- package/src/lib/server/record-facts.js +2 -1
- package/src/lib/server/record-facts.js.map +1 -1
- package/src/lib/server/task-reminders-route.d.ts +82 -0
- package/src/lib/server/task-reminders-route.js +497 -0
- package/src/lib/server/task-reminders-route.js.map +1 -0
- package/src/lib/server-deal-stage.js +2 -1
- package/src/lib/server-deal-stage.js.map +1 -1
- package/src/lib/server.d.ts +5 -2
- package/src/lib/server.js +47 -2
- package/src/lib/server.js.map +1 -1
- package/src/lib/testing/stand-in-site-record-routes.d.ts +26 -0
- package/src/lib/testing/stand-in-site-record-routes.js +55 -0
- package/src/lib/testing/stand-in-site-record-routes.js.map +1 -0
- package/src/lib/usage-axes.d.ts +35 -0
- package/src/lib/usage-axes.js +68 -0
- package/src/lib/usage-axes.js.map +1 -0
- package/src/lib/components/use-emails-hub-path.d.ts +0 -24
- package/src/lib/components/use-emails-hub-path.js +0 -59
- package/src/lib/components/use-emails-hub-path.js.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/export-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 checkEntitlement,\n consentGroupForHost,\n CRM_COLLECTIONS,\n csvCell,\n hostScopeToken,\n isOrgWideMember,\n MAX_SCOPE_HOSTS,\n memberCanSee,\n memberScopeTokens,\n scopeTokensForHost,\n planLabelGrantingFeature,\n pluginRequestFromWeb,\n} from '@aglyn/aglyn/server'\nimport {\n emailUnverifiedResponse,\n firebaseAdmin,\n isImpersonationSession,\n isServerReleaseFlagOnForOrg,\n lockdownRefusal,\n memberHasOrgPermission,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\nimport { FieldPath, FieldValue } from 'firebase-admin/firestore'\nimport { addAdminAudit } from '@aglyn/tenant-data-admin/server/admin-audit-write'\nimport { invalidIdTokenResponse } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport {\n contactCsvRowFromDoc,\n crmExportCells,\n crmExportHeader,\n type CrmExportOptions,\n type CrmExportResource,\n isCrmExportResource,\n} from '../model/crm-csv'\nimport { contactPrimaryGroup } from '../model/contact-holder'\n\n/**\n * Documents read per round trip. Not a cap — the stream keeps paging until\n * the collection is exhausted. It bounds what is held at once, which is the\n * only thing a page size should ever bound.\n */\nconst PAGE_SIZE = 500\n\n/** Sites read per page when the leads file spans an organization. */\nconst HOST_PAGE = 100\n\n/**\n * The most sites one organization-level leads file reads from.\n *\n * A ceiling rather than an unbounded sweep, and it is REPORTED: the file's\n * row header counts only what was read, so a short file is provably short\n * rather than quietly partial.\n */\nconst HOST_CEILING = 200\n\n/** The most linked records a tasks file resolves names for before writing ids. */\nconst NAME_CACHE_CEILING = 2000\n\n/** Header naming the row count the server undertook to send. */\nexport const EXPORT_ROWS_HEADER = 'X-Aglyn-Export-Rows'\n\nconst json = (body: unknown, status: number) => Response.json(body, { status })\n\n/** Which collection under the org root each resource is, bar leads. */\nconst ORG_COLLECTION: Record<Exclude<CrmExportResource, 'leads'>, string> = {\n contacts: 'contacts',\n companies: CRM_COLLECTIONS.companies,\n deals: CRM_COLLECTIONS.deals,\n tasks: CRM_COLLECTIONS.tasks,\n}\n\n/** The file's name, before the date stamp. */\nconst FILE_BASE: Record<CrmExportResource, string> = {\n contacts: 'contacts',\n companies: 'companies',\n deals: 'deals',\n tasks: 'tasks',\n leads: 'leads',\n}\n\n/**\n * THE WHOLE COLLECTION, STREAMED (AGL-2662).\n *\n * ## What this fixes\n *\n * Every CRM export until now wrote the rows the LIST had loaded — a\n * `limit(1000)` window for contacts, a `limit(200)` view for tasks — and\n * called the file `contacts.csv`. A merchant with 40,000 contacts got 1,000\n * of them and nothing on screen or in the file said so. `docs/bulk-actions.md`\n * said it in prose, which is not where somebody opening a spreadsheet looks.\n * This is the same file over every row there is, and it carries\n * `X-Aglyn-Export-Rows` so the client can prove what it received is whole —\n * a stream that dies halfway yields a perfectly well-formed shorter file, and\n * nothing about the bytes says they are short.\n *\n * ## Why it is a route and not a bigger client read\n *\n * A browser cannot hold an unbounded collection, and the tier that most\n * needs a complete export is the tier with the most rows. Cursor-paged and\n * streamed, completeness costs neither a hang nor a buffer.\n *\n * ## The columns are the client's columns\n *\n * `crmExportHeader` and `crmExportCells` (`model/crm-csv.ts`) are the\n * writers each section's Export button uses. One feature, one file format.\n *\n * ## The order is by document id, deliberately\n *\n * Firestore DROPS every document missing the ordered field, so ordering by\n * `updatedAt` or `dueAtMs` would silently omit rows written before that\n * field existed — the exact silent loss an export must not have. A document\n * id is on every document and is a total order.\n *\n * ## Scope is enforced here, because nothing else can\n *\n * The Admin SDK bypasses the rules, so `memberScopeTokens` IS the\n * enforcement: an org-wide member reads every row, and a scoped\n * collaborator's query carries their own tokens as the `array-contains-any`\n * the rules would have evaluated. Leads are host data by path, so the\n * equivalent check is whether the caller reaches the site.\n */\nexport async function crmExportRoute(request: Request): Promise<Response> {\n const { method, query, headers: rawHeaders } =\n await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'GET') return json({ error: 'Method not allowed' }, 405)\n\n const authorization = headers.authorization ?? ''\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return json({ error: 'Unauthenticated' }, 401)\n\n const orgId = String(query?.['orgId'] ?? '')\n const resource = String(query?.['resource'] ?? '')\n const hostId = String(query?.['hostId'] ?? '')\n if (!orgId || !isCrmExportResource(resource)) {\n return json({ error: 'Missing orgId or resource' }, 400)\n }\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n return emailUnverifiedResponse()\n }\n const staff = decoded['staff'] === true\n const membership = await resolveOrgMembership(decoded.uid, orgId)\n const member = membership?.member\n if (!member && !staff) return json({ error: 'Not found' }, 404)\n\n /*\n * THE PEOPLE FILES ARE EVERY WORKSPACE'S (AGL-2839, AGL-2851).\n *\n * Exporting the contacts and leads a workspace holds is an obligation,\n * not a CRM feature — a Free workspace takes them from its Privacy\n * settings — so neither the release flag nor the plan is asked of those\n * two. The CRM's own records, companies, deals and tasks, are asked\n * both: the flag here, and the plan once the org has been read.\n */\n const peopleFile = resource === 'contacts' || resource === 'leads'\n if (!peopleFile && !staff && !(await isServerReleaseFlagOnForOrg('release_crm', orgId))) {\n return json({ error: 'Not available' }, 404)\n }\n // The permission the CRM's own rules read for. Reading is not writing,\n // but these records ARE the audience: `data.manage` is what admits a\n // person to the list this file copies.\n if (!staff && !(await memberHasOrgPermission(orgId, member, 'data.manage'))) {\n return json({ error: 'Not found' }, 404)\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const orgRef = firestore.collection('orgs').doc(orgId)\n const orgSnapshot = await orgRef.get()\n if (!orgSnapshot.exists) return json({ error: 'Unknown organization' }, 404)\n const org = orgSnapshot.data()\n\n const locked = await lockdownRefusal({\n request,\n staff,\n uid: decoded.uid,\n org,\n })\n if (locked) return locked\n\n // The CRM's own records on a plan without the CRM, staff included: the\n // refusal every `crm/*` route gives, after the caller is known.\n if (!peopleFile && !checkEntitlement(org as never, 'crm')) {\n const plan = planLabelGrantingFeature('crm')\n return json(\n {\n error:\n `Exporting ${resource} is part of the CRM, which is not included in ` +\n 'your current plan. Manage your plan and add-ons from Billing.' +\n (plan ? ` Included from ${plan}.` : ''),\n reason: 'plan_required',\n code: 'crm',\n },\n 403,\n )\n }\n\n const orgWide = staff || isOrgWideMember(member)\n // `array-contains-any` takes at most thirty values, which is also the\n // widest a consent group can be — a reader past that is the org-wide\n // case and never reaches here.\n const tokens = memberScopeTokens(member).slice(0, MAX_SCOPE_HOSTS)\n if (!orgWide && !tokens.length) return json({ error: 'Not found' }, 404)\n\n /* The roster, once, so every owner and assignee cell is an address. */\n const ownerEmails = new Map<string, string>()\n for (const entry of (await orgRef.collection('members').get()).docs) {\n const email = String(entry.data()?.['email'] ?? '')\n if (email) ownerEmails.set(entry.id, email)\n }\n const ownerEmail = (uid: string) => ownerEmails.get(uid) ?? uid\n\n const options: CrmExportOptions = { ownerEmail, assigneeEmail: ownerEmail }\n\n if (resource === 'contacts') {\n /*\n * A contact's fields live in one holder's FACET, so the file has to\n * say which. Under a site that is the site's own group, for every\n * row; at the organization level it is each row's PRIMARY holder —\n * the same answer the org-level Contacts list flattens by, so the\n * file and the table read one profile per person. What a file must\n * never do is read the top of the document, which is where the\n * pre-facet migration left one holder's fields.\n */\n if (!hostId && !orgWide) return json({ error: 'Missing hostId' }, 400)\n const fields = await orgRef.collection(CRM_COLLECTIONS.contactFields).get()\n options.customFields = fields.docs\n .map((entry) => ({\n key: String(entry.data()?.['key'] ?? entry.id),\n label: String(entry.data()?.['label'] ?? entry.id),\n archivedAt: entry.data()?.['archivedAt'],\n }))\n .filter((field) => !field.archivedAt)\n .map(({ key, label }) => ({ key, label }))\n }\n\n if (resource === 'deals') {\n const pipelines = await orgRef.collection(CRM_COLLECTIONS.pipelines).get()\n const names = new Map<string, string>()\n const stages = new Map<string, string>()\n for (const entry of pipelines.docs) {\n const data = entry.data() ?? {}\n names.set(entry.id, String(data['name'] ?? entry.id))\n for (const stage of (data['stages'] ?? []) as Array<Record<string, unknown>>) {\n const stageId = String(stage?.['id'] ?? '')\n if (stageId) {\n stages.set(`${entry.id}/${stageId}`, String(stage?.['name'] ?? stageId))\n }\n }\n }\n options.pipelineName = (id) => names.get(id)\n options.stageName = (pipelineId, stageId) =>\n stages.get(`${pipelineId}/${stageId}`)\n }\n\n /*\n * A task names its contact, company and deal by ID, and a spreadsheet\n * cannot read one. The list resolves them from rows it already holds;\n * the server looks them up as it meets them — batched per page,\n * remembered, and capped, so a tasks file costs reads in proportion to\n * the records it actually references rather than to the org. Past the\n * cap the id is written, which is the documented degradation everywhere\n * else in these files.\n */\n const recordNames = new Map<string, string>()\n const resolveNames = async (\n wanted: ReadonlyArray<{ kind: 'contact' | 'company' | 'deal'; id: string }>,\n ) => {\n const missing = wanted.filter(\n (entry) =>\n entry.id &&\n !recordNames.has(`${entry.kind}:${entry.id}`) &&\n recordNames.size < NAME_CACHE_CEILING,\n )\n if (!missing.length) return\n const refs = missing.map((entry) =>\n orgRef\n .collection(\n entry.kind === 'contact'\n ? 'contacts'\n : entry.kind === 'company'\n ? CRM_COLLECTIONS.companies\n : CRM_COLLECTIONS.deals,\n )\n .doc(entry.id),\n )\n const snapshots = await firestore.getAll(...refs)\n snapshots.forEach((snapshot, index) => {\n const entry = missing[index]\n const data = snapshot.data() ?? {}\n const name =\n entry.kind === 'deal'\n ? String(data['title'] ?? '')\n : String(data['name'] ?? data['email'] ?? '')\n recordNames.set(`${entry.kind}:${entry.id}`, name || entry.id)\n })\n }\n if (resource === 'tasks') {\n options.recordName = (kind, id) => recordNames.get(`${kind}:${id}`)\n }\n\n /*\n * LEADS ARE HOST DATA BY PATH. Under one site the file is that site's;\n * with no site named, an org-wide member takes every site's leads in one\n * file with a `Site` column — the same file the organization-level Leads\n * list writes. A scoped collaborator must name a site they reach.\n */\n const siteNames = new Map<string, string>()\n let leadHostIds: string[] = []\n if (resource === 'leads') {\n if (hostId) {\n if (!orgWide && !memberCanSee(member, [hostScopeToken(hostId)])) {\n return json({ error: 'Not found' }, 404)\n }\n leadHostIds = [hostId]\n } else {\n if (!orgWide) return json({ error: 'Missing hostId' }, 400)\n let cursor: string | null = null\n for (;;) {\n let page = firestore\n .collection('hosts')\n .where('orgId', '==', orgId)\n .orderBy(FieldPath.documentId())\n .limit(HOST_PAGE)\n if (cursor) page = page.startAfter(firestore.collection('hosts').doc(cursor))\n const snapshot = await page.get()\n for (const entry of snapshot.docs) {\n leadHostIds.push(entry.id)\n siteNames.set(\n entry.id,\n String(\n entry.data()?.['displayName'] ??\n entry.data()?.['subdomain'] ??\n entry.id,\n ),\n )\n }\n if (snapshot.docs.length < HOST_PAGE) break\n if (leadHostIds.length >= HOST_CEILING) break\n cursor = snapshot.docs[snapshot.docs.length - 1].id\n }\n options.siteName = (id) => siteNames.get(id)\n }\n }\n\n /** One resource's rows, as a query per collection the file spans. */\n const sources =\n resource === 'leads'\n ? leadHostIds.map((id) => ({\n hostId: id,\n /*\n * The org collection, narrowed per SITE (AGL-3275).\n *\n * One source per site is still right — the file names the site a\n * row came from — but a site's leads are selected by `visibleTo`\n * now rather than by the collection's parent, so the narrowing\n * moves onto the reference itself. It cannot use the `scoped`\n * flag below: that applies the MEMBER's tokens, and here each\n * source needs the tokens of its own site.\n */\n reference: orgRef\n .collection('leads')\n .where('visibleTo', 'array-contains-any', scopeTokensForHost(id)),\n scoped: false,\n }))\n : [\n {\n hostId: '',\n reference: orgRef.collection(\n ORG_COLLECTION[resource as Exclude<CrmExportResource, 'leads'>],\n ),\n scoped: !orgWide,\n },\n ]\n\n // Taken BEFORE the first page, so it describes the collections the\n // export started from. A `count()` aggregate bills one read per 1,000\n // documents — cheap next to the export it is describing, and the only\n // way the client can tell a complete file from a truncated one.\n let total = 0\n for (const source of sources) {\n const counted = source.scoped\n ? source.reference.where('visibleTo', 'array-contains-any', tokens)\n : source.reference\n total += Number((await counted.count().get()).data().count ?? 0)\n }\n\n /** The holder one contact is written through, resolved per level. */\n const holderFor = (document: Record<string, unknown>) => {\n const group = hostId\n ? consentGroupForHost(org as Record<string, unknown>, hostId)\n : contactPrimaryGroup(document, org as Record<string, unknown>)\n return { groupId: group.groupId, hostIds: group.hostIds }\n }\n\n const encoder = new TextEncoder()\n const encode = (text: string) => encoder.encode(text)\n const line = (cells: readonly unknown[]) => cells.map(csvCell).join(',')\n let sourceIndex = 0\n let cursor: FirebaseFirestore.QueryDocumentSnapshot | null = null\n let phase: 'head' | 'body' | 'tail' = 'head'\n\n const stream = new ReadableStream<Uint8Array>({\n async pull(controller) {\n try {\n if (phase === 'head') {\n phase = sources.length ? 'body' : 'tail'\n controller.enqueue(encode(line(crmExportHeader(resource, options))))\n return\n }\n if (phase === 'body') {\n const source = sources[sourceIndex]\n let page: FirebaseFirestore.Query = source.reference\n if (source.scoped) {\n page = page.where('visibleTo', 'array-contains-any', tokens)\n }\n page = page.orderBy(FieldPath.documentId()).limit(PAGE_SIZE)\n if (cursor) page = page.startAfter(cursor)\n const snapshot = await page.get()\n const exhausted = snapshot.docs.length < PAGE_SIZE\n cursor = exhausted\n ? null\n : (snapshot.docs[snapshot.docs.length - 1] ?? null)\n if (exhausted) {\n sourceIndex += 1\n if (sourceIndex >= sources.length) phase = 'tail'\n }\n if (!snapshot.empty) {\n const documents = snapshot.docs.map((entry) => ({\n ...(entry.data() ?? {}),\n $id: entry.id,\n ...(source.hostId ? { hostId: source.hostId } : {}),\n }))\n if (resource === 'tasks') {\n await resolveNames(\n documents.flatMap((document) =>\n (['contact', 'company', 'deal'] as const).map((kind) => ({\n kind,\n id: String(\n document[\n kind === 'contact'\n ? 'contactId'\n : kind === 'company'\n ? 'companyId'\n : 'dealId'\n ] ?? '',\n ),\n })),\n ),\n )\n }\n const lines = documents.map((document) =>\n line(\n crmExportCells(\n resource,\n resource === 'contacts'\n ? (contactCsvRowFromDoc(\n document,\n holderFor(document),\n ) as Record<string, unknown>)\n : document,\n options,\n ),\n ),\n )\n controller.enqueue(encode(`\\n${lines.join('\\n')}`))\n return\n }\n if (phase === 'body') return\n }\n controller.enqueue(encode('\\n'))\n controller.close()\n } catch (error) {\n // Erroring the stream truncates the download rather than\n // completing it short — a half-written file the browser reports\n // as a failed transfer, not a quiet partial handover.\n controller.error(error)\n }\n },\n })\n\n // Ids and counts only, never content: a full copy of an audience\n // leaving the platform is worth a row; who was in it is not ours to log.\n void addAdminAudit(firestore, {\n actorUid: decoded.uid,\n action: 'crm.exported',\n target: `orgs/${orgId}/${resource}`,\n before: null,\n after: { resource, rows: total, hostId: hostId || null },\n at: FieldValue.serverTimestamp(),\n })\n .catch(() => undefined)\n\n const stamp = new Date().toISOString().slice(0, 10)\n return new Response(stream, {\n status: 200,\n headers: {\n 'Content-Type': 'text/csv; charset=utf-8',\n 'Content-Disposition': `attachment; filename=\"${FILE_BASE[resource]}-${stamp}.csv\"`,\n [EXPORT_ROWS_HEADER]: String(total),\n 'Cache-Control': 'no-store, private',\n },\n })\n } catch (error) {\n // A refused credential is a 401, not a fault of ours (AGL-1993). Null\n // for anything else, so a real failure keeps the answer below.\n const unauthenticated = invalidIdTokenResponse(error)\n if (unauthenticated) return unauthenticated\n return json({ error: 'Export failed' }, 500)\n }\n}\n\n"],"names":["checkEntitlement","consentGroupForHost","CRM_COLLECTIONS","csvCell","hostScopeToken","isOrgWideMember","MAX_SCOPE_HOSTS","memberCanSee","memberScopeTokens","scopeTokensForHost","planLabelGrantingFeature","pluginRequestFromWeb","emailUnverifiedResponse","firebaseAdmin","isImpersonationSession","isServerReleaseFlagOnForOrg","lockdownRefusal","memberHasOrgPermission","resolveOrgMembership","FieldPath","FieldValue","addAdminAudit","invalidIdTokenResponse","contactCsvRowFromDoc","crmExportCells","crmExportHeader","isCrmExportResource","contactPrimaryGroup","PAGE_SIZE","HOST_PAGE","HOST_CEILING","NAME_CACHE_CEILING","EXPORT_ROWS_HEADER","json","body","status","Response","ORG_COLLECTION","contacts","companies","deals","tasks","FILE_BASE","leads","crmExportRoute","request","headers","method","query","rawHeaders","error","authorization","idToken","startsWith","slice","length","undefined","orgId","String","resource","hostId","decoded","app","auth","verifyIdToken","email_verified","staff","membership","uid","member","peopleFile","firestore","orgRef","collection","doc","orgSnapshot","get","exists","org","data","locked","plan","reason","code","orgWide","tokens","ownerEmails","Map","entry","docs","email","set","id","ownerEmail","options","assigneeEmail","fields","contactFields","customFields","map","key","label","archivedAt","filter","field","pipelines","names","stages","stage","stageId","pipelineName","stageName","pipelineId","recordNames","resolveNames","wanted","missing","has","kind","size","refs","snapshots","getAll","forEach","snapshot","index","name","recordName","siteNames","leadHostIds","cursor","page","where","orderBy","documentId","limit","startAfter","push","siteName","sources","reference","scoped","total","source","counted","Number","count","holderFor","document","group","groupId","hostIds","encoder","TextEncoder","encode","text","line","cells","join","sourceIndex","phase","stream","ReadableStream","pull","controller","enqueue","exhausted","empty","documents","$id","flatMap","lines","close","actorUid","action","target","before","after","rows","at","serverTimestamp","catch","stamp","Date","toISOString","unauthenticated"],"mappings":"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"}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import { type ReceivedEmailSource } from '@aglyn/shared-util-email';
|
|
18
|
+
/**
|
|
19
|
+
* THE CAPTURE WEBHOOK (AGL-2657): `POST /api/crm/inbound`.
|
|
20
|
+
*
|
|
21
|
+
* The mail provider receives mail for the capture domain and announces each
|
|
22
|
+
* message with a notification, signed the Svix way. This route verifies the
|
|
23
|
+
* signature, asks the provider which message the notification announces and
|
|
24
|
+
* who it was for (`readInboundMailEvent`), finds the capture token among
|
|
25
|
+
* those recipients, resolves the workspace the token names, has the
|
|
26
|
+
* provider read the message, and files it on the record of whoever it was
|
|
27
|
+
* with — see
|
|
28
|
+
* `fileCrmInboundEmail` for the matching and `crm-inbound.ts` for the
|
|
29
|
+
* rules. A message nobody in the workspace knows is noted in the org's
|
|
30
|
+
* feed by its sender's domain and dropped.
|
|
31
|
+
*
|
|
32
|
+
* ## A machine's route
|
|
33
|
+
*
|
|
34
|
+
* The plugin dispatcher gates a path on the org's release flag, and
|
|
35
|
+
* resolves the org from a `hostId` the caller names. A webhook names no
|
|
36
|
+
* site — the workspace is inside the address — so gated that way every
|
|
37
|
+
* delivery would be a subject-less request, which a partial rollout
|
|
38
|
+
* refuses. So the CRM registers it as a MACHINE's route (`machine: true`,
|
|
39
|
+
* AGL-3080): the dispatcher leaves those gates to it, the org is resolved
|
|
40
|
+
* from the token first, and the plan and the flag are judged for THAT org,
|
|
41
|
+
* which is the gate the CRM actually ships behind.
|
|
42
|
+
*
|
|
43
|
+
* ## What the provider is told
|
|
44
|
+
*
|
|
45
|
+
* A signed event is never answered with an error the provider would retry
|
|
46
|
+
* forever: an event that is not a received message, a token nobody holds,
|
|
47
|
+
* a workspace whose CRM is off, a message that matched no record — each
|
|
48
|
+
* is acknowledged, `200` or `202`, with a `reason` in the body for a
|
|
49
|
+
* person reading the delivery log. Only a bad signature (`401`), a missing
|
|
50
|
+
* secret or a provider that cannot read received mail (`501`), and a
|
|
51
|
+
* failure of ours (`500`) are refused, and a `500` is the one the provider
|
|
52
|
+
* SHOULD retry.
|
|
53
|
+
*
|
|
54
|
+
* ## The body never reaches a log
|
|
55
|
+
*
|
|
56
|
+
* The message is read into memory, reduced to the row's bounded excerpt,
|
|
57
|
+
* and released. Nothing here prints it, and the feed line for an
|
|
58
|
+
* unmatched message carries the sender's domain and nothing else: a
|
|
59
|
+
* stranger's mail to a stale address is not the workspace's to keep.
|
|
60
|
+
*
|
|
61
|
+
* ## Two secrets
|
|
62
|
+
*
|
|
63
|
+
* A provider's webhook endpoint carries its own signing secret. The capture
|
|
64
|
+
* endpoint is its own subscription, so `CRM_INBOUND_WEBHOOK_SECRET` is
|
|
65
|
+
* tried first; `RESEND_WEBHOOK_SECRET` — the delivery-events endpoint's —
|
|
66
|
+
* is accepted too, for a deployment that subscribed received mail on the
|
|
67
|
+
* endpoint it already had. The check itself is the one the
|
|
68
|
+
* delivery-events webhook runs.
|
|
69
|
+
*/
|
|
70
|
+
/** The signing secrets a delivery may carry, dedicated first. */
|
|
71
|
+
export declare function inboundWebhookSecrets(env?: Record<string, string | undefined>): string[];
|
|
72
|
+
/** The spec's seam: reads without the provider. */
|
|
73
|
+
export declare function setInboundReaderForTesting(reader: ReceivedEmailSource | null): void;
|
|
74
|
+
export declare function crmInboundRoute(request: Request): Promise<Response>;
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ import { CRM_INBOUND_CEILING_ACTION, CRM_INBOUND_UNMATCHED_ACTION, crmInboundDomain, crmInboundTokensIn, isReleaseFlagOnForOrg, parseOrgReleaseFlagOverrides, pluginRequestFromWeb, resolveEffectivePlan, resolveOrgEntitlements } from "@aglyn/aglyn/server";
|
|
18
|
+
import { mailProviderReads, readInboundMailEvent } from "@aglyn/shared-util-email";
|
|
19
|
+
// By its own path, not the barrel: the check holds a `crypto` HMAC, and the
|
|
20
|
+
// barrel is reached from the browser through the campaign model.
|
|
21
|
+
import { verifySvixSignature } from "@aglyn/shared-util-email/svix-signature";
|
|
22
|
+
import { crmInboundHostIds, fileCrmInboundEmail, findOrgByCrmInboundToken, firebaseAdmin, getServerReleaseFlagValues, loadCrmInboundRoster, logOrgActivity } from "@aglyn/tenant-data-admin";
|
|
23
|
+
// 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
|
+
/**
|
|
25
|
+
* THE CAPTURE WEBHOOK (AGL-2657): `POST /api/crm/inbound`.
|
|
26
|
+
*
|
|
27
|
+
* The mail provider receives mail for the capture domain and announces each
|
|
28
|
+
* message with a notification, signed the Svix way. This route verifies the
|
|
29
|
+
* signature, asks the provider which message the notification announces and
|
|
30
|
+
* who it was for (`readInboundMailEvent`), finds the capture token among
|
|
31
|
+
* those recipients, resolves the workspace the token names, has the
|
|
32
|
+
* provider read the message, and files it on the record of whoever it was
|
|
33
|
+
* with — see
|
|
34
|
+
* `fileCrmInboundEmail` for the matching and `crm-inbound.ts` for the
|
|
35
|
+
* rules. A message nobody in the workspace knows is noted in the org's
|
|
36
|
+
* feed by its sender's domain and dropped.
|
|
37
|
+
*
|
|
38
|
+
* ## A machine's route
|
|
39
|
+
*
|
|
40
|
+
* The plugin dispatcher gates a path on the org's release flag, and
|
|
41
|
+
* resolves the org from a `hostId` the caller names. A webhook names no
|
|
42
|
+
* site — the workspace is inside the address — so gated that way every
|
|
43
|
+
* delivery would be a subject-less request, which a partial rollout
|
|
44
|
+
* refuses. So the CRM registers it as a MACHINE's route (`machine: true`,
|
|
45
|
+
* AGL-3080): the dispatcher leaves those gates to it, the org is resolved
|
|
46
|
+
* from the token first, and the plan and the flag are judged for THAT org,
|
|
47
|
+
* which is the gate the CRM actually ships behind.
|
|
48
|
+
*
|
|
49
|
+
* ## What the provider is told
|
|
50
|
+
*
|
|
51
|
+
* A signed event is never answered with an error the provider would retry
|
|
52
|
+
* forever: an event that is not a received message, a token nobody holds,
|
|
53
|
+
* a workspace whose CRM is off, a message that matched no record — each
|
|
54
|
+
* is acknowledged, `200` or `202`, with a `reason` in the body for a
|
|
55
|
+
* person reading the delivery log. Only a bad signature (`401`), a missing
|
|
56
|
+
* secret or a provider that cannot read received mail (`501`), and a
|
|
57
|
+
* failure of ours (`500`) are refused, and a `500` is the one the provider
|
|
58
|
+
* SHOULD retry.
|
|
59
|
+
*
|
|
60
|
+
* ## The body never reaches a log
|
|
61
|
+
*
|
|
62
|
+
* The message is read into memory, reduced to the row's bounded excerpt,
|
|
63
|
+
* and released. Nothing here prints it, and the feed line for an
|
|
64
|
+
* unmatched message carries the sender's domain and nothing else: a
|
|
65
|
+
* stranger's mail to a stale address is not the workspace's to keep.
|
|
66
|
+
*
|
|
67
|
+
* ## Two secrets
|
|
68
|
+
*
|
|
69
|
+
* A provider's webhook endpoint carries its own signing secret. The capture
|
|
70
|
+
* endpoint is its own subscription, so `CRM_INBOUND_WEBHOOK_SECRET` is
|
|
71
|
+
* tried first; `RESEND_WEBHOOK_SECRET` — the delivery-events endpoint's —
|
|
72
|
+
* is accepted too, for a deployment that subscribed received mail on the
|
|
73
|
+
* endpoint it already had. The check itself is the one the
|
|
74
|
+
* delivery-events webhook runs.
|
|
75
|
+
*/ /** The signing secrets a delivery may carry, dedicated first. */ export function inboundWebhookSecrets(env = process.env) {
|
|
76
|
+
const secrets = [
|
|
77
|
+
env['CRM_INBOUND_WEBHOOK_SECRET'],
|
|
78
|
+
env['RESEND_WEBHOOK_SECRET']
|
|
79
|
+
].map((value)=>String(value != null ? value : '').trim()).filter((value)=>value !== '');
|
|
80
|
+
return [
|
|
81
|
+
...new Set(secrets)
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* A reader a spec hands in through the module's seam, in place of the
|
|
86
|
+
* provider's. `null` — always, outside a spec — reads through the provider.
|
|
87
|
+
*/ let readerOverride = null;
|
|
88
|
+
/** The spec's seam: reads without the provider. */ export function setInboundReaderForTesting(reader) {
|
|
89
|
+
readerOverride = reader;
|
|
90
|
+
}
|
|
91
|
+
const acknowledge = (status, body)=>Response.json(body, {
|
|
92
|
+
status
|
|
93
|
+
});
|
|
94
|
+
export async function crmInboundRoute(request) {
|
|
95
|
+
var _req_rawBody, _headers_svixid, _headers_svixtimestamp, _headers_svixsignature;
|
|
96
|
+
const req = await pluginRequestFromWeb(request);
|
|
97
|
+
if (req.method !== 'POST') {
|
|
98
|
+
return Response.json({
|
|
99
|
+
error: 'Method not allowed'
|
|
100
|
+
}, {
|
|
101
|
+
status: 405
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
const secrets = inboundWebhookSecrets();
|
|
105
|
+
if (!secrets.length) {
|
|
106
|
+
return Response.json({
|
|
107
|
+
error: 'Email capture is not configured'
|
|
108
|
+
}, {
|
|
109
|
+
status: 501
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
const payload = Buffer.from((_req_rawBody = req.rawBody) != null ? _req_rawBody : '', 'utf8');
|
|
113
|
+
const headers = req.headers;
|
|
114
|
+
const svixId = String((_headers_svixid = headers['svix-id']) != null ? _headers_svixid : '');
|
|
115
|
+
const svixTimestamp = String((_headers_svixtimestamp = headers['svix-timestamp']) != null ? _headers_svixtimestamp : '');
|
|
116
|
+
const svixSignature = String((_headers_svixsignature = headers['svix-signature']) != null ? _headers_svixsignature : '');
|
|
117
|
+
const signed = svixId !== '' && svixTimestamp !== '' && secrets.some((secret)=>verifySvixSignature(secret, svixId, svixTimestamp, payload, svixSignature));
|
|
118
|
+
if (!signed) return Response.json({
|
|
119
|
+
error: 'Bad signature'
|
|
120
|
+
}, {
|
|
121
|
+
status: 401
|
|
122
|
+
});
|
|
123
|
+
let event;
|
|
124
|
+
try {
|
|
125
|
+
event = JSON.parse(payload.toString('utf8'));
|
|
126
|
+
} catch (unused) {
|
|
127
|
+
return acknowledge(200, {
|
|
128
|
+
ignored: true,
|
|
129
|
+
reason: 'not-json'
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
const inbound = readInboundMailEvent(event);
|
|
133
|
+
if (!inbound) return acknowledge(200, {
|
|
134
|
+
ignored: true,
|
|
135
|
+
reason: 'not-received-event'
|
|
136
|
+
});
|
|
137
|
+
const domain = crmInboundDomain();
|
|
138
|
+
const tokens = crmInboundTokensIn(inbound.recipients, domain);
|
|
139
|
+
if (!tokens.length) return acknowledge(202, {
|
|
140
|
+
filed: false,
|
|
141
|
+
reason: 'no-token'
|
|
142
|
+
});
|
|
143
|
+
try {
|
|
144
|
+
var _resolveOrgEntitlements_features;
|
|
145
|
+
const firestore = firebaseAdmin.app().firestore();
|
|
146
|
+
// The first token that names a workspace: a message copied to two
|
|
147
|
+
// workspaces' addresses is filed on the first, which is the only
|
|
148
|
+
// reading that keeps one message one row.
|
|
149
|
+
let resolved = null;
|
|
150
|
+
for (const token of tokens){
|
|
151
|
+
resolved = await findOrgByCrmInboundToken(firestore, token);
|
|
152
|
+
if (resolved) break;
|
|
153
|
+
}
|
|
154
|
+
if (!resolved) return acknowledge(202, {
|
|
155
|
+
filed: false,
|
|
156
|
+
reason: 'unknown-token'
|
|
157
|
+
});
|
|
158
|
+
const { orgId, org } = resolved;
|
|
159
|
+
// The gate the CRM ships behind, judged for the org the token named:
|
|
160
|
+
// the plan carries the CRM and the release flag is on for it.
|
|
161
|
+
if (((_resolveOrgEntitlements_features = resolveOrgEntitlements(org).features) == null ? void 0 : _resolveOrgEntitlements_features['crm']) !== true) {
|
|
162
|
+
return acknowledge(202, {
|
|
163
|
+
filed: false,
|
|
164
|
+
reason: 'not-entitled'
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
const flagValues = await getServerReleaseFlagValues();
|
|
168
|
+
const flagOn = isReleaseFlagOnForOrg('release_crm', flagValues['release_crm'], orgId, parseOrgReleaseFlagOverrides(org['releaseFlags']), resolveEffectivePlan(org));
|
|
169
|
+
if (!flagOn) return acknowledge(202, {
|
|
170
|
+
filed: false,
|
|
171
|
+
reason: 'release-flag'
|
|
172
|
+
});
|
|
173
|
+
const reads = mailProviderReads();
|
|
174
|
+
const unmet = reads.unmet();
|
|
175
|
+
if (unmet) {
|
|
176
|
+
console.warn(`[crm] inbound: received mail cannot be read — ${unmet}`);
|
|
177
|
+
return Response.json({
|
|
178
|
+
error: `Email capture cannot read received mail. ${unmet}`
|
|
179
|
+
}, {
|
|
180
|
+
status: 501
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
const message = await (readerOverride != null ? readerOverride : reads.received)(inbound.id);
|
|
184
|
+
if (!message) return acknowledge(202, {
|
|
185
|
+
filed: false,
|
|
186
|
+
reason: 'message-gone'
|
|
187
|
+
});
|
|
188
|
+
// The roster carries each member's confirmed aliases beside the address
|
|
189
|
+
// they sign in with (AGL-2975), so a send from an outbound-domain alias
|
|
190
|
+
// is read as the member's and filed on the person it was written to.
|
|
191
|
+
const [members, hostIds] = await Promise.all([
|
|
192
|
+
loadCrmInboundRoster(firestore, orgId),
|
|
193
|
+
crmInboundHostIds(firestore, orgId)
|
|
194
|
+
]);
|
|
195
|
+
const result = await fileCrmInboundEmail(firestore, {
|
|
196
|
+
orgId,
|
|
197
|
+
org,
|
|
198
|
+
message,
|
|
199
|
+
domain,
|
|
200
|
+
members,
|
|
201
|
+
hostIds
|
|
202
|
+
});
|
|
203
|
+
switch(result.outcome){
|
|
204
|
+
case 'filed':
|
|
205
|
+
return acknowledge(200, {
|
|
206
|
+
filed: true,
|
|
207
|
+
activityId: result.activityId,
|
|
208
|
+
direction: result.match.direction,
|
|
209
|
+
kind: result.match.kind
|
|
210
|
+
});
|
|
211
|
+
case 'duplicate':
|
|
212
|
+
return acknowledge(200, {
|
|
213
|
+
filed: false,
|
|
214
|
+
reason: 'duplicate',
|
|
215
|
+
activityId: result.activityId
|
|
216
|
+
});
|
|
217
|
+
case 'ceiling':
|
|
218
|
+
await logOrgActivity(orgId, {
|
|
219
|
+
uid: null
|
|
220
|
+
}, CRM_INBOUND_CEILING_ACTION, _extends({
|
|
221
|
+
type: result.match.kind === 'lead' ? 'lead' : 'contact'
|
|
222
|
+
}, result.match.link.contactId ? {
|
|
223
|
+
id: String(result.match.link.contactId)
|
|
224
|
+
} : result.match.link.leadId ? {
|
|
225
|
+
id: String(result.match.link.leadId)
|
|
226
|
+
} : {}));
|
|
227
|
+
return acknowledge(202, {
|
|
228
|
+
filed: false,
|
|
229
|
+
reason: 'ceiling'
|
|
230
|
+
});
|
|
231
|
+
case 'unmatched':
|
|
232
|
+
default:
|
|
233
|
+
// The sender's domain and nothing else: the feed says a message
|
|
234
|
+
// arrived that nobody could be matched to, never what it said.
|
|
235
|
+
await logOrgActivity(orgId, {
|
|
236
|
+
uid: null
|
|
237
|
+
}, CRM_INBOUND_UNMATCHED_ACTION, _extends({
|
|
238
|
+
type: 'contact'
|
|
239
|
+
}, result.senderDomain ? {
|
|
240
|
+
name: result.senderDomain
|
|
241
|
+
} : {}));
|
|
242
|
+
return acknowledge(202, {
|
|
243
|
+
filed: false,
|
|
244
|
+
reason: 'unmatched'
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
} catch (error) {
|
|
248
|
+
console.error('[crm] inbound capture failed', svixId, error == null ? void 0 : error.message);
|
|
249
|
+
return Response.json({
|
|
250
|
+
error: 'The message could not be filed'
|
|
251
|
+
}, {
|
|
252
|
+
status: 500
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
//# sourceMappingURL=inbound-route.js.map
|
|
@@ -0,0 +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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"}
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
* otherwise decide about.
|
|
35
35
|
*/
|
|
36
36
|
import { type PluginApiHandler } from '@aglyn/aglyn/server';
|
|
37
|
-
export { stageForNewDeal } from '
|
|
37
|
+
export { stageForNewDeal } from './convert-host-lead';
|
|
38
38
|
/** The body the console's convert dialog posts. */
|
|
39
39
|
export interface LeadConvertRequest {
|
|
40
40
|
hostId: string;
|
|
@@ -34,13 +34,13 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
34
34
|
* otherwise decide about.
|
|
35
35
|
*/ import { CONTACT_ERASED_MESSAGE, CRM_RECORDS_BAND_FULL_MESSAGE, normalizeCompanyDomain } from "@aglyn/aglyn/server";
|
|
36
36
|
import { firebaseAdmin, getOrgForHost } from "@aglyn/tenant-data-admin";
|
|
37
|
-
import { convertHostLead } from "
|
|
37
|
+
import { convertHostLead } from "./convert-host-lead.js";
|
|
38
38
|
import { resolveOrgPermissions } from "@aglyn/tenant-runtime/org-permissions";
|
|
39
39
|
import { holdsDataManage } from "./org-caller.js";
|
|
40
40
|
import { crmSuiteRefusal } from "./suite-gate.js";
|
|
41
41
|
// The stage picker moved to the runtime with the writes; re-exported so the
|
|
42
42
|
// pipeline code that imports it from here keeps its import.
|
|
43
|
-
export { stageForNewDeal } from "
|
|
43
|
+
export { stageForNewDeal } from "./convert-host-lead.js";
|
|
44
44
|
const COMPANY_NAME_MAX = 120;
|
|
45
45
|
const DEAL_TITLE_MAX = 200;
|
|
46
46
|
const CURRENCY_PATTERN = /^[a-z]{3}$/;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/lead-convert.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * `POST /api/crm/lead-convert` — a lead becomes a contact, and optionally a\n * company and a deal (AGL-2608).\n *\n * This is the console's door onto `convertHostLead` (the tenancy runtime),\n * which owns the four writes, their order, and the idempotency on the lead;\n * the REST API's `POST /v1/leads/{id}/convert` is the other door onto the\n * same function (AGL-2627). What lives here is what only this door knows:\n * the ID token, the body the convert dialog posts, and the statuses and\n * sentences the dialog shows.\n *\n * ## What the caller has to be\n *\n * A signed-in member of the host's org holding `data.manage` — the same key\n * the console gate on the CRM surface enforces — with a role on this site.\n * Checked here with the Admin SDK rather than trusted from the client,\n * because this route writes into collections the caller's own rules would\n * otherwise decide about.\n */\n\nimport {\n CONTACT_ERASED_MESSAGE,\n CRM_RECORDS_BAND_FULL_MESSAGE,\n normalizeCompanyDomain,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport { firebaseAdmin, getOrgForHost } from '@aglyn/tenant-data-admin'\nimport {\n type ConvertHostLeadRefusal,\n convertHostLead,\n} from '@aglyn/tenant-runtime/convert-host-lead'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { holdsDataManage } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\n// The stage picker moved to the runtime with the writes; re-exported so the\n// pipeline code that imports it from here keeps its import.\nexport { stageForNewDeal } from '@aglyn/tenant-runtime/convert-host-lead'\n\n/** The body the console's convert dialog posts. */\nexport interface LeadConvertRequest {\n hostId: string\n /** `hosts/{hostId}/leads/{leadId}` — the document id, which is the person key. */\n leadId: string\n /**\n * Who owns the resulting contact (and deal). Defaults to the lead's own\n * owner; failing that the org's assignment rules and the site's default\n * owner decide (AGL-2618), and failing those the caller — somebody\n * converted this person, and a record with no owner is one nobody\n * follows up.\n */\n ownerUid?: string\n /** Link an existing `orgs/{orgId}/companies/{companyId}`. */\n companyId?: string\n /** Or create one. Ignored when `companyId` is given. */\n createCompany?: { name: string; domain?: string }\n /** Open a deal in the org's default pipeline. */\n deal?: {\n title: string\n amountCents?: number\n /** Lowercase ISO 4217; `usd` when absent or malformed. */\n currency?: string\n /** A stage of the default pipeline; its first open stage when absent. */\n stageId?: string\n }\n}\n\n/** What the route answers on success. */\nexport interface LeadConvertResponse {\n ok: true\n contactId: string\n companyId?: string\n dealId?: string\n /** The lead was converted before this call; nothing was created now. */\n alreadyConverted: boolean\n}\n\nconst COMPANY_NAME_MAX = 120\nconst DEAL_TITLE_MAX = 200\nconst CURRENCY_PATTERN = /^[a-z]{3}$/\n\n/**\n * How each refusal the writes can answer reads at this door — the status\n * and the sentence the convert dialog has always shown for it.\n */\nconst REFUSALS: Record<\n ConvertHostLeadRefusal,\n { status: number; body: Record<string, unknown> }\n> = {\n 'unknown-lead': { status: 404, body: { error: 'Unknown lead' } },\n 'no-email': {\n status: 422,\n body: { error: 'This lead has no usable email address to convert' },\n },\n 'contact-not-created': {\n status: 409,\n body: {\n error:\n 'The contact could not be created — the audience band may be ' +\n 'full. Nothing was changed.',\n },\n },\n 'band-full': {\n status: 409,\n body: { error: CRM_RECORDS_BAND_FULL_MESSAGE, reason: 'band' },\n },\n // The contact create route's own answer for an erased address (AGL-2623),\n // so the convert dialog and the add-contact drawer say one thing about\n // one person.\n erased: {\n status: 409,\n body: { error: CONTACT_ERASED_MESSAGE, reason: 'erased' },\n },\n 'unknown-company': { status: 404, body: { error: 'Unknown company' } },\n 'no-stages': {\n status: 409,\n body: { error: 'The default pipeline has no stages to open a deal in' },\n },\n}\n\nexport const leadConvertHandler: 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 authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n res.status(401).json({ error: 'Unauthenticated' })\n return\n }\n const body: Partial<LeadConvertRequest> =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '').trim()\n const leadId = String(body.leadId ?? '').trim()\n if (!hostId || !leadId) {\n res.status(400).json({ error: 'Missing hostId or leadId' })\n return\n }\n\n /*\n * Validate the optional parts BEFORE any read, so a malformed request costs\n * nothing and a bad company domain is refused rather than silently dropped\n * — a converter who typed a domain expects the company to carry it.\n */\n const requestedCompanyId = String(body.companyId ?? '').trim()\n let createCompany: { name: string; domain: string | null } | null = null\n if (!requestedCompanyId && body.createCompany) {\n const name = String(body.createCompany.name ?? '')\n .trim()\n .slice(0, COMPANY_NAME_MAX)\n if (!name) {\n res.status(400).json({ error: 'A company needs a name' })\n return\n }\n const rawDomain = String(body.createCompany.domain ?? '').trim()\n const domain = rawDomain ? normalizeCompanyDomain(rawDomain) : null\n if (rawDomain && !domain) {\n res.status(400).json({ error: 'That does not look like a domain' })\n return\n }\n createCompany = { name, domain }\n }\n let deal: {\n title: string\n amountCents: number | null\n currency: string\n stageId: string | undefined\n } | null = null\n if (body.deal) {\n const title = String(body.deal.title ?? '')\n .trim()\n .slice(0, DEAL_TITLE_MAX)\n if (!title) {\n res.status(400).json({ error: 'A deal needs a title' })\n return\n }\n const amount = Number(body.deal.amountCents)\n const currency = String(body.deal.currency ?? '')\n .trim()\n .toLowerCase()\n deal = {\n title,\n amountCents:\n Number.isFinite(amount) && amount >= 0 ? Math.round(amount) : null,\n currency: CURRENCY_PATTERN.test(currency) ? currency : 'usd',\n stageId: body.deal.stageId ? String(body.deal.stageId) : undefined,\n }\n }\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const staff = decoded['staff'] === true\n /*\n * The console's own gate on the CRM is `data.manage`, asked of the\n * permission catalog (`holdsDataManage`), and additionally that the\n * caller has a role on THIS site — an org member scoped to a sibling site\n * holds the key but not the host, and a lead is the host's record.\n * `resolveOrgPermissions` answers the site role and fails closed on a\n * lookup error when a host is named, so an absent membership refuses.\n */\n const membership = await resolveOrgPermissions(decoded.uid, { hostId })\n if (\n !staff &&\n !(membership.hostRole && (await holdsDataManage(membership.orgId, decoded.uid)))\n ) {\n res\n .status(403)\n .json({ error: 'Converting a lead requires the data permission on this site' })\n return\n }\n\n const resolved = await getOrgForHost(hostId)\n if (!resolved) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n const { orgId, org } = resolved\n // A lead's conversion is the CRM's, like its status and owner (AGL-2787).\n const suite = crmSuiteRefusal(org, 'Converting a lead')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n const result = await convertHostLead({\n firestore: firebaseAdmin.app().firestore(),\n hostId,\n orgId,\n org: org as Record<string, unknown>,\n leadId,\n actor: { uid: decoded.uid, email: decoded.email ?? null, kind: 'member' },\n ...(body.ownerUid ? { ownerUid: String(body.ownerUid) } : {}),\n ...(requestedCompanyId ? { companyId: requestedCompanyId } : {}),\n createCompany,\n deal,\n })\n if (result.ok === false) {\n const refusal = REFUSALS[result.reason]\n res.status(refusal.status).json(refusal.body)\n return\n }\n const answer: LeadConvertResponse = {\n ok: true,\n contactId: result.contactId,\n ...(result.companyId ? { companyId: result.companyId } : {}),\n ...(result.dealId ? { dealId: result.dealId } : {}),\n alreadyConverted: result.alreadyConverted,\n }\n res.status(200).json(answer)\n } catch (error) {\n console.error('lead-convert failed', error)\n res.status(500).json({ error: 'The lead could not be converted.' })\n }\n}\n\nexport default leadConvertHandler\n"],"names":["CONTACT_ERASED_MESSAGE","CRM_RECORDS_BAND_FULL_MESSAGE","normalizeCompanyDomain","firebaseAdmin","getOrgForHost","convertHostLead","resolveOrgPermissions","holdsDataManage","crmSuiteRefusal","stageForNewDeal","COMPANY_NAME_MAX","DEAL_TITLE_MAX","CURRENCY_PATTERN","REFUSALS","status","body","error","reason","erased","leadConvertHandler","req","res","method","setHeader","json","authorization","String","headers","idToken","startsWith","slice","length","undefined","JSON","parse","hostId","trim","leadId","requestedCompanyId","companyId","createCompany","name","rawDomain","domain","deal","title","amount","Number","amountCents","currency","toLowerCase","isFinite","Math","round","test","stageId","decoded","app","auth","verifyIdToken","staff","membership","uid","hostRole","orgId","resolved","org","suite","result","firestore","actor","email","kind","ownerUid","ok","refusal","answer","contactId","dealId","alreadyConverted","console"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/lead-convert.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * `POST /api/crm/lead-convert` — a lead becomes a contact, and optionally a\n * company and a deal (AGL-2608).\n *\n * This is the console's door onto `convertHostLead` (the tenancy runtime),\n * which owns the four writes, their order, and the idempotency on the lead;\n * the REST API's `POST /v1/leads/{id}/convert` is the other door onto the\n * same function (AGL-2627). What lives here is what only this door knows:\n * the ID token, the body the convert dialog posts, and the statuses and\n * sentences the dialog shows.\n *\n * ## What the caller has to be\n *\n * A signed-in member of the host's org holding `data.manage` — the same key\n * the console gate on the CRM surface enforces — with a role on this site.\n * Checked here with the Admin SDK rather than trusted from the client,\n * because this route writes into collections the caller's own rules would\n * otherwise decide about.\n */\n\nimport {\n CONTACT_ERASED_MESSAGE,\n CRM_RECORDS_BAND_FULL_MESSAGE,\n normalizeCompanyDomain,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport { firebaseAdmin, getOrgForHost } from '@aglyn/tenant-data-admin'\nimport {\n type ConvertHostLeadRefusal,\n convertHostLead,\n} from './convert-host-lead'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { holdsDataManage } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\n// The stage picker moved to the runtime with the writes; re-exported so the\n// pipeline code that imports it from here keeps its import.\nexport { stageForNewDeal } from './convert-host-lead'\n\n/** The body the console's convert dialog posts. */\nexport interface LeadConvertRequest {\n hostId: string\n /** `hosts/{hostId}/leads/{leadId}` — the document id, which is the person key. */\n leadId: string\n /**\n * Who owns the resulting contact (and deal). Defaults to the lead's own\n * owner; failing that the org's assignment rules and the site's default\n * owner decide (AGL-2618), and failing those the caller — somebody\n * converted this person, and a record with no owner is one nobody\n * follows up.\n */\n ownerUid?: string\n /** Link an existing `orgs/{orgId}/companies/{companyId}`. */\n companyId?: string\n /** Or create one. Ignored when `companyId` is given. */\n createCompany?: { name: string; domain?: string }\n /** Open a deal in the org's default pipeline. */\n deal?: {\n title: string\n amountCents?: number\n /** Lowercase ISO 4217; `usd` when absent or malformed. */\n currency?: string\n /** A stage of the default pipeline; its first open stage when absent. */\n stageId?: string\n }\n}\n\n/** What the route answers on success. */\nexport interface LeadConvertResponse {\n ok: true\n contactId: string\n companyId?: string\n dealId?: string\n /** The lead was converted before this call; nothing was created now. */\n alreadyConverted: boolean\n}\n\nconst COMPANY_NAME_MAX = 120\nconst DEAL_TITLE_MAX = 200\nconst CURRENCY_PATTERN = /^[a-z]{3}$/\n\n/**\n * How each refusal the writes can answer reads at this door — the status\n * and the sentence the convert dialog has always shown for it.\n */\nconst REFUSALS: Record<\n ConvertHostLeadRefusal,\n { status: number; body: Record<string, unknown> }\n> = {\n 'unknown-lead': { status: 404, body: { error: 'Unknown lead' } },\n 'no-email': {\n status: 422,\n body: { error: 'This lead has no usable email address to convert' },\n },\n 'contact-not-created': {\n status: 409,\n body: {\n error:\n 'The contact could not be created — the audience band may be ' +\n 'full. Nothing was changed.',\n },\n },\n 'band-full': {\n status: 409,\n body: { error: CRM_RECORDS_BAND_FULL_MESSAGE, reason: 'band' },\n },\n // The contact create route's own answer for an erased address (AGL-2623),\n // so the convert dialog and the add-contact drawer say one thing about\n // one person.\n erased: {\n status: 409,\n body: { error: CONTACT_ERASED_MESSAGE, reason: 'erased' },\n },\n 'unknown-company': { status: 404, body: { error: 'Unknown company' } },\n 'no-stages': {\n status: 409,\n body: { error: 'The default pipeline has no stages to open a deal in' },\n },\n}\n\nexport const leadConvertHandler: 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 authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n res.status(401).json({ error: 'Unauthenticated' })\n return\n }\n const body: Partial<LeadConvertRequest> =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '').trim()\n const leadId = String(body.leadId ?? '').trim()\n if (!hostId || !leadId) {\n res.status(400).json({ error: 'Missing hostId or leadId' })\n return\n }\n\n /*\n * Validate the optional parts BEFORE any read, so a malformed request costs\n * nothing and a bad company domain is refused rather than silently dropped\n * — a converter who typed a domain expects the company to carry it.\n */\n const requestedCompanyId = String(body.companyId ?? '').trim()\n let createCompany: { name: string; domain: string | null } | null = null\n if (!requestedCompanyId && body.createCompany) {\n const name = String(body.createCompany.name ?? '')\n .trim()\n .slice(0, COMPANY_NAME_MAX)\n if (!name) {\n res.status(400).json({ error: 'A company needs a name' })\n return\n }\n const rawDomain = String(body.createCompany.domain ?? '').trim()\n const domain = rawDomain ? normalizeCompanyDomain(rawDomain) : null\n if (rawDomain && !domain) {\n res.status(400).json({ error: 'That does not look like a domain' })\n return\n }\n createCompany = { name, domain }\n }\n let deal: {\n title: string\n amountCents: number | null\n currency: string\n stageId: string | undefined\n } | null = null\n if (body.deal) {\n const title = String(body.deal.title ?? '')\n .trim()\n .slice(0, DEAL_TITLE_MAX)\n if (!title) {\n res.status(400).json({ error: 'A deal needs a title' })\n return\n }\n const amount = Number(body.deal.amountCents)\n const currency = String(body.deal.currency ?? '')\n .trim()\n .toLowerCase()\n deal = {\n title,\n amountCents:\n Number.isFinite(amount) && amount >= 0 ? Math.round(amount) : null,\n currency: CURRENCY_PATTERN.test(currency) ? currency : 'usd',\n stageId: body.deal.stageId ? String(body.deal.stageId) : undefined,\n }\n }\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const staff = decoded['staff'] === true\n /*\n * The console's own gate on the CRM is `data.manage`, asked of the\n * permission catalog (`holdsDataManage`), and additionally that the\n * caller has a role on THIS site — an org member scoped to a sibling site\n * holds the key but not the host, and a lead is the host's record.\n * `resolveOrgPermissions` answers the site role and fails closed on a\n * lookup error when a host is named, so an absent membership refuses.\n */\n const membership = await resolveOrgPermissions(decoded.uid, { hostId })\n if (\n !staff &&\n !(membership.hostRole && (await holdsDataManage(membership.orgId, decoded.uid)))\n ) {\n res\n .status(403)\n .json({ error: 'Converting a lead requires the data permission on this site' })\n return\n }\n\n const resolved = await getOrgForHost(hostId)\n if (!resolved) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n const { orgId, org } = resolved\n // A lead's conversion is the CRM's, like its status and owner (AGL-2787).\n const suite = crmSuiteRefusal(org, 'Converting a lead')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n const result = await convertHostLead({\n firestore: firebaseAdmin.app().firestore(),\n hostId,\n orgId,\n org: org as Record<string, unknown>,\n leadId,\n actor: { uid: decoded.uid, email: decoded.email ?? null, kind: 'member' },\n ...(body.ownerUid ? { ownerUid: String(body.ownerUid) } : {}),\n ...(requestedCompanyId ? { companyId: requestedCompanyId } : {}),\n createCompany,\n deal,\n })\n if (result.ok === false) {\n const refusal = REFUSALS[result.reason]\n res.status(refusal.status).json(refusal.body)\n return\n }\n const answer: LeadConvertResponse = {\n ok: true,\n contactId: result.contactId,\n ...(result.companyId ? { companyId: result.companyId } : {}),\n ...(result.dealId ? { dealId: result.dealId } : {}),\n alreadyConverted: result.alreadyConverted,\n }\n res.status(200).json(answer)\n } catch (error) {\n console.error('lead-convert failed', error)\n res.status(500).json({ error: 'The lead could not be converted.' })\n }\n}\n\nexport default leadConvertHandler\n"],"names":["CONTACT_ERASED_MESSAGE","CRM_RECORDS_BAND_FULL_MESSAGE","normalizeCompanyDomain","firebaseAdmin","getOrgForHost","convertHostLead","resolveOrgPermissions","holdsDataManage","crmSuiteRefusal","stageForNewDeal","COMPANY_NAME_MAX","DEAL_TITLE_MAX","CURRENCY_PATTERN","REFUSALS","status","body","error","reason","erased","leadConvertHandler","req","res","method","setHeader","json","authorization","String","headers","idToken","startsWith","slice","length","undefined","JSON","parse","hostId","trim","leadId","requestedCompanyId","companyId","createCompany","name","rawDomain","domain","deal","title","amount","Number","amountCents","currency","toLowerCase","isFinite","Math","round","test","stageId","decoded","app","auth","verifyIdToken","staff","membership","uid","hostRole","orgId","resolved","org","suite","result","firestore","actor","email","kind","ownerUid","ok","refusal","answer","contactId","dealId","alreadyConverted","console"],"mappings":"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"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import type { HostActivityActor } from '@aglyn/aglyn/app-utils/activity-presenter';
|
|
18
|
+
export interface MergeContactsOptions {
|
|
19
|
+
firestore: FirebaseFirestore.Firestore;
|
|
20
|
+
/** `orgs/{orgId}` — the org whose contacts both records are. */
|
|
21
|
+
orgRef: FirebaseFirestore.DocumentReference;
|
|
22
|
+
/** The record that stays, and keeps its address as the identity. */
|
|
23
|
+
survivorId: string;
|
|
24
|
+
/** The record folded in and deleted. */
|
|
25
|
+
mergedId: string;
|
|
26
|
+
actor: HostActivityActor;
|
|
27
|
+
/**
|
|
28
|
+
* The site whose console did the work: where the activity feed entry
|
|
29
|
+
* lands, and the site a timeline note is filed under. `null` for a door
|
|
30
|
+
* with no site — the REST API — which logs no host entry.
|
|
31
|
+
*/
|
|
32
|
+
hostId: string | null;
|
|
33
|
+
/** The actor's display name for the timeline note, when the door knows it. */
|
|
34
|
+
actorName?: string | null;
|
|
35
|
+
}
|
|
36
|
+
export interface MergeContactsRepointed {
|
|
37
|
+
deals: number;
|
|
38
|
+
tasks: number;
|
|
39
|
+
activities: number;
|
|
40
|
+
leads: number;
|
|
41
|
+
}
|
|
42
|
+
export type MergeContactsResult = {
|
|
43
|
+
ok: true;
|
|
44
|
+
survivorId: string;
|
|
45
|
+
survivorEmail: string;
|
|
46
|
+
mergedId: string;
|
|
47
|
+
mergedEmail: string;
|
|
48
|
+
/** Every address the survivor answers to now, primary first. */
|
|
49
|
+
emails: string[];
|
|
50
|
+
repointed: MergeContactsRepointed;
|
|
51
|
+
} | {
|
|
52
|
+
ok: false;
|
|
53
|
+
reason: 'same-record' | 'survivor-missing' | 'merged-missing';
|
|
54
|
+
};
|
|
55
|
+
export declare function mergeContacts(options: MergeContactsOptions): Promise<MergeContactsResult>;
|