@aglyn/plugins-crm 1.0.0-beta.218 → 1.0.0-beta.220
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 -14
- 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-associations-card.js +4 -3
- package/src/lib/components/contact-associations-card.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-booking-zone.d.ts +8 -2
- package/src/lib/components/crm-booking-zone.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-glance-card.js +2 -1
- package/src/lib/components/crm-glance-card.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/deal-products-card.js +27 -16
- package/src/lib/components/deal-products-card.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.d.ts +4 -4
- package/src/lib/components/lead-campaigns-card.js +15 -8
- 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 +10 -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/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 +34 -11
- package/src/lib/components/leads-bulk-bar.js.map +1 -1
- package/src/lib/components/leads-section.js +41 -8
- 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/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/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/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/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 +41 -0
- package/src/lib/declarations.console-server.js +115 -0
- package/src/lib/declarations.console-server.js.map +1 -0
- package/src/lib/declarations.server.d.ts +12 -0
- package/src/lib/declarations.server.js +84 -1
- 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-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/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/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/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 +7 -6
- 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 +227 -0
- package/src/lib/model/crm-digest.js +336 -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-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-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-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/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 +5 -4
- package/src/lib/model/lead-filters.js +7 -5
- package/src/lib/model/lead-filters.js.map +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 +39 -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 +833 -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/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 +15 -4
- package/src/lib/server/capture-contact.js.map +1 -1
- 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-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-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 +4 -3
- 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/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/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-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/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 +101 -0
- package/src/lib/server/daily-digest-route.js +578 -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-address.js +5 -3
- package/src/lib/server/inbound-address.js.map +1 -1
- package/src/lib/server/inbound-route.d.ts +74 -0
- package/src/lib/server/inbound-route.js +260 -0
- package/src/lib/server/inbound-route.js.map +1 -0
- 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-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/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/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-email-state.js +23 -0
- package/src/lib/server/record-email-state.js.map +1 -1
- package/src/lib/server/record-facts.js +2 -1
- package/src/lib/server/record-facts.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.d.ts +82 -0
- package/src/lib/server/task-reminders-route.js +499 -0
- package/src/lib/server/task-reminders-route.js.map +1 -0
- 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/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 +75 -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
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/contacts-import.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/contacts-import` — one chunk of a contact file, written\n * (AGL-2602).\n *\n * The browser has already read the file and applied the operator's column\n * mapping; what arrives here is up to {@link CONTACT_IMPORT_CHUNK_SIZE} raw\n * rows in the field vocabulary `crm-import.ts` defines. This route judges\n * each one and writes it, and it is the ONLY place either happens: the\n * drawer shows a preview from the same mapper but stores nothing, so a\n * stale tab cannot store something the server would have refused.\n *\n * ## Every row goes through the door every capture goes through\n *\n * `captureHostContact` — the runtime's wrapper over `upsertHostContact`,\n * the one writer of the contacts collection for every door that is not the\n * v1 API: forms, checkout, bookings, the newsletter box — is the writer\n * here too, so a row this file creates raises `contactCreated` like any\n * other capture (AGL-2605). That is what makes an import\n * dedupe on the address the way a second form submission does, land in the\n * capturing group's facet the way a form capture does, record consent\n * against the capturing site, and stop at a hard band where a form\n * capture stops. A bulk path with its own `add()` would be the fastest way\n * to grow an unscoped, unbanded, unconsented copy of the address book.\n *\n * What the door did not know how to write until now — a phone, a title, a\n * company, an owner, a stage, custom values — it now takes as `facet`, and\n * what it did not say until now — created or merged or refused — it now\n * returns as a verdict. Both changes are the door's, so the v1 API and the\n * manual add can reach them next.\n *\n * ## Two lookups paid once per request, not once per row\n *\n * An owner is named by email in the file and stored by uid on the record,\n * so the org's roster is read once and every row resolves against it. A\n * company is named by name and stored by id, so each distinct name is\n * looked up once, created once when missing, and remembered for the rest\n * of the request — two hundred rows at one company is one read and at most\n * one write, not two hundred of each.\n *\n * ## Who may call it\n *\n * `resolveImportContext` (`import-context.ts`) — the two gates every CSV\n * import route asks, shared with the companies import so one permission\n * has one spelling.\n */\n\nimport {\n type ContactFieldDefinition,\n CRM_COLLECTIONS,\n crmNewRecordListFields,\n nameSearchFields,\n type PluginApiHandler,\n visibleToTokens,\n} from '@aglyn/aglyn/server'\nimport {\n CONTACT_IMPORT_CHUNK_SIZE,\n CONTACT_IMPORT_MAX_BODY_BYTES,\n type ContactImportChunkResult,\n type ContactImportRawRow,\n type ContactImportRow,\n type ContactImportSkippedRow,\n normalizeContactImportRow,\n} from '../model/crm-import'\nimport { crmRecordsQuotaForOrg, firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { captureHostContact } from '@aglyn/tenant-runtime'\n// The leaf, so a spec that stands a partial barrel in still reaches it.\nimport {\n countUndeliverableEmails,\n findUndeliverableEmails,\n IMPORT_MAIL_CHECK_GRACE_MS,\n settleWithin,\n} from '@aglyn/tenant-data-admin/server/capture-email-check'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n type ImportContext,\n ownerDirectory,\n readImportRows,\n resolveImportContext,\n} from './import-context'\n\n/** The one sentence every imported contact's timeline opens with. */\nexport const CONTACT_IMPORT_INTERACTION_SUMMARY = 'Imported from CSV'\n\n/**\n * The holder's live custom-field definitions.\n *\n * Read in full and filtered in memory: a holder has a handful of fields,\n * and a `where` on `retiredAt` would need an index for a collection that\n * fits in one page. Definitions the caller's scope cannot see are left out\n * for the same reason a value under an undefined key is — a value written\n * under a field the holder's own form will never show is a value nobody\n * can edit.\n */\nasync function loadFieldDefinitions(\n orgId: string,\n readTokens: readonly string[],\n): Promise<ContactFieldDefinition[]> {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.contactFields)\n .limit(200)\n .get()\n return snapshot.docs\n .map((doc) => doc.data() as ContactFieldDefinition)\n .filter(\n (field) =>\n !!field.key &&\n !field.retiredAt &&\n visibleToTokens(field.visibleTo, readTokens),\n )\n}\n\n/** A company an import row named, as the CRM stores it. */\ninterface ImportCompany {\n id: string\n name: string\n}\n\n/**\n * The company a name refers to in this scope, created when there is none.\n *\n * Looked up by `nameLower`, the search twin `nameSearchFields` writes on\n * every company, and narrowed in memory to the ones the caller's scope can\n * see — two clients of one agency may each have an \"Acme\" and must each get\n * their own. Created with the same scope stamp every CRM creator uses, so\n * the company lands exactly where a contact captured on this site would.\n */\nasync function resolveCompany(\n context: Extract<ImportContext, { ok: true }>,\n name: string,\n cache: Map<string, ImportCompany>,\n tally: { created: number },\n): Promise<ImportCompany | null> {\n const fields = nameSearchFields(name)\n const cached = cache.get(fields.nameLower)\n if (cached) return cached\n const orgRef = firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(context.orgId)\n const companies = orgRef.collection(CRM_COLLECTIONS.companies)\n const matches = await companies\n .where('nameLower', '==', fields.nameLower)\n .limit(10)\n .get()\n const seen = matches.docs.find((doc) =>\n visibleToTokens(doc.get('visibleTo'), context.readTokens),\n )\n if (seen) {\n // The name as the record spells it, not as this row typed it, so the\n // contact's facet reads the same as the company page.\n const found = { id: seen.id, name: String(seen.get('name') ?? name) }\n cache.set(fields.nameLower, found)\n return found\n }\n /*\n * THE RECORDS BAND (AGL-2611). A company is a record of the same band\n * the contact behind this row will meet at its own door, so on an org\n * at a full hard band the row gets no company rather than a company it\n * was not allowed to hold. Nothing is reported here: the contact is\n * refused `audience-band` a few lines on and the row is listed skipped,\n * which is the one message the operator needs. A plan with an overage\n * rate never reaches this branch — a rate is what makes the band meter\n * instead of refuse.\n */\n const room = await crmRecordsQuotaForOrg(context.org as never, orgRef)\n if (!room.allowed) return null\n const created = await companies.add({\n ...fields,\n hostId: context.hostId,\n visibleTo: context.scopeTokens,\n createdByUid: context.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n // What the Companies list searches and filters by (AGL-3321).\n ...crmNewRecordListFields('companies', { ...fields, visibleTo: context.scopeTokens }),\n })\n tally.created += 1\n const made = { id: created.id, name }\n cache.set(fields.nameLower, made)\n return made\n}\n\n/**\n * `POST crm/contacts-import` — `{ hostId, rows }` → a {@link ContactImportChunkResult}.\n *\n * Rows are written one after another rather than in parallel, because the\n * door's create path counts the collection before it adds: two hundred\n * creates racing one another would each count the same audience and each\n * pass a band the last of them should have hit.\n */\nexport const crmContactsImportHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const read = readImportRows<ContactImportRawRow>(req, {\n maxBodyBytes: CONTACT_IMPORT_MAX_BODY_BYTES,\n chunkSize: CONTACT_IMPORT_CHUNK_SIZE,\n })\n if ('error' in read) return res.status(read.status).json({ error: read.error })\n try {\n const context = await resolveImportContext(req)\n if (context.ok === false) return res.status(context.status).json(context.body)\n\n const fields = await loadFieldDefinitions(context.orgId, context.readTokens)\n const skipped: ContactImportSkippedRow[] = []\n const dropped: Record<string, number> = {}\n const normalized: { index: number; row: ContactImportRow }[] = []\n /*\n * A duplicate WITHIN the request is skipped rather than merged: the\n * second row would merge onto the first's record and the count would\n * read one created and one merged for one person in one file, which is\n * a number the operator cannot reconcile against the rows they sent.\n * Across requests the door's own dedupe answers, and reports a merge.\n */\n const seen = new Set<string>()\n read.rows.forEach((raw, index) => {\n const verdict = normalizeContactImportRow(raw, fields)\n if (verdict.ok === false) {\n skipped.push({ index, email: verdict.input, reason: verdict.reason })\n return\n }\n if (seen.has(verdict.row.email)) {\n skipped.push({ index, email: verdict.row.email, reason: 'duplicate' })\n return\n }\n seen.add(verdict.row.email)\n for (const entry of verdict.row.dropped) {\n dropped[entry.field] = (dropped[entry.field] ?? 0) + 1\n }\n normalized.push({ index, row: verdict.row })\n })\n\n // The addresses' mail servers (AGL-3328), asked while the rows are\n // written — see the leads import. The check `upsertHostContact` queues\n // stamps the records; this is the count the result states.\n const mailServers = findUndeliverableEmails(normalized.map((entry) => entry.row.email))\n const imported: string[] = []\n const owners = await ownerDirectory(\n context.orgId,\n normalized.map((entry) => entry.row),\n )\n const ownersUnresolved = new Set<string>()\n const companies = new Map<string, ImportCompany>()\n const companyTally = { created: 0 }\n let created = 0\n let merged = 0\n\n for (const { index, row } of normalized) {\n let ownerUid: string | undefined\n if (row.ownerEmail) {\n ownerUid = owners.get(row.ownerEmail)\n if (!ownerUid) ownersUnresolved.add(row.ownerEmail)\n }\n const company = row.companyName\n ? await resolveCompany(context, row.companyName, companies, companyTally)\n : null\n const verdict = await captureHostContact({\n hostId: context.hostId,\n email: row.email,\n ...(row.name ? { name: row.name } : {}),\n source: 'import',\n actor: { kind: 'member', uid: context.uid, email: context.email },\n interaction: { summary: CONTACT_IMPORT_INTERACTION_SUMMARY },\n marketingConsent: row.marketingConsent,\n tags: row.tags,\n facet: {\n ...(row.phone ? { phone: row.phone } : {}),\n ...(row.jobTitle ? { jobTitle: row.jobTitle } : {}),\n // The link AND the name: the merge fields read the facet's\n // `companyName`, and a link written alone renders as nothing.\n ...(company ? { companyId: company.id, companyName: company.name } : {}),\n ...(row.address ? { address: row.address } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(row.lifecycleStage ? { lifecycleStage: row.lifecycleStage } : {}),\n ...(Object.keys(row.custom).length ? { custom: row.custom } : {}),\n },\n // An import is the merchant's own act and files nobody under a\n // campaign; the picker on the profile is where that happens.\n campaignIds: [],\n })\n if ('refused' in verdict) {\n skipped.push({\n index,\n email: row.email,\n reason:\n verdict.refused === 'band'\n ? 'audience-band'\n : verdict.refused === 'invalid-email'\n ? 'invalid-email'\n : verdict.refused === 'erased'\n ? 'erased'\n : 'write-failed',\n })\n continue\n }\n imported.push(row.email)\n if (verdict.created) created += 1\n else merged += 1\n }\n const noMailServer = countUndeliverableEmails(\n imported,\n await settleWithin(mailServers, IMPORT_MAIL_CHECK_GRACE_MS),\n )\n\n const result: ContactImportChunkResult = {\n received: read.rows.length,\n created,\n merged,\n skipped: skipped.sort((a, b) => a.index - b.index),\n dropped,\n companiesCreated: companyTally.created,\n ownersUnresolved: [...ownersUnresolved],\n ...(noMailServer !== undefined ? { noMailServer } : {}),\n }\n return res.status(200).json(result)\n } catch (error) {\n console.error('crm/contacts-import failed', error)\n return res.status(500).json({ error: 'The import could not continue.' })\n }\n}\n"],"names":["CRM_COLLECTIONS","crmNewRecordListFields","nameSearchFields","visibleToTokens","CONTACT_IMPORT_CHUNK_SIZE","CONTACT_IMPORT_MAX_BODY_BYTES","normalizeContactImportRow","crmRecordsQuotaForOrg","firebaseAdmin","captureHostContact","countUndeliverableEmails","findUndeliverableEmails","IMPORT_MAIL_CHECK_GRACE_MS","settleWithin","FieldValue","ownerDirectory","readImportRows","resolveImportContext","CONTACT_IMPORT_INTERACTION_SUMMARY","loadFieldDefinitions","orgId","readTokens","snapshot","app","firestore","collection","doc","contactFields","limit","get","docs","map","data","filter","field","key","retiredAt","visibleTo","resolveCompany","context","name","cache","tally","fields","cached","nameLower","orgRef","companies","matches","where","seen","find","found","id","String","set","room","org","allowed","created","add","hostId","scopeTokens","createdByUid","uid","createdAt","serverTimestamp","updatedAt","made","crmContactsImportHandler","req","res","method","setHeader","status","json","error","read","maxBodyBytes","chunkSize","ok","body","skipped","dropped","normalized","Set","rows","forEach","raw","index","verdict","push","email","input","reason","has","row","entry","mailServers","imported","owners","ownersUnresolved","Map","companyTally","merged","ownerUid","ownerEmail","company","companyName","source","actor","kind","interaction","summary","marketingConsent","tags","facet","phone","jobTitle","companyId","address","lifecycleStage","Object","keys","custom","length","campaignIds","refused","noMailServer","result","received","sort","a","b","companiesCreated","undefined","console"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/contacts-import.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/contacts-import` — one chunk of a contact file, written\n * (AGL-2602).\n *\n * The browser has already read the file and applied the operator's column\n * mapping; what arrives here is up to {@link CONTACT_IMPORT_CHUNK_SIZE} raw\n * rows in the field vocabulary `crm-import.ts` defines. This route judges\n * each one and writes it, and it is the ONLY place either happens: the\n * drawer shows a preview from the same mapper but stores nothing, so a\n * stale tab cannot store something the server would have refused.\n *\n * ## Every row goes through the door every capture goes through\n *\n * `captureHostContact` — the runtime's wrapper over `upsertHostContact`,\n * the one writer of the contacts collection for every door that is not the\n * v1 API: forms, checkout, bookings, the newsletter box — is the writer\n * here too, so a row this file creates raises `contactCreated` like any\n * other capture (AGL-2605). That is what makes an import\n * dedupe on the address the way a second form submission does, land in the\n * capturing group's facet the way a form capture does, record consent\n * against the capturing site, and stop at a hard band where a form\n * capture stops. A bulk path with its own `add()` would be the fastest way\n * to grow an unscoped, unbanded, unconsented copy of the address book.\n *\n * What the door did not know how to write until now — a phone, a title, a\n * company, an owner, a stage, custom values — it now takes as `facet`, and\n * what it did not say until now — created or merged or refused — it now\n * returns as a verdict. Both changes are the door's, so the v1 API and the\n * manual add can reach them next.\n *\n * ## Every lookup paid once per request, not once per row (AGL-3423)\n *\n * A request has sixty seconds, and a row that reads for itself spends them\n * a round trip at a time: the door alone was a dozen serial reads and three\n * aggregate counts per new person, and a two-hundred-row chunk of an\n * Apollo export ran out of time before it answered. So nothing here is\n * read per row:\n *\n * - The door's own lookups — the site, the consent group, who each address\n * already is, who the site erased, the room in the records band — come\n * from one `prepareContactCaptureBatch` for the whole chunk, which every\n * row's capture is handed. The door still makes every decision.\n * - An owner is named by email and stored by uid, so the org's roster is\n * read once and every row resolves against it.\n * - A company is named by name and stored by id, so the distinct names are\n * looked up together, thirty to an `in` query, and the missing ones are\n * created in one batched write — an Apollo file names a different company\n * on nearly every row, and that was a read, a count and a write each.\n *\n * What is left per row is the row's own writes and what a new person sets\n * off, and those run {@link CONTACT_IMPORT_CONCURRENCY} at a time. A row is\n * started only inside {@link CONTACT_IMPORT_TIME_BUDGET_MS}; the rest are\n * reported `not-reached`, so a slow chunk answers with what it did instead\n * of being cut off at the limit with no answer at all.\n *\n * ## A retry is a merge, never a second person\n *\n * The door dedupes on the address, through the index and then the `email`\n * query, so a row that landed before a request was cut off is found by the\n * next attempt and merged into — the one-person-one-record rule a second\n * form submission meets. A company is found by its `nameLower` the same way.\n *\n * ## Who may call it\n *\n * `resolveImportContext` (`import-context.ts`) — the two gates every CSV\n * import route asks, shared with the companies import so one permission\n * has one spelling.\n */\n\nimport {\n type ContactFieldDefinition,\n CRM_COLLECTIONS,\n crmNewRecordListFields,\n nameSearchFields,\n type PluginApiHandler,\n visibleToTokens,\n} from '@aglyn/aglyn/server'\nimport {\n CONTACT_IMPORT_CHUNK_SIZE,\n CONTACT_IMPORT_MAX_BODY_BYTES,\n type ContactImportChunkResult,\n type ContactImportRawRow,\n type ContactImportRow,\n type ContactImportSkippedRow,\n normalizeContactImportRow,\n} from '../model/crm-import'\nimport {\n type ContactCaptureBatch,\n firebaseAdmin,\n prepareContactCaptureBatch,\n} from '@aglyn/tenant-data-admin'\nimport { captureHostContact } from '@aglyn/tenant-runtime'\n// The leaf, so a spec that stands a partial barrel in still reaches it.\nimport {\n countUndeliverableEmails,\n findUndeliverableEmails,\n IMPORT_MAIL_CHECK_GRACE_MS,\n settleWithin,\n} from '@aglyn/tenant-data-admin/server/capture-email-check'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n type ImportContext,\n ownerDirectory,\n readImportRows,\n resolveImportContext,\n} from './import-context'\n\n/** The one sentence every imported contact's timeline opens with. */\nexport const CONTACT_IMPORT_INTERACTION_SUMMARY = 'Imported from CSV'\n\n/**\n * How many rows are written at once. Enough to overlap the round trips a\n * row still pays; few enough that a chunk never has more writes in flight\n * than one client connection handles comfortably.\n */\nexport const CONTACT_IMPORT_CONCURRENCY = 8\n\n/**\n * How long after the request arrived a row may still be STARTED. The\n * function is stopped at sixty seconds; this leaves the rows already in\n * flight, and the mail-server check's grace, room to finish before then.\n */\nexport const CONTACT_IMPORT_TIME_BUDGET_MS = 40_000\n\n/** The most values one Firestore `in` filter may carry. */\nconst IN_FILTER_LIMIT = 30\n\n/** The most writes one batch may carry. */\nconst BATCH_WRITE_LIMIT = 500\n\n/**\n * The holder's live custom-field definitions.\n *\n * Read in full and filtered in memory: a holder has a handful of fields,\n * and a `where` on `retiredAt` would need an index for a collection that\n * fits in one page. Definitions the caller's scope cannot see are left out\n * for the same reason a value under an undefined key is — a value written\n * under a field the holder's own form will never show is a value nobody\n * can edit.\n */\nasync function loadFieldDefinitions(\n orgId: string,\n readTokens: readonly string[],\n): Promise<ContactFieldDefinition[]> {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.contactFields)\n .limit(200)\n .get()\n return snapshot.docs\n .map((doc) => doc.data() as ContactFieldDefinition)\n .filter(\n (field) =>\n !!field.key &&\n !field.retiredAt &&\n visibleToTokens(field.visibleTo, readTokens),\n )\n}\n\n/** A company an import row named, as the CRM stores it. */\ninterface ImportCompany {\n id: string\n name: string\n}\n\nfunction pagesOf<T>(items: readonly T[], size: number): T[][] {\n const out: T[][] = []\n for (let at = 0; at < items.length; at += size) out.push(items.slice(at, at + size))\n return out\n}\n\n/**\n * The company each row names, in this scope, with the missing ones created.\n *\n * Looked up by `nameLower`, the search twin `nameSearchFields` writes on\n * every company, thirty names to an `in` query and every page at once, and\n * narrowed in memory to the ones the caller's scope can see — two clients\n * of one agency may each have an \"Acme\" and must each get their own. A\n * name nobody in scope holds is created once for the request, with the\n * same scope stamp every CRM creator uses, so the company lands exactly\n * where a contact captured on this site would; the creates go out in one\n * batched write.\n *\n * THE RECORDS BAND (AGL-2611). A company is a record of the same band the\n * contact behind its row will meet at the door, so the band is walked in\n * file order — the row's company, then the row's contact when the address\n * is new — from the capture batch's one tally. On an org at a full hard\n * band the row gets no company rather than a company it was not allowed to\n * hold, and a later row's company never takes the room an earlier row's\n * contact needed. Nothing is reported here: the contact is refused\n * `audience-band` at its door and the row is listed skipped, which is the\n * one message the operator needs. A plan with an overage rate always\n * admits — a rate is what makes the band meter instead of refuse.\n *\n * Keyed by `nameLower`; a name absent from the map gets no company.\n */\nasync function resolveCompanies(\n context: Extract<ImportContext, { ok: true }>,\n capture: ContactCaptureBatch,\n rows: readonly { row: ContactImportRow }[],\n): Promise<{ companies: Map<string, ImportCompany>; created: number }> {\n const firestore = firebaseAdmin.app().firestore()\n const collection = firestore\n .collection('orgs')\n .doc(context.orgId)\n .collection(CRM_COLLECTIONS.companies)\n const named = new Map<string, ReturnType<typeof nameSearchFields>>()\n for (const { row } of rows) {\n if (!row.companyName) continue\n const fields = nameSearchFields(row.companyName)\n if (!named.has(fields.nameLower)) named.set(fields.nameLower, fields)\n }\n\n const companies = new Map<string, ImportCompany>()\n await Promise.all(\n pagesOf([...named.keys()], IN_FILTER_LIMIT).map(async (page) => {\n const matches = await collection.where('nameLower', 'in', page).get()\n for (const doc of matches.docs) {\n const nameLower = String(doc.get('nameLower') ?? '')\n if (!named.has(nameLower) || companies.has(nameLower)) continue\n if (!visibleToTokens(doc.get('visibleTo'), context.readTokens)) continue\n // The name as the record spells it, not as this row typed it, so the\n // contact's facet reads the same as the company page.\n companies.set(nameLower, {\n id: doc.id,\n name: String(doc.get('name') ?? named.get(nameLower)?.name ?? ''),\n })\n }\n }),\n )\n\n const creates: { ref: FirebaseFirestore.DocumentReference; record: Record<string, unknown> }[] = []\n for (const { row } of rows) {\n if (row.companyName) {\n const nameLower = nameSearchFields(row.companyName).nameLower\n if (!companies.has(nameLower) && capture.admit()) {\n const fields = named.get(nameLower) as ReturnType<typeof nameSearchFields>\n const ref = collection.doc()\n creates.push({\n ref,\n record: {\n ...fields,\n hostId: context.hostId,\n visibleTo: context.scopeTokens,\n createdByUid: context.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n // What the Companies list searches and filters by (AGL-3321).\n ...crmNewRecordListFields('companies', { ...fields, visibleTo: context.scopeTokens }),\n },\n })\n companies.set(nameLower, { id: ref.id, name: row.companyName })\n }\n }\n if (capture.peek(row.email) === null && capture.erased(row.email) !== true) {\n capture.reserve(row.email)\n }\n }\n for (const page of pagesOf(creates, BATCH_WRITE_LIMIT)) {\n const batch = firestore.batch()\n for (const { ref, record } of page) batch.set(ref, record)\n await batch.commit()\n }\n return { companies, created: creates.length }\n}\n\n/**\n * `POST crm/contacts-import` — `{ hostId, rows }` → a {@link ContactImportChunkResult}.\n *\n * Rows are written {@link CONTACT_IMPORT_CONCURRENCY} at a time, which the\n * band allows because it is no longer counted per row: the capture batch\n * counted once, and every create is admitted from its tally — reserved in\n * file order before any row is written, so which rows the band keeps does\n * not depend on which write happened to finish first.\n */\nexport const crmContactsImportHandler: PluginApiHandler = async (req, res) => {\n const startedAt = Date.now()\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const read = readImportRows<ContactImportRawRow>(req, {\n maxBodyBytes: CONTACT_IMPORT_MAX_BODY_BYTES,\n chunkSize: CONTACT_IMPORT_CHUNK_SIZE,\n })\n if ('error' in read) return res.status(read.status).json({ error: read.error })\n try {\n const context = await resolveImportContext(req)\n if (context.ok === false) return res.status(context.status).json(context.body)\n\n const fields = await loadFieldDefinitions(context.orgId, context.readTokens)\n const skipped: ContactImportSkippedRow[] = []\n const dropped: Record<string, number> = {}\n const normalized: { index: number; row: ContactImportRow }[] = []\n /*\n * A duplicate WITHIN the request is skipped rather than merged: the\n * second row would merge onto the first's record and the count would\n * read one created and one merged for one person in one file, which is\n * a number the operator cannot reconcile against the rows they sent.\n * Across requests the door's own dedupe answers, and reports a merge.\n */\n const seen = new Set<string>()\n read.rows.forEach((raw, index) => {\n const verdict = normalizeContactImportRow(raw, fields)\n if (verdict.ok === false) {\n skipped.push({ index, email: verdict.input, reason: verdict.reason })\n return\n }\n if (seen.has(verdict.row.email)) {\n skipped.push({ index, email: verdict.row.email, reason: 'duplicate' })\n return\n }\n seen.add(verdict.row.email)\n for (const entry of verdict.row.dropped) {\n dropped[entry.field] = (dropped[entry.field] ?? 0) + 1\n }\n normalized.push({ index, row: verdict.row })\n })\n\n // The addresses' mail servers (AGL-3328), asked while the rows are\n // written — see the leads import. The check `upsertHostContact` queues\n // stamps the records; this is the count the result states.\n const mailServers = findUndeliverableEmails(normalized.map((entry) => entry.row.email))\n const imported: string[] = []\n const [owners, capture] = await Promise.all([\n ownerDirectory(\n context.orgId,\n normalized.map((entry) => entry.row),\n ),\n prepareContactCaptureBatch(\n context.hostId,\n normalized.map((entry) => entry.row.email),\n ),\n ])\n const { companies, created: companiesCreated } = await resolveCompanies(\n context,\n capture,\n normalized,\n )\n const ownersUnresolved = new Set<string>()\n let created = 0\n let merged = 0\n\n const writeRow = async ({ index, row }: { index: number; row: ContactImportRow }) => {\n let ownerUid: string | undefined\n if (row.ownerEmail) {\n ownerUid = owners.get(row.ownerEmail)\n if (!ownerUid) ownersUnresolved.add(row.ownerEmail)\n }\n const company = row.companyName\n ? companies.get(nameSearchFields(row.companyName).nameLower)\n : undefined\n const verdict = await captureHostContact({\n hostId: context.hostId,\n email: row.email,\n ...(row.name ? { name: row.name } : {}),\n source: 'import',\n actor: { kind: 'member', uid: context.uid, email: context.email },\n interaction: { summary: CONTACT_IMPORT_INTERACTION_SUMMARY },\n marketingConsent: row.marketingConsent,\n tags: row.tags,\n facet: {\n ...(row.phone ? { phone: row.phone } : {}),\n ...(row.jobTitle ? { jobTitle: row.jobTitle } : {}),\n // The link AND the name: the merge fields read the facet's\n // `companyName`, and a link written alone renders as nothing.\n ...(company ? { companyId: company.id, companyName: company.name } : {}),\n ...(row.address ? { address: row.address } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(row.lifecycleStage ? { lifecycleStage: row.lifecycleStage } : {}),\n ...(Object.keys(row.custom).length ? { custom: row.custom } : {}),\n },\n // An import is the merchant's own act and files nobody under a\n // campaign; the picker on the profile is where that happens.\n campaignIds: [],\n batch: capture,\n })\n if ('refused' in verdict) {\n skipped.push({\n index,\n email: row.email,\n reason:\n verdict.refused === 'band'\n ? 'audience-band'\n : verdict.refused === 'invalid-email'\n ? 'invalid-email'\n : verdict.refused === 'erased'\n ? 'erased'\n : 'write-failed',\n })\n return\n }\n imported.push(row.email)\n if (verdict.created) created += 1\n else merged += 1\n }\n\n // A fixed pool drawing from one queue in file order; a row is started\n // only inside the budget, and whatever is left is reported, not dropped.\n let next = 0\n const worker = async () => {\n while (next < normalized.length) {\n if (Date.now() - startedAt >= CONTACT_IMPORT_TIME_BUDGET_MS) return\n const entry = normalized[next]\n next += 1\n await writeRow(entry)\n }\n }\n await Promise.all(\n Array.from(\n { length: Math.min(CONTACT_IMPORT_CONCURRENCY, normalized.length) },\n worker,\n ),\n )\n for (const { index, row } of normalized.slice(next)) {\n skipped.push({ index, email: row.email, reason: 'not-reached' })\n }\n\n const noMailServer = countUndeliverableEmails(\n imported,\n await settleWithin(mailServers, IMPORT_MAIL_CHECK_GRACE_MS),\n )\n\n const result: ContactImportChunkResult = {\n received: read.rows.length,\n created,\n merged,\n skipped: skipped.sort((a, b) => a.index - b.index),\n dropped,\n companiesCreated,\n ownersUnresolved: [...ownersUnresolved],\n ...(noMailServer !== undefined ? { noMailServer } : {}),\n }\n return res.status(200).json(result)\n } catch (error) {\n console.error('crm/contacts-import failed', error)\n return res.status(500).json({ error: 'The import could not continue.' })\n }\n}\n"],"names":["CRM_COLLECTIONS","crmNewRecordListFields","nameSearchFields","visibleToTokens","CONTACT_IMPORT_CHUNK_SIZE","CONTACT_IMPORT_MAX_BODY_BYTES","normalizeContactImportRow","firebaseAdmin","prepareContactCaptureBatch","captureHostContact","countUndeliverableEmails","findUndeliverableEmails","IMPORT_MAIL_CHECK_GRACE_MS","settleWithin","FieldValue","ownerDirectory","readImportRows","resolveImportContext","CONTACT_IMPORT_INTERACTION_SUMMARY","CONTACT_IMPORT_CONCURRENCY","CONTACT_IMPORT_TIME_BUDGET_MS","IN_FILTER_LIMIT","BATCH_WRITE_LIMIT","loadFieldDefinitions","orgId","readTokens","snapshot","app","firestore","collection","doc","contactFields","limit","get","docs","map","data","filter","field","key","retiredAt","visibleTo","pagesOf","items","size","out","at","length","push","slice","resolveCompanies","context","capture","rows","companies","named","Map","row","companyName","fields","has","nameLower","set","Promise","all","keys","page","matches","where","String","id","name","creates","admit","ref","record","hostId","scopeTokens","createdByUid","uid","createdAt","serverTimestamp","updatedAt","peek","email","erased","reserve","batch","commit","created","crmContactsImportHandler","req","res","startedAt","Date","now","method","setHeader","status","json","error","read","maxBodyBytes","chunkSize","ok","body","skipped","dropped","normalized","seen","Set","forEach","raw","index","verdict","input","reason","add","entry","mailServers","imported","owners","companiesCreated","ownersUnresolved","merged","writeRow","ownerUid","ownerEmail","company","undefined","source","actor","kind","interaction","summary","marketingConsent","tags","facet","phone","jobTitle","companyId","address","lifecycleStage","Object","custom","campaignIds","refused","next","worker","Array","from","Math","min","noMailServer","result","received","sort","a","b","console"],"mappings":"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"}
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* Body: `{ hostId, survivorId, mergedId }`. The survivor keeps its address
|
|
22
22
|
* as the identity; the merged record's address becomes an alternate on it,
|
|
23
23
|
* every deal, task, activity and lead naming the merged record is repointed,
|
|
24
|
-
* and the merged document is deleted. `mergeContacts`
|
|
24
|
+
* and the merged document is deleted. `mergeContacts` (`merge-contacts.ts`)
|
|
25
25
|
* is the whole of the work, so the REST door performs the same merge.
|
|
26
26
|
*
|
|
27
27
|
* ## Who may call it
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* Body: `{ hostId, survivorId, mergedId }`. The survivor keeps its address
|
|
21
21
|
* as the identity; the merged record's address becomes an alternate on it,
|
|
22
22
|
* every deal, task, activity and lead naming the merged record is repointed,
|
|
23
|
-
* and the merged document is deleted. `mergeContacts`
|
|
23
|
+
* and the merged document is deleted. `mergeContacts` (`merge-contacts.ts`)
|
|
24
24
|
* is the whole of the work, so the REST door performs the same merge.
|
|
25
25
|
*
|
|
26
26
|
* ## Who may call it
|
|
@@ -51,8 +51,9 @@
|
|
|
51
51
|
* the act logged in the org's feed. A site beside the org is the record's
|
|
52
52
|
* own, and is where the data library files the timeline note and the
|
|
53
53
|
* site's feed line when there is one.
|
|
54
|
-
*/ import { firebaseAdmin, getOrgForHost, logOrgActivity
|
|
54
|
+
*/ import { firebaseAdmin, getOrgForHost, logOrgActivity } from "@aglyn/tenant-data-admin";
|
|
55
55
|
import { resolveOrgPermissions } from "@aglyn/tenant-runtime/org-permissions";
|
|
56
|
+
import { mergeContacts } from "./merge-contacts.js";
|
|
56
57
|
import { authorizeOrgCaller, holdsDataManage, readCrmRouteScope } from "./org-caller.js";
|
|
57
58
|
import { crmSuiteRefusal } from "./suite-gate.js";
|
|
58
59
|
export const CONTACTS_MERGE_ROUTE = 'crm/contacts-merge';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/contacts-merge.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/contacts-merge` — two records for one person become one\n * (AGL-2625).\n *\n * Body: `{ hostId, survivorId, mergedId }`. The survivor keeps its address\n * as the identity; the merged record's address becomes an alternate on it,\n * every deal, task, activity and lead naming the merged record is repointed,\n * and the merged document is deleted. `mergeContacts` in the data library\n * is the whole of the work, so the REST door performs the same merge.\n *\n * ## Who may call it\n *\n * An ORG-WIDE member holding `data.manage`. Narrower than the other CRM\n * routes, which admit a site-scoped member for the site they reach, and\n * deliberately: a merge is org-scoped by nature. Contacts are org documents\n * shared by every site that captured the person, and folding two of them\n * together touches every holder's facet, every site's consent entry and\n * every deal in the org that named the merged record — a decision about\n * records a site-scoped member cannot read, made on their behalf. The\n * console offers the action to org-wide members only, and this is the door\n * that makes that a rule rather than a hint.\n *\n * ## Why a server route\n *\n * A browser could write the survivor and delete the merged record under the\n * rules, and could not do the rest: a scoped `array-contains-any` listener\n * cannot repoint deals it may not list, the address index is closed to\n * clients, and the transaction over both documents is what keeps two merges\n * of one pair from both succeeding.\n *\n * ## The organization variant (AGL-2634)\n *\n * `{ orgId, hostId?, survivorId, mergedId }` from the org-level hub. The\n * same org-wide caller, authorized by the org directly rather than through\n * a site it names — so a record no site captured can be merged there — and\n * the act logged in the org's feed. A site beside the org is the record's\n * own, and is where the data library files the timeline note and the\n * site's feed line when there is one.\n */\n\nimport type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport {\n firebaseAdmin,\n getOrgForHost,\n logOrgActivity,\n mergeContacts,\n} from '@aglyn/tenant-data-admin'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { authorizeOrgCaller, holdsDataManage, readCrmRouteScope } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\nexport const CONTACTS_MERGE_ROUTE = 'crm/contacts-merge'\n\n/** The body the console's merge dialog posts. */\nexport interface ContactsMergeRequest {\n /** The site the merge runs as; at the organization level the record's own, or absent. */\n hostId?: string\n /** The organization, at the organization level (AGL-2634). */\n orgId?: string\n /** `orgs/{orgId}/contacts/{id}` — the record that stays. */\n survivorId: string\n /** The record folded into it and deleted. */\n mergedId: string\n}\n\n/** What the route answers on success. */\nexport interface ContactsMergeResponse {\n ok: true\n survivorId: string\n survivorEmail: string\n mergedId: string\n mergedEmail: string\n /** Every address the survivor answers to now, primary first. */\n emails: string[]\n repointed: { deals: number; tasks: number; activities: number; leads: number }\n}\n\nfunction typed(value: unknown, max: number): string {\n return String(value ?? '')\n .trim()\n .slice(0, max)\n}\n\nexport const contactsMergeHandler: 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: Partial<ContactsMergeRequest> =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const routeScope = readCrmRouteScope(body as Record<string, unknown>)\n const survivorId = typed(body.survivorId, 200)\n const mergedId = typed(body.mergedId, 200)\n if (!routeScope || !survivorId || !mergedId) {\n res.status(400).json({ error: 'Missing hostId, survivorId or mergedId' })\n return\n }\n if (survivorId === mergedId) {\n res.status(400).json({ error: 'Pick two different contacts to merge' })\n return\n }\n const { hostId } = routeScope\n const refusal =\n 'Merging contacts requires the data permission across the whole workspace'\n\n try {\n let orgId: string\n let org: unknown\n let actor: { uid: string; email: string | null }\n let actorName: string | null\n let orgActor: typeof actor | null = null\n if (routeScope.level === 'org') {\n const caller = await authorizeOrgCaller(req, routeScope.orgId, {\n needs: 'data.manage',\n refusal,\n })\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n orgId = caller.orgId\n org = caller.org\n actor = { uid: caller.uid, email: caller.email }\n actorName = caller.name || null\n orgActor = actor\n } else {\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 decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const staff = decoded['staff'] === true\n const membership = await resolveOrgPermissions(decoded.uid, { hostId })\n if (\n !staff &&\n !(membership.orgWide && (await holdsDataManage(membership.orgId, decoded.uid)))\n ) {\n res.status(403).json({ error: refusal })\n return\n }\n const resolved = await getOrgForHost(hostId)\n if (!resolved) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n orgId = resolved.orgId\n org = resolved.org\n actor = { uid: decoded.uid, email: decoded.email ?? null }\n actorName = typeof decoded['name'] === 'string' ? decoded['name'] : null\n }\n // Merging is the CRM's at either level (AGL-2787).\n const suite = crmSuiteRefusal(org, 'Merging two contacts')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n const firestore = firebaseAdmin.app().firestore()\n const result = await mergeContacts({\n firestore,\n orgRef: firestore.collection('orgs').doc(orgId),\n survivorId,\n mergedId,\n actor,\n hostId: hostId || null,\n actorName,\n })\n if (result.ok === false) {\n if (result.reason === 'same-record') {\n res.status(400).json({ error: 'Pick two different contacts to merge' })\n return\n }\n res.status(404).json({\n error:\n result.reason === 'survivor-missing'\n ? 'The contact to keep could not be found'\n : 'The contact to merge could not be found — it may already have been merged',\n })\n return\n }\n // The org-level act, in the org's feed: the data library wrote the\n // site's line when a site was named, and the org's is this door's.\n if (orgActor) {\n await logOrgActivity(orgId, orgActor, `Merged with ${result.mergedEmail}`, {\n type: 'contact',\n id: result.survivorId,\n name: result.survivorEmail,\n })\n }\n const answer: ContactsMergeResponse = {\n ok: true,\n survivorId: result.survivorId,\n survivorEmail: result.survivorEmail,\n mergedId: result.mergedId,\n mergedEmail: result.mergedEmail,\n emails: result.emails,\n repointed: result.repointed,\n }\n res.status(200).json(answer)\n } catch (error) {\n console.error('[crm] contacts-merge failed', routeScope, survivorId, mergedId, error)\n res.status(500).json({ error: 'The contacts could not be merged.' })\n }\n}\n\nexport default contactsMergeHandler\n"],"names":["firebaseAdmin","getOrgForHost","logOrgActivity","mergeContacts","resolveOrgPermissions","authorizeOrgCaller","holdsDataManage","readCrmRouteScope","crmSuiteRefusal","CONTACTS_MERGE_ROUTE","typed","value","max","String","trim","slice","contactsMergeHandler","req","res","method","setHeader","status","json","error","body","JSON","parse","routeScope","survivorId","mergedId","hostId","refusal","orgId","org","actor","actorName","orgActor","level","caller","needs","ok","uid","email","name","decoded","authorization","headers","idToken","startsWith","length","undefined","app","auth","verifyIdToken","staff","membership","orgWide","resolved","suite","firestore","result","orgRef","collection","doc","reason","mergedEmail","type","id","survivorEmail","answer","emails","repointed","console"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/contacts-merge.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/contacts-merge` — two records for one person become one\n * (AGL-2625).\n *\n * Body: `{ hostId, survivorId, mergedId }`. The survivor keeps its address\n * as the identity; the merged record's address becomes an alternate on it,\n * every deal, task, activity and lead naming the merged record is repointed,\n * and the merged document is deleted. `mergeContacts` (`merge-contacts.ts`)\n * is the whole of the work, so the REST door performs the same merge.\n *\n * ## Who may call it\n *\n * An ORG-WIDE member holding `data.manage`. Narrower than the other CRM\n * routes, which admit a site-scoped member for the site they reach, and\n * deliberately: a merge is org-scoped by nature. Contacts are org documents\n * shared by every site that captured the person, and folding two of them\n * together touches every holder's facet, every site's consent entry and\n * every deal in the org that named the merged record — a decision about\n * records a site-scoped member cannot read, made on their behalf. The\n * console offers the action to org-wide members only, and this is the door\n * that makes that a rule rather than a hint.\n *\n * ## Why a server route\n *\n * A browser could write the survivor and delete the merged record under the\n * rules, and could not do the rest: a scoped `array-contains-any` listener\n * cannot repoint deals it may not list, the address index is closed to\n * clients, and the transaction over both documents is what keeps two merges\n * of one pair from both succeeding.\n *\n * ## The organization variant (AGL-2634)\n *\n * `{ orgId, hostId?, survivorId, mergedId }` from the org-level hub. The\n * same org-wide caller, authorized by the org directly rather than through\n * a site it names — so a record no site captured can be merged there — and\n * the act logged in the org's feed. A site beside the org is the record's\n * own, and is where the data library files the timeline note and the\n * site's feed line when there is one.\n */\n\nimport type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport { firebaseAdmin, getOrgForHost, logOrgActivity } from '@aglyn/tenant-data-admin'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { mergeContacts } from './merge-contacts'\nimport { authorizeOrgCaller, holdsDataManage, readCrmRouteScope } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\nexport const CONTACTS_MERGE_ROUTE = 'crm/contacts-merge'\n\n/** The body the console's merge dialog posts. */\nexport interface ContactsMergeRequest {\n /** The site the merge runs as; at the organization level the record's own, or absent. */\n hostId?: string\n /** The organization, at the organization level (AGL-2634). */\n orgId?: string\n /** `orgs/{orgId}/contacts/{id}` — the record that stays. */\n survivorId: string\n /** The record folded into it and deleted. */\n mergedId: string\n}\n\n/** What the route answers on success. */\nexport interface ContactsMergeResponse {\n ok: true\n survivorId: string\n survivorEmail: string\n mergedId: string\n mergedEmail: string\n /** Every address the survivor answers to now, primary first. */\n emails: string[]\n repointed: { deals: number; tasks: number; activities: number; leads: number }\n}\n\nfunction typed(value: unknown, max: number): string {\n return String(value ?? '')\n .trim()\n .slice(0, max)\n}\n\nexport const contactsMergeHandler: 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: Partial<ContactsMergeRequest> =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const routeScope = readCrmRouteScope(body as Record<string, unknown>)\n const survivorId = typed(body.survivorId, 200)\n const mergedId = typed(body.mergedId, 200)\n if (!routeScope || !survivorId || !mergedId) {\n res.status(400).json({ error: 'Missing hostId, survivorId or mergedId' })\n return\n }\n if (survivorId === mergedId) {\n res.status(400).json({ error: 'Pick two different contacts to merge' })\n return\n }\n const { hostId } = routeScope\n const refusal =\n 'Merging contacts requires the data permission across the whole workspace'\n\n try {\n let orgId: string\n let org: unknown\n let actor: { uid: string; email: string | null }\n let actorName: string | null\n let orgActor: typeof actor | null = null\n if (routeScope.level === 'org') {\n const caller = await authorizeOrgCaller(req, routeScope.orgId, {\n needs: 'data.manage',\n refusal,\n })\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n orgId = caller.orgId\n org = caller.org\n actor = { uid: caller.uid, email: caller.email }\n actorName = caller.name || null\n orgActor = actor\n } else {\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 decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const staff = decoded['staff'] === true\n const membership = await resolveOrgPermissions(decoded.uid, { hostId })\n if (\n !staff &&\n !(membership.orgWide && (await holdsDataManage(membership.orgId, decoded.uid)))\n ) {\n res.status(403).json({ error: refusal })\n return\n }\n const resolved = await getOrgForHost(hostId)\n if (!resolved) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n orgId = resolved.orgId\n org = resolved.org\n actor = { uid: decoded.uid, email: decoded.email ?? null }\n actorName = typeof decoded['name'] === 'string' ? decoded['name'] : null\n }\n // Merging is the CRM's at either level (AGL-2787).\n const suite = crmSuiteRefusal(org, 'Merging two contacts')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n const firestore = firebaseAdmin.app().firestore()\n const result = await mergeContacts({\n firestore,\n orgRef: firestore.collection('orgs').doc(orgId),\n survivorId,\n mergedId,\n actor,\n hostId: hostId || null,\n actorName,\n })\n if (result.ok === false) {\n if (result.reason === 'same-record') {\n res.status(400).json({ error: 'Pick two different contacts to merge' })\n return\n }\n res.status(404).json({\n error:\n result.reason === 'survivor-missing'\n ? 'The contact to keep could not be found'\n : 'The contact to merge could not be found — it may already have been merged',\n })\n return\n }\n // The org-level act, in the org's feed: the data library wrote the\n // site's line when a site was named, and the org's is this door's.\n if (orgActor) {\n await logOrgActivity(orgId, orgActor, `Merged with ${result.mergedEmail}`, {\n type: 'contact',\n id: result.survivorId,\n name: result.survivorEmail,\n })\n }\n const answer: ContactsMergeResponse = {\n ok: true,\n survivorId: result.survivorId,\n survivorEmail: result.survivorEmail,\n mergedId: result.mergedId,\n mergedEmail: result.mergedEmail,\n emails: result.emails,\n repointed: result.repointed,\n }\n res.status(200).json(answer)\n } catch (error) {\n console.error('[crm] contacts-merge failed', routeScope, survivorId, mergedId, error)\n res.status(500).json({ error: 'The contacts could not be merged.' })\n }\n}\n\nexport default contactsMergeHandler\n"],"names":["firebaseAdmin","getOrgForHost","logOrgActivity","resolveOrgPermissions","mergeContacts","authorizeOrgCaller","holdsDataManage","readCrmRouteScope","crmSuiteRefusal","CONTACTS_MERGE_ROUTE","typed","value","max","String","trim","slice","contactsMergeHandler","req","res","method","setHeader","status","json","error","body","JSON","parse","routeScope","survivorId","mergedId","hostId","refusal","orgId","org","actor","actorName","orgActor","level","caller","needs","ok","uid","email","name","decoded","authorization","headers","idToken","startsWith","length","undefined","app","auth","verifyIdToken","staff","membership","orgWide","resolved","suite","firestore","result","orgRef","collection","doc","reason","mergedEmail","type","id","survivorEmail","answer","emails","repointed","console"],"mappings":"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"}
|
|
@@ -0,0 +1,31 @@
|
|
|
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 { PluginMembershipDetacher } from '@aglyn/aglyn/plugin-manager/plugin-membership-detach';
|
|
18
|
+
export interface CrmContainerDetachDeps {
|
|
19
|
+
firestore(): FirebaseFirestore.Firestore;
|
|
20
|
+
/** The site's consent group: whose facet on a shared contact is the site's. */
|
|
21
|
+
consentGroupId(hostId: string): Promise<string>;
|
|
22
|
+
/** Restamps the list fields of leads just written, as each now stands. */
|
|
23
|
+
restampLeads(refs: readonly FirebaseFirestore.DocumentReference[]): Promise<unknown>;
|
|
24
|
+
}
|
|
25
|
+
/** The detacher, on the dependencies it is handed. */
|
|
26
|
+
export declare function createCrmContainerDetacher(deps: CrmContainerDetachDeps): PluginMembershipDetacher;
|
|
27
|
+
/**
|
|
28
|
+
* The detacher on the platform's Firestore, as the console registers it —
|
|
29
|
+
* this module is imported with the first removal, not at boot.
|
|
30
|
+
*/
|
|
31
|
+
export declare const crmContainerDetacher: PluginMembershipDetacher;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ import { containerMembershipField, contactContainerFieldPath } from "@aglyn/aglyn/app-utils/container-membership";
|
|
17
|
+
import { listPluginContainerKinds } from "@aglyn/aglyn/plugin-manager/plugin-containers";
|
|
18
|
+
import { restampCrmListFieldsOf } from "@aglyn/tenant-data-admin/server/crm-records";
|
|
19
|
+
import firebaseAdmin from "@aglyn/tenant-data-admin/server/firebase-admin";
|
|
20
|
+
import { consentGroupForSite } from "@aglyn/tenant-data-admin/server/organizations";
|
|
21
|
+
import { FieldValue } from "firebase-admin/firestore";
|
|
22
|
+
/**
|
|
23
|
+
* THE CRM'S SHARE OF TAKING A CONTAINER AWAY (AGL-3080).
|
|
24
|
+
*
|
|
25
|
+
* A lead is filed under a container by the container's id in its own
|
|
26
|
+
* `<kind>Ids`, at the top of its document: on the organization
|
|
27
|
+
* (`orgs/{orgId}/leads`), and on the site for the rows the lead migration has
|
|
28
|
+
* not reached (`hosts/{hostId}/leads`). A contact is shared by every site of
|
|
29
|
+
* the organization, so what one merchant filed a person under lives in that
|
|
30
|
+
* merchant's facet, and the field PATH names the site's consent group.
|
|
31
|
+
*
|
|
32
|
+
* The plugin that keeps a container cannot name either, so when it removes
|
|
33
|
+
* one it asks every plugin's membership detacher, once per site, with the
|
|
34
|
+
* container's id and its membership field. This clears the CRM's records
|
|
35
|
+
* with the walk the owner makes over its own: `array-contains` on the
|
|
36
|
+
* automatic single-field index, `arrayRemove` of the one id, in pages under
|
|
37
|
+
* the batch limit, bounded per request and reporting `remaining` so the
|
|
38
|
+
* removal holds the container for a second run. A lead's list fields are
|
|
39
|
+
* restamped as it now stands, because the Leads list's container filter
|
|
40
|
+
* reads them. The records themselves stay.
|
|
41
|
+
*
|
|
42
|
+
* Asked once per site, the organization's leads are walked on every call:
|
|
43
|
+
* after the first finds them, each later walk is one query that answers
|
|
44
|
+
* nothing. Two sites in one consent group share a facet the same way.
|
|
45
|
+
*
|
|
46
|
+
* Any declared container kind: the membership field names its kind
|
|
47
|
+
* (`<kind>Ids`), and a field no declared kind owns is not one a CRM record
|
|
48
|
+
* carries, so it is answered with nothing detached.
|
|
49
|
+
*/ /** Members detached in one write, under the 500-operation batch limit. */ const DETACH_BATCH = 400;
|
|
50
|
+
/** How many pages one request walks per collection. */ const DETACH_PASSES = 25;
|
|
51
|
+
async function detachFrom(collection, fieldPath, id, afterWrite) {
|
|
52
|
+
const firestore = collection.firestore;
|
|
53
|
+
let detached = 0;
|
|
54
|
+
for(let pass = 0; pass < DETACH_PASSES; pass += 1){
|
|
55
|
+
const page = await collection.where(fieldPath, 'array-contains', id).limit(DETACH_BATCH).get();
|
|
56
|
+
if (page.empty) return {
|
|
57
|
+
detached,
|
|
58
|
+
remaining: false
|
|
59
|
+
};
|
|
60
|
+
const batch = firestore.batch();
|
|
61
|
+
for (const member of page.docs){
|
|
62
|
+
batch.update(member.ref, {
|
|
63
|
+
[fieldPath]: FieldValue.arrayRemove(id)
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
await batch.commit();
|
|
67
|
+
await (afterWrite == null ? void 0 : afterWrite(page.docs.map((member)=>member.ref)));
|
|
68
|
+
detached += page.size;
|
|
69
|
+
if (page.size < DETACH_BATCH) return {
|
|
70
|
+
detached,
|
|
71
|
+
remaining: false
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
detached,
|
|
76
|
+
remaining: true
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/** The detacher, on the dependencies it is handed. */ export function createCrmContainerDetacher(deps) {
|
|
80
|
+
return async ({ hostId, orgId, field, id })=>{
|
|
81
|
+
var _listPluginContainerKinds_find;
|
|
82
|
+
const kind = (_listPluginContainerKinds_find = listPluginContainerKinds().find((declared)=>containerMembershipField(declared.kind) === field)) == null ? void 0 : _listPluginContainerKinds_find.kind;
|
|
83
|
+
if (!kind || !id) return {
|
|
84
|
+
detached: 0,
|
|
85
|
+
remaining: false
|
|
86
|
+
};
|
|
87
|
+
const firestore = deps.firestore();
|
|
88
|
+
const passes = [];
|
|
89
|
+
if (hostId) {
|
|
90
|
+
passes.push(await detachFrom(firestore.collection('hosts').doc(hostId).collection('leads'), field, id, deps.restampLeads));
|
|
91
|
+
}
|
|
92
|
+
if (orgId) {
|
|
93
|
+
const org = firestore.collection('orgs').doc(orgId);
|
|
94
|
+
passes.push(await detachFrom(org.collection('leads'), field, id, deps.restampLeads));
|
|
95
|
+
if (hostId) {
|
|
96
|
+
const groupId = await deps.consentGroupId(hostId);
|
|
97
|
+
passes.push(await detachFrom(org.collection('contacts'), contactContainerFieldPath(groupId, kind), id));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
detached: passes.reduce((sum, pass)=>sum + pass.detached, 0),
|
|
102
|
+
remaining: passes.some((pass)=>pass.remaining)
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The detacher on the platform's Firestore, as the console registers it —
|
|
108
|
+
* this module is imported with the first removal, not at boot.
|
|
109
|
+
*/ export const crmContainerDetacher = createCrmContainerDetacher({
|
|
110
|
+
firestore: ()=>firebaseAdmin.app().firestore(),
|
|
111
|
+
consentGroupId: async (hostId)=>(await consentGroupForSite(hostId)).groupId,
|
|
112
|
+
restampLeads: (refs)=>restampCrmListFieldsOf(refs, 'leads')
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
//# sourceMappingURL=container-detach.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/container-detach.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 containerMembershipField,\n contactContainerFieldPath,\n} from '@aglyn/aglyn/app-utils/container-membership'\nimport { listPluginContainerKinds } from '@aglyn/aglyn/plugin-manager/plugin-containers'\nimport type {\n PluginMembershipDetacher,\n PluginMembershipDetachReport,\n} from '@aglyn/aglyn/plugin-manager/plugin-membership-detach'\nimport { restampCrmListFieldsOf } from '@aglyn/tenant-data-admin/server/crm-records'\nimport firebaseAdmin from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { consentGroupForSite } from '@aglyn/tenant-data-admin/server/organizations'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/**\n * THE CRM'S SHARE OF TAKING A CONTAINER AWAY (AGL-3080).\n *\n * A lead is filed under a container by the container's id in its own\n * `<kind>Ids`, at the top of its document: on the organization\n * (`orgs/{orgId}/leads`), and on the site for the rows the lead migration has\n * not reached (`hosts/{hostId}/leads`). A contact is shared by every site of\n * the organization, so what one merchant filed a person under lives in that\n * merchant's facet, and the field PATH names the site's consent group.\n *\n * The plugin that keeps a container cannot name either, so when it removes\n * one it asks every plugin's membership detacher, once per site, with the\n * container's id and its membership field. This clears the CRM's records\n * with the walk the owner makes over its own: `array-contains` on the\n * automatic single-field index, `arrayRemove` of the one id, in pages under\n * the batch limit, bounded per request and reporting `remaining` so the\n * removal holds the container for a second run. A lead's list fields are\n * restamped as it now stands, because the Leads list's container filter\n * reads them. The records themselves stay.\n *\n * Asked once per site, the organization's leads are walked on every call:\n * after the first finds them, each later walk is one query that answers\n * nothing. Two sites in one consent group share a facet the same way.\n *\n * Any declared container kind: the membership field names its kind\n * (`<kind>Ids`), and a field no declared kind owns is not one a CRM record\n * carries, so it is answered with nothing detached.\n */\n\n/** Members detached in one write, under the 500-operation batch limit. */\nconst DETACH_BATCH = 400\n\n/** How many pages one request walks per collection. */\nconst DETACH_PASSES = 25\n\nexport interface CrmContainerDetachDeps {\n firestore(): FirebaseFirestore.Firestore\n /** The site's consent group: whose facet on a shared contact is the site's. */\n consentGroupId(hostId: string): Promise<string>\n /** Restamps the list fields of leads just written, as each now stands. */\n restampLeads(refs: readonly FirebaseFirestore.DocumentReference[]): Promise<unknown>\n}\n\nasync function detachFrom(\n collection: FirebaseFirestore.CollectionReference,\n fieldPath: string,\n id: string,\n afterWrite?: (refs: FirebaseFirestore.DocumentReference[]) => Promise<unknown>,\n): Promise<PluginMembershipDetachReport> {\n const firestore = collection.firestore\n let detached = 0\n for (let pass = 0; pass < DETACH_PASSES; pass += 1) {\n const page = await collection.where(fieldPath, 'array-contains', id).limit(DETACH_BATCH).get()\n if (page.empty) return { detached, remaining: false }\n const batch = firestore.batch()\n for (const member of page.docs) {\n batch.update(member.ref, { [fieldPath]: FieldValue.arrayRemove(id) })\n }\n await batch.commit()\n await afterWrite?.(page.docs.map((member) => member.ref))\n detached += page.size\n if (page.size < DETACH_BATCH) return { detached, remaining: false }\n }\n return { detached, remaining: true }\n}\n\n/** The detacher, on the dependencies it is handed. */\nexport function createCrmContainerDetacher(deps: CrmContainerDetachDeps): PluginMembershipDetacher {\n return async ({ hostId, orgId, field, id }) => {\n const kind = listPluginContainerKinds().find(\n (declared) => containerMembershipField(declared.kind) === field,\n )?.kind\n if (!kind || !id) return { detached: 0, remaining: false }\n const firestore = deps.firestore()\n const passes: PluginMembershipDetachReport[] = []\n if (hostId) {\n passes.push(\n await detachFrom(\n firestore.collection('hosts').doc(hostId).collection('leads'),\n field,\n id,\n deps.restampLeads,\n ),\n )\n }\n if (orgId) {\n const org = firestore.collection('orgs').doc(orgId)\n passes.push(await detachFrom(org.collection('leads'), field, id, deps.restampLeads))\n if (hostId) {\n const groupId = await deps.consentGroupId(hostId)\n passes.push(\n await detachFrom(org.collection('contacts'), contactContainerFieldPath(groupId, kind), id),\n )\n }\n }\n return {\n detached: passes.reduce((sum, pass) => sum + pass.detached, 0),\n remaining: passes.some((pass) => pass.remaining),\n }\n }\n}\n\n/**\n * The detacher on the platform's Firestore, as the console registers it —\n * this module is imported with the first removal, not at boot.\n */\nexport const crmContainerDetacher: PluginMembershipDetacher = createCrmContainerDetacher({\n firestore: () => firebaseAdmin.app().firestore(),\n consentGroupId: async (hostId) => (await consentGroupForSite(hostId)).groupId,\n restampLeads: (refs) => restampCrmListFieldsOf(refs, 'leads'),\n})\n"],"names":["containerMembershipField","contactContainerFieldPath","listPluginContainerKinds","restampCrmListFieldsOf","firebaseAdmin","consentGroupForSite","FieldValue","DETACH_BATCH","DETACH_PASSES","detachFrom","collection","fieldPath","id","afterWrite","firestore","detached","pass","page","where","limit","get","empty","remaining","batch","member","docs","update","ref","arrayRemove","commit","map","size","createCrmContainerDetacher","deps","hostId","orgId","field","kind","find","declared","passes","push","doc","restampLeads","org","groupId","consentGroupId","reduce","sum","some","crmContainerDetacher","app","refs"],"mappings":"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"}
|
|
@@ -0,0 +1,189 @@
|
|
|
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
|
+
/**
|
|
18
|
+
* A lead becomes a contact, and optionally a company and a deal (AGL-2608,
|
|
19
|
+
* AGL-2627).
|
|
20
|
+
*
|
|
21
|
+
* ## Why one function under two doors
|
|
22
|
+
*
|
|
23
|
+
* The console's convert dialog posts to the CRM plugin's `crm/lead-convert`
|
|
24
|
+
* route, and an integration posts to `POST /v1/leads/{id}/convert`. The two
|
|
25
|
+
* doors authenticate differently — an ID token against the site's role, an
|
|
26
|
+
* API key against its scopes — and answer in different envelopes, but what
|
|
27
|
+
* they DO has to be one thing: a conversion that opened a second deal for
|
|
28
|
+
* one person because two callers reached the same lead through two
|
|
29
|
+
* implementations is the bug this file exists to make impossible. The doors
|
|
30
|
+
* own their transport; this owns the writes.
|
|
31
|
+
*
|
|
32
|
+
* It lives here, in the tenancy runtime, rather than beside the plugin
|
|
33
|
+
* route it came from, because the console's REST layer may not import a
|
|
34
|
+
* feature plugin (the app→addons boundary), while both the plugin and the
|
|
35
|
+
* console may import the runtime — which already holds the capture door
|
|
36
|
+
* and the owner assignment this conversion goes through.
|
|
37
|
+
*
|
|
38
|
+
* ## Why a server function and not four client writes
|
|
39
|
+
*
|
|
40
|
+
* The lead is a host document the browser may edit, and a company or a deal
|
|
41
|
+
* is a client-creatable org document. What the browser cannot do is CREATE A
|
|
42
|
+
* CONTACT: the contacts collection is written only by `captureHostContact`
|
|
43
|
+
* (the runtime's wrapper over `upsertHostContact`, so `contactCreated`
|
|
44
|
+
* fires), because that function is the dedupe — one human, one row, found by
|
|
45
|
+
* normalized address across every site in the org — and the audience-band
|
|
46
|
+
* gate. A client that wrote a contact document of its own would mint a
|
|
47
|
+
* second row for a person the org already holds, and would do it past the
|
|
48
|
+
* band.
|
|
49
|
+
*
|
|
50
|
+
* Once the contact has to come from the server, the rest follows it: the
|
|
51
|
+
* conversion is one act with four writes, and a caller that made three of
|
|
52
|
+
* them and lost the connection before the fourth leaves a lead marked
|
|
53
|
+
* qualified with no contact, or a deal pointing at a contact that was never
|
|
54
|
+
* stamped on the lead. Here the order is fixed — contact, then company, then
|
|
55
|
+
* deal, then the lead — and the lead is stamped LAST, so a lead that carries
|
|
56
|
+
* `convertedContactId` names a contact that exists.
|
|
57
|
+
*
|
|
58
|
+
* ## Idempotent on the lead
|
|
59
|
+
*
|
|
60
|
+
* A lead already carrying `convertedContactId` answers with the ids it has
|
|
61
|
+
* and creates nothing more. A double-click on the dialog's button, a retry
|
|
62
|
+
* after a slow response, or a Zapier zap replaying a step must not open a
|
|
63
|
+
* second deal for one person.
|
|
64
|
+
*
|
|
65
|
+
* ## The actor
|
|
66
|
+
*
|
|
67
|
+
* A signed-in member converting from the console owns what nobody else
|
|
68
|
+
* claimed: their uid is the owner of last resort, written directly rather
|
|
69
|
+
* than through the deliberate reassignment, because they may be staff
|
|
70
|
+
* converting on a workspace's behalf and a roster check would refuse the one
|
|
71
|
+
* person who is actually here. An API key is not a person and is on no
|
|
72
|
+
* roster, so for an `api` actor that last step is skipped and a contact
|
|
73
|
+
* nobody chose an owner for — and no rule assigned — stays unowned, which
|
|
74
|
+
* the console renders honestly as unassigned. Either way the caller has
|
|
75
|
+
* already been authorized by its door; nothing here asks again.
|
|
76
|
+
*/
|
|
77
|
+
import { type CrmDealStage, type CrmPipeline } from '@aglyn/aglyn/server';
|
|
78
|
+
/** Who is converting, as far as the writes need to know. */
|
|
79
|
+
export interface LeadConvertActor {
|
|
80
|
+
/**
|
|
81
|
+
* What `createdByUid` records on every record the conversion opens, and
|
|
82
|
+
* who the audit line names. A member's uid, or the literal `'api'` for a
|
|
83
|
+
* key — the same attribution the REST creates stamp.
|
|
84
|
+
*/
|
|
85
|
+
uid: string;
|
|
86
|
+
email?: string | null;
|
|
87
|
+
/**
|
|
88
|
+
* `member`: a signed-in person, who becomes the owner when nobody else
|
|
89
|
+
* does. `api`: a key, which cannot own a record — see the header.
|
|
90
|
+
*/
|
|
91
|
+
kind: 'member' | 'api';
|
|
92
|
+
/**
|
|
93
|
+
* For an `api` actor, the key's name — what the audit line attributes the
|
|
94
|
+
* conversion to, since a key has no address to be named by.
|
|
95
|
+
*/
|
|
96
|
+
apiKeyName?: string | null;
|
|
97
|
+
}
|
|
98
|
+
/** What a conversion is asked to do. Validated by the door before it gets here. */
|
|
99
|
+
export interface ConvertHostLeadInput {
|
|
100
|
+
firestore: FirebaseFirestore.Firestore;
|
|
101
|
+
hostId: string;
|
|
102
|
+
orgId: string;
|
|
103
|
+
/** The org document, as the door resolved it. */
|
|
104
|
+
org: Record<string, unknown>;
|
|
105
|
+
/** `hosts/{hostId}/leads/{leadId}` — the document id, which is the person key. */
|
|
106
|
+
leadId: string;
|
|
107
|
+
actor: LeadConvertActor;
|
|
108
|
+
/**
|
|
109
|
+
* Who owns the resulting contact (and deal). Defaults to the lead's own
|
|
110
|
+
* owner; failing that the org's assignment rules and the site's default
|
|
111
|
+
* owner decide (AGL-2618), and failing those a member actor — somebody
|
|
112
|
+
* converted this person, and a record with no owner is one nobody follows
|
|
113
|
+
* up.
|
|
114
|
+
*/
|
|
115
|
+
ownerUid?: string;
|
|
116
|
+
/** Link an existing `orgs/{orgId}/companies/{companyId}`. */
|
|
117
|
+
companyId?: string;
|
|
118
|
+
/** Or create one. Ignored when `companyId` is given. Already normalized. */
|
|
119
|
+
createCompany?: {
|
|
120
|
+
name: string;
|
|
121
|
+
domain: string | null;
|
|
122
|
+
} | null;
|
|
123
|
+
/** Open a deal in the org's default pipeline. Already normalized. */
|
|
124
|
+
deal?: {
|
|
125
|
+
title: string;
|
|
126
|
+
amountCents: number | null;
|
|
127
|
+
/** Lowercase ISO 4217. */
|
|
128
|
+
currency: string;
|
|
129
|
+
/** A stage of the default pipeline; its first open stage when absent. */
|
|
130
|
+
stageId?: string;
|
|
131
|
+
} | null;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Why a conversion did not happen. Each is a state of the data, not a fault
|
|
135
|
+
* of the request — the door has already refused a malformed body — and each
|
|
136
|
+
* leaves the lead unconverted so a retry after the remedy finds it where it
|
|
137
|
+
* was.
|
|
138
|
+
*/
|
|
139
|
+
export type ConvertHostLeadRefusal =
|
|
140
|
+
/** No document at `hosts/{hostId}/leads/{leadId}`. */
|
|
141
|
+
'unknown-lead'
|
|
142
|
+
/** The lead's address cannot become a contact. */
|
|
143
|
+
| 'no-email'
|
|
144
|
+
/**
|
|
145
|
+
* The capture door produced no contact — it swallows its own failures and
|
|
146
|
+
* drops a creation past a Free org's audience band. Nothing was written.
|
|
147
|
+
*/
|
|
148
|
+
| 'contact-not-created'
|
|
149
|
+
/**
|
|
150
|
+
* The site holds an erasure for the lead's address (AGL-2623): the person
|
|
151
|
+
* asked to be forgotten, and the capture door refuses to rebuild a record
|
|
152
|
+
* for them — by a conversion as by a form. Nothing was written, and the
|
|
153
|
+
* lead stays as it was; the erasure removes it when it runs.
|
|
154
|
+
*/
|
|
155
|
+
| 'erased'
|
|
156
|
+
/** The CRM records band is full (AGL-2611); the contact stands, nothing more was opened. */
|
|
157
|
+
| 'band-full'
|
|
158
|
+
/** `companyId` names no company in this organization. The contact stands. */
|
|
159
|
+
| 'unknown-company'
|
|
160
|
+
/** The default pipeline has no stages to open a deal in. The contact stands. */
|
|
161
|
+
| 'no-stages';
|
|
162
|
+
export type ConvertHostLeadResult = {
|
|
163
|
+
ok: true;
|
|
164
|
+
contactId: string;
|
|
165
|
+
companyId?: string;
|
|
166
|
+
dealId?: string;
|
|
167
|
+
/** The lead was converted before this call; nothing was created now. */
|
|
168
|
+
alreadyConverted: boolean;
|
|
169
|
+
} | {
|
|
170
|
+
ok: false;
|
|
171
|
+
reason: ConvertHostLeadRefusal;
|
|
172
|
+
};
|
|
173
|
+
/**
|
|
174
|
+
* The stage a new deal opens in.
|
|
175
|
+
*
|
|
176
|
+
* The caller's choice when the pipeline has it, otherwise the first OPEN
|
|
177
|
+
* stage by order — a deal created by converting a lead is by definition not
|
|
178
|
+
* yet won or lost, so defaulting to a closed stage would record an outcome
|
|
179
|
+
* nobody reached. A pipeline with no open stage at all falls back to its
|
|
180
|
+
* first stage rather than refusing: the merchant edited their pipeline into
|
|
181
|
+
* that shape, and a conversion that failed because of it would read as a bug
|
|
182
|
+
* in the lead.
|
|
183
|
+
*/
|
|
184
|
+
export declare function stageForNewDeal(pipeline: Pick<CrmPipeline, 'stages'>, requestedStageId: string | undefined): CrmDealStage | null;
|
|
185
|
+
/**
|
|
186
|
+
* Convert one lead. Throws only on infrastructure failure; every refusal
|
|
187
|
+
* the data can produce is a `ConvertHostLeadResult`.
|
|
188
|
+
*/
|
|
189
|
+
export declare function convertHostLead(input: ConvertHostLeadInput): Promise<ConvertHostLeadResult>;
|