@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
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import type { HostActivityActor } from '@aglyn/aglyn/app-utils/activity-presenter';
|
|
18
|
+
export interface MergeContactsOptions {
|
|
19
|
+
firestore: FirebaseFirestore.Firestore;
|
|
20
|
+
/** `orgs/{orgId}` — the org whose contacts both records are. */
|
|
21
|
+
orgRef: FirebaseFirestore.DocumentReference;
|
|
22
|
+
/** The record that stays, and keeps its address as the identity. */
|
|
23
|
+
survivorId: string;
|
|
24
|
+
/** The record folded in and deleted. */
|
|
25
|
+
mergedId: string;
|
|
26
|
+
actor: HostActivityActor;
|
|
27
|
+
/**
|
|
28
|
+
* The site whose console did the work: where the activity feed entry
|
|
29
|
+
* lands, and the site a timeline note is filed under. `null` for a door
|
|
30
|
+
* with no site — the REST API — which logs no host entry.
|
|
31
|
+
*/
|
|
32
|
+
hostId: string | null;
|
|
33
|
+
/** The actor's display name for the timeline note, when the door knows it. */
|
|
34
|
+
actorName?: string | null;
|
|
35
|
+
}
|
|
36
|
+
export interface MergeContactsRepointed {
|
|
37
|
+
deals: number;
|
|
38
|
+
tasks: number;
|
|
39
|
+
activities: number;
|
|
40
|
+
leads: number;
|
|
41
|
+
}
|
|
42
|
+
export type MergeContactsResult = {
|
|
43
|
+
ok: true;
|
|
44
|
+
survivorId: string;
|
|
45
|
+
survivorEmail: string;
|
|
46
|
+
mergedId: string;
|
|
47
|
+
mergedEmail: string;
|
|
48
|
+
/** Every address the survivor answers to now, primary first. */
|
|
49
|
+
emails: string[];
|
|
50
|
+
repointed: MergeContactsRepointed;
|
|
51
|
+
} | {
|
|
52
|
+
ok: false;
|
|
53
|
+
reason: 'same-record' | 'survivor-missing' | 'merged-missing';
|
|
54
|
+
};
|
|
55
|
+
export declare function mergeContacts(options: MergeContactsOptions): Promise<MergeContactsResult>;
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ /**
|
|
18
|
+
* MERGING TWO CONTACTS — the write (AGL-2625).
|
|
19
|
+
*
|
|
20
|
+
* `planContactMerge` says what the survivor becomes; this performs it, and
|
|
21
|
+
* moves everything that pointed at the merged record. One function behind
|
|
22
|
+
* two doors — the console's `crm/contacts-merge` route and
|
|
23
|
+
* `POST /v1/contacts/{id}/merge` — so the REST caller and the record page
|
|
24
|
+
* cannot merge two different ways.
|
|
25
|
+
*
|
|
26
|
+
* ## The order is the crash-safety argument
|
|
27
|
+
*
|
|
28
|
+
* 1. Every deal, task and activity naming the merged contact is repointed
|
|
29
|
+
* at the survivor, and every lead converted into it. Batched, bounded,
|
|
30
|
+
* idempotent: a re-run finds nothing left to move.
|
|
31
|
+
* 2. Then, in ONE transaction: the survivor takes the plan, every address
|
|
32
|
+
* it now answers to is indexed at it, and the merged document is
|
|
33
|
+
* deleted — with both records re-read inside the transaction, so two
|
|
34
|
+
* merges racing on one pair cannot both succeed and a plan is never
|
|
35
|
+
* computed on a document that changed under it.
|
|
36
|
+
*
|
|
37
|
+
* Children first because a process that dies between the two steps leaves
|
|
38
|
+
* children pointing at a record that EXISTS — the survivor — while the merged
|
|
39
|
+
* document is still there for a retry to fold in. The other order would
|
|
40
|
+
* leave deals pointing at a deleted contact, which is a record nobody can
|
|
41
|
+
* open from anywhere.
|
|
42
|
+
*
|
|
43
|
+
* ## What it does not move
|
|
44
|
+
*
|
|
45
|
+
* List memberships, suppression entries and the delivery log are keyed by
|
|
46
|
+
* ADDRESS, not by contact id, and stay under the merged address; a capture
|
|
47
|
+
* on that address resolves to the survivor through the index, which is the
|
|
48
|
+
* fact those keys need. A company's contacts count drops only for a company
|
|
49
|
+
* both records named — the survivor still names the rest.
|
|
50
|
+
*/ import { CONTACT_EMAIL_INDEX_COLLECTION, contactEmails } from "@aglyn/aglyn/app-utils/contacts";
|
|
51
|
+
import { planContactMerge } from "../model/contact-merge.js";
|
|
52
|
+
import { CRM_COLLECTIONS } from "@aglyn/aglyn/app-utils/crm";
|
|
53
|
+
import { personKey } from "@aglyn/aglyn/app-utils/person-key";
|
|
54
|
+
import { FieldValue } from "firebase-admin/firestore";
|
|
55
|
+
import { settleCompanyContactsCounts } from "@aglyn/tenant-data-admin/server/contact-company-link";
|
|
56
|
+
import { recomputeCrmNextTaskAt } from "@aglyn/tenant-data-admin/server/crm-next-activity";
|
|
57
|
+
import { restampCrmListFieldsAt } from "@aglyn/tenant-data-admin/server/crm-records";
|
|
58
|
+
import { logHostActivity } from "@aglyn/tenant-data-admin/server/organizations";
|
|
59
|
+
/** How many pointing rows one repoint pass reads — and one batch writes. */ const REPOINT_PAGE = 400;
|
|
60
|
+
/** A ceiling on passes, so a query that keeps answering cannot loop forever. */ const REPOINT_PASSES = 25;
|
|
61
|
+
/**
|
|
62
|
+
* Point every row of `collection` whose `contactId` is `from` at `to`.
|
|
63
|
+
*
|
|
64
|
+
* Page, batch, repeat: each pass reads the rows still pointing at the
|
|
65
|
+
* merged record — the ones a previous pass moved no longer match — so the
|
|
66
|
+
* loop ends when a pass finds nothing. `updatedAt` moves with the pointer
|
|
67
|
+
* because an integration syncing by `updatedAfter` has to see the change.
|
|
68
|
+
*/ async function repointContactId(firestore, collection, from, to) {
|
|
69
|
+
let moved = 0;
|
|
70
|
+
for(let pass = 0; pass < REPOINT_PASSES; pass += 1){
|
|
71
|
+
const page = await collection.where('contactId', '==', from).limit(REPOINT_PAGE).get();
|
|
72
|
+
if (page.empty) break;
|
|
73
|
+
const batch = firestore.batch();
|
|
74
|
+
for (const row of page.docs){
|
|
75
|
+
batch.update(row.ref, {
|
|
76
|
+
contactId: to,
|
|
77
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
await batch.commit();
|
|
81
|
+
moved += page.size;
|
|
82
|
+
if (page.size < REPOINT_PAGE) break;
|
|
83
|
+
}
|
|
84
|
+
return moved;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The leads converted into the merged contact, restamped at the survivor.
|
|
88
|
+
*
|
|
89
|
+
* A lead lives at `orgs/{orgId}/leads/{personKey(email)}` (AGL-3275), so there
|
|
90
|
+
* is still no query — one document per address, asked for directly. This used
|
|
91
|
+
* to be bounded by sites TIMES addresses because a lead lived under each site;
|
|
92
|
+
* it is bounded by addresses now, and the sites drop out of the signature
|
|
93
|
+
* because the collection no longer has one per site to walk.
|
|
94
|
+
*/ async function repointLeads(orgRef, emails, from, to) {
|
|
95
|
+
let moved = 0;
|
|
96
|
+
/*
|
|
97
|
+
* ONE ROW PER ADDRESS, on the org (AGL-3275). This walked the sites because
|
|
98
|
+
* there was a lead under each of them; re-pointing the same document once
|
|
99
|
+
* per site would now be the same write repeated, and the second would find
|
|
100
|
+
* `convertedContactId` already moved and skip — a silent undercount rather
|
|
101
|
+
* than a wrong answer, but an undercount the caller reports to the operator.
|
|
102
|
+
*/ for (const email of emails){
|
|
103
|
+
const key = personKey(email);
|
|
104
|
+
if (!key) continue;
|
|
105
|
+
const leadRef = orgRef.collection('leads').doc(key);
|
|
106
|
+
const lead = await leadRef.get();
|
|
107
|
+
if (!lead.exists || lead.get('convertedContactId') !== from) continue;
|
|
108
|
+
await leadRef.update({
|
|
109
|
+
convertedContactId: to,
|
|
110
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
111
|
+
});
|
|
112
|
+
moved += 1;
|
|
113
|
+
}
|
|
114
|
+
return moved;
|
|
115
|
+
}
|
|
116
|
+
export async function mergeContacts(options) {
|
|
117
|
+
var _mergedBefore_data, _survivorBefore_data, _contactEmails_, _plan_emails_;
|
|
118
|
+
const { firestore, orgRef, survivorId, mergedId, actor, hostId } = options;
|
|
119
|
+
if (!survivorId || !mergedId || survivorId === mergedId) {
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
reason: 'same-record'
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const contactsRef = orgRef.collection('contacts');
|
|
126
|
+
const survivorRef = contactsRef.doc(survivorId);
|
|
127
|
+
const mergedRef = contactsRef.doc(mergedId);
|
|
128
|
+
const [survivorBefore, mergedBefore] = await Promise.all([
|
|
129
|
+
survivorRef.get(),
|
|
130
|
+
mergedRef.get()
|
|
131
|
+
]);
|
|
132
|
+
if (!survivorBefore.exists) return {
|
|
133
|
+
ok: false,
|
|
134
|
+
reason: 'survivor-missing'
|
|
135
|
+
};
|
|
136
|
+
if (!mergedBefore.exists) return {
|
|
137
|
+
ok: false,
|
|
138
|
+
reason: 'merged-missing'
|
|
139
|
+
};
|
|
140
|
+
const mergedData = (_mergedBefore_data = mergedBefore.data()) != null ? _mergedBefore_data : {};
|
|
141
|
+
const survivorData = (_survivorBefore_data = survivorBefore.data()) != null ? _survivorBefore_data : {};
|
|
142
|
+
/*==========================================
|
|
143
|
+
* 1. THE CHILDREN — before the parent, for the reason the header gives.
|
|
144
|
+
*=========================================*/ const [deals, tasks, activities] = await Promise.all([
|
|
145
|
+
repointContactId(firestore, orgRef.collection(CRM_COLLECTIONS.deals), mergedId, survivorId),
|
|
146
|
+
repointContactId(firestore, orgRef.collection(CRM_COLLECTIONS.tasks), mergedId, survivorId),
|
|
147
|
+
repointContactId(firestore, orgRef.collection(CRM_COLLECTIONS.activities), mergedId, survivorId)
|
|
148
|
+
]);
|
|
149
|
+
const leads = await repointLeads(orgRef, contactEmails(mergedData), mergedId, survivorId);
|
|
150
|
+
/*==========================================
|
|
151
|
+
* 2. THE SWAP — one transaction over both documents and the index.
|
|
152
|
+
*=========================================*/ const index = orgRef.collection(CONTACT_EMAIL_INDEX_COLLECTION);
|
|
153
|
+
const swapped = await firestore.runTransaction(async (transaction)=>{
|
|
154
|
+
var _survivor_data, _merged_data, _survivor_data1;
|
|
155
|
+
const [survivor, merged] = await Promise.all([
|
|
156
|
+
transaction.get(survivorRef),
|
|
157
|
+
transaction.get(mergedRef)
|
|
158
|
+
]);
|
|
159
|
+
if (!survivor.exists) return {
|
|
160
|
+
reason: 'survivor-missing'
|
|
161
|
+
};
|
|
162
|
+
if (!merged.exists) return {
|
|
163
|
+
reason: 'merged-missing'
|
|
164
|
+
};
|
|
165
|
+
const plan = planContactMerge((_survivor_data = survivor.data()) != null ? _survivor_data : {}, (_merged_data = merged.data()) != null ? _merged_data : {});
|
|
166
|
+
transaction.set(survivorRef, _extends({}, plan.survivor, {
|
|
167
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
168
|
+
}), {
|
|
169
|
+
merge: true
|
|
170
|
+
});
|
|
171
|
+
for (const email of plan.emails){
|
|
172
|
+
const key = personKey(email);
|
|
173
|
+
if (!key) continue;
|
|
174
|
+
transaction.set(index.doc(key), {
|
|
175
|
+
email,
|
|
176
|
+
contactId: survivorId,
|
|
177
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
178
|
+
}, {
|
|
179
|
+
merge: true
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
transaction.delete(mergedRef);
|
|
183
|
+
return {
|
|
184
|
+
plan,
|
|
185
|
+
survivor: (_survivor_data1 = survivor.data()) != null ? _survivor_data1 : {}
|
|
186
|
+
};
|
|
187
|
+
});
|
|
188
|
+
if ('reason' in swapped) return {
|
|
189
|
+
ok: false,
|
|
190
|
+
reason: swapped.reason
|
|
191
|
+
};
|
|
192
|
+
const { plan } = swapped;
|
|
193
|
+
// The survivor now carries both records' addresses, holders and facets —
|
|
194
|
+
// what the Contacts list searches and filters by (AGL-3321).
|
|
195
|
+
await restampCrmListFieldsAt(survivorRef, 'contacts');
|
|
196
|
+
/*
|
|
197
|
+
* The companies both records named counted two people and now count one
|
|
198
|
+
* (AGL-2613). Settled after the swap the way the capture settles its
|
|
199
|
+
* counts: a figure that could not move is logged, and the company page's
|
|
200
|
+
* live aggregate corrects it.
|
|
201
|
+
*/ await settleCompanyContactsCounts(orgRef.collection(CRM_COLLECTIONS.companies), {
|
|
202
|
+
companyId: null,
|
|
203
|
+
mirror: null,
|
|
204
|
+
counts: plan.companyCounts
|
|
205
|
+
});
|
|
206
|
+
/*
|
|
207
|
+
* The survivor now owns the merged record's open tasks too, so its next
|
|
208
|
+
* activity is the earliest of both (AGL-2661). Settled the same way: a
|
|
209
|
+
* figure that could not move is logged, and the Fields section's
|
|
210
|
+
* recompute corrects it.
|
|
211
|
+
*/ await recomputeCrmNextTaskAt(firestore, orgRef.id, [
|
|
212
|
+
{
|
|
213
|
+
contactId: survivorId
|
|
214
|
+
}
|
|
215
|
+
]).catch((error)=>{
|
|
216
|
+
console.error('contact merge next activity could not be recomputed', survivorId, error);
|
|
217
|
+
});
|
|
218
|
+
const mergedEmail = (_contactEmails_ = contactEmails(mergedData)[0]) != null ? _contactEmails_ : '';
|
|
219
|
+
const survivorEmail = (_plan_emails_ = plan.emails[0]) != null ? _plan_emails_ : '';
|
|
220
|
+
// The canonical name — the one identity every holder shares — for the
|
|
221
|
+
// feed; the address stands in for a person nobody has named.
|
|
222
|
+
const survivorName = typeof swapped.survivor['name'] === 'string' ? swapped.survivor['name'] : '';
|
|
223
|
+
/*
|
|
224
|
+
* THE TIMELINE NOTE, on the survivor: the merge is an event in this
|
|
225
|
+
* person's history, and the record page is where a reader looks for one.
|
|
226
|
+
* Filed under the calling site, or the survivor's first site for a door
|
|
227
|
+
* with none; its own catch, because a note that could not be written must
|
|
228
|
+
* not read as a merge that did not happen.
|
|
229
|
+
*/ try {
|
|
230
|
+
var _survivorData_hostId;
|
|
231
|
+
await orgRef.collection(CRM_COLLECTIONS.activities).add(_extends({
|
|
232
|
+
kind: 'note',
|
|
233
|
+
body: `Merged with ${mergedEmail}`,
|
|
234
|
+
atMs: Date.now(),
|
|
235
|
+
byUid: actor.uid
|
|
236
|
+
}, options.actorName || actor.email ? {
|
|
237
|
+
byName: options.actorName || actor.email
|
|
238
|
+
} : {}, {
|
|
239
|
+
contactId: survivorId,
|
|
240
|
+
visibleTo: plan.visibleTo,
|
|
241
|
+
hostId: hostId != null ? hostId : String((_survivorData_hostId = survivorData['hostId']) != null ? _survivorData_hostId : ''),
|
|
242
|
+
createdByUid: actor.uid,
|
|
243
|
+
createdAt: FieldValue.serverTimestamp(),
|
|
244
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
245
|
+
}));
|
|
246
|
+
} catch (error) {
|
|
247
|
+
console.error('[contact-merge] timeline note failed', survivorId, error);
|
|
248
|
+
}
|
|
249
|
+
// The audit line (AGL-2622), in the feed of the site whose console did
|
|
250
|
+
// the work. A door with no site — the API — has no host feed to write.
|
|
251
|
+
if (hostId) {
|
|
252
|
+
await logHostActivity(hostId, actor, `Merged with ${mergedEmail}`, {
|
|
253
|
+
type: 'contact',
|
|
254
|
+
id: survivorId,
|
|
255
|
+
name: survivorName || survivorEmail
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
ok: true,
|
|
260
|
+
survivorId,
|
|
261
|
+
survivorEmail,
|
|
262
|
+
mergedId,
|
|
263
|
+
mergedEmail,
|
|
264
|
+
emails: plan.emails,
|
|
265
|
+
repointed: {
|
|
266
|
+
deals,
|
|
267
|
+
tasks,
|
|
268
|
+
activities,
|
|
269
|
+
leads
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
//# sourceMappingURL=merge-contacts.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/merge-contacts.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 * MERGING TWO CONTACTS — the write (AGL-2625).\n *\n * `planContactMerge` says what the survivor becomes; this performs it, and\n * moves everything that pointed at the merged record. One function behind\n * two doors — the console's `crm/contacts-merge` route and\n * `POST /v1/contacts/{id}/merge` — so the REST caller and the record page\n * cannot merge two different ways.\n *\n * ## The order is the crash-safety argument\n *\n * 1. Every deal, task and activity naming the merged contact is repointed\n * at the survivor, and every lead converted into it. Batched, bounded,\n * idempotent: a re-run finds nothing left to move.\n * 2. Then, in ONE transaction: the survivor takes the plan, every address\n * it now answers to is indexed at it, and the merged document is\n * deleted — with both records re-read inside the transaction, so two\n * merges racing on one pair cannot both succeed and a plan is never\n * computed on a document that changed under it.\n *\n * Children first because a process that dies between the two steps leaves\n * children pointing at a record that EXISTS — the survivor — while the merged\n * document is still there for a retry to fold in. The other order would\n * leave deals pointing at a deleted contact, which is a record nobody can\n * open from anywhere.\n *\n * ## What it does not move\n *\n * List memberships, suppression entries and the delivery log are keyed by\n * ADDRESS, not by contact id, and stay under the merged address; a capture\n * on that address resolves to the survivor through the index, which is the\n * fact those keys need. A company's contacts count drops only for a company\n * both records named — the survivor still names the rest.\n */\n\nimport {\n CONTACT_EMAIL_INDEX_COLLECTION,\n contactEmails,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport type { HostActivityActor } from '@aglyn/aglyn/app-utils/activity-presenter'\nimport { planContactMerge } from '../model/contact-merge'\nimport { CRM_COLLECTIONS } from '@aglyn/aglyn/app-utils/crm'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { settleCompanyContactsCounts } from '@aglyn/tenant-data-admin/server/contact-company-link'\nimport { recomputeCrmNextTaskAt } from '@aglyn/tenant-data-admin/server/crm-next-activity'\nimport { restampCrmListFieldsAt } from '@aglyn/tenant-data-admin/server/crm-records'\nimport { logHostActivity } from '@aglyn/tenant-data-admin/server/organizations'\n\n/** How many pointing rows one repoint pass reads — and one batch writes. */\nconst REPOINT_PAGE = 400\n/** A ceiling on passes, so a query that keeps answering cannot loop forever. */\nconst REPOINT_PASSES = 25\n\nexport interface MergeContactsOptions {\n firestore: FirebaseFirestore.Firestore\n /** `orgs/{orgId}` — the org whose contacts both records are. */\n orgRef: FirebaseFirestore.DocumentReference\n /** The record that stays, and keeps its address as the identity. */\n survivorId: string\n /** The record folded in and deleted. */\n mergedId: string\n actor: HostActivityActor\n /**\n * The site whose console did the work: where the activity feed entry\n * lands, and the site a timeline note is filed under. `null` for a door\n * with no site — the REST API — which logs no host entry.\n */\n hostId: string | null\n /** The actor's display name for the timeline note, when the door knows it. */\n actorName?: string | null\n}\n\nexport interface MergeContactsRepointed {\n deals: number\n tasks: number\n activities: number\n leads: number\n}\n\nexport type MergeContactsResult =\n | {\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: MergeContactsRepointed\n }\n | { ok: false; reason: 'same-record' | 'survivor-missing' | 'merged-missing' }\n\n/**\n * Point every row of `collection` whose `contactId` is `from` at `to`.\n *\n * Page, batch, repeat: each pass reads the rows still pointing at the\n * merged record — the ones a previous pass moved no longer match — so the\n * loop ends when a pass finds nothing. `updatedAt` moves with the pointer\n * because an integration syncing by `updatedAfter` has to see the change.\n */\nasync function repointContactId(\n firestore: FirebaseFirestore.Firestore,\n collection: FirebaseFirestore.CollectionReference,\n from: string,\n to: string,\n): Promise<number> {\n let moved = 0\n for (let pass = 0; pass < REPOINT_PASSES; pass += 1) {\n const page = await collection\n .where('contactId', '==', from)\n .limit(REPOINT_PAGE)\n .get()\n if (page.empty) break\n const batch = firestore.batch()\n for (const row of page.docs) {\n batch.update(row.ref, {\n contactId: to,\n updatedAt: FieldValue.serverTimestamp(),\n })\n }\n await batch.commit()\n moved += page.size\n if (page.size < REPOINT_PAGE) break\n }\n return moved\n}\n\n/**\n * The leads converted into the merged contact, restamped at the survivor.\n *\n * A lead lives at `orgs/{orgId}/leads/{personKey(email)}` (AGL-3275), so there\n * is still no query — one document per address, asked for directly. This used\n * to be bounded by sites TIMES addresses because a lead lived under each site;\n * it is bounded by addresses now, and the sites drop out of the signature\n * because the collection no longer has one per site to walk.\n */\nasync function repointLeads(\n orgRef: FirebaseFirestore.DocumentReference,\n emails: readonly string[],\n from: string,\n to: string,\n): Promise<number> {\n let moved = 0\n /*\n * ONE ROW PER ADDRESS, on the org (AGL-3275). This walked the sites because\n * there was a lead under each of them; re-pointing the same document once\n * per site would now be the same write repeated, and the second would find\n * `convertedContactId` already moved and skip — a silent undercount rather\n * than a wrong answer, but an undercount the caller reports to the operator.\n */\n for (const email of emails) {\n const key = personKey(email)\n if (!key) continue\n const leadRef = orgRef.collection('leads').doc(key)\n const lead = await leadRef.get()\n if (!lead.exists || lead.get('convertedContactId') !== from) continue\n await leadRef.update({\n convertedContactId: to,\n updatedAt: FieldValue.serverTimestamp(),\n })\n moved += 1\n }\n return moved\n}\n\nexport async function mergeContacts(\n options: MergeContactsOptions,\n): Promise<MergeContactsResult> {\n const { firestore, orgRef, survivorId, mergedId, actor, hostId } = options\n if (!survivorId || !mergedId || survivorId === mergedId) {\n return { ok: false, reason: 'same-record' }\n }\n const contactsRef = orgRef.collection('contacts')\n const survivorRef = contactsRef.doc(survivorId)\n const mergedRef = contactsRef.doc(mergedId)\n const [survivorBefore, mergedBefore] = await Promise.all([\n survivorRef.get(),\n mergedRef.get(),\n ])\n if (!survivorBefore.exists) return { ok: false, reason: 'survivor-missing' }\n if (!mergedBefore.exists) return { ok: false, reason: 'merged-missing' }\n const mergedData = (mergedBefore.data() ?? {}) as Record<string, unknown>\n const survivorData = (survivorBefore.data() ?? {}) as Record<string, unknown>\n\n /*==========================================\n * 1. THE CHILDREN — before the parent, for the reason the header gives.\n *=========================================*/\n const [deals, tasks, activities] = await Promise.all([\n repointContactId(firestore, orgRef.collection(CRM_COLLECTIONS.deals), mergedId, survivorId),\n repointContactId(firestore, orgRef.collection(CRM_COLLECTIONS.tasks), mergedId, survivorId),\n repointContactId(\n firestore,\n orgRef.collection(CRM_COLLECTIONS.activities),\n mergedId,\n survivorId,\n ),\n ])\n const leads = await repointLeads(\n orgRef,\n contactEmails(mergedData),\n mergedId,\n survivorId,\n )\n\n /*==========================================\n * 2. THE SWAP — one transaction over both documents and the index.\n *=========================================*/\n const index = orgRef.collection(CONTACT_EMAIL_INDEX_COLLECTION)\n const swapped = await firestore.runTransaction(async (transaction) => {\n const [survivor, merged] = await Promise.all([\n transaction.get(survivorRef),\n transaction.get(mergedRef),\n ])\n if (!survivor.exists) return { reason: 'survivor-missing' as const }\n if (!merged.exists) return { reason: 'merged-missing' as const }\n const plan = planContactMerge(\n (survivor.data() ?? {}) as Record<string, unknown>,\n (merged.data() ?? {}) as Record<string, unknown>,\n )\n transaction.set(\n survivorRef,\n { ...plan.survivor, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n for (const email of plan.emails) {\n const key = personKey(email)\n if (!key) continue\n transaction.set(\n index.doc(key),\n { email, contactId: survivorId, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n }\n transaction.delete(mergedRef)\n return { plan, survivor: (survivor.data() ?? {}) as Record<string, unknown> }\n })\n if ('reason' in swapped) return { ok: false, reason: swapped.reason }\n const { plan } = swapped\n // The survivor now carries both records' addresses, holders and facets —\n // what the Contacts list searches and filters by (AGL-3321).\n await restampCrmListFieldsAt(survivorRef, 'contacts')\n\n /*\n * The companies both records named counted two people and now count one\n * (AGL-2613). Settled after the swap the way the capture settles its\n * counts: a figure that could not move is logged, and the company page's\n * live aggregate corrects it.\n */\n await settleCompanyContactsCounts(orgRef.collection(CRM_COLLECTIONS.companies), {\n companyId: null,\n mirror: null,\n counts: plan.companyCounts,\n })\n /*\n * The survivor now owns the merged record's open tasks too, so its next\n * activity is the earliest of both (AGL-2661). Settled the same way: a\n * figure that could not move is logged, and the Fields section's\n * recompute corrects it.\n */\n await recomputeCrmNextTaskAt(firestore, orgRef.id, [{ contactId: survivorId }]).catch(\n (error: unknown) => {\n console.error('contact merge next activity could not be recomputed', survivorId, error)\n },\n )\n\n const mergedEmail = contactEmails(mergedData)[0] ?? ''\n const survivorEmail = plan.emails[0] ?? ''\n // The canonical name — the one identity every holder shares — for the\n // feed; the address stands in for a person nobody has named.\n const survivorName =\n typeof swapped.survivor['name'] === 'string' ? swapped.survivor['name'] : ''\n\n /*\n * THE TIMELINE NOTE, on the survivor: the merge is an event in this\n * person's history, and the record page is where a reader looks for one.\n * Filed under the calling site, or the survivor's first site for a door\n * with none; its own catch, because a note that could not be written must\n * not read as a merge that did not happen.\n */\n try {\n await orgRef.collection(CRM_COLLECTIONS.activities).add({\n kind: 'note',\n body: `Merged with ${mergedEmail}`,\n atMs: Date.now(),\n byUid: actor.uid,\n ...(options.actorName || actor.email\n ? { byName: options.actorName || actor.email }\n : {}),\n contactId: survivorId,\n visibleTo: plan.visibleTo,\n hostId: hostId ?? String(survivorData['hostId'] ?? ''),\n createdByUid: actor.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n } catch (error) {\n console.error('[contact-merge] timeline note failed', survivorId, error)\n }\n\n // The audit line (AGL-2622), in the feed of the site whose console did\n // the work. A door with no site — the API — has no host feed to write.\n if (hostId) {\n await logHostActivity(hostId, actor, `Merged with ${mergedEmail}`, {\n type: 'contact',\n id: survivorId,\n name: survivorName || survivorEmail,\n })\n }\n\n return {\n ok: true,\n survivorId,\n survivorEmail,\n mergedId,\n mergedEmail,\n emails: plan.emails,\n repointed: { deals, tasks, activities, leads },\n }\n}\n"],"names":["CONTACT_EMAIL_INDEX_COLLECTION","contactEmails","planContactMerge","CRM_COLLECTIONS","personKey","FieldValue","settleCompanyContactsCounts","recomputeCrmNextTaskAt","restampCrmListFieldsAt","logHostActivity","REPOINT_PAGE","REPOINT_PASSES","repointContactId","firestore","collection","from","to","moved","pass","page","where","limit","get","empty","batch","row","docs","update","ref","contactId","updatedAt","serverTimestamp","commit","size","repointLeads","orgRef","emails","email","key","leadRef","doc","lead","exists","convertedContactId","mergeContacts","options","mergedBefore","survivorBefore","plan","survivorId","mergedId","actor","hostId","ok","reason","contactsRef","survivorRef","mergedRef","Promise","all","mergedData","data","survivorData","deals","tasks","activities","leads","index","swapped","runTransaction","transaction","survivor","merged","set","merge","delete","companies","companyId","mirror","counts","companyCounts","id","catch","error","console","mergedEmail","survivorEmail","survivorName","add","kind","body","atMs","Date","now","byUid","uid","actorName","byName","visibleTo","String","createdByUid","createdAt","type","name","repointed"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiCC,GAED,SACEA,8BAA8B,EAC9BC,aAAa,QACR,kCAAiC;AAExC,SAASC,gBAAgB,QAAQ,4BAAwB;AACzD,SAASC,eAAe,QAAQ,6BAA4B;AAC5D,SAASC,SAAS,QAAQ,oCAAmC;AAC7D,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,2BAA2B,QAAQ,uDAAsD;AAClG,SAASC,sBAAsB,QAAQ,oDAAmD;AAC1F,SAASC,sBAAsB,QAAQ,8CAA6C;AACpF,SAASC,eAAe,QAAQ,gDAA+C;AAE/E,0EAA0E,GAC1E,MAAMC,eAAe;AACrB,8EAA8E,GAC9E,MAAMC,iBAAiB;AAyCvB;;;;;;;CAOC,GACD,eAAeC,iBACbC,SAAsC,EACtCC,UAAiD,EACjDC,IAAY,EACZC,EAAU;IAEV,IAAIC,QAAQ;IACZ,IAAK,IAAIC,OAAO,GAAGA,OAAOP,gBAAgBO,QAAQ,EAAG;QACnD,MAAMC,OAAO,MAAML,WAChBM,KAAK,CAAC,aAAa,MAAML,MACzBM,KAAK,CAACX,cACNY,GAAG;QACN,IAAIH,KAAKI,KAAK,EAAE;QAChB,MAAMC,QAAQX,UAAUW,KAAK;QAC7B,KAAK,MAAMC,OAAON,KAAKO,IAAI,CAAE;YAC3BF,MAAMG,MAAM,CAACF,IAAIG,GAAG,EAAE;gBACpBC,WAAWb;gBACXc,WAAWzB,WAAW0B,eAAe;YACvC;QACF;QACA,MAAMP,MAAMQ,MAAM;QAClBf,SAASE,KAAKc,IAAI;QAClB,IAAId,KAAKc,IAAI,GAAGvB,cAAc;IAChC;IACA,OAAOO;AACT;AAEA;;;;;;;;CAQC,GACD,eAAeiB,aACbC,MAA2C,EAC3CC,MAAyB,EACzBrB,IAAY,EACZC,EAAU;IAEV,IAAIC,QAAQ;IACZ;;;;;;GAMC,GACD,KAAK,MAAMoB,SAASD,OAAQ;QAC1B,MAAME,MAAMlC,UAAUiC;QACtB,IAAI,CAACC,KAAK;QACV,MAAMC,UAAUJ,OAAOrB,UAAU,CAAC,SAAS0B,GAAG,CAACF;QAC/C,MAAMG,OAAO,MAAMF,QAAQjB,GAAG;QAC9B,IAAI,CAACmB,KAAKC,MAAM,IAAID,KAAKnB,GAAG,CAAC,0BAA0BP,MAAM;QAC7D,MAAMwB,QAAQZ,MAAM,CAAC;YACnBgB,oBAAoB3B;YACpBc,WAAWzB,WAAW0B,eAAe;QACvC;QACAd,SAAS;IACX;IACA,OAAOA;AACT;AAEA,OAAO,eAAe2B,cACpBC,OAA6B;QAeTC,oBACEC,sBAmFF9C,iBACE+C;IAlGtB,MAAM,EAAEnC,SAAS,EAAEsB,MAAM,EAAEc,UAAU,EAAEC,QAAQ,EAAEC,KAAK,EAAEC,MAAM,EAAE,GAAGP;IACnE,IAAI,CAACI,cAAc,CAACC,YAAYD,eAAeC,UAAU;QACvD,OAAO;YAAEG,IAAI;YAAOC,QAAQ;QAAc;IAC5C;IACA,MAAMC,cAAcpB,OAAOrB,UAAU,CAAC;IACtC,MAAM0C,cAAcD,YAAYf,GAAG,CAACS;IACpC,MAAMQ,YAAYF,YAAYf,GAAG,CAACU;IAClC,MAAM,CAACH,gBAAgBD,aAAa,GAAG,MAAMY,QAAQC,GAAG,CAAC;QACvDH,YAAYlC,GAAG;QACfmC,UAAUnC,GAAG;KACd;IACD,IAAI,CAACyB,eAAeL,MAAM,EAAE,OAAO;QAAEW,IAAI;QAAOC,QAAQ;IAAmB;IAC3E,IAAI,CAACR,aAAaJ,MAAM,EAAE,OAAO;QAAEW,IAAI;QAAOC,QAAQ;IAAiB;IACvE,MAAMM,cAAcd,qBAAAA,aAAae,IAAI,cAAjBf,qBAAuB,CAAC;IAC5C,MAAMgB,gBAAgBf,uBAAAA,eAAec,IAAI,cAAnBd,uBAAyB,CAAC;IAEhD;;6CAE2C,GAC3C,MAAM,CAACgB,OAAOC,OAAOC,WAAW,GAAG,MAAMP,QAAQC,GAAG,CAAC;QACnD/C,iBAAiBC,WAAWsB,OAAOrB,UAAU,CAACX,gBAAgB4D,KAAK,GAAGb,UAAUD;QAChFrC,iBAAiBC,WAAWsB,OAAOrB,UAAU,CAACX,gBAAgB6D,KAAK,GAAGd,UAAUD;QAChFrC,iBACEC,WACAsB,OAAOrB,UAAU,CAACX,gBAAgB8D,UAAU,GAC5Cf,UACAD;KAEH;IACD,MAAMiB,QAAQ,MAAMhC,aAClBC,QACAlC,cAAc2D,aACdV,UACAD;IAGF;;6CAE2C,GAC3C,MAAMkB,QAAQhC,OAAOrB,UAAU,CAACd;IAChC,MAAMoE,UAAU,MAAMvD,UAAUwD,cAAc,CAAC,OAAOC;YAQjDC,gBACAC,cAiBuBD;QAzB1B,MAAM,CAACA,UAAUC,OAAO,GAAG,MAAMd,QAAQC,GAAG,CAAC;YAC3CW,YAAYhD,GAAG,CAACkC;YAChBc,YAAYhD,GAAG,CAACmC;SACjB;QACD,IAAI,CAACc,SAAS7B,MAAM,EAAE,OAAO;YAAEY,QAAQ;QAA4B;QACnE,IAAI,CAACkB,OAAO9B,MAAM,EAAE,OAAO;YAAEY,QAAQ;QAA0B;QAC/D,MAAMN,OAAO9C,kBACVqE,iBAAAA,SAASV,IAAI,cAAbU,iBAAmB,CAAC,IACpBC,eAAAA,OAAOX,IAAI,cAAXW,eAAiB,CAAC;QAErBF,YAAYG,GAAG,CACbjB,aACA,aAAKR,KAAKuB,QAAQ;YAAEzC,WAAWzB,WAAW0B,eAAe;YACzD;YAAE2C,OAAO;QAAK;QAEhB,KAAK,MAAMrC,SAASW,KAAKZ,MAAM,CAAE;YAC/B,MAAME,MAAMlC,UAAUiC;YACtB,IAAI,CAACC,KAAK;YACVgC,YAAYG,GAAG,CACbN,MAAM3B,GAAG,CAACF,MACV;gBAAED;gBAAOR,WAAWoB;gBAAYnB,WAAWzB,WAAW0B,eAAe;YAAG,GACxE;gBAAE2C,OAAO;YAAK;QAElB;QACAJ,YAAYK,MAAM,CAAClB;QACnB,OAAO;YAAET;YAAMuB,QAAQ,GAAGA,kBAAAA,SAASV,IAAI,cAAbU,kBAAmB,CAAC;QAA8B;IAC9E;IACA,IAAI,YAAYH,SAAS,OAAO;QAAEf,IAAI;QAAOC,QAAQc,QAAQd,MAAM;IAAC;IACpE,MAAM,EAAEN,IAAI,EAAE,GAAGoB;IACjB,yEAAyE;IACzE,6DAA6D;IAC7D,MAAM5D,uBAAuBgD,aAAa;IAE1C;;;;;GAKC,GACD,MAAMlD,4BAA4B6B,OAAOrB,UAAU,CAACX,gBAAgByE,SAAS,GAAG;QAC9EC,WAAW;QACXC,QAAQ;QACRC,QAAQ/B,KAAKgC,aAAa;IAC5B;IACA;;;;;GAKC,GACD,MAAMzE,uBAAuBM,WAAWsB,OAAO8C,EAAE,EAAE;QAAC;YAAEpD,WAAWoB;QAAW;KAAE,EAAEiC,KAAK,CACnF,CAACC;QACCC,QAAQD,KAAK,CAAC,uDAAuDlC,YAAYkC;IACnF;IAGF,MAAME,eAAcpF,kBAAAA,cAAc2D,WAAW,CAAC,EAAE,YAA5B3D,kBAAgC;IACpD,MAAMqF,iBAAgBtC,gBAAAA,KAAKZ,MAAM,CAAC,EAAE,YAAdY,gBAAkB;IACxC,sEAAsE;IACtE,6DAA6D;IAC7D,MAAMuC,eACJ,OAAOnB,QAAQG,QAAQ,CAAC,OAAO,KAAK,WAAWH,QAAQG,QAAQ,CAAC,OAAO,GAAG;IAE5E;;;;;;GAMC,GACD,IAAI;YAWyBT;QAV3B,MAAM3B,OAAOrB,UAAU,CAACX,gBAAgB8D,UAAU,EAAEuB,GAAG,CAAC;YACtDC,MAAM;YACNC,MAAM,CAAC,YAAY,EAAEL,aAAa;YAClCM,MAAMC,KAAKC,GAAG;YACdC,OAAO3C,MAAM4C,GAAG;WACZlD,QAAQmD,SAAS,IAAI7C,MAAMd,KAAK,GAChC;YAAE4D,QAAQpD,QAAQmD,SAAS,IAAI7C,MAAMd,KAAK;QAAC,IAC3C,CAAC;YACLR,WAAWoB;YACXiD,WAAWlD,KAAKkD,SAAS;YACzB9C,MAAM,EAAEA,iBAAAA,SAAU+C,QAAOrC,uBAAAA,YAAY,CAAC,SAAS,YAAtBA,uBAA0B;YACnDsC,cAAcjD,MAAM4C,GAAG;YACvBM,WAAWhG,WAAW0B,eAAe;YACrCD,WAAWzB,WAAW0B,eAAe;;IAEzC,EAAE,OAAOoD,OAAO;QACdC,QAAQD,KAAK,CAAC,wCAAwClC,YAAYkC;IACpE;IAEA,uEAAuE;IACvE,uEAAuE;IACvE,IAAI/B,QAAQ;QACV,MAAM3C,gBAAgB2C,QAAQD,OAAO,CAAC,YAAY,EAAEkC,aAAa,EAAE;YACjEiB,MAAM;YACNrB,IAAIhC;YACJsD,MAAMhB,gBAAgBD;QACxB;IACF;IAEA,OAAO;QACLjC,IAAI;QACJJ;QACAqC;QACApC;QACAmC;QACAjD,QAAQY,KAAKZ,MAAM;QACnBoE,WAAW;YAAEzC;YAAOC;YAAOC;YAAYC;QAAM;IAC/C;AACF"}
|
|
@@ -0,0 +1,36 @@
|
|
|
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 { PluginRecordIndex } from '@aglyn/aglyn/plugin-manager/plugin-record-index';
|
|
18
|
+
/**
|
|
19
|
+
* The CRM's `pipeline` index (AGL-3080): the organization's deal pipelines
|
|
20
|
+
* and their stages, as another plugin reads them — an automation drafted by
|
|
21
|
+
* the AI names a stage a deal moves to — without knowing where pipelines are
|
|
22
|
+
* stored or what an archived one looks like.
|
|
23
|
+
*
|
|
24
|
+
* Org-scoped (`orgId` required; a site-only scope answers nothing). With a
|
|
25
|
+
* `hostId`, only the pipelines that site can see — the ones shared with it
|
|
26
|
+
* or with every site — which is the set that site's CRM offers. An archived
|
|
27
|
+
* pipeline is left out of every answer.
|
|
28
|
+
*
|
|
29
|
+
* `name` is the pipeline's, or `Untitled pipeline` for one saved without,
|
|
30
|
+
* which is what the CRM itself calls it. A pipeline's `facts`:
|
|
31
|
+
* `stages: Array<{ id: string, name: string }>`, in the pipeline's order,
|
|
32
|
+
* each stage that has an id (a stage saved without a name has `name: ''`).
|
|
33
|
+
*/
|
|
34
|
+
/** What the CRM calls a pipeline saved without a name. */
|
|
35
|
+
export declare const UNTITLED_PIPELINE = "Untitled pipeline";
|
|
36
|
+
export declare const pipelineRecordIndex: PluginRecordIndex;
|
|
@@ -0,0 +1,87 @@
|
|
|
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 { visibleToHost } from "@aglyn/aglyn/app-utils/scope-tokens";
|
|
17
|
+
import { firebaseAdmin } from "@aglyn/tenant-data-admin/server/firebase-admin";
|
|
18
|
+
import { scopedToHost } from "@aglyn/tenant-data-admin/server/organizations";
|
|
19
|
+
/**
|
|
20
|
+
* The CRM's `pipeline` index (AGL-3080): the organization's deal pipelines
|
|
21
|
+
* and their stages, as another plugin reads them — an automation drafted by
|
|
22
|
+
* the AI names a stage a deal moves to — without knowing where pipelines are
|
|
23
|
+
* stored or what an archived one looks like.
|
|
24
|
+
*
|
|
25
|
+
* Org-scoped (`orgId` required; a site-only scope answers nothing). With a
|
|
26
|
+
* `hostId`, only the pipelines that site can see — the ones shared with it
|
|
27
|
+
* or with every site — which is the set that site's CRM offers. An archived
|
|
28
|
+
* pipeline is left out of every answer.
|
|
29
|
+
*
|
|
30
|
+
* `name` is the pipeline's, or `Untitled pipeline` for one saved without,
|
|
31
|
+
* which is what the CRM itself calls it. A pipeline's `facts`:
|
|
32
|
+
* `stages: Array<{ id: string, name: string }>`, in the pipeline's order,
|
|
33
|
+
* each stage that has an id (a stage saved without a name has `name: ''`).
|
|
34
|
+
*/ /** What the CRM calls a pipeline saved without a name. */ export const UNTITLED_PIPELINE = 'Untitled pipeline';
|
|
35
|
+
const PIPELINE_FIELDS = [
|
|
36
|
+
'name',
|
|
37
|
+
'stages',
|
|
38
|
+
'archivedAt'
|
|
39
|
+
];
|
|
40
|
+
const str = (value)=>typeof value === 'string' ? value.trim() : '';
|
|
41
|
+
function pipelineRecord(id, data) {
|
|
42
|
+
if (!data || Number(data['archivedAt']) > 0) return null;
|
|
43
|
+
const stages = (Array.isArray(data['stages']) ? data['stages'] : []).map((stage)=>{
|
|
44
|
+
const entry = stage != null ? stage : {};
|
|
45
|
+
return {
|
|
46
|
+
id: str(entry['id']),
|
|
47
|
+
name: str(entry['name'])
|
|
48
|
+
};
|
|
49
|
+
}).filter((stage)=>stage.id);
|
|
50
|
+
return {
|
|
51
|
+
id,
|
|
52
|
+
name: str(data['name']) || UNTITLED_PIPELINE,
|
|
53
|
+
facts: {
|
|
54
|
+
stages
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function pipelineCollection(orgId) {
|
|
59
|
+
return firebaseAdmin.app().firestore().collection('orgs').doc(orgId).collection('pipelines');
|
|
60
|
+
}
|
|
61
|
+
export const pipelineRecordIndex = {
|
|
62
|
+
async list ({ orgId, hostId, limit }) {
|
|
63
|
+
if (!orgId || limit <= 0) return {
|
|
64
|
+
records: [],
|
|
65
|
+
truncated: false
|
|
66
|
+
};
|
|
67
|
+
const collection = pipelineCollection(orgId);
|
|
68
|
+
// One more than asked, so `truncated` is a fact rather than a guess; the
|
|
69
|
+
// archived rows are filtered after the read.
|
|
70
|
+
const snapshot = await (hostId ? scopedToHost(collection, hostId) : collection).select(...PIPELINE_FIELDS).limit(limit + 1).get();
|
|
71
|
+
const records = snapshot.docs.slice(0, limit).map((doc)=>pipelineRecord(doc.id, doc.data())).filter((record)=>record !== null);
|
|
72
|
+
return {
|
|
73
|
+
records,
|
|
74
|
+
truncated: snapshot.docs.length > limit
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
async get ({ orgId, hostId, id }) {
|
|
78
|
+
if (!orgId || !id) return null;
|
|
79
|
+
const snapshot = await pipelineCollection(orgId).doc(id).get();
|
|
80
|
+
const data = snapshot.exists ? snapshot.data() : undefined;
|
|
81
|
+
// A site asks about the pipelines it can see, and no other.
|
|
82
|
+
if (hostId && !visibleToHost(data == null ? void 0 : data['visibleTo'], hostId)) return null;
|
|
83
|
+
return pipelineRecord(snapshot.id, data);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
//# sourceMappingURL=pipeline-record-index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/pipeline-record-index.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type {\n PluginIndexedRecord,\n PluginRecordIndex,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-index'\nimport { visibleToHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { scopedToHost } from '@aglyn/tenant-data-admin/server/organizations'\n\n/**\n * The CRM's `pipeline` index (AGL-3080): the organization's deal pipelines\n * and their stages, as another plugin reads them — an automation drafted by\n * the AI names a stage a deal moves to — without knowing where pipelines are\n * stored or what an archived one looks like.\n *\n * Org-scoped (`orgId` required; a site-only scope answers nothing). With a\n * `hostId`, only the pipelines that site can see — the ones shared with it\n * or with every site — which is the set that site's CRM offers. An archived\n * pipeline is left out of every answer.\n *\n * `name` is the pipeline's, or `Untitled pipeline` for one saved without,\n * which is what the CRM itself calls it. A pipeline's `facts`:\n * `stages: Array<{ id: string, name: string }>`, in the pipeline's order,\n * each stage that has an id (a stage saved without a name has `name: ''`).\n */\n\n/** What the CRM calls a pipeline saved without a name. */\nexport const UNTITLED_PIPELINE = 'Untitled pipeline'\n\nconst PIPELINE_FIELDS = ['name', 'stages', 'archivedAt'] as const\n\nconst str = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')\n\nfunction pipelineRecord(\n id: string,\n data: Record<string, unknown> | undefined,\n): PluginIndexedRecord | null {\n if (!data || Number(data['archivedAt']) > 0) return null\n const stages = (Array.isArray(data['stages']) ? data['stages'] : [])\n .map((stage) => {\n const entry = (stage ?? {}) as Record<string, unknown>\n return { id: str(entry['id']), name: str(entry['name']) }\n })\n .filter((stage) => stage.id)\n return { id, name: str(data['name']) || UNTITLED_PIPELINE, facts: { stages } }\n}\n\nfunction pipelineCollection(orgId: string): FirebaseFirestore.CollectionReference {\n return firebaseAdmin.app().firestore().collection('orgs').doc(orgId).collection('pipelines')\n}\n\nexport const pipelineRecordIndex: PluginRecordIndex = {\n async list({ orgId, hostId, limit }) {\n if (!orgId || limit <= 0) return { records: [], truncated: false }\n const collection = pipelineCollection(orgId)\n // One more than asked, so `truncated` is a fact rather than a guess; the\n // archived rows are filtered after the read.\n const snapshot = await (hostId ? scopedToHost(collection, hostId) : collection)\n .select(...PIPELINE_FIELDS)\n .limit(limit + 1)\n .get()\n const records = snapshot.docs\n .slice(0, limit)\n .map((doc) => pipelineRecord(doc.id, doc.data()))\n .filter((record): record is PluginIndexedRecord => record !== null)\n return { records, truncated: snapshot.docs.length > limit }\n },\n async get({ orgId, hostId, id }) {\n if (!orgId || !id) return null\n const snapshot = await pipelineCollection(orgId).doc(id).get()\n const data = snapshot.exists ? snapshot.data() : undefined\n // A site asks about the pipelines it can see, and no other.\n if (hostId && !visibleToHost(data?.['visibleTo'] as string[] | undefined, hostId)) return null\n return pipelineRecord(snapshot.id, data)\n },\n}\n"],"names":["visibleToHost","firebaseAdmin","scopedToHost","UNTITLED_PIPELINE","PIPELINE_FIELDS","str","value","trim","pipelineRecord","id","data","Number","stages","Array","isArray","map","stage","entry","name","filter","facts","pipelineCollection","orgId","app","firestore","collection","doc","pipelineRecordIndex","list","hostId","limit","records","truncated","snapshot","select","get","docs","slice","record","length","exists","undefined"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAMD,SAASA,aAAa,QAAQ,sCAAqC;AACnE,SAASC,aAAa,QAAQ,iDAAgD;AAC9E,SAASC,YAAY,QAAQ,gDAA+C;AAE5E;;;;;;;;;;;;;;;CAeC,GAED,wDAAwD,GACxD,OAAO,MAAMC,oBAAoB,oBAAmB;AAEpD,MAAMC,kBAAkB;IAAC;IAAQ;IAAU;CAAa;AAExD,MAAMC,MAAM,CAACC,QAA4B,OAAOA,UAAU,WAAWA,MAAMC,IAAI,KAAK;AAEpF,SAASC,eACPC,EAAU,EACVC,IAAyC;IAEzC,IAAI,CAACA,QAAQC,OAAOD,IAAI,CAAC,aAAa,IAAI,GAAG,OAAO;IACpD,MAAME,SAAS,AAACC,CAAAA,MAAMC,OAAO,CAACJ,IAAI,CAAC,SAAS,IAAIA,IAAI,CAAC,SAAS,GAAG,EAAE,AAAD,EAC/DK,GAAG,CAAC,CAACC;QACJ,MAAMC,QAASD,gBAAAA,QAAS,CAAC;QACzB,OAAO;YAAEP,IAAIJ,IAAIY,KAAK,CAAC,KAAK;YAAGC,MAAMb,IAAIY,KAAK,CAAC,OAAO;QAAE;IAC1D,GACCE,MAAM,CAAC,CAACH,QAAUA,MAAMP,EAAE;IAC7B,OAAO;QAAEA;QAAIS,MAAMb,IAAIK,IAAI,CAAC,OAAO,KAAKP;QAAmBiB,OAAO;YAAER;QAAO;IAAE;AAC/E;AAEA,SAASS,mBAAmBC,KAAa;IACvC,OAAOrB,cAAcsB,GAAG,GAAGC,SAAS,GAAGC,UAAU,CAAC,QAAQC,GAAG,CAACJ,OAAOG,UAAU,CAAC;AAClF;AAEA,OAAO,MAAME,sBAAyC;IACpD,MAAMC,MAAK,EAAEN,KAAK,EAAEO,MAAM,EAAEC,KAAK,EAAE;QACjC,IAAI,CAACR,SAASQ,SAAS,GAAG,OAAO;YAAEC,SAAS,EAAE;YAAEC,WAAW;QAAM;QACjE,MAAMP,aAAaJ,mBAAmBC;QACtC,yEAAyE;QACzE,6CAA6C;QAC7C,MAAMW,WAAW,MAAM,AAACJ,CAAAA,SAAS3B,aAAauB,YAAYI,UAAUJ,UAAS,EAC1ES,MAAM,IAAI9B,iBACV0B,KAAK,CAACA,QAAQ,GACdK,GAAG;QACN,MAAMJ,UAAUE,SAASG,IAAI,CAC1BC,KAAK,CAAC,GAAGP,OACTf,GAAG,CAAC,CAACW,MAAQlB,eAAekB,IAAIjB,EAAE,EAAEiB,IAAIhB,IAAI,KAC5CS,MAAM,CAAC,CAACmB,SAA0CA,WAAW;QAChE,OAAO;YAAEP;YAASC,WAAWC,SAASG,IAAI,CAACG,MAAM,GAAGT;QAAM;IAC5D;IACA,MAAMK,KAAI,EAAEb,KAAK,EAAEO,MAAM,EAAEpB,EAAE,EAAE;QAC7B,IAAI,CAACa,SAAS,CAACb,IAAI,OAAO;QAC1B,MAAMwB,WAAW,MAAMZ,mBAAmBC,OAAOI,GAAG,CAACjB,IAAI0B,GAAG;QAC5D,MAAMzB,OAAOuB,SAASO,MAAM,GAAGP,SAASvB,IAAI,KAAK+B;QACjD,4DAA4D;QAC5D,IAAIZ,UAAU,CAAC7B,cAAcU,wBAAAA,IAAM,CAAC,YAAY,EAA0BmB,SAAS,OAAO;QAC1F,OAAOrB,eAAeyB,SAASxB,EAAE,EAAEC;IACrC;AACF,EAAC"}
|
|
@@ -19,6 +19,8 @@ import { CRM_EMAIL_STATUS_FIELD, CRM_EMAIL_STATUS_NONE, EMAIL_STATE_FIELD, nextE
|
|
|
19
19
|
import { findContactByEmail, firebaseAdmin } from "@aglyn/tenant-data-admin";
|
|
20
20
|
import { FieldValue } from "firebase-admin/firestore";
|
|
21
21
|
import { BUNDLE_ID } from "../constants/bundle-common.js";
|
|
22
|
+
import { recordContactEmailEngagement } from "./contact-email-engagement.js";
|
|
23
|
+
import { nurtureReachedLeads } from "./lead-nurturing.js";
|
|
22
24
|
const NONE = {
|
|
23
25
|
records: 0
|
|
24
26
|
};
|
|
@@ -121,6 +123,27 @@ export function createCrmRecordEmailStateWriter(deps) {
|
|
|
121
123
|
return {
|
|
122
124
|
records
|
|
123
125
|
};
|
|
126
|
+
},
|
|
127
|
+
async engaged (request) {
|
|
128
|
+
try {
|
|
129
|
+
const records = await recordContactEmailEngagement({
|
|
130
|
+
hostId: request.hostId,
|
|
131
|
+
events: request.events,
|
|
132
|
+
firestore: deps.firestore()
|
|
133
|
+
});
|
|
134
|
+
return {
|
|
135
|
+
records
|
|
136
|
+
};
|
|
137
|
+
} catch (error) {
|
|
138
|
+
console.error('[crm] the contacts could not be stamped with an engagement', request.hostId, error);
|
|
139
|
+
return NONE;
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
// A delivered campaign moves the New leads it reached to Nurturing (AGL-3446).
|
|
143
|
+
async reached (request) {
|
|
144
|
+
return {
|
|
145
|
+
records: await nurtureReachedLeads(deps.firestore(), request)
|
|
146
|
+
};
|
|
124
147
|
}
|
|
125
148
|
};
|
|
126
149
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/record-email-state.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 registerPluginRecordEmailStateWriter,\n type PluginRecordEmailStateReport,\n type PluginRecordEmailStateRequest,\n type PluginRecordEmailStateWriter,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\nimport {\n CRM_EMAIL_STATUS_FIELD,\n CRM_EMAIL_STATUS_NONE,\n EMAIL_STATE_FIELD,\n nextEmailState,\n normalizeContactEmail,\n personKey,\n readEmailState,\n type EmailState,\n} from '@aglyn/aglyn/server'\nimport { findContactByEmail, firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { BUNDLE_ID } from '../constants/bundle-common'\n\n/**\n * THE CRM'S WRITER ON THE CORE'S RECORD EMAIL-STATE SEAM (AGL-3245).\n *\n * A sender filed a verdict on an address — the outreach runtime's bounce,\n * the campaign webhook's complaint, a member's do-not-contact mark, the\n * unsubscribe link — and says so here; this writes it onto every record\n * the address is, as `emailState`, so the record a person reads and the\n * lists a send consults agree.\n *\n * One address can be several records: the contact the organization holds\n * (found through the address index, `findContactByEmail`), and a lead on\n * each site the organization owns — leads are keyed by `personKey`, so a\n * lead is addressed directly under every host. All of them are stamped;\n * the verdict is about the address, not about which record a rep opened.\n * A caller that knows only the site reads the organization off it.\n *\n * The write keeps the STRONGER verdict (`nextEmailState`): a member's\n * do-not-contact mark is not undone by a later bounce, and `ok` lands only\n * when a caller forces it. `updatedAt` is left alone — a verdict is\n * something that happened to the person, not an edit the team made, and a\n * list sorted on recency must not reshuffle on a bounce.\n *\n * Never throws: the list is the control, and a record that could not be\n * stamped is a chip a page goes without, logged.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\nexport interface CrmRecordEmailStateDeps {\n firestore(): Firestore\n}\n\nconst NONE: PluginRecordEmailStateReport = { records: 0 }\n\n/**\n * Applies the verdict to one record; answers whether it moved. A read then\n * a merge rather than a transaction: two verdicts landing together each\n * compare against a state at least as old as their own, and the ranking\n * makes the order of two writes of the same rank immaterial.\n */\nasync function stampRecord(\n ref: FirebaseFirestore.DocumentReference,\n incoming: EmailState,\n force: boolean,\n withdraw = false,\n): Promise<boolean> {\n const snapshot = await ref.get()\n if (!snapshot.exists) return false\n const current = readEmailState(snapshot.data() as Record<string, unknown>)\n /*\n * A WITHDRAWAL takes back the one verdict it names (AGL-3328): the\n * deliverability check's \"Would bounce\" once the domain takes mail again.\n * Any other standing verdict — a bounce DNS cannot see — is left alone.\n */\n if (withdraw) {\n if (current?.status !== incoming.status) return false\n await ref.update({\n [EMAIL_STATE_FIELD]: FieldValue.delete(),\n [CRM_EMAIL_STATUS_FIELD]: CRM_EMAIL_STATUS_NONE,\n })\n return true\n }\n // The deliverability check saying again what the record already holds:\n // the date it was first found is the one worth keeping.\n if (incoming.source === 'check' && current?.status === incoming.status) return false\n const next = nextEmailState(current, incoming, { force })\n // The current verdict stood, or the same verdict arrived again — a second\n // run of the caller — and there is nothing to write.\n if (\n next === current ||\n (current &&\n next.status === current.status &&\n next.atMs === current.atMs &&\n next.source === current.source &&\n next.detail === current.detail)\n ) {\n return false\n }\n // `emailStatus` beside it: the verdict as the CRM lists filter by it\n // (AGL-3321) — a key a query can ask, where the state is a map.\n await ref.set(\n { [EMAIL_STATE_FIELD]: next, [CRM_EMAIL_STATUS_FIELD]: next.status },\n { merge: true },\n )\n return true\n}\n\n/** The organization a request names, or the one its site belongs to. */\nasync function resolveOrgId(firestore: Firestore, request: PluginRecordEmailStateRequest): Promise<string> {\n const named = String(request.orgId ?? '').trim()\n if (named) return named\n const hostId = String(request.hostId ?? '').trim()\n if (!hostId) return ''\n const host = await firestore.collection('hosts').doc(hostId).get()\n return host.exists ? String(host.get('orgId') ?? '') : ''\n}\n\nexport function createCrmRecordEmailStateWriter(deps: CrmRecordEmailStateDeps): PluginRecordEmailStateWriter {\n return {\n async stamp(request) {\n const email = normalizeContactEmail(request.email)\n const key = email ? personKey(email) : null\n if (!email || !key) return NONE\n const firestore = deps.firestore()\n let orgId: string\n try {\n orgId = await resolveOrgId(firestore, request)\n } catch (error) {\n console.error('[crm] the site’s organization could not be read for an email state', request.hostId, error)\n return NONE\n }\n if (!orgId) return NONE\n const state: EmailState = {\n status: request.state.status,\n atMs: Number.isFinite(request.state.atMs) && request.state.atMs > 0 ? request.state.atMs : Date.now(),\n source: request.state.source,\n detail: request.state.detail\n ? String(request.state.detail).replace(/\\s+/g, ' ').trim().slice(0, 500)\n : null,\n ...(request.state.enrollmentId ? { enrollmentId: request.state.enrollmentId } : {}),\n }\n const force = request.force === true\n const withdraw = request.withdraw === true\n let records = 0\n try {\n const contacts = firestore.collection('orgs').doc(orgId).collection('contacts')\n const contact = await findContactByEmail(contacts, email)\n if (contact && (await stampRecord(contact.ref, state, force, withdraw))) records += 1\n } catch (error) {\n console.error('[crm] the contact could not be stamped with an email state', orgId, error)\n }\n try {\n /*\n * ONE ORG ROW. A bounce or a do-not-contact mark is the platform's\n * verdict on the ADDRESS, and since AGL-3277 there is one document\n * carrying it — the per-site loop beside this had nothing left to\n * find once AGL-3276 emptied the host path.\n */\n if (await stampRecord(\n firestore.collection('orgs').doc(orgId).collection('leads').doc(key),\n state,\n force,\n withdraw,\n )) {\n records += 1\n }\n } catch (error) {\n console.error('[crm] the leads could not be stamped with an email state', orgId, error)\n }\n return { records }\n },\n }\n}\n\n/** The platform's own dependencies. Specs build their own. */\nexport function defaultCrmRecordEmailStateDeps(): CrmRecordEmailStateDeps {\n return { firestore: () => firebaseAdmin.app().firestore() }\n}\n\n/** Registers the CRM as the workspace's record system on the email-state seam. */\nexport function registerCrmRecordEmailStateWriter(\n deps: CrmRecordEmailStateDeps = defaultCrmRecordEmailStateDeps(),\n): void {\n registerPluginRecordEmailStateWriter(createCrmRecordEmailStateWriter(deps), { pluginId: BUNDLE_ID })\n}\n"],"names":["registerPluginRecordEmailStateWriter","CRM_EMAIL_STATUS_FIELD","CRM_EMAIL_STATUS_NONE","EMAIL_STATE_FIELD","nextEmailState","normalizeContactEmail","personKey","readEmailState","findContactByEmail","firebaseAdmin","FieldValue","BUNDLE_ID","NONE","records","stampRecord","ref","incoming","force","withdraw","snapshot","get","exists","current","data","status","update","delete","source","next","atMs","detail","set","merge","resolveOrgId","firestore","request","host","named","String","orgId","trim","hostId","collection","doc","createCrmRecordEmailStateWriter","deps","stamp","email","key","error","console","state","Number","isFinite","Date","now","replace","slice","enrollmentId","contacts","contact","defaultCrmRecordEmailStateDeps","app","registerCrmRecordEmailStateWriter","pluginId"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/record-email-state.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 registerPluginRecordEmailStateWriter,\n type PluginRecordEmailStateReport,\n type PluginRecordEmailStateRequest,\n type PluginRecordEmailStateWriter,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\nimport {\n CRM_EMAIL_STATUS_FIELD,\n CRM_EMAIL_STATUS_NONE,\n EMAIL_STATE_FIELD,\n nextEmailState,\n normalizeContactEmail,\n personKey,\n readEmailState,\n type EmailState,\n} from '@aglyn/aglyn/server'\nimport { findContactByEmail, firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport { recordContactEmailEngagement } from './contact-email-engagement'\nimport { nurtureReachedLeads } from './lead-nurturing'\n\n/**\n * THE CRM'S WRITER ON THE CORE'S RECORD EMAIL-STATE SEAM (AGL-3245).\n *\n * A sender filed a verdict on an address — the outreach runtime's bounce,\n * the campaign webhook's complaint, a member's do-not-contact mark, the\n * unsubscribe link — and says so here; this writes it onto every record\n * the address is, as `emailState`, so the record a person reads and the\n * lists a send consults agree.\n *\n * One address can be several records: the contact the organization holds\n * (found through the address index, `findContactByEmail`), and a lead on\n * each site the organization owns — leads are keyed by `personKey`, so a\n * lead is addressed directly under every host. All of them are stamped;\n * the verdict is about the address, not about which record a rep opened.\n * A caller that knows only the site reads the organization off it.\n *\n * The write keeps the STRONGER verdict (`nextEmailState`): a member's\n * do-not-contact mark is not undone by a later bounce, and `ok` lands only\n * when a caller forces it. `updatedAt` is left alone — a verdict is\n * something that happened to the person, not an edit the team made, and a\n * list sorted on recency must not reshuffle on a bounce.\n *\n * Never throws: the list is the control, and a record that could not be\n * stamped is a chip a page goes without, logged.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\nexport interface CrmRecordEmailStateDeps {\n firestore(): Firestore\n}\n\nconst NONE: PluginRecordEmailStateReport = { records: 0 }\n\n/**\n * Applies the verdict to one record; answers whether it moved. A read then\n * a merge rather than a transaction: two verdicts landing together each\n * compare against a state at least as old as their own, and the ranking\n * makes the order of two writes of the same rank immaterial.\n */\nasync function stampRecord(\n ref: FirebaseFirestore.DocumentReference,\n incoming: EmailState,\n force: boolean,\n withdraw = false,\n): Promise<boolean> {\n const snapshot = await ref.get()\n if (!snapshot.exists) return false\n const current = readEmailState(snapshot.data() as Record<string, unknown>)\n /*\n * A WITHDRAWAL takes back the one verdict it names (AGL-3328): the\n * deliverability check's \"Would bounce\" once the domain takes mail again.\n * Any other standing verdict — a bounce DNS cannot see — is left alone.\n */\n if (withdraw) {\n if (current?.status !== incoming.status) return false\n await ref.update({\n [EMAIL_STATE_FIELD]: FieldValue.delete(),\n [CRM_EMAIL_STATUS_FIELD]: CRM_EMAIL_STATUS_NONE,\n })\n return true\n }\n // The deliverability check saying again what the record already holds:\n // the date it was first found is the one worth keeping.\n if (incoming.source === 'check' && current?.status === incoming.status) return false\n const next = nextEmailState(current, incoming, { force })\n // The current verdict stood, or the same verdict arrived again — a second\n // run of the caller — and there is nothing to write.\n if (\n next === current ||\n (current &&\n next.status === current.status &&\n next.atMs === current.atMs &&\n next.source === current.source &&\n next.detail === current.detail)\n ) {\n return false\n }\n // `emailStatus` beside it: the verdict as the CRM lists filter by it\n // (AGL-3321) — a key a query can ask, where the state is a map.\n await ref.set(\n { [EMAIL_STATE_FIELD]: next, [CRM_EMAIL_STATUS_FIELD]: next.status },\n { merge: true },\n )\n return true\n}\n\n/** The organization a request names, or the one its site belongs to. */\nasync function resolveOrgId(firestore: Firestore, request: PluginRecordEmailStateRequest): Promise<string> {\n const named = String(request.orgId ?? '').trim()\n if (named) return named\n const hostId = String(request.hostId ?? '').trim()\n if (!hostId) return ''\n const host = await firestore.collection('hosts').doc(hostId).get()\n return host.exists ? String(host.get('orgId') ?? '') : ''\n}\n\nexport function createCrmRecordEmailStateWriter(deps: CrmRecordEmailStateDeps): PluginRecordEmailStateWriter {\n return {\n async stamp(request) {\n const email = normalizeContactEmail(request.email)\n const key = email ? personKey(email) : null\n if (!email || !key) return NONE\n const firestore = deps.firestore()\n let orgId: string\n try {\n orgId = await resolveOrgId(firestore, request)\n } catch (error) {\n console.error('[crm] the site’s organization could not be read for an email state', request.hostId, error)\n return NONE\n }\n if (!orgId) return NONE\n const state: EmailState = {\n status: request.state.status,\n atMs: Number.isFinite(request.state.atMs) && request.state.atMs > 0 ? request.state.atMs : Date.now(),\n source: request.state.source,\n detail: request.state.detail\n ? String(request.state.detail).replace(/\\s+/g, ' ').trim().slice(0, 500)\n : null,\n ...(request.state.enrollmentId ? { enrollmentId: request.state.enrollmentId } : {}),\n }\n const force = request.force === true\n const withdraw = request.withdraw === true\n let records = 0\n try {\n const contacts = firestore.collection('orgs').doc(orgId).collection('contacts')\n const contact = await findContactByEmail(contacts, email)\n if (contact && (await stampRecord(contact.ref, state, force, withdraw))) records += 1\n } catch (error) {\n console.error('[crm] the contact could not be stamped with an email state', orgId, error)\n }\n try {\n /*\n * ONE ORG ROW. A bounce or a do-not-contact mark is the platform's\n * verdict on the ADDRESS, and since AGL-3277 there is one document\n * carrying it — the per-site loop beside this had nothing left to\n * find once AGL-3276 emptied the host path.\n */\n if (await stampRecord(\n firestore.collection('orgs').doc(orgId).collection('leads').doc(key),\n state,\n force,\n withdraw,\n )) {\n records += 1\n }\n } catch (error) {\n console.error('[crm] the leads could not be stamped with an email state', orgId, error)\n }\n return { records }\n },\n async engaged(request) {\n try {\n const records = await recordContactEmailEngagement({\n hostId: request.hostId,\n events: request.events,\n firestore: deps.firestore(),\n })\n return { records }\n } catch (error) {\n console.error('[crm] the contacts could not be stamped with an engagement', request.hostId, error)\n return NONE\n }\n },\n // A delivered campaign moves the New leads it reached to Nurturing (AGL-3446).\n async reached(request) {\n return { records: await nurtureReachedLeads(deps.firestore(), request) }\n },\n }\n}\n\n/** The platform's own dependencies. Specs build their own. */\nexport function defaultCrmRecordEmailStateDeps(): CrmRecordEmailStateDeps {\n return { firestore: () => firebaseAdmin.app().firestore() }\n}\n\n/** Registers the CRM as the workspace's record system on the email-state seam. */\nexport function registerCrmRecordEmailStateWriter(\n deps: CrmRecordEmailStateDeps = defaultCrmRecordEmailStateDeps(),\n): void {\n registerPluginRecordEmailStateWriter(createCrmRecordEmailStateWriter(deps), { pluginId: BUNDLE_ID })\n}\n"],"names":["registerPluginRecordEmailStateWriter","CRM_EMAIL_STATUS_FIELD","CRM_EMAIL_STATUS_NONE","EMAIL_STATE_FIELD","nextEmailState","normalizeContactEmail","personKey","readEmailState","findContactByEmail","firebaseAdmin","FieldValue","BUNDLE_ID","recordContactEmailEngagement","nurtureReachedLeads","NONE","records","stampRecord","ref","incoming","force","withdraw","snapshot","get","exists","current","data","status","update","delete","source","next","atMs","detail","set","merge","resolveOrgId","firestore","request","host","named","String","orgId","trim","hostId","collection","doc","createCrmRecordEmailStateWriter","deps","stamp","email","key","error","console","state","Number","isFinite","Date","now","replace","slice","enrollmentId","contacts","contact","engaged","events","reached","defaultCrmRecordEmailStateDeps","app","registerCrmRecordEmailStateWriter","pluginId"],"mappings":"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"}
|
|
@@ -15,11 +15,12 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
15
15
|
* See the License for the specific language governing permissions and
|
|
16
16
|
* limitations under the License.
|
|
17
17
|
*/ import { registerPluginRecordFactsReader } from "@aglyn/aglyn/plugin-manager/plugin-record-facts";
|
|
18
|
-
import { CRM_COLLECTIONS,
|
|
18
|
+
import { CRM_COLLECTIONS, crmReadTokens, isOrgWideMember } from "@aglyn/aglyn/server";
|
|
19
19
|
import { consentGroupForSite, firebaseAdmin, getOrgDoc, memberHasOrgPermission, readLeadForHost, resolveOrgIdForHost, resolveOrgMembership } from "@aglyn/tenant-data-admin";
|
|
20
20
|
import { BUNDLE_ID } from "../constants/bundle-common.js";
|
|
21
21
|
import { CRM_IMPORT_FACTS_RESOURCE, CRM_RECORD_FACTS_RESOURCES, companyFacts, contactFacts, dealFacts, importFacts, isCrmImportFactsCollection, leadFacts } from "../model/record-facts.js";
|
|
22
22
|
import { crmSuiteRefusal } from "./suite-gate.js";
|
|
23
|
+
import { contactPrimaryGroup } from "../model/contact-holder.js";
|
|
23
24
|
/** The org collection a contact lives in, beside the CRM's own. */ const CONTACTS_COLLECTION = 'contacts';
|
|
24
25
|
/** Logged activities read per record before visibility and the timeline cut. */ export const CRM_FACTS_ACTIVITIES_READ = 40;
|
|
25
26
|
/** Tasks read per record, the record page's own window. */ export const CRM_FACTS_TASKS_READ = 50;
|