@aglyn/plugins-crm 1.0.0-beta.220 → 1.0.0-beta.222
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 -1
- package/package.json +13 -13
- package/src/lib/components/activity-list.js +12 -5
- package/src/lib/components/activity-list.js.map +1 -1
- package/src/lib/components/companies-bulk-bar.d.ts +1 -4
- package/src/lib/components/companies-bulk-bar.js +7 -20
- package/src/lib/components/companies-bulk-bar.js.map +1 -1
- package/src/lib/components/companies-section.d.ts +3 -3
- package/src/lib/components/companies-section.js +149 -46
- package/src/lib/components/companies-section.js.map +1 -1
- package/src/lib/components/company-edit-drawer.js +281 -20
- package/src/lib/components/company-edit-drawer.js.map +1 -1
- package/src/lib/components/company-properties-card.js +99 -8
- package/src/lib/components/company-properties-card.js.map +1 -1
- package/src/lib/components/contact-detail-page.js +3 -1
- package/src/lib/components/contact-detail-page.js.map +1 -1
- package/src/lib/components/contact-merge-dialog.js +33 -10
- package/src/lib/components/contact-merge-dialog.js.map +1 -1
- package/src/lib/components/contact-properties-card.d.ts +0 -69
- package/src/lib/components/contact-properties-card.js +350 -60
- package/src/lib/components/contact-properties-card.js.map +1 -1
- package/src/lib/components/contact-reports-to-field.d.ts +46 -0
- package/src/lib/components/contact-reports-to-field.js +67 -0
- package/src/lib/components/contact-reports-to-field.js.map +1 -0
- package/src/lib/components/contacts-bulk-bar.d.ts +1 -8
- package/src/lib/components/contacts-bulk-bar.js +7 -19
- package/src/lib/components/contacts-bulk-bar.js.map +1 -1
- package/src/lib/components/contacts-section.js +46 -33
- package/src/lib/components/contacts-section.js.map +1 -1
- package/src/lib/components/crm-call-actions.d.ts +10 -0
- package/src/lib/components/crm-call-actions.js +26 -9
- package/src/lib/components/crm-call-actions.js.map +1 -1
- package/src/lib/components/crm-record-picker.d.ts +15 -0
- package/src/lib/components/crm-record-picker.js +16 -10
- package/src/lib/components/crm-record-picker.js.map +1 -1
- package/src/lib/components/crm-transfer-buttons.d.ts +54 -0
- package/src/lib/components/crm-transfer-buttons.js +167 -0
- package/src/lib/components/crm-transfer-buttons.js.map +1 -0
- package/src/lib/components/deal-contact-roles-card.d.ts +46 -0
- package/src/lib/components/deal-contact-roles-card.js +368 -0
- package/src/lib/components/deal-contact-roles-card.js.map +1 -0
- package/src/lib/components/deal-detail-page.d.ts +3 -2
- package/src/lib/components/deal-detail-page.js +25 -6
- package/src/lib/components/deal-detail-page.js.map +1 -1
- package/src/lib/components/deal-edit-drawer.d.ts +10 -0
- package/src/lib/components/deal-edit-drawer.js +219 -10
- package/src/lib/components/deal-edit-drawer.js.map +1 -1
- package/src/lib/components/deal-properties-card.d.ts +7 -0
- package/src/lib/components/deal-properties-card.js +34 -3
- package/src/lib/components/deal-properties-card.js.map +1 -1
- package/src/lib/components/deal-stage-card.d.ts +4 -0
- package/src/lib/components/deal-stage-card.js +10 -2
- package/src/lib/components/deal-stage-card.js.map +1 -1
- package/src/lib/components/deals-bulk-bar.d.ts +0 -3
- package/src/lib/components/deals-bulk-bar.js +7 -20
- package/src/lib/components/deals-bulk-bar.js.map +1 -1
- package/src/lib/components/deals-section.d.ts +3 -3
- package/src/lib/components/deals-section.js +123 -63
- package/src/lib/components/deals-section.js.map +1 -1
- package/src/lib/components/fields-section.js +31 -17
- package/src/lib/components/fields-section.js.map +1 -1
- package/src/lib/components/lead-convert-dialog.d.ts +4 -0
- package/src/lib/components/lead-convert-dialog.js +23 -1
- package/src/lib/components/lead-convert-dialog.js.map +1 -1
- package/src/lib/components/lead-detail-page.js +8 -2
- package/src/lib/components/lead-detail-page.js.map +1 -1
- package/src/lib/components/lead-profile-fields.d.ts +71 -0
- package/src/lib/components/lead-profile-fields.js +386 -0
- package/src/lib/components/lead-profile-fields.js.map +1 -0
- package/src/lib/components/lead-properties-card.d.ts +2 -1
- package/src/lib/components/lead-properties-card.js +85 -176
- package/src/lib/components/lead-properties-card.js.map +1 -1
- package/src/lib/components/lead-source-select.d.ts +4 -23
- package/src/lib/components/lead-source-select.js +9 -73
- package/src/lib/components/lead-source-select.js.map +1 -1
- package/src/lib/components/lead-status-chip.d.ts +9 -3
- package/src/lib/components/lead-status-chip.js +8 -4
- package/src/lib/components/lead-status-chip.js.map +1 -1
- package/src/lib/components/lead-status-options.d.ts +20 -0
- package/src/lib/components/lead-status-options.js +51 -0
- package/src/lib/components/lead-status-options.js.map +1 -0
- package/src/lib/components/lead-unqualify-dialog.d.ts +5 -0
- package/src/lib/components/lead-unqualify-dialog.js +50 -18
- package/src/lib/components/lead-unqualify-dialog.js.map +1 -1
- package/src/lib/components/leads-bulk-bar.d.ts +0 -3
- package/src/lib/components/leads-bulk-bar.js +29 -29
- package/src/lib/components/leads-bulk-bar.js.map +1 -1
- package/src/lib/components/leads-section.js +120 -68
- package/src/lib/components/leads-section.js.map +1 -1
- package/src/lib/components/linked-deals-card.d.ts +4 -0
- package/src/lib/components/linked-deals-card.js +47 -3
- package/src/lib/components/linked-deals-card.js.map +1 -1
- package/src/lib/components/log-activity-dialog.js +43 -3
- package/src/lib/components/log-activity-dialog.js.map +1 -1
- package/src/lib/components/new-contact-drawer.d.ts +8 -0
- package/src/lib/components/new-contact-drawer.js +108 -6
- package/src/lib/components/new-contact-drawer.js.map +1 -1
- package/src/lib/components/new-lead-drawer.d.ts +9 -2
- package/src/lib/components/new-lead-drawer.js +88 -121
- package/src/lib/components/new-lead-drawer.js.map +1 -1
- package/src/lib/components/picklist-select.d.ts +36 -0
- package/src/lib/components/picklist-select.js +114 -0
- package/src/lib/components/picklist-select.js.map +1 -0
- package/src/lib/components/picklist-values-card.d.ts +34 -0
- package/src/lib/components/{lead-source-values-card.js → picklist-values-card.js} +241 -58
- package/src/lib/components/picklist-values-card.js.map +1 -0
- package/src/lib/components/pipeline-stages-dialog.d.ts +7 -0
- package/src/lib/components/pipeline-stages-dialog.js +26 -4
- package/src/lib/components/pipeline-stages-dialog.js.map +1 -1
- package/src/lib/components/pipelines-dialog.js +15 -6
- package/src/lib/components/pipelines-dialog.js.map +1 -1
- package/src/lib/components/recent-activity-feed.js +20 -5
- package/src/lib/components/recent-activity-feed.js.map +1 -1
- package/src/lib/components/recipes-card.js +4 -2
- package/src/lib/components/recipes-card.js.map +1 -1
- package/src/lib/components/record-tasks-card.js +15 -8
- package/src/lib/components/record-tasks-card.js.map +1 -1
- package/src/lib/components/reports/forecast-card.d.ts +6 -0
- package/src/lib/components/reports/forecast-card.js +101 -3
- package/src/lib/components/reports/forecast-card.js.map +1 -1
- package/src/lib/components/reports/lead-funnel-card.d.ts +12 -0
- package/src/lib/components/reports/lead-funnel-card.js +9 -5
- package/src/lib/components/reports/lead-funnel-card.js.map +1 -1
- package/src/lib/components/reports/lead-source-card.d.ts +23 -0
- package/src/lib/components/reports/lead-source-card.js +318 -0
- package/src/lib/components/reports/lead-source-card.js.map +1 -0
- package/src/lib/components/reports/report-export.js +1 -1
- package/src/lib/components/reports/report-export.js.map +1 -1
- package/src/lib/components/reports-section.js +8 -0
- package/src/lib/components/reports-section.js.map +1 -1
- package/src/lib/components/sharing-rule-drawer.d.ts +5 -0
- package/src/lib/components/sharing-rule-drawer.js +108 -19
- package/src/lib/components/sharing-rule-drawer.js.map +1 -1
- package/src/lib/components/sharing-rules-card.js +11 -3
- package/src/lib/components/sharing-rules-card.js.map +1 -1
- package/src/lib/components/task-cells.d.ts +9 -1
- package/src/lib/components/task-cells.js +9 -4
- package/src/lib/components/task-cells.js.map +1 -1
- package/src/lib/components/task-edit-drawer.js +111 -43
- package/src/lib/components/task-edit-drawer.js.map +1 -1
- package/src/lib/components/tasks-bulk-bar.d.ts +0 -3
- package/src/lib/components/tasks-bulk-bar.js +7 -20
- package/src/lib/components/tasks-bulk-bar.js.map +1 -1
- package/src/lib/components/tasks-calendar.d.ts +3 -0
- package/src/lib/components/tasks-calendar.js +8 -1
- package/src/lib/components/tasks-calendar.js.map +1 -1
- package/src/lib/components/tasks-section.d.ts +3 -3
- package/src/lib/components/tasks-section.js +58 -43
- package/src/lib/components/tasks-section.js.map +1 -1
- package/src/lib/constants/api-routes.d.ts +2 -9
- package/src/lib/constants/api-routes.js +1 -7
- package/src/lib/constants/api-routes.js.map +1 -1
- package/src/lib/constants/bundle-common.d.ts +6 -0
- package/src/lib/constants/bundle-common.js +11 -0
- package/src/lib/constants/bundle-common.js.map +1 -1
- package/src/lib/constants/company-filters.d.ts +14 -0
- package/src/lib/constants/company-filters.js +51 -1
- package/src/lib/constants/company-filters.js.map +1 -1
- package/src/lib/constants/contact-filters.d.ts +2 -0
- package/src/lib/constants/contact-filters.js +18 -1
- package/src/lib/constants/contact-filters.js.map +1 -1
- package/src/lib/constants/deal-filters.d.ts +22 -1
- package/src/lib/constants/deal-filters.js +67 -4
- package/src/lib/constants/deal-filters.js.map +1 -1
- package/src/lib/declarations.console-server.d.ts +3 -0
- package/src/lib/declarations.console-server.js +35 -0
- package/src/lib/declarations.console-server.js.map +1 -1
- package/src/lib/declarations.d.ts +26 -0
- package/src/lib/declarations.js +35 -0
- package/src/lib/declarations.js.map +1 -0
- package/src/lib/declarations.server.d.ts +11 -0
- package/src/lib/declarations.server.js +92 -1
- package/src/lib/declarations.server.js.map +1 -1
- package/src/lib/hooks/use-company-picklists.d.ts +15 -0
- package/src/lib/hooks/use-company-picklists.js +55 -0
- package/src/lib/hooks/use-company-picklists.js.map +1 -0
- package/src/lib/hooks/use-crm-picklist.d.ts +25 -0
- package/src/lib/hooks/use-crm-picklist.js +53 -0
- package/src/lib/hooks/use-crm-picklist.js.map +1 -0
- package/src/lib/hooks/use-crm-record-names.d.ts +16 -0
- package/src/lib/hooks/use-crm-record-names.js +45 -19
- package/src/lib/hooks/use-crm-record-names.js.map +1 -1
- package/src/lib/hooks/use-crm-task-picklists.d.ts +11 -0
- package/src/lib/hooks/use-crm-task-picklists.js +45 -0
- package/src/lib/hooks/use-crm-task-picklists.js.map +1 -0
- package/src/lib/hooks/use-deals.d.ts +14 -0
- package/src/lib/hooks/use-deals.js +29 -0
- package/src/lib/hooks/use-deals.js.map +1 -1
- package/src/lib/hooks/use-lead-picklists.d.ts +14 -0
- package/src/lib/hooks/use-lead-picklists.js +46 -0
- package/src/lib/hooks/use-lead-picklists.js.map +1 -0
- package/src/lib/hooks/use-lead-source-picklist.d.ts +5 -19
- package/src/lib/hooks/use-lead-source-picklist.js +12 -22
- package/src/lib/hooks/use-lead-source-picklist.js.map +1 -1
- package/src/lib/hooks/use-lead-status-picklist.d.ts +9 -0
- package/src/lib/hooks/use-lead-status-picklist.js +40 -0
- package/src/lib/hooks/use-lead-status-picklist.js.map +1 -0
- package/src/lib/model/companies.d.ts +32 -3
- package/src/lib/model/companies.js +133 -16
- package/src/lib/model/companies.js.map +1 -1
- package/src/lib/model/contact-merge.d.ts +27 -3
- package/src/lib/model/contact-merge.js +81 -7
- package/src/lib/model/contact-merge.js.map +1 -1
- package/src/lib/model/contact-record.d.ts +17 -0
- package/src/lib/model/contact-record.js +15 -1
- package/src/lib/model/contact-record.js.map +1 -1
- package/src/lib/model/contact-update.d.ts +31 -2
- package/src/lib/model/contact-update.js +14 -0
- package/src/lib/model/contact-update.js.map +1 -1
- package/src/lib/model/crm-list-query.d.ts +5 -0
- package/src/lib/model/crm-list-query.js +8 -0
- package/src/lib/model/crm-list-query.js.map +1 -1
- package/src/lib/model/crm-recipes.d.ts +40 -0
- package/src/lib/model/crm-recipes.js +244 -0
- package/src/lib/model/crm-recipes.js.map +1 -0
- package/src/lib/model/crm-record-counts.d.ts +36 -0
- package/src/lib/model/crm-record-counts.js +54 -0
- package/src/lib/model/crm-record-counts.js.map +1 -0
- package/src/lib/model/crm-record-lists.d.ts +23 -0
- package/src/lib/model/crm-record-lists.js +161 -0
- package/src/lib/model/crm-record-lists.js.map +1 -0
- package/src/lib/model/crm-reports.d.ts +73 -3
- package/src/lib/model/crm-reports.js +120 -1
- package/src/lib/model/crm-reports.js.map +1 -1
- package/src/lib/model/crm-sharing.d.ts +21 -3
- package/src/lib/model/crm-sharing.js +27 -6
- package/src/lib/model/crm-sharing.js.map +1 -1
- package/src/lib/model/deal-board-model.d.ts +8 -2
- package/src/lib/model/deal-board-model.js +15 -2
- package/src/lib/model/deal-board-model.js.map +1 -1
- package/src/lib/model/deal-contact-roles.d.ts +45 -0
- package/src/lib/model/deal-contact-roles.js +73 -0
- package/src/lib/model/deal-contact-roles.js.map +1 -0
- package/src/lib/model/deal-form-model.d.ts +56 -0
- package/src/lib/model/deal-form-model.js +79 -7
- package/src/lib/model/deal-form-model.js.map +1 -1
- package/src/lib/model/download-text-file.d.ts +23 -0
- package/src/lib/model/download-text-file.js +34 -0
- package/src/lib/model/download-text-file.js.map +1 -0
- package/src/lib/model/lead-filters.d.ts +36 -2
- package/src/lib/model/lead-filters.js +123 -2
- package/src/lib/model/lead-filters.js.map +1 -1
- package/src/lib/model/lead-source-direction.d.ts +38 -0
- package/src/lib/model/lead-source-direction.js +52 -0
- package/src/lib/model/lead-source-direction.js.map +1 -0
- package/src/lib/model/picklist-values.d.ts +119 -0
- package/src/lib/model/picklist-values.js +256 -0
- package/src/lib/model/picklist-values.js.map +1 -0
- package/src/lib/model/record-facts-disclosed.d.ts +81 -0
- package/src/lib/model/record-facts-disclosed.js +281 -0
- package/src/lib/model/record-facts-disclosed.js.map +1 -0
- package/src/lib/model/record-facts.d.ts +215 -34
- package/src/lib/model/record-facts.js +307 -128
- package/src/lib/model/record-facts.js.map +1 -1
- package/src/lib/model/task-picklists.d.ts +52 -0
- package/src/lib/model/task-picklists.js +83 -0
- package/src/lib/model/task-picklists.js.map +1 -0
- package/src/lib/model/task-routes.d.ts +13 -0
- package/src/lib/model/task-routes.js +23 -4
- package/src/lib/model/task-routes.js.map +1 -1
- package/src/lib/plan-entitlements.js +6 -4
- package/src/lib/plan-entitlements.js.map +1 -1
- package/src/lib/plugin.js +17 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/server/api-v1/contacts.js +220 -14
- package/src/lib/server/api-v1/contacts.js.map +1 -1
- package/src/lib/server/api-v1/crm-activities.js +14 -1
- package/src/lib/server/api-v1/crm-activities.js.map +1 -1
- package/src/lib/server/api-v1/crm-companies.js +123 -14
- package/src/lib/server/api-v1/crm-companies.js.map +1 -1
- package/src/lib/server/api-v1/crm-deals.js +291 -26
- package/src/lib/server/api-v1/crm-deals.js.map +1 -1
- package/src/lib/server/api-v1/crm-leads.js +144 -38
- package/src/lib/server/api-v1/crm-leads.js.map +1 -1
- package/src/lib/server/api-v1/crm-pipelines.d.ts +6 -0
- package/src/lib/server/api-v1/crm-pipelines.js +8 -2
- package/src/lib/server/api-v1/crm-pipelines.js.map +1 -1
- package/src/lib/server/api-v1/crm-tasks.js +75 -33
- package/src/lib/server/api-v1/crm-tasks.js.map +1 -1
- package/src/lib/server/api-v1/openapi.d.ts +0 -11
- package/src/lib/server/api-v1/openapi.js +171 -16
- package/src/lib/server/api-v1/openapi.js.map +1 -1
- package/src/lib/server/assign-contact-owner.d.ts +162 -0
- package/src/lib/server/assign-contact-owner.js +340 -0
- package/src/lib/server/assign-contact-owner.js.map +1 -0
- package/src/lib/server/associate-company-by-domain.d.ts +70 -0
- package/src/lib/server/associate-company-by-domain.js +153 -0
- package/src/lib/server/associate-company-by-domain.js.map +1 -0
- package/src/lib/server/automation-steps.d.ts +139 -0
- package/src/lib/server/automation-steps.js +472 -0
- package/src/lib/server/automation-steps.js.map +1 -0
- package/src/lib/server/capture-contact.js +69 -22
- package/src/lib/server/capture-contact.js.map +1 -1
- package/src/lib/server/capture-host-contact.d.ts +104 -0
- package/src/lib/server/capture-host-contact.js +151 -0
- package/src/lib/server/capture-host-contact.js.map +1 -0
- package/src/lib/server/company-children.d.ts +25 -0
- package/src/lib/server/company-children.js +42 -0
- package/src/lib/server/company-children.js.map +1 -0
- package/src/lib/server/company-delete.d.ts +18 -0
- package/src/lib/server/company-delete.js +44 -21
- package/src/lib/server/company-delete.js.map +1 -1
- package/src/lib/server/contact-email-history.js +21 -19
- package/src/lib/server/contact-email-history.js.map +1 -1
- package/src/lib/server/contact-remove.js +27 -2
- package/src/lib/server/contact-remove.js.map +1 -1
- package/src/lib/server/contact-reports-to.d.ts +61 -0
- package/src/lib/server/contact-reports-to.js +114 -0
- package/src/lib/server/contact-reports-to.js.map +1 -0
- package/src/lib/server/contact-update.d.ts +42 -1
- package/src/lib/server/contact-update.js +204 -23
- package/src/lib/server/contact-update.js.map +1 -1
- package/src/lib/server/convert-host-lead.d.ts +46 -1
- package/src/lib/server/convert-host-lead.js +173 -15
- package/src/lib/server/convert-host-lead.js.map +1 -1
- package/src/lib/server/convert-open-lead.js +12 -5
- package/src/lib/server/convert-open-lead.js.map +1 -1
- package/src/lib/server/crm-record-indexes.d.ts +45 -0
- package/src/lib/server/crm-record-indexes.js +78 -0
- package/src/lib/server/crm-record-indexes.js.map +1 -0
- package/src/lib/server/crm-sharing.d.ts +9 -1
- package/src/lib/server/crm-sharing.js +23 -3
- package/src/lib/server/crm-sharing.js.map +1 -1
- package/src/lib/server/deal-contact-roles.d.ts +27 -0
- package/src/lib/server/deal-contact-roles.js +89 -0
- package/src/lib/server/deal-contact-roles.js.map +1 -0
- package/src/lib/server/lead-convert.d.ts +5 -0
- package/src/lib/server/lead-convert.js +19 -3
- package/src/lib/server/lead-convert.js.map +1 -1
- package/src/lib/server/lead-create.d.ts +17 -2
- package/src/lib/server/lead-create.js +46 -14
- package/src/lib/server/lead-create.js.map +1 -1
- package/src/lib/server/lead-nurturing.d.ts +6 -0
- package/src/lib/server/lead-nurturing.js +57 -20
- package/src/lib/server/lead-nurturing.js.map +1 -1
- package/src/lib/server/lead-picklists.d.ts +35 -0
- package/src/lib/server/lead-picklists.js +52 -0
- package/src/lib/server/lead-picklists.js.map +1 -0
- package/src/lib/server/lead-source-picklist.d.ts +1 -13
- package/src/lib/server/lead-source-picklist.js +6 -33
- package/src/lib/server/lead-source-picklist.js.map +1 -1
- package/src/lib/server/merge-contacts.js +22 -4
- package/src/lib/server/merge-contacts.js.map +1 -1
- package/src/lib/server/person-eraser.d.ts +53 -0
- package/src/lib/server/person-eraser.js +149 -0
- package/src/lib/server/person-eraser.js.map +1 -0
- package/src/lib/server/person-records.d.ts +69 -0
- package/src/lib/server/person-records.js +317 -0
- package/src/lib/server/person-records.js.map +1 -0
- package/src/lib/server/person-refund.d.ts +97 -0
- package/src/lib/server/person-refund.js +221 -0
- package/src/lib/server/person-refund.js.map +1 -0
- package/src/lib/server/picklist-values.d.ts +21 -0
- package/src/lib/server/picklist-values.js +308 -0
- package/src/lib/server/picklist-values.js.map +1 -0
- package/src/lib/server/read-picklist.d.ts +44 -0
- package/src/lib/server/read-picklist.js +65 -0
- package/src/lib/server/read-picklist.js.map +1 -0
- package/src/lib/server/recipe-routes.js +15 -7
- package/src/lib/server/recipe-routes.js.map +1 -1
- package/src/lib/server/record-email-state.js +7 -1
- package/src/lib/server/record-email-state.js.map +1 -1
- package/src/lib/server/record-facts.d.ts +3 -0
- package/src/lib/server/record-facts.js +233 -31
- package/src/lib/server/record-facts.js.map +1 -1
- package/src/lib/server/record-origin.d.ts +27 -0
- package/src/lib/server/record-origin.js +104 -0
- package/src/lib/server/record-origin.js.map +1 -0
- package/src/lib/server/record-timeline.js +15 -2
- package/src/lib/server/record-timeline.js.map +1 -1
- package/src/lib/server/suite-gate.js +4 -3
- package/src/lib/server/suite-gate.js.map +1 -1
- package/src/lib/server/task-routes.js +30 -16
- package/src/lib/server/task-routes.js.map +1 -1
- package/src/lib/server-deal-stage.d.ts +5 -1
- package/src/lib/server-deal-stage.js +10 -5
- package/src/lib/server-deal-stage.js.map +1 -1
- package/src/lib/server.d.ts +8 -3
- package/src/lib/server.js +47 -43
- package/src/lib/server.js.map +1 -1
- package/src/lib/testing/memory-firestore.d.ts +55 -0
- package/src/lib/testing/memory-firestore.js +335 -0
- package/src/lib/testing/memory-firestore.js.map +1 -0
- package/src/lib/testing/stand-in-email-send-index.d.ts +25 -0
- package/src/lib/testing/stand-in-email-send-index.js +49 -0
- package/src/lib/testing/stand-in-email-send-index.js.map +1 -0
- package/src/lib/transfer/activities.d.ts +30 -0
- package/src/lib/transfer/activities.js +132 -0
- package/src/lib/transfer/activities.js.map +1 -0
- package/src/lib/transfer/aliases.d.ts +22 -0
- package/src/lib/transfer/aliases.js +1342 -0
- package/src/lib/transfer/aliases.js.map +1 -0
- package/src/lib/transfer/common.d.ts +222 -0
- package/src/lib/transfer/common.js +832 -0
- package/src/lib/transfer/common.js.map +1 -0
- package/src/lib/transfer/companies.d.ts +43 -0
- package/src/lib/transfer/companies.js +549 -0
- package/src/lib/transfer/companies.js.map +1 -0
- package/src/lib/transfer/contacts.d.ts +31 -0
- package/src/lib/transfer/contacts.js +727 -0
- package/src/lib/transfer/contacts.js.map +1 -0
- package/src/lib/transfer/deals.d.ts +66 -0
- package/src/lib/transfer/deals.js +698 -0
- package/src/lib/transfer/deals.js.map +1 -0
- package/src/lib/transfer/email-templates-package.d.ts +40 -0
- package/src/lib/transfer/email-templates-package.js +76 -0
- package/src/lib/transfer/email-templates-package.js.map +1 -0
- package/src/lib/transfer/email-templates-package.server.d.ts +32 -0
- package/src/lib/transfer/email-templates-package.server.js +176 -0
- package/src/lib/transfer/email-templates-package.server.js.map +1 -0
- package/src/lib/transfer/fields.d.ts +102 -0
- package/src/lib/transfer/fields.js +1755 -0
- package/src/lib/transfer/fields.js.map +1 -0
- package/src/lib/transfer/leads.d.ts +30 -0
- package/src/lib/transfer/leads.js +582 -0
- package/src/lib/transfer/leads.js.map +1 -0
- package/src/lib/transfer/pipelines.d.ts +21 -0
- package/src/lib/transfer/pipelines.js +172 -0
- package/src/lib/transfer/pipelines.js.map +1 -0
- package/src/lib/transfer/register.d.ts +47 -0
- package/src/lib/transfer/register.js +277 -0
- package/src/lib/transfer/register.js.map +1 -0
- package/src/lib/transfer/tasks.d.ts +30 -0
- package/src/lib/transfer/tasks.js +418 -0
- package/src/lib/transfer/tasks.js.map +1 -0
- package/src/lib/transfer/ui.d.ts +20 -0
- package/src/lib/transfer/ui.js +42 -0
- package/src/lib/transfer/ui.js.map +1 -0
- package/src/lib/components/company-import-drawer.d.ts +0 -42
- package/src/lib/components/company-import-drawer.js +0 -99
- package/src/lib/components/company-import-drawer.js.map +0 -1
- package/src/lib/components/contact-import-drawer.d.ts +0 -46
- package/src/lib/components/contact-import-drawer.js +0 -149
- package/src/lib/components/contact-import-drawer.js.map +0 -1
- package/src/lib/components/crm-export-all-button.d.ts +0 -39
- package/src/lib/components/crm-export-all-button.js +0 -98
- package/src/lib/components/crm-export-all-button.js.map +0 -1
- package/src/lib/components/csv-import-drawer.d.ts +0 -139
- package/src/lib/components/csv-import-drawer.js +0 -757
- package/src/lib/components/csv-import-drawer.js.map +0 -1
- package/src/lib/components/deal-import-drawer.d.ts +0 -24
- package/src/lib/components/deal-import-drawer.js +0 -83
- package/src/lib/components/deal-import-drawer.js.map +0 -1
- package/src/lib/components/lead-import-drawer.d.ts +0 -27
- package/src/lib/components/lead-import-drawer.js +0 -118
- package/src/lib/components/lead-import-drawer.js.map +0 -1
- package/src/lib/components/lead-source-values-card.d.ts +0 -27
- package/src/lib/components/lead-source-values-card.js.map +0 -1
- package/src/lib/components/task-import-drawer.d.ts +0 -24
- package/src/lib/components/task-import-drawer.js +0 -82
- package/src/lib/components/task-import-drawer.js.map +0 -1
- package/src/lib/model/companies-csv.d.ts +0 -36
- package/src/lib/model/companies-csv.js +0 -38
- package/src/lib/model/companies-csv.js.map +0 -1
- package/src/lib/model/contacts-csv.d.ts +0 -54
- package/src/lib/model/contacts-csv.js +0 -67
- package/src/lib/model/contacts-csv.js.map +0 -1
- package/src/lib/model/crm-company-import.d.ts +0 -154
- package/src/lib/model/crm-company-import.js +0 -331
- package/src/lib/model/crm-company-import.js.map +0 -1
- package/src/lib/model/crm-csv.d.ts +0 -239
- package/src/lib/model/crm-csv.js +0 -467
- package/src/lib/model/crm-csv.js.map +0 -1
- package/src/lib/model/crm-deal-import.d.ts +0 -112
- package/src/lib/model/crm-deal-import.js +0 -263
- package/src/lib/model/crm-deal-import.js.map +0 -1
- package/src/lib/model/crm-import.d.ts +0 -250
- package/src/lib/model/crm-import.js +0 -467
- package/src/lib/model/crm-import.js.map +0 -1
- package/src/lib/model/crm-lead-import.d.ts +0 -138
- package/src/lib/model/crm-lead-import.js +0 -446
- package/src/lib/model/crm-lead-import.js.map +0 -1
- package/src/lib/model/crm-task-import.d.ts +0 -94
- package/src/lib/model/crm-task-import.js +0 -275
- package/src/lib/model/crm-task-import.js.map +0 -1
- package/src/lib/model/deals-csv.d.ts +0 -32
- package/src/lib/model/deals-csv.js +0 -36
- package/src/lib/model/deals-csv.js.map +0 -1
- package/src/lib/model/import-column-shapes.d.ts +0 -36
- package/src/lib/model/import-column-shapes.js +0 -76
- package/src/lib/model/import-column-shapes.js.map +0 -1
- package/src/lib/model/lead-source-values.d.ts +0 -82
- package/src/lib/model/lead-source-values.js +0 -149
- package/src/lib/model/lead-source-values.js.map +0 -1
- package/src/lib/model/leads-csv.d.ts +0 -40
- package/src/lib/model/leads-csv.js +0 -40
- package/src/lib/model/leads-csv.js.map +0 -1
- package/src/lib/model/tasks-csv.d.ts +0 -28
- package/src/lib/model/tasks-csv.js +0 -28
- package/src/lib/model/tasks-csv.js.map +0 -1
- package/src/lib/server/companies-import.d.ts +0 -57
- package/src/lib/server/companies-import.js +0 -242
- package/src/lib/server/companies-import.js.map +0 -1
- package/src/lib/server/contacts-import.d.ts +0 -109
- package/src/lib/server/contacts-import.js +0 -393
- package/src/lib/server/contacts-import.js.map +0 -1
- package/src/lib/server/deals-import.d.ts +0 -76
- package/src/lib/server/deals-import.js +0 -248
- package/src/lib/server/deals-import.js.map +0 -1
- package/src/lib/server/export-route.d.ts +0 -60
- package/src/lib/server/export-route.js +0 -447
- package/src/lib/server/export-route.js.map +0 -1
- package/src/lib/server/import-context.d.ts +0 -87
- package/src/lib/server/import-context.js +0 -182
- package/src/lib/server/import-context.js.map +0 -1
- package/src/lib/server/lead-source-values.d.ts +0 -50
- package/src/lib/server/lead-source-values.js +0 -191
- package/src/lib/server/lead-source-values.js.map +0 -1
- package/src/lib/server/leads-import.d.ts +0 -84
- package/src/lib/server/leads-import.js +0 -367
- package/src/lib/server/leads-import.js.map +0 -1
- package/src/lib/server/tasks-import.d.ts +0 -54
- package/src/lib/server/tasks-import.js +0 -160
- package/src/lib/server/tasks-import.js.map +0 -1
|
@@ -0,0 +1,42 @@
|
|
|
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 { FieldValue } from "firebase-admin/firestore";
|
|
17
|
+
import { COMPANY_DETACH_LIMIT } from "../model/company-delete-route.js";
|
|
18
|
+
/**
|
|
19
|
+
* Clear `parentCompanyId` on at most `limit` companies under `companyId`
|
|
20
|
+
* (AGL-3514), and say whether more remain. Shared by `crm/company-delete`
|
|
21
|
+
* and the REST resource's delete, which calls it until none remain.
|
|
22
|
+
*/ export async function detachChildCompanies(firestore, companies, companyId, limit = COMPANY_DETACH_LIMIT) {
|
|
23
|
+
// One past the bound, so "more remain" is a fact from the probe row.
|
|
24
|
+
const probe = await companies.where('parentCompanyId', '==', companyId).limit(limit + 1).get();
|
|
25
|
+
const children = probe.docs.slice(0, limit);
|
|
26
|
+
if (children.length) {
|
|
27
|
+
const batch = firestore.batch();
|
|
28
|
+
for (const child of children){
|
|
29
|
+
batch.update(child.ref, {
|
|
30
|
+
parentCompanyId: FieldValue.delete(),
|
|
31
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
await batch.commit();
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
detached: children.length,
|
|
38
|
+
moreRemain: probe.docs.length > limit
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
//# sourceMappingURL=company-children.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/company-children.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 { FieldValue } from 'firebase-admin/firestore'\nimport { COMPANY_DETACH_LIMIT } from '../model/company-delete-route'\n\n/**\n * Clear `parentCompanyId` on at most `limit` companies under `companyId`\n * (AGL-3514), and say whether more remain. Shared by `crm/company-delete`\n * and the REST resource's delete, which calls it until none remain.\n */\nexport async function detachChildCompanies(\n firestore: FirebaseFirestore.Firestore,\n companies: FirebaseFirestore.CollectionReference,\n companyId: string,\n limit: number = COMPANY_DETACH_LIMIT,\n): Promise<{ detached: number; moreRemain: boolean }> {\n // One past the bound, so \"more remain\" is a fact from the probe row.\n const probe = await companies\n .where('parentCompanyId', '==', companyId)\n .limit(limit + 1)\n .get()\n const children = probe.docs.slice(0, limit)\n if (children.length) {\n const batch = firestore.batch()\n for (const child of children) {\n batch.update(child.ref, {\n parentCompanyId: FieldValue.delete(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n }\n await batch.commit()\n }\n return { detached: children.length, moreRemain: probe.docs.length > limit }\n}\n"],"names":["FieldValue","COMPANY_DETACH_LIMIT","detachChildCompanies","firestore","companies","companyId","limit","probe","where","get","children","docs","slice","length","batch","child","update","ref","parentCompanyId","delete","updatedAt","serverTimestamp","commit","detached","moreRemain"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,UAAU,QAAQ,2BAA0B;AACrD,SAASC,oBAAoB,QAAQ,mCAA+B;AAEpE;;;;CAIC,GACD,OAAO,eAAeC,qBACpBC,SAAsC,EACtCC,SAAgD,EAChDC,SAAiB,EACjBC,QAAgBL,oBAAoB;IAEpC,qEAAqE;IACrE,MAAMM,QAAQ,MAAMH,UACjBI,KAAK,CAAC,mBAAmB,MAAMH,WAC/BC,KAAK,CAACA,QAAQ,GACdG,GAAG;IACN,MAAMC,WAAWH,MAAMI,IAAI,CAACC,KAAK,CAAC,GAAGN;IACrC,IAAII,SAASG,MAAM,EAAE;QACnB,MAAMC,QAAQX,UAAUW,KAAK;QAC7B,KAAK,MAAMC,SAASL,SAAU;YAC5BI,MAAME,MAAM,CAACD,MAAME,GAAG,EAAE;gBACtBC,iBAAiBlB,WAAWmB,MAAM;gBAClCC,WAAWpB,WAAWqB,eAAe;YACvC;QACF;QACA,MAAMP,MAAMQ,MAAM;IACpB;IACA,OAAO;QAAEC,UAAUb,SAASG,MAAM;QAAEW,YAAYjB,MAAMI,IAAI,CAACE,MAAM,GAAGP;IAAM;AAC5E"}
|
|
@@ -38,6 +38,13 @@
|
|
|
38
38
|
* answer says more remain, so the next delete continues where this one
|
|
39
39
|
* stopped. A company is never deleted with a link still on a contact.
|
|
40
40
|
*
|
|
41
|
+
* ## The companies under it
|
|
42
|
+
*
|
|
43
|
+
* A company may be another's parent (AGL-3514). Deleting it clears
|
|
44
|
+
* `parentCompanyId` on the companies that name it, in the same bounded
|
|
45
|
+
* pass, so no company is left under one that is gone; they stand on their
|
|
46
|
+
* own, as Salesforce leaves the children of a deleted account.
|
|
47
|
+
*
|
|
41
48
|
* ## Who may call it
|
|
42
49
|
*
|
|
43
50
|
* A CRM writer (`authorizeCrmWriter`) whose reach is the whole organization.
|
|
@@ -56,4 +63,15 @@
|
|
|
56
63
|
import { type PluginApiHandler } from '@aglyn/aglyn/server';
|
|
57
64
|
/** What a member scoped to particular sites is told. */
|
|
58
65
|
export declare const COMPANY_DELETE_SCOPED_REFUSAL: string;
|
|
66
|
+
/**
|
|
67
|
+
* Deletes a company the way the route does: its linked contacts let go of
|
|
68
|
+
* it ({@link COMPANY_DETACH_LIMIT} a call), the companies under it stand on
|
|
69
|
+
* their own, and the document goes once nothing points at it. Shared with
|
|
70
|
+
* an import's undo (AGL-3527), which removes the companies it created.
|
|
71
|
+
*/
|
|
72
|
+
export declare function deleteCrmCompany(firestore: FirebaseFirestore.Firestore, orgRef: FirebaseFirestore.DocumentReference, companyId: string): Promise<{
|
|
73
|
+
deleted: boolean;
|
|
74
|
+
detached: number;
|
|
75
|
+
moreRemain: boolean;
|
|
76
|
+
}>;
|
|
59
77
|
export declare const crmCompanyDeleteHandler: PluginApiHandler;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
1
2
|
/**
|
|
2
3
|
* @license
|
|
3
4
|
* Copyright 2026 Aglyn LLC
|
|
@@ -37,6 +38,13 @@
|
|
|
37
38
|
* answer says more remain, so the next delete continues where this one
|
|
38
39
|
* stopped. A company is never deleted with a link still on a contact.
|
|
39
40
|
*
|
|
41
|
+
* ## The companies under it
|
|
42
|
+
*
|
|
43
|
+
* A company may be another's parent (AGL-3514). Deleting it clears
|
|
44
|
+
* `parentCompanyId` on the companies that name it, in the same bounded
|
|
45
|
+
* pass, so no company is left under one that is gone; they stand on their
|
|
46
|
+
* own, as Salesforce leaves the children of a deleted account.
|
|
47
|
+
*
|
|
40
48
|
* ## Who may call it
|
|
41
49
|
*
|
|
42
50
|
* A CRM writer (`authorizeCrmWriter`) whose reach is the whole organization.
|
|
@@ -55,10 +63,42 @@
|
|
|
55
63
|
import { firebaseAdmin, restampCrmListFields } from "@aglyn/tenant-data-admin";
|
|
56
64
|
import { FieldValue } from "firebase-admin/firestore";
|
|
57
65
|
import { COMPANY_DETACH_LIMIT } from "../model/company-delete-route.js";
|
|
66
|
+
import { detachChildCompanies } from "./company-children.js";
|
|
58
67
|
import { typed } from "./contact-profile.js";
|
|
59
68
|
import { readCrmRouteScope } from "./org-caller.js";
|
|
60
69
|
import { authorizeCrmWriter, canReach } from "./task-routes.js";
|
|
61
70
|
/** What a member scoped to particular sites is told. */ export const COMPANY_DELETE_SCOPED_REFUSAL = 'Your access is limited to specific sites, so the contacts at this company ' + 'could not be read to unlink them. Ask an organization administrator to delete it.';
|
|
71
|
+
/**
|
|
72
|
+
* Deletes a company the way the route does: its linked contacts let go of
|
|
73
|
+
* it ({@link COMPANY_DETACH_LIMIT} a call), the companies under it stand on
|
|
74
|
+
* their own, and the document goes once nothing points at it. Shared with
|
|
75
|
+
* an import's undo (AGL-3527), which removes the companies it created.
|
|
76
|
+
*/ export async function deleteCrmCompany(firestore, orgRef, companyId) {
|
|
77
|
+
const companyRef = orgRef.collection(CRM_COLLECTIONS.companies).doc(companyId);
|
|
78
|
+
// One past the bound, so "more remain" is a fact from the probe row
|
|
79
|
+
// rather than a guess from a full page.
|
|
80
|
+
const probe = await orgRef.collection('contacts').where(CONTACT_COMPANY_IDS_FIELD, 'array-contains', companyId).limit(COMPANY_DETACH_LIMIT + 1).get();
|
|
81
|
+
const linked = probe.docs.slice(0, COMPANY_DETACH_LIMIT);
|
|
82
|
+
const moreRemain = probe.docs.length > COMPANY_DETACH_LIMIT;
|
|
83
|
+
if (linked.length) {
|
|
84
|
+
const batch = firestore.batch();
|
|
85
|
+
for (const snapshot of linked){
|
|
86
|
+
batch.update(snapshot.ref, companyDetachFields(snapshot.data(), companyId));
|
|
87
|
+
}
|
|
88
|
+
await batch.commit();
|
|
89
|
+
// The company is what the Contacts list filters by (AGL-3321).
|
|
90
|
+
await restampCrmListFields(firestore, orgRef.id, 'contacts', linked.map((snapshot)=>snapshot.id));
|
|
91
|
+
}
|
|
92
|
+
// The companies under this one stand on their own once it is gone (AGL-3514).
|
|
93
|
+
const children = await detachChildCompanies(firestore, orgRef.collection(CRM_COLLECTIONS.companies), companyId);
|
|
94
|
+
const remaining = moreRemain || children.moreRemain;
|
|
95
|
+
if (!remaining) await companyRef.delete();
|
|
96
|
+
return {
|
|
97
|
+
deleted: !remaining,
|
|
98
|
+
detached: linked.length,
|
|
99
|
+
moreRemain: remaining
|
|
100
|
+
};
|
|
101
|
+
}
|
|
62
102
|
/**
|
|
63
103
|
* A contact's update when a company it names is deleted: the id out of the
|
|
64
104
|
* mirror, and out of every holder's facet that named it. Another holder's
|
|
@@ -115,27 +155,10 @@ export const crmCompanyDeleteHandler = async (req, res)=>{
|
|
|
115
155
|
});
|
|
116
156
|
return;
|
|
117
157
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const moreRemain = probe.docs.length > COMPANY_DETACH_LIMIT;
|
|
123
|
-
if (linked.length) {
|
|
124
|
-
const batch = firestore.batch();
|
|
125
|
-
for (const snapshot of linked){
|
|
126
|
-
batch.update(snapshot.ref, companyDetachFields(snapshot.data(), companyId));
|
|
127
|
-
}
|
|
128
|
-
await batch.commit();
|
|
129
|
-
// The company is what the Contacts list filters by (AGL-3321).
|
|
130
|
-
await restampCrmListFields(firestore, writer.orgId, 'contacts', linked.map((snapshot)=>snapshot.id));
|
|
131
|
-
}
|
|
132
|
-
if (!moreRemain) await companyRef.delete();
|
|
133
|
-
const answer = {
|
|
134
|
-
ok: true,
|
|
135
|
-
deleted: !moreRemain,
|
|
136
|
-
detached: linked.length,
|
|
137
|
-
moreRemain
|
|
138
|
-
};
|
|
158
|
+
const removal = await deleteCrmCompany(firestore, orgRef, companyId);
|
|
159
|
+
const answer = _extends({
|
|
160
|
+
ok: true
|
|
161
|
+
}, removal);
|
|
139
162
|
res.status(200).json(answer);
|
|
140
163
|
} catch (error) {
|
|
141
164
|
console.error('[crm] company-delete failed', companyId, error);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/company-delete.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/company-delete` — unlink the contacts that name a company,\n * then delete it (AGL-2804).\n *\n * Body: `{ hostId, companyId }` under a site, or `{ orgId, companyId }` at\n * the organization level. Answers `CompanyDeleteResponse`.\n *\n * ## Why a route\n *\n * Firestore does not cascade. A company deleted on its own leaves every\n * contact at it naming a record that no longer exists: their page links to\n * nothing and the `companyIds` mirror the company list queries keeps\n * matching a ghost. So a delete is a detach pass and then a delete, and the\n * detach clears each holder's facet that named the company — which the rules\n * refuse a client, because they cannot tell a company link inside a facet\n * from an owner or a stage beside it.\n *\n * ## Bounded, and honest past the bound\n *\n * One pass unlinks at most `COMPANY_DETACH_LIMIT` contacts, a batch's worth.\n * A company past that is left standing with a pass's worth unlinked, and the\n * answer says more remain, so the next delete continues where this one\n * stopped. A company is never deleted with a link still on a contact.\n *\n * ## Who may call it\n *\n * A CRM writer (`authorizeCrmWriter`) whose reach is the whole organization.\n * The contacts are found by the mirror, and that query cannot carry a scope\n * clause beside its `array-contains`: a member scoped to particular sites\n * could not read them in the browser either, and the route does not unlink\n * people on behalf of someone who may not see them.\n *\n * ## The plan\n *\n * Companies are the CRM's, and the CRM is included from Starter (AGL-2851):\n * a workspace whose plan does not carry it is refused once the writer is\n * known, staff included, before anything is read or unlinked\n * (`suite-gate.ts`).\n */\n\nimport {\n CONTACT_COMPANY_IDS_FIELD,\n contactFacetPath,\n contactGroupsNamingCompany,\n CRM_COLLECTIONS,\n isOrgWideMember,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport { firebaseAdmin, restampCrmListFields } from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n COMPANY_DETACH_LIMIT,\n type CompanyDeleteResponse,\n} from '../model/company-delete-route'\nimport { typed } from './contact-profile'\nimport { readCrmRouteScope } from './org-caller'\nimport { authorizeCrmWriter, canReach } from './task-routes'\n\n/** What a member scoped to particular sites is told. */\nexport const COMPANY_DELETE_SCOPED_REFUSAL =\n 'Your access is limited to specific sites, so the contacts at this company ' +\n 'could not be read to unlink them. Ask an organization administrator to delete it.'\n\n/**\n * A contact's update when a company it names is deleted: the id out of the\n * mirror, and out of every holder's facet that named it. Another holder's\n * link to a different company is not read or written.\n */\nfunction companyDetachFields(\n contact: Record<string, unknown>,\n companyId: string,\n): Record<string, unknown> {\n const update: Record<string, unknown> = {\n [CONTACT_COMPANY_IDS_FIELD]: FieldValue.arrayRemove(companyId),\n updatedAt: FieldValue.serverTimestamp(),\n }\n for (const groupId of contactGroupsNamingCompany(contact, companyId)) {\n update[contactFacetPath(groupId, 'companyId')] = FieldValue.delete()\n }\n return update\n}\n\nexport const crmCompanyDeleteHandler: 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 = (req.body ?? {}) as Record<string, unknown>\n const scope = readCrmRouteScope(body)\n const companyId = typed(body['companyId'], 200)\n if (!scope || !companyId || companyId.includes('/')) {\n res.status(400).json({ error: 'Missing hostId or companyId' })\n return\n }\n\n try {\n const writer = await authorizeCrmWriter(req, scope, { suiteAct: 'Deleting a company' })\n if (writer.ok === false) {\n res.status(writer.status).json(writer.body)\n return\n }\n if (!(writer.staff || writer.level === 'org' || isOrgWideMember(writer.member))) {\n res.status(403).json({ error: COMPANY_DELETE_SCOPED_REFUSAL })\n return\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const orgRef = firestore.collection('orgs').doc(writer.orgId)\n const companyRef = orgRef.collection(CRM_COLLECTIONS.companies).doc(companyId)\n const company = await companyRef.get()\n if (!company.exists || !canReach(writer, company.get('visibleTo'))) {\n res.status(404).json({ error: 'That company no longer exists.' })\n return\n }\n\n // One past the bound, so \"more remain\" is a fact from the probe row\n // rather than a guess from a full page.\n const probe = await orgRef\n .collection('contacts')\n .where(CONTACT_COMPANY_IDS_FIELD, 'array-contains', companyId)\n .limit(COMPANY_DETACH_LIMIT + 1)\n .get()\n const linked = probe.docs.slice(0, COMPANY_DETACH_LIMIT)\n const moreRemain = probe.docs.length > COMPANY_DETACH_LIMIT\n if (linked.length) {\n const batch = firestore.batch()\n for (const snapshot of linked) {\n batch.update(\n snapshot.ref,\n companyDetachFields(snapshot.data() as Record<string, unknown>, companyId),\n )\n }\n await batch.commit()\n // The company is what the Contacts list filters by (AGL-3321).\n await restampCrmListFields(\n firestore,\n writer.orgId,\n 'contacts',\n linked.map((snapshot) => snapshot.id),\n )\n }\n if (!moreRemain) await companyRef.delete()\n\n const answer: CompanyDeleteResponse = {\n ok: true,\n deleted: !moreRemain,\n detached: linked.length,\n moreRemain,\n }\n res.status(200).json(answer)\n } catch (error) {\n console.error('[crm] company-delete failed', companyId, error)\n res.status(500).json({ error: 'The company could not be deleted.' })\n }\n}\n"],"names":["CONTACT_COMPANY_IDS_FIELD","contactFacetPath","contactGroupsNamingCompany","CRM_COLLECTIONS","isOrgWideMember","firebaseAdmin","restampCrmListFields","FieldValue","COMPANY_DETACH_LIMIT","typed","readCrmRouteScope","authorizeCrmWriter","canReach","COMPANY_DELETE_SCOPED_REFUSAL","companyDetachFields","contact","companyId","update","arrayRemove","updatedAt","serverTimestamp","groupId","delete","crmCompanyDeleteHandler","req","res","method","setHeader","status","json","error","body","scope","includes","writer","suiteAct","ok","staff","level","member","firestore","app","orgRef","collection","doc","orgId","companyRef","companies","company","get","exists","probe","where","limit","linked","docs","slice","moreRemain","length","batch","snapshot","ref","data","commit","map","id","answer","deleted","detached","console"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/company-delete.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/company-delete` — unlink the contacts that name a company,\n * then delete it (AGL-2804).\n *\n * Body: `{ hostId, companyId }` under a site, or `{ orgId, companyId }` at\n * the organization level. Answers `CompanyDeleteResponse`.\n *\n * ## Why a route\n *\n * Firestore does not cascade. A company deleted on its own leaves every\n * contact at it naming a record that no longer exists: their page links to\n * nothing and the `companyIds` mirror the company list queries keeps\n * matching a ghost. So a delete is a detach pass and then a delete, and the\n * detach clears each holder's facet that named the company — which the rules\n * refuse a client, because they cannot tell a company link inside a facet\n * from an owner or a stage beside it.\n *\n * ## Bounded, and honest past the bound\n *\n * One pass unlinks at most `COMPANY_DETACH_LIMIT` contacts, a batch's worth.\n * A company past that is left standing with a pass's worth unlinked, and the\n * answer says more remain, so the next delete continues where this one\n * stopped. A company is never deleted with a link still on a contact.\n *\n * ## The companies under it\n *\n * A company may be another's parent (AGL-3514). Deleting it clears\n * `parentCompanyId` on the companies that name it, in the same bounded\n * pass, so no company is left under one that is gone; they stand on their\n * own, as Salesforce leaves the children of a deleted account.\n *\n * ## Who may call it\n *\n * A CRM writer (`authorizeCrmWriter`) whose reach is the whole organization.\n * The contacts are found by the mirror, and that query cannot carry a scope\n * clause beside its `array-contains`: a member scoped to particular sites\n * could not read them in the browser either, and the route does not unlink\n * people on behalf of someone who may not see them.\n *\n * ## The plan\n *\n * Companies are the CRM's, and the CRM is included from Starter (AGL-2851):\n * a workspace whose plan does not carry it is refused once the writer is\n * known, staff included, before anything is read or unlinked\n * (`suite-gate.ts`).\n */\n\nimport {\n CONTACT_COMPANY_IDS_FIELD,\n contactFacetPath,\n contactGroupsNamingCompany,\n CRM_COLLECTIONS,\n isOrgWideMember,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport { firebaseAdmin, restampCrmListFields } from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n COMPANY_DETACH_LIMIT,\n type CompanyDeleteResponse,\n} from '../model/company-delete-route'\nimport { detachChildCompanies } from './company-children'\nimport { typed } from './contact-profile'\nimport { readCrmRouteScope } from './org-caller'\nimport { authorizeCrmWriter, canReach } from './task-routes'\n\n/** What a member scoped to particular sites is told. */\nexport const COMPANY_DELETE_SCOPED_REFUSAL =\n 'Your access is limited to specific sites, so the contacts at this company ' +\n 'could not be read to unlink them. Ask an organization administrator to delete it.'\n\n/**\n * Deletes a company the way the route does: its linked contacts let go of\n * it ({@link COMPANY_DETACH_LIMIT} a call), the companies under it stand on\n * their own, and the document goes once nothing points at it. Shared with\n * an import's undo (AGL-3527), which removes the companies it created.\n */\nexport async function deleteCrmCompany(\n firestore: FirebaseFirestore.Firestore,\n orgRef: FirebaseFirestore.DocumentReference,\n companyId: string,\n): Promise<{ deleted: boolean; detached: number; moreRemain: boolean }> {\n const companyRef = orgRef.collection(CRM_COLLECTIONS.companies).doc(companyId)\n // One past the bound, so \"more remain\" is a fact from the probe row\n // rather than a guess from a full page.\n const probe = await orgRef\n .collection('contacts')\n .where(CONTACT_COMPANY_IDS_FIELD, 'array-contains', companyId)\n .limit(COMPANY_DETACH_LIMIT + 1)\n .get()\n const linked = probe.docs.slice(0, COMPANY_DETACH_LIMIT)\n const moreRemain = probe.docs.length > COMPANY_DETACH_LIMIT\n if (linked.length) {\n const batch = firestore.batch()\n for (const snapshot of linked) {\n batch.update(\n snapshot.ref,\n companyDetachFields(snapshot.data() as Record<string, unknown>, companyId),\n )\n }\n await batch.commit()\n // The company is what the Contacts list filters by (AGL-3321).\n await restampCrmListFields(\n firestore,\n orgRef.id,\n 'contacts',\n linked.map((snapshot) => snapshot.id),\n )\n }\n // The companies under this one stand on their own once it is gone (AGL-3514).\n const children = await detachChildCompanies(\n firestore,\n orgRef.collection(CRM_COLLECTIONS.companies),\n companyId,\n )\n const remaining = moreRemain || children.moreRemain\n if (!remaining) await companyRef.delete()\n return { deleted: !remaining, detached: linked.length, moreRemain: remaining }\n}\n\n/**\n * A contact's update when a company it names is deleted: the id out of the\n * mirror, and out of every holder's facet that named it. Another holder's\n * link to a different company is not read or written.\n */\nfunction companyDetachFields(\n contact: Record<string, unknown>,\n companyId: string,\n): Record<string, unknown> {\n const update: Record<string, unknown> = {\n [CONTACT_COMPANY_IDS_FIELD]: FieldValue.arrayRemove(companyId),\n updatedAt: FieldValue.serverTimestamp(),\n }\n for (const groupId of contactGroupsNamingCompany(contact, companyId)) {\n update[contactFacetPath(groupId, 'companyId')] = FieldValue.delete()\n }\n return update\n}\n\nexport const crmCompanyDeleteHandler: 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 = (req.body ?? {}) as Record<string, unknown>\n const scope = readCrmRouteScope(body)\n const companyId = typed(body['companyId'], 200)\n if (!scope || !companyId || companyId.includes('/')) {\n res.status(400).json({ error: 'Missing hostId or companyId' })\n return\n }\n\n try {\n const writer = await authorizeCrmWriter(req, scope, { suiteAct: 'Deleting a company' })\n if (writer.ok === false) {\n res.status(writer.status).json(writer.body)\n return\n }\n if (!(writer.staff || writer.level === 'org' || isOrgWideMember(writer.member))) {\n res.status(403).json({ error: COMPANY_DELETE_SCOPED_REFUSAL })\n return\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const orgRef = firestore.collection('orgs').doc(writer.orgId)\n const companyRef = orgRef.collection(CRM_COLLECTIONS.companies).doc(companyId)\n const company = await companyRef.get()\n if (!company.exists || !canReach(writer, company.get('visibleTo'))) {\n res.status(404).json({ error: 'That company no longer exists.' })\n return\n }\n\n const removal = await deleteCrmCompany(firestore, orgRef, companyId)\n\n const answer: CompanyDeleteResponse = { ok: true, ...removal }\n res.status(200).json(answer)\n } catch (error) {\n console.error('[crm] company-delete failed', companyId, error)\n res.status(500).json({ error: 'The company could not be deleted.' })\n }\n}\n"],"names":["CONTACT_COMPANY_IDS_FIELD","contactFacetPath","contactGroupsNamingCompany","CRM_COLLECTIONS","isOrgWideMember","firebaseAdmin","restampCrmListFields","FieldValue","COMPANY_DETACH_LIMIT","detachChildCompanies","typed","readCrmRouteScope","authorizeCrmWriter","canReach","COMPANY_DELETE_SCOPED_REFUSAL","deleteCrmCompany","firestore","orgRef","companyId","companyRef","collection","companies","doc","probe","where","limit","get","linked","docs","slice","moreRemain","length","batch","snapshot","update","ref","companyDetachFields","data","commit","id","map","children","remaining","delete","deleted","detached","contact","arrayRemove","updatedAt","serverTimestamp","groupId","crmCompanyDeleteHandler","req","res","method","setHeader","status","json","error","body","scope","includes","writer","suiteAct","ok","staff","level","member","app","orgId","company","exists","removal","answer","console"],"mappings":"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"}
|
|
@@ -56,7 +56,9 @@ import { consentGroupForSite, firebaseAdmin, getOrgForHost, memberHasOrgPermissi
|
|
|
56
56
|
// mocks the admin barrel wholesale must not silently replace the reader.
|
|
57
57
|
import { EMAIL_DELIVERY_READ_LIMIT, readEmailDeliveryHistory } from "@aglyn/tenant-data-admin/server/email-delivery-log";
|
|
58
58
|
import { isRefusedIdToken } from "@aglyn/tenant-data-admin/server/id-token-refusal";
|
|
59
|
+
import { pluginRecordIndex } from "@aglyn/aglyn/plugin-manager/plugin-record-index";
|
|
59
60
|
import { crmSuiteRefusal } from "./suite-gate.js";
|
|
61
|
+
/** The record kind the plugin that keeps campaign sends indexes them under. */ const EMAIL_SEND_RECORD_KIND = 'emailSend';
|
|
60
62
|
/** The route key, as `registerCrmConsoleApi` registers it. */ export const CONTACT_EMAIL_HISTORY_ROUTE = 'crm/contact-email-history';
|
|
61
63
|
const refuse = (status, error)=>({
|
|
62
64
|
ok: false,
|
|
@@ -159,19 +161,21 @@ const refuse = (status, error)=>({
|
|
|
159
161
|
});
|
|
160
162
|
}
|
|
161
163
|
/**
|
|
162
|
-
* The names of the emails these rows name,
|
|
164
|
+
* The names of the emails these rows name, asked of the plugin that keeps
|
|
165
|
+
* the sends (AGL-3080).
|
|
163
166
|
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
167
|
+
* A send is the campaign owner's record (`emailSend`, `plugin-record-index`),
|
|
168
|
+
* and this route only needs what a person calls it: one keyed read per
|
|
169
|
+
* distinct send, all at once, each answering the send's name or `null` for
|
|
170
|
+
* one the team has since deleted — which the timeline draws as the subject
|
|
171
|
+
* the person received with no report to link to. With no plugin keeping
|
|
172
|
+
* sends here, every name is `null` the same way.
|
|
168
173
|
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
* all of which belong to that org.
|
|
174
|
+
* The sends are the organization's; the rows reaching here were already
|
|
175
|
+
* narrowed to the reading group's sites, all of which belong to that org.
|
|
172
176
|
*/ async function readCampaignNames(orgId, rows) {
|
|
177
|
+
var _pluginRecordIndex;
|
|
173
178
|
const names = new Map();
|
|
174
|
-
const firestore = firebaseAdmin.app().firestore();
|
|
175
179
|
const pending = [];
|
|
176
180
|
for (const row of rows){
|
|
177
181
|
const key = `${row.hostId}/${row.campaignId}`;
|
|
@@ -179,19 +183,17 @@ const refuse = (status, error)=>({
|
|
|
179
183
|
names.set(key, null);
|
|
180
184
|
pending.push({
|
|
181
185
|
key,
|
|
182
|
-
hostId: String(row.hostId),
|
|
183
186
|
sendId: String(row.campaignId)
|
|
184
187
|
});
|
|
185
188
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
if (snapshot.exists) names.set(pending[index].key, nameOf(snapshot));
|
|
189
|
+
const sends = (_pluginRecordIndex = pluginRecordIndex(EMAIL_SEND_RECORD_KIND)) == null ? void 0 : _pluginRecordIndex.index;
|
|
190
|
+
if (!pending.length || !sends) return names;
|
|
191
|
+
const found = await Promise.all(pending.map((entry)=>sends.get({
|
|
192
|
+
orgId,
|
|
193
|
+
id: entry.sendId
|
|
194
|
+
}).catch(()=>null)));
|
|
195
|
+
found.forEach((record, index)=>{
|
|
196
|
+
if (record) names.set(pending[index].key, record.name);
|
|
195
197
|
});
|
|
196
198
|
return names;
|
|
197
199
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/contact-email-history.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/contact-email-history` (AGL-2616): the campaign mail one\n * contact was sent, as the timeline draws it.\n *\n * ## Why a server route\n *\n * The per-recipient delivery log — `emailDeliveries/{key}/messages` — is\n * closed to every client by the rules, and rightly: it is keyed by an\n * address hash across every org on the platform, and a row carries the\n * recipient's address, the provider and the links they followed. What a\n * contact's timeline needs is one slice of it — this person, this group's\n * campaigns — projected to the fields a timeline shows. That projection is\n * this route, and the authorization in front of it is the contacts read\n * rule restated for the Admin SDK, which evaluates no rules of its own.\n *\n * ## What one open of a contact page costs\n *\n * Bounded by the PERSON, never by the campaign ledger: one keyed query down\n * the person's own message subcollection, newest first and capped at\n * {@link EMAIL_DELIVERY_READ_LIMIT}, then one `getAll` for the names of the\n * distinct emails those rows name. Nothing here walks a campaign's\n * recipients — that direction is the campaign report's, and is a\n * collection-group query this route never issues.\n *\n * ## Only this group's campaigns\n *\n * The log holds every message ever sent to the address, from every site on\n * the platform. The rows handed back are those a campaign on one of the\n * reading group's sites sent — the `hostId` tag `campaign-send` stamps,\n * filtered against the consent group's `hostIds` — so a sibling business\n * sharing the same contact row never sees the mail another business sent\n * the person, and transactional mail (a receipt, an invite, which carry no\n * site) is not on a CRM timeline at all. The filter runs over the newest\n * fifty messages, so a person mailed heavily by several orgs can have this\n * group's older campaigns fall past the window; the response says what the\n * window was.\n */\n\nimport {\n type AglynOrgMember,\n type ContactCampaignEmail,\n memberCanSee,\n type PluginApiHandler,\n type PluginApiRequest,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n firebaseAdmin,\n getOrgForHost,\n memberHasOrgPermission,\n orgDataCollectionForHost,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\n// The leaf, for the reason the webhook imports it from the leaf: a spec that\n// mocks the admin barrel wholesale must not silently replace the reader.\nimport {\n EMAIL_DELIVERY_READ_LIMIT,\n type EmailDeliveryRecord,\n readEmailDeliveryHistory,\n} from '@aglyn/tenant-data-admin/server/email-delivery-log'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/** The route key, as `registerCrmConsoleApi` registers it. */\nexport const CONTACT_EMAIL_HISTORY_ROUTE = 'crm/contact-email-history'\n\n/** What the route answers with. */\nexport interface ContactEmailHistoryResponse {\n ok: true\n /** Newest first, by the instant each was sent. */\n emails: ContactCampaignEmail[]\n /**\n * True when the log could not be read, so an empty list means \"unknown\"\n * rather than \"nothing was sent\" — the two lead a reader to opposite\n * conclusions about the relationship.\n */\n lookupFailed: boolean\n /** How many of the person's newest messages the campaign filter ran over. */\n limit: number\n}\n\ntype Refusal = { ok: false; status: number; error: string }\n\ninterface Reader {\n ok: true\n uid: string\n staff: boolean\n orgId: string\n org: Record<string, unknown>\n member: Partial<AglynOrgMember> | null\n}\n\nconst refuse = (status: number, error: string): Refusal => ({\n ok: false,\n status,\n error,\n})\n\n/**\n * Who is asking, and whether they may read this site's CRM at all — the\n * rules' `canReadScopedPeople` restated, minus the per-document half that\n * the handler asks against the contact once it has it.\n *\n * `data.manage` is the key, because it is the key the contacts read rule\n * gates on: an org viewer holding no grant may open no contact in the\n * console and may not read one here. Staff pass on the claim alone, as the\n * rules admit them.\n */\nasync function authorizeCrmReader(\n req: PluginApiRequest,\n hostId: string,\n): Promise<Reader | Refusal> {\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return refuse(401, 'Unauthenticated')\n let decoded: { uid: string; staff?: unknown }\n try {\n decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n } catch (error) {\n // A refused credential is the caller's 401; a failure to check one is\n // ours and keeps a 5xx (AGL-2852).\n if (isRefusedIdToken(error)) return refuse(401, 'Unauthenticated')\n console.error('[crm] contact-email-history could not verify the reader', error)\n return refuse(500, 'The sign-in could not be checked. Try again.')\n }\n const staff = decoded.staff === true\n const resolved = await getOrgForHost(hostId).catch(() => null)\n if (!resolved) {\n return refuse(404, 'This site has no organization, so it has no CRM.')\n }\n const { orgId, org } = resolved\n const membership = await resolveOrgMembership(decoded.uid, orgId).catch(\n () => null,\n )\n const member = (membership?.member ?? null) as Partial<AglynOrgMember> | null\n if (!staff) {\n const suspended =\n (member as { orgSuspended?: boolean } | null)?.orgSuspended === true\n if (!member || suspended) {\n return refuse(403, 'Your organization role does not allow reading the CRM.')\n }\n if (!(await memberHasOrgPermission(orgId, member, 'data.manage'))) {\n return refuse(403, 'Your organization role does not allow reading the CRM.')\n }\n }\n return {\n ok: true,\n uid: decoded.uid,\n staff,\n orgId,\n org: (org ?? {}) as Record<string, unknown>,\n member,\n }\n}\n\n/** A finite, positive instant, or nothing. */\nconst instant = (value: unknown): number | undefined => {\n const parsed = Number(value)\n return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined\n}\n\n/**\n * One delivery-log row as the timeline reads it.\n *\n * Exported for the spec, and pure: it is the projection that decides what\n * leaves the server — the address, the provider, the followed links and the\n * bounce detail stay behind. `sentAtMs` prefers the provider's own `sent`\n * instant and falls back to the first event seen, which the log guarantees\n * on every row, so an entry can always be placed.\n */\nexport function contactCampaignEmailFromDelivery(\n row: EmailDeliveryRecord,\n campaignName: string | null,\n): ContactCampaignEmail | null {\n if (!row.campaignId || !row.hostId) return null\n const stamps = row.timestamps ?? {}\n const sentAtMs = instant(stamps.sent) ?? instant(row.firstSeenAtMs)\n if (!sentAtMs) return null\n const delivered = instant(stamps.delivered)\n const opened = instant(stamps.opened)\n const clicked = instant(stamps.clicked)\n const bounced = instant(stamps.bounced)\n const complained = instant(stamps.complained)\n return {\n messageId: row.messageId,\n hostId: row.hostId,\n campaignId: row.campaignId,\n campaignName,\n subject: row.subject ?? null,\n sentAtMs,\n ...(delivered ? { deliveredAtMs: delivered } : {}),\n ...(opened ? { openedAtMs: opened } : {}),\n ...(clicked ? { clickedAtMs: clicked } : {}),\n ...(bounced ? { bouncedAtMs: bounced } : {}),\n ...(complained ? { complainedAtMs: complained } : {}),\n openCount: Number(row.openCount ?? 0),\n clickCount: Number(row.clickCount ?? 0),\n }\n}\n\n/**\n * The names of the emails these rows name, in one `getAll`.\n *\n * Keyed reads rather than a query: the ids are known, a `getAll` is one round\n * trip however many there are, and it needs no index. An email the team has\n * since deleted reads as `null`, which the timeline draws as the subject the\n * person received with no report to link to.\n *\n * A send is the organization's (`orgs/{orgId}/campaigns/{sendId}`); the\n * rows reaching here were already narrowed to the reading group's sites,\n * all of which belong to that org.\n */\nasync function readCampaignNames(\n orgId: string,\n rows: readonly EmailDeliveryRecord[],\n): Promise<Map<string, string | null>> {\n const names = new Map<string, string | null>()\n const firestore = firebaseAdmin.app().firestore()\n const pending: { key: string; hostId: string; sendId: string }[] = []\n for (const row of rows) {\n const key = `${row.hostId}/${row.campaignId}`\n if (names.has(key)) continue\n names.set(key, null)\n pending.push({ key, hostId: String(row.hostId), sendId: String(row.campaignId) })\n }\n if (!pending.length) return names\n const nameOf = (snapshot: FirebaseFirestore.DocumentSnapshot) =>\n String(snapshot.get('displayName') ?? '').trim() ||\n String(snapshot.get('subject') ?? '').trim() ||\n null\n const sends = firestore.collection('orgs').doc(orgId).collection('campaigns')\n const found = await firestore.getAll(...pending.map((entry) => sends.doc(entry.sendId)))\n found.forEach((snapshot, index) => {\n if (snapshot.exists) names.set(pending[index].key, nameOf(snapshot))\n })\n return names\n}\n\nexport const contactEmailHistoryHandler: 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 hostId = String(req.body?.hostId ?? '').trim()\n const contactId = String(req.body?.contactId ?? '').trim()\n if (!hostId || !contactId) {\n res.status(400).json({ error: 'Missing hostId or contactId' })\n return\n }\n const reader = await authorizeCrmReader(req, hostId)\n if (reader.ok === false) {\n res.status(reader.status).json({ error: reader.error })\n return\n }\n // The plan after the reader, before any contact or log is read (AGL-2851).\n const suite = crmSuiteRefusal(reader.org, \"A contact's campaign email history\")\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n try {\n /*\n * The contact by id, then the per-document half of the read rule: the\n * Admin SDK evaluates no rules, so a scoped member who can guess an id\n * must be refused here exactly as their listener would refuse the row.\n * \"Not visible\" and \"not there\" are one answer on purpose — a 403 would\n * confirm the id exists.\n */\n const contactsRef = await orgDataCollectionForHost(hostId, 'contacts')\n const snapshot = await contactsRef.doc(contactId).get()\n const visibleTo = snapshot.exists\n ? (snapshot.get('visibleTo') as string[] | undefined)\n : undefined\n if (!snapshot.exists || !(reader.staff || memberCanSee(reader.member, visibleTo))) {\n res.status(404).json({ error: 'Unknown contact' })\n return\n }\n const email = String(snapshot.get('email') ?? '')\n const group = await consentGroupForSite(hostId, reader.org)\n const history = await readEmailDeliveryHistory(email, {\n limit: EMAIL_DELIVERY_READ_LIMIT,\n })\n const rows = history.rows.filter(\n (row) =>\n Boolean(row.campaignId) &&\n Boolean(row.hostId) &&\n group.hostIds.includes(String(row.hostId)),\n )\n const names = await readCampaignNames(reader.orgId, rows)\n const emails = rows\n .map((row) =>\n contactCampaignEmailFromDelivery(\n row,\n names.get(`${row.hostId}/${row.campaignId}`) ?? null,\n ),\n )\n .filter((entry): entry is ContactCampaignEmail => entry !== null)\n .sort((a, b) => b.sentAtMs - a.sentAtMs)\n const body: ContactEmailHistoryResponse = {\n ok: true,\n emails,\n lookupFailed: history.lookupFailed,\n limit: EMAIL_DELIVERY_READ_LIMIT,\n }\n res.status(200).json(body)\n } catch (error) {\n console.error('[crm] contact-email-history failed', hostId, contactId, error)\n res.status(500).json({ error: 'The campaign history could not be read' })\n }\n}\n"],"names":["memberCanSee","consentGroupForSite","firebaseAdmin","getOrgForHost","memberHasOrgPermission","orgDataCollectionForHost","resolveOrgMembership","EMAIL_DELIVERY_READ_LIMIT","readEmailDeliveryHistory","isRefusedIdToken","crmSuiteRefusal","CONTACT_EMAIL_HISTORY_ROUTE","refuse","status","error","ok","authorizeCrmReader","req","hostId","authorization","String","headers","idToken","startsWith","slice","length","undefined","decoded","app","auth","verifyIdToken","console","staff","resolved","catch","orgId","org","membership","uid","member","suspended","orgSuspended","instant","value","parsed","Number","isFinite","contactCampaignEmailFromDelivery","row","campaignName","campaignId","stamps","timestamps","sentAtMs","sent","firstSeenAtMs","delivered","opened","clicked","bounced","complained","messageId","subject","deliveredAtMs","openedAtMs","clickedAtMs","bouncedAtMs","complainedAtMs","openCount","clickCount","readCampaignNames","rows","names","Map","firestore","pending","key","has","set","push","sendId","nameOf","snapshot","get","trim","sends","collection","doc","found","getAll","map","entry","forEach","index","exists","contactEmailHistoryHandler","res","method","setHeader","json","body","contactId","reader","suite","contactsRef","visibleTo","email","group","history","limit","filter","Boolean","hostIds","includes","emails","sort","a","b","lookupFailed"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoCC,GAED,SAGEA,YAAY,QAGP,sBAAqB;AAC5B,SACEC,mBAAmB,EACnBC,aAAa,EACbC,aAAa,EACbC,sBAAsB,EACtBC,wBAAwB,EACxBC,oBAAoB,QACf,2BAA0B;AACjC,6EAA6E;AAC7E,yEAAyE;AACzE,SACEC,yBAAyB,EAEzBC,wBAAwB,QACnB,qDAAoD;AAC3D,SAASC,gBAAgB,QAAQ,mDAAkD;AACnF,SAASC,eAAe,QAAQ,kBAAc;AAE9C,4DAA4D,GAC5D,OAAO,MAAMC,8BAA8B,4BAA2B;AA4BtE,MAAMC,SAAS,CAACC,QAAgBC,QAA4B,CAAA;QAC1DC,IAAI;QACJF;QACAC;IACF,CAAA;AAEA;;;;;;;;;CASC,GACD,eAAeE,mBACbC,GAAqB,EACrBC,MAAc;QAEeD;IAA7B,MAAME,gBAAgBC,QAAOH,6BAAAA,IAAII,OAAO,CAACF,aAAa,YAAzBF,6BAA6B;IAC1D,MAAMK,UAAUH,cAAcI,UAAU,CAAC,aACrCJ,cAAcK,KAAK,CAAC,UAAUC,MAAM,IACpCC;IACJ,IAAI,CAACJ,SAAS,OAAOV,OAAO,KAAK;IACjC,IAAIe;IACJ,IAAI;QACFA,UAAU,MAAMzB,cAAc0B,GAAG,GAAGC,IAAI,GAAGC,aAAa,CAACR;IAC3D,EAAE,OAAOR,OAAO;QACd,sEAAsE;QACtE,mCAAmC;QACnC,IAAIL,iBAAiBK,QAAQ,OAAOF,OAAO,KAAK;QAChDmB,QAAQjB,KAAK,CAAC,2DAA2DA;QACzE,OAAOF,OAAO,KAAK;IACrB;IACA,MAAMoB,QAAQL,QAAQK,KAAK,KAAK;IAChC,MAAMC,WAAW,MAAM9B,cAAce,QAAQgB,KAAK,CAAC,IAAM;IACzD,IAAI,CAACD,UAAU;QACb,OAAOrB,OAAO,KAAK;IACrB;IACA,MAAM,EAAEuB,KAAK,EAAEC,GAAG,EAAE,GAAGH;IACvB,MAAMI,aAAa,MAAM/B,qBAAqBqB,QAAQW,GAAG,EAAEH,OAAOD,KAAK,CACrE,IAAM;IAER,MAAMK,iBAAUF,8BAAAA,WAAYE,MAAM,mBAAI;IACtC,IAAI,CAACP,OAAO;QACV,MAAMQ,YACJ,CAACD,0BAAD,AAACA,OAA8CE,YAAY,MAAK;QAClE,IAAI,CAACF,UAAUC,WAAW;YACxB,OAAO5B,OAAO,KAAK;QACrB;QACA,IAAI,CAAE,MAAMR,uBAAuB+B,OAAOI,QAAQ,gBAAiB;YACjE,OAAO3B,OAAO,KAAK;QACrB;IACF;IACA,OAAO;QACLG,IAAI;QACJuB,KAAKX,QAAQW,GAAG;QAChBN;QACAG;QACAC,GAAG,EAAGA,cAAAA,MAAO,CAAC;QACdG;IACF;AACF;AAEA,4CAA4C,GAC5C,MAAMG,UAAU,CAACC;IACf,MAAMC,SAASC,OAAOF;IACtB,OAAOE,OAAOC,QAAQ,CAACF,WAAWA,SAAS,IAAIA,SAASlB;AAC1D;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASqB,iCACdC,GAAwB,EACxBC,YAA2B;QAGZD,iBACEN,UAYNM,cAOSA,gBACCA;IAtBrB,IAAI,CAACA,IAAIE,UAAU,IAAI,CAACF,IAAI9B,MAAM,EAAE,OAAO;IAC3C,MAAMiC,UAASH,kBAAAA,IAAII,UAAU,YAAdJ,kBAAkB,CAAC;IAClC,MAAMK,YAAWX,WAAAA,QAAQS,OAAOG,IAAI,aAAnBZ,WAAwBA,QAAQM,IAAIO,aAAa;IAClE,IAAI,CAACF,UAAU,OAAO;IACtB,MAAMG,YAAYd,QAAQS,OAAOK,SAAS;IAC1C,MAAMC,SAASf,QAAQS,OAAOM,MAAM;IACpC,MAAMC,UAAUhB,QAAQS,OAAOO,OAAO;IACtC,MAAMC,UAAUjB,QAAQS,OAAOQ,OAAO;IACtC,MAAMC,aAAalB,QAAQS,OAAOS,UAAU;IAC5C,OAAO;QACLC,WAAWb,IAAIa,SAAS;QACxB3C,QAAQ8B,IAAI9B,MAAM;QAClBgC,YAAYF,IAAIE,UAAU;QAC1BD;QACAa,OAAO,GAAEd,eAAAA,IAAIc,OAAO,YAAXd,eAAe;QACxBK;OACIG,YAAY;QAAEO,eAAeP;IAAU,IAAI,CAAC,GAC5CC,SAAS;QAAEO,YAAYP;IAAO,IAAI,CAAC,GACnCC,UAAU;QAAEO,aAAaP;IAAQ,IAAI,CAAC,GACtCC,UAAU;QAAEO,aAAaP;IAAQ,IAAI,CAAC,GACtCC,aAAa;QAAEO,gBAAgBP;IAAW,IAAI,CAAC;QACnDQ,WAAWvB,QAAOG,iBAAAA,IAAIoB,SAAS,YAAbpB,iBAAiB;QACnCqB,YAAYxB,QAAOG,kBAAAA,IAAIqB,UAAU,YAAdrB,kBAAkB;;AAEzC;AAEA;;;;;;;;;;;CAWC,GACD,eAAesB,kBACbnC,KAAa,EACboC,IAAoC;IAEpC,MAAMC,QAAQ,IAAIC;IAClB,MAAMC,YAAYxE,cAAc0B,GAAG,GAAG8C,SAAS;IAC/C,MAAMC,UAA6D,EAAE;IACrE,KAAK,MAAM3B,OAAOuB,KAAM;QACtB,MAAMK,MAAM,GAAG5B,IAAI9B,MAAM,CAAC,CAAC,EAAE8B,IAAIE,UAAU,EAAE;QAC7C,IAAIsB,MAAMK,GAAG,CAACD,MAAM;QACpBJ,MAAMM,GAAG,CAACF,KAAK;QACfD,QAAQI,IAAI,CAAC;YAAEH;YAAK1D,QAAQE,OAAO4B,IAAI9B,MAAM;YAAG8D,QAAQ5D,OAAO4B,IAAIE,UAAU;QAAE;IACjF;IACA,IAAI,CAACyB,QAAQlD,MAAM,EAAE,OAAO+C;IAC5B,MAAMS,SAAS,CAACC;YACPA,eACAA;eADP9D,QAAO8D,gBAAAA,SAASC,GAAG,CAAC,0BAAbD,gBAA+B,IAAIE,IAAI,MAC9ChE,QAAO8D,iBAAAA,SAASC,GAAG,CAAC,sBAAbD,iBAA2B,IAAIE,IAAI,MAC1C;;IACF,MAAMC,QAAQX,UAAUY,UAAU,CAAC,QAAQC,GAAG,CAACpD,OAAOmD,UAAU,CAAC;IACjE,MAAME,QAAQ,MAAMd,UAAUe,MAAM,IAAId,QAAQe,GAAG,CAAC,CAACC,QAAUN,MAAME,GAAG,CAACI,MAAMX,MAAM;IACrFQ,MAAMI,OAAO,CAAC,CAACV,UAAUW;QACvB,IAAIX,SAASY,MAAM,EAAEtB,MAAMM,GAAG,CAACH,OAAO,CAACkB,MAAM,CAACjB,GAAG,EAAEK,OAAOC;IAC5D;IACA,OAAOV;AACT;AAEA,OAAO,MAAMuB,6BAA+C,OAAO9E,KAAK+E;;QAMhD/E,WACGA;IANzB,IAAIA,IAAIgF,MAAM,KAAK,QAAQ;QACzBD,IAAIE,SAAS,CAAC,SAAS;QACvBF,IAAInF,MAAM,CAAC,KAAKsF,IAAI,CAAC;YAAErF,OAAO;QAAqB;QACnD;IACF;IACA,MAAMI,SAASE,gBAAOH,YAAAA,IAAImF,IAAI,qBAARnF,UAAUC,MAAM,mBAAI,IAAIkE,IAAI;IAClD,MAAMiB,YAAYjF,iBAAOH,aAAAA,IAAImF,IAAI,qBAARnF,WAAUoF,SAAS,oBAAI,IAAIjB,IAAI;IACxD,IAAI,CAAClE,UAAU,CAACmF,WAAW;QACzBL,IAAInF,MAAM,CAAC,KAAKsF,IAAI,CAAC;YAAErF,OAAO;QAA8B;QAC5D;IACF;IACA,MAAMwF,SAAS,MAAMtF,mBAAmBC,KAAKC;IAC7C,IAAIoF,OAAOvF,EAAE,KAAK,OAAO;QACvBiF,IAAInF,MAAM,CAACyF,OAAOzF,MAAM,EAAEsF,IAAI,CAAC;YAAErF,OAAOwF,OAAOxF,KAAK;QAAC;QACrD;IACF;IACA,2EAA2E;IAC3E,MAAMyF,QAAQ7F,gBAAgB4F,OAAOlE,GAAG,EAAE;IAC1C,IAAImE,OAAO;QACTP,IAAInF,MAAM,CAAC0F,MAAM1F,MAAM,EAAEsF,IAAI,CAACI,MAAMH,IAAI;QACxC;IACF;IACA,IAAI;YAiBmBlB;QAhBrB;;;;;;KAMC,GACD,MAAMsB,cAAc,MAAMnG,yBAAyBa,QAAQ;QAC3D,MAAMgE,WAAW,MAAMsB,YAAYjB,GAAG,CAACc,WAAWlB,GAAG;QACrD,MAAMsB,YAAYvB,SAASY,MAAM,GAC5BZ,SAASC,GAAG,CAAC,eACdzD;QACJ,IAAI,CAACwD,SAASY,MAAM,IAAI,CAAEQ,CAAAA,OAAOtE,KAAK,IAAIhC,aAAasG,OAAO/D,MAAM,EAAEkE,UAAS,GAAI;YACjFT,IAAInF,MAAM,CAAC,KAAKsF,IAAI,CAAC;gBAAErF,OAAO;YAAkB;YAChD;QACF;QACA,MAAM4F,QAAQtF,QAAO8D,gBAAAA,SAASC,GAAG,CAAC,oBAAbD,gBAAyB;QAC9C,MAAMyB,QAAQ,MAAM1G,oBAAoBiB,QAAQoF,OAAOlE,GAAG;QAC1D,MAAMwE,UAAU,MAAMpG,yBAAyBkG,OAAO;YACpDG,OAAOtG;QACT;QACA,MAAMgE,OAAOqC,QAAQrC,IAAI,CAACuC,MAAM,CAC9B,CAAC9D,MACC+D,QAAQ/D,IAAIE,UAAU,KACtB6D,QAAQ/D,IAAI9B,MAAM,KAClByF,MAAMK,OAAO,CAACC,QAAQ,CAAC7F,OAAO4B,IAAI9B,MAAM;QAE5C,MAAMsD,QAAQ,MAAMF,kBAAkBgC,OAAOnE,KAAK,EAAEoC;QACpD,MAAM2C,SAAS3C,KACZmB,GAAG,CAAC,CAAC1C;gBAGFwB;mBAFFzB,iCACEC,MACAwB,aAAAA,MAAMW,GAAG,CAAC,GAAGnC,IAAI9B,MAAM,CAAC,CAAC,EAAE8B,IAAIE,UAAU,EAAE,aAA3CsB,aAAgD;WAGnDsC,MAAM,CAAC,CAACnB,QAAyCA,UAAU,MAC3DwB,IAAI,CAAC,CAACC,GAAGC,IAAMA,EAAEhE,QAAQ,GAAG+D,EAAE/D,QAAQ;QACzC,MAAM+C,OAAoC;YACxCrF,IAAI;YACJmG;YACAI,cAAcV,QAAQU,YAAY;YAClCT,OAAOtG;QACT;QACAyF,IAAInF,MAAM,CAAC,KAAKsF,IAAI,CAACC;IACvB,EAAE,OAAOtF,OAAO;QACdiB,QAAQjB,KAAK,CAAC,sCAAsCI,QAAQmF,WAAWvF;QACvEkF,IAAInF,MAAM,CAAC,KAAKsF,IAAI,CAAC;YAAErF,OAAO;QAAyC;IACzE;AACF,EAAC"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/contact-email-history.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/contact-email-history` (AGL-2616): the campaign mail one\n * contact was sent, as the timeline draws it.\n *\n * ## Why a server route\n *\n * The per-recipient delivery log — `emailDeliveries/{key}/messages` — is\n * closed to every client by the rules, and rightly: it is keyed by an\n * address hash across every org on the platform, and a row carries the\n * recipient's address, the provider and the links they followed. What a\n * contact's timeline needs is one slice of it — this person, this group's\n * campaigns — projected to the fields a timeline shows. That projection is\n * this route, and the authorization in front of it is the contacts read\n * rule restated for the Admin SDK, which evaluates no rules of its own.\n *\n * ## What one open of a contact page costs\n *\n * Bounded by the PERSON, never by the campaign ledger: one keyed query down\n * the person's own message subcollection, newest first and capped at\n * {@link EMAIL_DELIVERY_READ_LIMIT}, then one `getAll` for the names of the\n * distinct emails those rows name. Nothing here walks a campaign's\n * recipients — that direction is the campaign report's, and is a\n * collection-group query this route never issues.\n *\n * ## Only this group's campaigns\n *\n * The log holds every message ever sent to the address, from every site on\n * the platform. The rows handed back are those a campaign on one of the\n * reading group's sites sent — the `hostId` tag `campaign-send` stamps,\n * filtered against the consent group's `hostIds` — so a sibling business\n * sharing the same contact row never sees the mail another business sent\n * the person, and transactional mail (a receipt, an invite, which carry no\n * site) is not on a CRM timeline at all. The filter runs over the newest\n * fifty messages, so a person mailed heavily by several orgs can have this\n * group's older campaigns fall past the window; the response says what the\n * window was.\n */\n\nimport {\n type AglynOrgMember,\n type ContactCampaignEmail,\n memberCanSee,\n type PluginApiHandler,\n type PluginApiRequest,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n firebaseAdmin,\n getOrgForHost,\n memberHasOrgPermission,\n orgDataCollectionForHost,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\n// The leaf, for the reason the webhook imports it from the leaf: a spec that\n// mocks the admin barrel wholesale must not silently replace the reader.\nimport {\n EMAIL_DELIVERY_READ_LIMIT,\n type EmailDeliveryRecord,\n readEmailDeliveryHistory,\n} from '@aglyn/tenant-data-admin/server/email-delivery-log'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { pluginRecordIndex } from '@aglyn/aglyn/plugin-manager/plugin-record-index'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/** The record kind the plugin that keeps campaign sends indexes them under. */\nconst EMAIL_SEND_RECORD_KIND = 'emailSend'\n\n/** The route key, as `registerCrmConsoleApi` registers it. */\nexport const CONTACT_EMAIL_HISTORY_ROUTE = 'crm/contact-email-history'\n\n/** What the route answers with. */\nexport interface ContactEmailHistoryResponse {\n ok: true\n /** Newest first, by the instant each was sent. */\n emails: ContactCampaignEmail[]\n /**\n * True when the log could not be read, so an empty list means \"unknown\"\n * rather than \"nothing was sent\" — the two lead a reader to opposite\n * conclusions about the relationship.\n */\n lookupFailed: boolean\n /** How many of the person's newest messages the campaign filter ran over. */\n limit: number\n}\n\ntype Refusal = { ok: false; status: number; error: string }\n\ninterface Reader {\n ok: true\n uid: string\n staff: boolean\n orgId: string\n org: Record<string, unknown>\n member: Partial<AglynOrgMember> | null\n}\n\nconst refuse = (status: number, error: string): Refusal => ({\n ok: false,\n status,\n error,\n})\n\n/**\n * Who is asking, and whether they may read this site's CRM at all — the\n * rules' `canReadScopedPeople` restated, minus the per-document half that\n * the handler asks against the contact once it has it.\n *\n * `data.manage` is the key, because it is the key the contacts read rule\n * gates on: an org viewer holding no grant may open no contact in the\n * console and may not read one here. Staff pass on the claim alone, as the\n * rules admit them.\n */\nasync function authorizeCrmReader(\n req: PluginApiRequest,\n hostId: string,\n): Promise<Reader | Refusal> {\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return refuse(401, 'Unauthenticated')\n let decoded: { uid: string; staff?: unknown }\n try {\n decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n } catch (error) {\n // A refused credential is the caller's 401; a failure to check one is\n // ours and keeps a 5xx (AGL-2852).\n if (isRefusedIdToken(error)) return refuse(401, 'Unauthenticated')\n console.error('[crm] contact-email-history could not verify the reader', error)\n return refuse(500, 'The sign-in could not be checked. Try again.')\n }\n const staff = decoded.staff === true\n const resolved = await getOrgForHost(hostId).catch(() => null)\n if (!resolved) {\n return refuse(404, 'This site has no organization, so it has no CRM.')\n }\n const { orgId, org } = resolved\n const membership = await resolveOrgMembership(decoded.uid, orgId).catch(\n () => null,\n )\n const member = (membership?.member ?? null) as Partial<AglynOrgMember> | null\n if (!staff) {\n const suspended =\n (member as { orgSuspended?: boolean } | null)?.orgSuspended === true\n if (!member || suspended) {\n return refuse(403, 'Your organization role does not allow reading the CRM.')\n }\n if (!(await memberHasOrgPermission(orgId, member, 'data.manage'))) {\n return refuse(403, 'Your organization role does not allow reading the CRM.')\n }\n }\n return {\n ok: true,\n uid: decoded.uid,\n staff,\n orgId,\n org: (org ?? {}) as Record<string, unknown>,\n member,\n }\n}\n\n/** A finite, positive instant, or nothing. */\nconst instant = (value: unknown): number | undefined => {\n const parsed = Number(value)\n return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined\n}\n\n/**\n * One delivery-log row as the timeline reads it.\n *\n * Exported for the spec, and pure: it is the projection that decides what\n * leaves the server — the address, the provider, the followed links and the\n * bounce detail stay behind. `sentAtMs` prefers the provider's own `sent`\n * instant and falls back to the first event seen, which the log guarantees\n * on every row, so an entry can always be placed.\n */\nexport function contactCampaignEmailFromDelivery(\n row: EmailDeliveryRecord,\n campaignName: string | null,\n): ContactCampaignEmail | null {\n if (!row.campaignId || !row.hostId) return null\n const stamps = row.timestamps ?? {}\n const sentAtMs = instant(stamps.sent) ?? instant(row.firstSeenAtMs)\n if (!sentAtMs) return null\n const delivered = instant(stamps.delivered)\n const opened = instant(stamps.opened)\n const clicked = instant(stamps.clicked)\n const bounced = instant(stamps.bounced)\n const complained = instant(stamps.complained)\n return {\n messageId: row.messageId,\n hostId: row.hostId,\n campaignId: row.campaignId,\n campaignName,\n subject: row.subject ?? null,\n sentAtMs,\n ...(delivered ? { deliveredAtMs: delivered } : {}),\n ...(opened ? { openedAtMs: opened } : {}),\n ...(clicked ? { clickedAtMs: clicked } : {}),\n ...(bounced ? { bouncedAtMs: bounced } : {}),\n ...(complained ? { complainedAtMs: complained } : {}),\n openCount: Number(row.openCount ?? 0),\n clickCount: Number(row.clickCount ?? 0),\n }\n}\n\n/**\n * The names of the emails these rows name, asked of the plugin that keeps\n * the sends (AGL-3080).\n *\n * A send is the campaign owner's record (`emailSend`, `plugin-record-index`),\n * and this route only needs what a person calls it: one keyed read per\n * distinct send, all at once, each answering the send's name or `null` for\n * one the team has since deleted — which the timeline draws as the subject\n * the person received with no report to link to. With no plugin keeping\n * sends here, every name is `null` the same way.\n *\n * The sends are the organization's; the rows reaching here were already\n * narrowed to the reading group's sites, all of which belong to that org.\n */\nasync function readCampaignNames(\n orgId: string,\n rows: readonly EmailDeliveryRecord[],\n): Promise<Map<string, string | null>> {\n const names = new Map<string, string | null>()\n const pending: { key: string; sendId: string }[] = []\n for (const row of rows) {\n const key = `${row.hostId}/${row.campaignId}`\n if (names.has(key)) continue\n names.set(key, null)\n pending.push({ key, sendId: String(row.campaignId) })\n }\n const sends = pluginRecordIndex(EMAIL_SEND_RECORD_KIND)?.index\n if (!pending.length || !sends) return names\n const found = await Promise.all(\n pending.map((entry) => sends.get({ orgId, id: entry.sendId }).catch(() => null)),\n )\n found.forEach((record, index) => {\n if (record) names.set(pending[index].key, record.name)\n })\n return names\n}\n\nexport const contactEmailHistoryHandler: 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 hostId = String(req.body?.hostId ?? '').trim()\n const contactId = String(req.body?.contactId ?? '').trim()\n if (!hostId || !contactId) {\n res.status(400).json({ error: 'Missing hostId or contactId' })\n return\n }\n const reader = await authorizeCrmReader(req, hostId)\n if (reader.ok === false) {\n res.status(reader.status).json({ error: reader.error })\n return\n }\n // The plan after the reader, before any contact or log is read (AGL-2851).\n const suite = crmSuiteRefusal(reader.org, \"A contact's campaign email history\")\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n try {\n /*\n * The contact by id, then the per-document half of the read rule: the\n * Admin SDK evaluates no rules, so a scoped member who can guess an id\n * must be refused here exactly as their listener would refuse the row.\n * \"Not visible\" and \"not there\" are one answer on purpose — a 403 would\n * confirm the id exists.\n */\n const contactsRef = await orgDataCollectionForHost(hostId, 'contacts')\n const snapshot = await contactsRef.doc(contactId).get()\n const visibleTo = snapshot.exists\n ? (snapshot.get('visibleTo') as string[] | undefined)\n : undefined\n if (!snapshot.exists || !(reader.staff || memberCanSee(reader.member, visibleTo))) {\n res.status(404).json({ error: 'Unknown contact' })\n return\n }\n const email = String(snapshot.get('email') ?? '')\n const group = await consentGroupForSite(hostId, reader.org)\n const history = await readEmailDeliveryHistory(email, {\n limit: EMAIL_DELIVERY_READ_LIMIT,\n })\n const rows = history.rows.filter(\n (row) =>\n Boolean(row.campaignId) &&\n Boolean(row.hostId) &&\n group.hostIds.includes(String(row.hostId)),\n )\n const names = await readCampaignNames(reader.orgId, rows)\n const emails = rows\n .map((row) =>\n contactCampaignEmailFromDelivery(\n row,\n names.get(`${row.hostId}/${row.campaignId}`) ?? null,\n ),\n )\n .filter((entry): entry is ContactCampaignEmail => entry !== null)\n .sort((a, b) => b.sentAtMs - a.sentAtMs)\n const body: ContactEmailHistoryResponse = {\n ok: true,\n emails,\n lookupFailed: history.lookupFailed,\n limit: EMAIL_DELIVERY_READ_LIMIT,\n }\n res.status(200).json(body)\n } catch (error) {\n console.error('[crm] contact-email-history failed', hostId, contactId, error)\n res.status(500).json({ error: 'The campaign history could not be read' })\n }\n}\n"],"names":["memberCanSee","consentGroupForSite","firebaseAdmin","getOrgForHost","memberHasOrgPermission","orgDataCollectionForHost","resolveOrgMembership","EMAIL_DELIVERY_READ_LIMIT","readEmailDeliveryHistory","isRefusedIdToken","pluginRecordIndex","crmSuiteRefusal","EMAIL_SEND_RECORD_KIND","CONTACT_EMAIL_HISTORY_ROUTE","refuse","status","error","ok","authorizeCrmReader","req","hostId","authorization","String","headers","idToken","startsWith","slice","length","undefined","decoded","app","auth","verifyIdToken","console","staff","resolved","catch","orgId","org","membership","uid","member","suspended","orgSuspended","instant","value","parsed","Number","isFinite","contactCampaignEmailFromDelivery","row","campaignName","campaignId","stamps","timestamps","sentAtMs","sent","firstSeenAtMs","delivered","opened","clicked","bounced","complained","messageId","subject","deliveredAtMs","openedAtMs","clickedAtMs","bouncedAtMs","complainedAtMs","openCount","clickCount","readCampaignNames","rows","names","Map","pending","key","has","set","push","sendId","sends","index","found","Promise","all","map","entry","get","id","forEach","record","name","contactEmailHistoryHandler","res","method","setHeader","json","body","trim","contactId","reader","suite","snapshot","contactsRef","doc","visibleTo","exists","email","group","history","limit","filter","Boolean","hostIds","includes","emails","sort","a","b","lookupFailed"],"mappings":"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"}
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
* Letting a holder go is the CRM's "Remove from this site", included from
|
|
58
58
|
* Starter (AGL-2851), so a detach is refused on a plan without the CRM. A
|
|
59
59
|
* delete is not: removing a workspace's own record stays open on every plan.
|
|
60
|
-
*/ import { consentGroupForHost, crmReadTokens, heldScopeTokens, isOrgWideMember, memberScopeTokens, planContactDetach, seenOnlyThroughGrant, visibleToTokens } from "@aglyn/aglyn/server";
|
|
60
|
+
*/ import { consentGroupForHost, CRM_COLLECTIONS, crmReadTokens, heldScopeTokens, isOrgWideMember, memberScopeTokens, planContactDetach, seenOnlyThroughGrant, visibleToTokens } from "@aglyn/aglyn/server";
|
|
61
61
|
import { firebaseAdmin, removeContactKeepingRefusals, restampCrmListFieldsOf } from "@aglyn/tenant-data-admin";
|
|
62
62
|
import { CRM_CONTACT_REMOVE_MAX } from "../model/contact-remove.js";
|
|
63
63
|
import { typed } from "./contact-profile.js";
|
|
@@ -65,6 +65,8 @@ import { readCrmRouteScope } from "./org-caller.js";
|
|
|
65
65
|
import { crmSuiteRefusal } from "./suite-gate.js";
|
|
66
66
|
import { authorizeCrmWriter, canReach } from "./task-routes.js";
|
|
67
67
|
import { contactPrimaryGroup } from "../model/contact-holder.js";
|
|
68
|
+
import { sweepDealContactRoles } from "./deal-contact-roles.js";
|
|
69
|
+
import { clearReportsToOf, contactFacetHolders } from "./contact-reports-to.js";
|
|
68
70
|
/** What the suite gate names for a detach on a plan without the CRM. */ export const CONTACT_DETACH_SUITE_ACT = 'Removing a contact from one site';
|
|
69
71
|
/**
|
|
70
72
|
* What a site is told for a contact it sees only because it was SHARED with
|
|
@@ -183,13 +185,28 @@ export const crmContactRemoveHandler = async (req, res)=>{
|
|
|
183
185
|
};
|
|
184
186
|
const outcomes = new Map();
|
|
185
187
|
const detached = [];
|
|
188
|
+
/*
|
|
189
|
+
* The holders whose facets may point at each removed contact as their
|
|
190
|
+
* reports-to (AGL-3537), as decided: every holder of a deleted contact,
|
|
191
|
+
* and the one letting go of a detached one — which no longer holds the
|
|
192
|
+
* person it would point at.
|
|
193
|
+
*/ const lettingGo = new Map();
|
|
194
|
+
const deciding = (contactId)=>(contact)=>{
|
|
195
|
+
const decided = decide(contact);
|
|
196
|
+
if (!('refused' in decided)) {
|
|
197
|
+
lettingGo.set(contactId, decided.action === 'delete' ? contactFacetHolders(contact) : [
|
|
198
|
+
(siteGroup != null ? siteGroup : contactPrimaryGroup(contact, writer.org)).groupId
|
|
199
|
+
]);
|
|
200
|
+
}
|
|
201
|
+
return decided;
|
|
202
|
+
};
|
|
186
203
|
for(let at = 0; at < contactIds.length; at += CONCURRENCY){
|
|
187
204
|
await Promise.all(contactIds.slice(at, at + CONCURRENCY).map(async (contactId)=>{
|
|
188
205
|
const contactRef = contacts.doc(contactId);
|
|
189
206
|
try {
|
|
190
207
|
const removal = await removeContactKeepingRefusals({
|
|
191
208
|
contactRef,
|
|
192
|
-
decide,
|
|
209
|
+
decide: deciding(contactId),
|
|
193
210
|
nowMs
|
|
194
211
|
});
|
|
195
212
|
if (removal.outcome === 'missing') {
|
|
@@ -197,18 +214,26 @@ export const crmContactRemoveHandler = async (req, res)=>{
|
|
|
197
214
|
} else if (removal.outcome === 'refused') {
|
|
198
215
|
outcomes.set(contactId, refused(contactId, removal.error));
|
|
199
216
|
} else if (removal.outcome === 'detached') {
|
|
217
|
+
var _lettingGo_get;
|
|
200
218
|
detached.push(contactRef);
|
|
201
219
|
outcomes.set(contactId, {
|
|
202
220
|
contactId,
|
|
203
221
|
ok: true,
|
|
204
222
|
removed: 'detached'
|
|
205
223
|
});
|
|
224
|
+
// The holder letting go points at the person no more (AGL-3537).
|
|
225
|
+
await clearReportsToOf(firestore, contacts, (_lettingGo_get = lettingGo.get(contactId)) != null ? _lettingGo_get : [], contactId);
|
|
206
226
|
} else {
|
|
227
|
+
var _lettingGo_get1;
|
|
207
228
|
outcomes.set(contactId, {
|
|
208
229
|
contactId,
|
|
209
230
|
ok: true,
|
|
210
231
|
removed: 'deleted'
|
|
211
232
|
});
|
|
233
|
+
// Nobody reports to a deleted person (AGL-3537)…
|
|
234
|
+
await clearReportsToOf(firestore, contacts, (_lettingGo_get1 = lettingGo.get(contactId)) != null ? _lettingGo_get1 : [], contactId);
|
|
235
|
+
// …and they leave every deal's contact roles (AGL-3521).
|
|
236
|
+
await sweepDealContactRoles(firestore, firestore.collection('orgs').doc(writer.orgId).collection(CRM_COLLECTIONS.deals), contactId, null, '[crm] contact-remove');
|
|
212
237
|
}
|
|
213
238
|
} catch (error) {
|
|
214
239
|
console.error('[crm] contact-remove could not remove a contact', contactId, error);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/contact-remove.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/contact-remove` — a holder lets contacts go, and a person's\n * \"no\" to marketing stays (AGL-3338).\n *\n * Body: `{ hostId, contactIds }` under a site, or `{ orgId, hostId?,\n * contactIds }` at the organization level. Answers `ContactRemoveResponse`:\n * one outcome per contact, in the order the ids were sent.\n *\n * ## Delete is a detach, and a refusal is not let go\n *\n * A contact is shared by every site that captured the person. Removing it\n * from one drops that holder's facet, consent grants, capture attribution\n * and scope tokens, and the document is deleted only when nobody else holds\n * it (`planContactDetach`). A refusal of marketing email is kept either way:\n * a detach leaves it on the document for the holders that remain, and the\n * last holder's delete copies every refusal into the organization's retained\n * store in the same transaction (`removeContactKeepingRefusals`). The list\n * gate reads both, so deleting a person is not a way to put somebody who\n * said no on a list.\n *\n * ## Why a route\n *\n * The decision has to be made against the document's consent map, which a\n * table row does not project, and it has to land in one commit with the\n * retained store, which no client may write. So the rules close a client's\n * update and delete of a contact, and the console's removals arrive here.\n *\n * ## Who may call it\n *\n * The CRM writer the other contact routes admit (`authorizeCrmWriter`),\n * whose access reaches the site under a site. Each contact must be one the\n * caller may read and, under a site, one the site's hub lists. Then, as the\n * rules asked before the route existed:\n *\n * - a DELETE needs the caller's reach to cover every holder of the row, so\n * a site collaborator cannot delete a person another site also holds;\n * - a DETACH changes who a record is shared with, which only an org-wide\n * member may do (`scopeUnchanged()` in the rules).\n *\n * ## The plan\n *\n * Letting a holder go is the CRM's \"Remove from this site\", included from\n * Starter (AGL-2851), so a detach is refused on a plan without the CRM. A\n * delete is not: removing a workspace's own record stays open on every plan.\n */\n\nimport {\n consentGroupForHost,\n crmReadTokens,\n heldScopeTokens,\n isOrgWideMember,\n memberScopeTokens,\n planContactDetach,\n type PluginApiHandler,\n seenOnlyThroughGrant,\n visibleToTokens,\n} from '@aglyn/aglyn/server'\nimport {\n firebaseAdmin,\n removeContactKeepingRefusals,\n restampCrmListFieldsOf,\n} from '@aglyn/tenant-data-admin'\nimport {\n type ContactRemoveOutcome,\n type ContactRemoveResponse,\n CRM_CONTACT_REMOVE_MAX,\n} from '../model/contact-remove'\nimport { typed } from './contact-profile'\nimport { readCrmRouteScope } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\nimport { authorizeCrmWriter, canReach, type Writer } from './task-routes'\nimport { contactPrimaryGroup } from '../model/contact-holder'\n\n/** What the suite gate names for a detach on a plan without the CRM. */\nexport const CONTACT_DETACH_SUITE_ACT = 'Removing a contact from one site'\n\n/**\n * What a site is told for a contact it sees only because it was SHARED with\n * it (AGL-3336): it holds nothing there to let go of, and taking the share\n * away is a manager's unshare, not a removal.\n */\nexport const CONTACT_REMOVE_SHARED_REFUSAL =\n 'This contact was shared with this site by another site. An owner or ' +\n 'admin can stop sharing it from its Sharing card.'\n\n/** A contact read at the organization level that no site has captured. */\nexport const CONTACT_REMOVE_NO_HOLDER =\n 'No site holds this contact yet, so there is no site to remove it from.'\n\n/** What a site collaborator is told for a person another site also holds. */\nexport const CONTACT_REMOVE_SCOPED_REFUSAL =\n 'Other sites hold this contact too, and your access is limited to ' +\n 'specific sites. Ask an organization administrator to remove it.'\n\n/** How many contacts are removed at once — each in its own transaction. */\nconst CONCURRENCY = 20\n\nconst refused = (contactId: string, error: string): ContactRemoveOutcome => ({\n contactId,\n ok: false,\n error,\n})\n\n/** The ids a body names, deduplicated — or `null` when any is unreadable or there are too many. */\nfunction readContactIds(value: unknown): string[] | null {\n if (!Array.isArray(value) || !value.length || value.length > CRM_CONTACT_REMOVE_MAX) {\n return null\n }\n const ids = value.map((id) => typed(id, 200))\n if (ids.some((id) => !id || id.includes('/'))) return null\n return [...new Set(ids)]\n}\n\n/** Under a site, whether the caller's access reaches it. */\nfunction reachesSite(writer: Writer, hostId: string): boolean {\n return (\n writer.staff ||\n isOrgWideMember(writer.member) ||\n Boolean((writer.member?.hostAccess as Record<string, unknown> | undefined)?.[hostId])\n )\n}\n\n/** Whether the caller's reach is the whole organization. */\nconst orgWide = (writer: Writer): boolean =>\n writer.staff || writer.level === 'org' || isOrgWideMember(writer.member)\n\nexport const crmContactRemoveHandler: 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 = (req.body ?? {}) as Record<string, unknown>\n const scope = readCrmRouteScope(body)\n if (!scope) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const contactIds = readContactIds(body['contactIds'])\n if (!contactIds) {\n res.status(400).json({ error: `Name between 1 and ${CRM_CONTACT_REMOVE_MAX} contacts.` })\n return\n }\n\n try {\n const writer = await authorizeCrmWriter(req, scope, { suiteAct: null })\n if (writer.ok === false) {\n res.status(writer.status).json(writer.body)\n return\n }\n if (scope.level === 'site' && !reachesSite(writer, scope.hostId)) {\n res.status(403).json({ error: 'Your access to this organization does not reach this site.' })\n return\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const contacts = firestore.collection('orgs').doc(writer.orgId).collection('contacts')\n const siteGroup =\n scope.level === 'site' ? consentGroupForHost(writer.org, scope.hostId) : null\n const siteTokens = siteGroup ? crmReadTokens(siteGroup) : null\n const suite = crmSuiteRefusal(writer.org, CONTACT_DETACH_SUITE_ACT)\n const callerTokens = new Set<string>(memberScopeTokens(writer.member))\n const nowMs = Date.now()\n\n /*\n * Decided inside each contact's transaction, against the document as it\n * was read there: the reach, the holder letting go, and what that means.\n */\n const decide = (contact: Record<string, unknown>) => {\n const visibleTo = contact['visibleTo'] as string[] | undefined\n if (!canReach(writer, visibleTo)) return { refused: 'That contact is not visible to you.' }\n if (siteTokens && !visibleToTokens(visibleTo, siteTokens)) {\n return { refused: 'That contact is not visible to this site.' }\n }\n if (siteGroup && siteGroup.hostIds.every((hostId) => seenOnlyThroughGrant(contact, hostId))) {\n return { refused: CONTACT_REMOVE_SHARED_REFUSAL }\n }\n const group = siteGroup ?? contactPrimaryGroup(contact, writer.org)\n if (!group.groupId || !group.hostIds.length) return { refused: CONTACT_REMOVE_NO_HOLDER }\n const plan = planContactDetach(contact, group)\n if (plan.action === 'delete') {\n // The HOLDERS: a site the contact was only shared with holds\n // nothing, so it never stands between a holder and its delete.\n const holders = heldScopeTokens(contact)\n if (!orgWide(writer) && !holders.every((token) => callerTokens.has(token))) {\n return { refused: CONTACT_REMOVE_SCOPED_REFUSAL }\n }\n return plan\n }\n if (!orgWide(writer)) return { refused: CONTACT_REMOVE_SCOPED_REFUSAL }\n if (suite) return { refused: suite.body.error }\n return plan\n }\n\n const outcomes = new Map<string, ContactRemoveOutcome>()\n const detached: FirebaseFirestore.DocumentReference[] = []\n for (let at = 0; at < contactIds.length; at += CONCURRENCY) {\n await Promise.all(\n contactIds.slice(at, at + CONCURRENCY).map(async (contactId) => {\n const contactRef = contacts.doc(contactId)\n try {\n const removal = await removeContactKeepingRefusals({ contactRef, decide, nowMs })\n if (removal.outcome === 'missing') {\n outcomes.set(contactId, refused(contactId, 'That contact no longer exists.'))\n } else if (removal.outcome === 'refused') {\n outcomes.set(contactId, refused(contactId, removal.error))\n } else if (removal.outcome === 'detached') {\n detached.push(contactRef)\n outcomes.set(contactId, { contactId, ok: true, removed: 'detached' })\n } else {\n outcomes.set(contactId, { contactId, ok: true, removed: 'deleted' })\n }\n } catch (error) {\n console.error('[crm] contact-remove could not remove a contact', contactId, error)\n outcomes.set(contactId, refused(contactId, 'The contact could not be removed.'))\n }\n }),\n )\n }\n // A detached row still carries the facet keys and scoped search tokens of\n // the holder that let it go, which the Contacts list filters by (AGL-3321).\n // Its own catch: the contacts are removed by now, and a list stamp that\n // could not move is not a removal that failed.\n if (detached.length) {\n await restampCrmListFieldsOf(detached, 'contacts').catch((error: unknown) => {\n console.error('[crm] contact-remove could not restamp list fields', error)\n })\n }\n\n const answer: ContactRemoveResponse = {\n ok: true,\n results: contactIds.map(\n (contactId) =>\n outcomes.get(contactId) ?? refused(contactId, 'The contact could not be removed.'),\n ),\n }\n res.status(200).json(answer)\n } catch (error) {\n console.error('[crm] contact-remove failed', error)\n res.status(500).json({ error: 'The contacts could not be removed.' })\n }\n}\n"],"names":["consentGroupForHost","crmReadTokens","heldScopeTokens","isOrgWideMember","memberScopeTokens","planContactDetach","seenOnlyThroughGrant","visibleToTokens","firebaseAdmin","removeContactKeepingRefusals","restampCrmListFieldsOf","CRM_CONTACT_REMOVE_MAX","typed","readCrmRouteScope","crmSuiteRefusal","authorizeCrmWriter","canReach","contactPrimaryGroup","CONTACT_DETACH_SUITE_ACT","CONTACT_REMOVE_SHARED_REFUSAL","CONTACT_REMOVE_NO_HOLDER","CONTACT_REMOVE_SCOPED_REFUSAL","CONCURRENCY","refused","contactId","error","ok","readContactIds","value","Array","isArray","length","ids","map","id","some","includes","Set","reachesSite","writer","hostId","staff","member","Boolean","hostAccess","orgWide","level","crmContactRemoveHandler","req","res","method","setHeader","status","json","body","scope","contactIds","suiteAct","firestore","app","contacts","collection","doc","orgId","siteGroup","org","siteTokens","suite","callerTokens","nowMs","Date","now","decide","contact","visibleTo","hostIds","every","group","groupId","plan","action","holders","token","has","outcomes","Map","detached","at","Promise","all","slice","contactRef","removal","outcome","set","push","removed","console","catch","answer","results","get"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4CC,GAED,SACEA,mBAAmB,EACnBC,aAAa,EACbC,eAAe,EACfC,eAAe,EACfC,iBAAiB,EACjBC,iBAAiB,EAEjBC,oBAAoB,EACpBC,eAAe,QACV,sBAAqB;AAC5B,SACEC,aAAa,EACbC,4BAA4B,EAC5BC,sBAAsB,QACjB,2BAA0B;AACjC,SAGEC,sBAAsB,QACjB,6BAAyB;AAChC,SAASC,KAAK,QAAQ,uBAAmB;AACzC,SAASC,iBAAiB,QAAQ,kBAAc;AAChD,SAASC,eAAe,QAAQ,kBAAc;AAC9C,SAASC,kBAAkB,EAAEC,QAAQ,QAAqB,mBAAe;AACzE,SAASC,mBAAmB,QAAQ,6BAAyB;AAE7D,sEAAsE,GACtE,OAAO,MAAMC,2BAA2B,mCAAkC;AAE1E;;;;CAIC,GACD,OAAO,MAAMC,gCACX,yEACA,mDAAkD;AAEpD,wEAAwE,GACxE,OAAO,MAAMC,2BACX,yEAAwE;AAE1E,2EAA2E,GAC3E,OAAO,MAAMC,gCACX,sEACA,kEAAiE;AAEnE,yEAAyE,GACzE,MAAMC,cAAc;AAEpB,MAAMC,UAAU,CAACC,WAAmBC,QAAyC,CAAA;QAC3ED;QACAE,IAAI;QACJD;IACF,CAAA;AAEA,iGAAiG,GACjG,SAASE,eAAeC,KAAc;IACpC,IAAI,CAACC,MAAMC,OAAO,CAACF,UAAU,CAACA,MAAMG,MAAM,IAAIH,MAAMG,MAAM,GAAGpB,wBAAwB;QACnF,OAAO;IACT;IACA,MAAMqB,MAAMJ,MAAMK,GAAG,CAAC,CAACC,KAAOtB,MAAMsB,IAAI;IACxC,IAAIF,IAAIG,IAAI,CAAC,CAACD,KAAO,CAACA,MAAMA,GAAGE,QAAQ,CAAC,OAAO,OAAO;IACtD,OAAO;WAAI,IAAIC,IAAIL;KAAK;AAC1B;AAEA,0DAA0D,GAC1D,SAASM,YAAYC,MAAc,EAAEC,MAAc;QAItCD,2BAAAA;IAHX,OACEA,OAAOE,KAAK,IACZtC,gBAAgBoC,OAAOG,MAAM,KAC7BC,SAASJ,iBAAAA,OAAOG,MAAM,sBAAbH,4BAAAA,eAAeK,UAAU,qBAA1B,AAACL,yBAAmE,CAACC,OAAO;AAExF;AAEA,0DAA0D,GAC1D,MAAMK,UAAU,CAACN,SACfA,OAAOE,KAAK,IAAIF,OAAOO,KAAK,KAAK,SAAS3C,gBAAgBoC,OAAOG,MAAM;AAEzE,OAAO,MAAMK,0BAA4C,OAAOC,KAAKC;QAMrDD;IALd,IAAIA,IAAIE,MAAM,KAAK,QAAQ;QACzBD,IAAIE,SAAS,CAAC,SAAS;QACvBF,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAE5B,OAAO;QAAqB;QACnD;IACF;IACA,MAAM6B,QAAQN,YAAAA,IAAIM,IAAI,YAARN,YAAY,CAAC;IAC3B,MAAMO,QAAQ1C,kBAAkByC;IAChC,IAAI,CAACC,OAAO;QACVN,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAE5B,OAAO;QAAiB;QAC/C;IACF;IACA,MAAM+B,aAAa7B,eAAe2B,IAAI,CAAC,aAAa;IACpD,IAAI,CAACE,YAAY;QACfP,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAE5B,OAAO,CAAC,mBAAmB,EAAEd,uBAAuB,UAAU,CAAC;QAAC;QACvF;IACF;IAEA,IAAI;QACF,MAAM4B,SAAS,MAAMxB,mBAAmBiC,KAAKO,OAAO;YAAEE,UAAU;QAAK;QACrE,IAAIlB,OAAOb,EAAE,KAAK,OAAO;YACvBuB,IAAIG,MAAM,CAACb,OAAOa,MAAM,EAAEC,IAAI,CAACd,OAAOe,IAAI;YAC1C;QACF;QACA,IAAIC,MAAMT,KAAK,KAAK,UAAU,CAACR,YAAYC,QAAQgB,MAAMf,MAAM,GAAG;YAChES,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAE5B,OAAO;YAA6D;YAC3F;QACF;QAEA,MAAMiC,YAAYlD,cAAcmD,GAAG,GAAGD,SAAS;QAC/C,MAAME,WAAWF,UAAUG,UAAU,CAAC,QAAQC,GAAG,CAACvB,OAAOwB,KAAK,EAAEF,UAAU,CAAC;QAC3E,MAAMG,YACJT,MAAMT,KAAK,KAAK,SAAS9C,oBAAoBuC,OAAO0B,GAAG,EAAEV,MAAMf,MAAM,IAAI;QAC3E,MAAM0B,aAAaF,YAAY/D,cAAc+D,aAAa;QAC1D,MAAMG,QAAQrD,gBAAgByB,OAAO0B,GAAG,EAAE/C;QAC1C,MAAMkD,eAAe,IAAI/B,IAAYjC,kBAAkBmC,OAAOG,MAAM;QACpE,MAAM2B,QAAQC,KAAKC,GAAG;QAEtB;;;KAGC,GACD,MAAMC,SAAS,CAACC;YACd,MAAMC,YAAYD,OAAO,CAAC,YAAY;YACtC,IAAI,CAACzD,SAASuB,QAAQmC,YAAY,OAAO;gBAAEnD,SAAS;YAAsC;YAC1F,IAAI2C,cAAc,CAAC3D,gBAAgBmE,WAAWR,aAAa;gBACzD,OAAO;oBAAE3C,SAAS;gBAA4C;YAChE;YACA,IAAIyC,aAAaA,UAAUW,OAAO,CAACC,KAAK,CAAC,CAACpC,SAAWlC,qBAAqBmE,SAASjC,UAAU;gBAC3F,OAAO;oBAAEjB,SAASJ;gBAA8B;YAClD;YACA,MAAM0D,QAAQb,oBAAAA,YAAa/C,oBAAoBwD,SAASlC,OAAO0B,GAAG;YAClE,IAAI,CAACY,MAAMC,OAAO,IAAI,CAACD,MAAMF,OAAO,CAAC5C,MAAM,EAAE,OAAO;gBAAER,SAASH;YAAyB;YACxF,MAAM2D,OAAO1E,kBAAkBoE,SAASI;YACxC,IAAIE,KAAKC,MAAM,KAAK,UAAU;gBAC5B,6DAA6D;gBAC7D,+DAA+D;gBAC/D,MAAMC,UAAU/E,gBAAgBuE;gBAChC,IAAI,CAAC5B,QAAQN,WAAW,CAAC0C,QAAQL,KAAK,CAAC,CAACM,QAAUd,aAAae,GAAG,CAACD,SAAS;oBAC1E,OAAO;wBAAE3D,SAASF;oBAA8B;gBAClD;gBACA,OAAO0D;YACT;YACA,IAAI,CAAClC,QAAQN,SAAS,OAAO;gBAAEhB,SAASF;YAA8B;YACtE,IAAI8C,OAAO,OAAO;gBAAE5C,SAAS4C,MAAMb,IAAI,CAAC7B,KAAK;YAAC;YAC9C,OAAOsD;QACT;QAEA,MAAMK,WAAW,IAAIC;QACrB,MAAMC,WAAkD,EAAE;QAC1D,IAAK,IAAIC,KAAK,GAAGA,KAAK/B,WAAWzB,MAAM,EAAEwD,MAAMjE,YAAa;YAC1D,MAAMkE,QAAQC,GAAG,CACfjC,WAAWkC,KAAK,CAACH,IAAIA,KAAKjE,aAAaW,GAAG,CAAC,OAAOT;gBAChD,MAAMmE,aAAa/B,SAASE,GAAG,CAACtC;gBAChC,IAAI;oBACF,MAAMoE,UAAU,MAAMnF,6BAA6B;wBAAEkF;wBAAYnB;wBAAQH;oBAAM;oBAC/E,IAAIuB,QAAQC,OAAO,KAAK,WAAW;wBACjCT,SAASU,GAAG,CAACtE,WAAWD,QAAQC,WAAW;oBAC7C,OAAO,IAAIoE,QAAQC,OAAO,KAAK,WAAW;wBACxCT,SAASU,GAAG,CAACtE,WAAWD,QAAQC,WAAWoE,QAAQnE,KAAK;oBAC1D,OAAO,IAAImE,QAAQC,OAAO,KAAK,YAAY;wBACzCP,SAASS,IAAI,CAACJ;wBACdP,SAASU,GAAG,CAACtE,WAAW;4BAAEA;4BAAWE,IAAI;4BAAMsE,SAAS;wBAAW;oBACrE,OAAO;wBACLZ,SAASU,GAAG,CAACtE,WAAW;4BAAEA;4BAAWE,IAAI;4BAAMsE,SAAS;wBAAU;oBACpE;gBACF,EAAE,OAAOvE,OAAO;oBACdwE,QAAQxE,KAAK,CAAC,mDAAmDD,WAAWC;oBAC5E2D,SAASU,GAAG,CAACtE,WAAWD,QAAQC,WAAW;gBAC7C;YACF;QAEJ;QACA,0EAA0E;QAC1E,4EAA4E;QAC5E,wEAAwE;QACxE,+CAA+C;QAC/C,IAAI8D,SAASvD,MAAM,EAAE;YACnB,MAAMrB,uBAAuB4E,UAAU,YAAYY,KAAK,CAAC,CAACzE;gBACxDwE,QAAQxE,KAAK,CAAC,sDAAsDA;YACtE;QACF;QAEA,MAAM0E,SAAgC;YACpCzE,IAAI;YACJ0E,SAAS5C,WAAWvB,GAAG,CACrB,CAACT;oBACC4D;wBAAAA,gBAAAA,SAASiB,GAAG,CAAC7E,sBAAb4D,gBAA2B7D,QAAQC,WAAW;;QAEpD;QACAyB,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC8C;IACvB,EAAE,OAAO1E,OAAO;QACdwE,QAAQxE,KAAK,CAAC,+BAA+BA;QAC7CwB,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAE5B,OAAO;QAAqC;IACrE;AACF,EAAC"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/contact-remove.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/contact-remove` — a holder lets contacts go, and a person's\n * \"no\" to marketing stays (AGL-3338).\n *\n * Body: `{ hostId, contactIds }` under a site, or `{ orgId, hostId?,\n * contactIds }` at the organization level. Answers `ContactRemoveResponse`:\n * one outcome per contact, in the order the ids were sent.\n *\n * ## Delete is a detach, and a refusal is not let go\n *\n * A contact is shared by every site that captured the person. Removing it\n * from one drops that holder's facet, consent grants, capture attribution\n * and scope tokens, and the document is deleted only when nobody else holds\n * it (`planContactDetach`). A refusal of marketing email is kept either way:\n * a detach leaves it on the document for the holders that remain, and the\n * last holder's delete copies every refusal into the organization's retained\n * store in the same transaction (`removeContactKeepingRefusals`). The list\n * gate reads both, so deleting a person is not a way to put somebody who\n * said no on a list.\n *\n * ## Why a route\n *\n * The decision has to be made against the document's consent map, which a\n * table row does not project, and it has to land in one commit with the\n * retained store, which no client may write. So the rules close a client's\n * update and delete of a contact, and the console's removals arrive here.\n *\n * ## Who may call it\n *\n * The CRM writer the other contact routes admit (`authorizeCrmWriter`),\n * whose access reaches the site under a site. Each contact must be one the\n * caller may read and, under a site, one the site's hub lists. Then, as the\n * rules asked before the route existed:\n *\n * - a DELETE needs the caller's reach to cover every holder of the row, so\n * a site collaborator cannot delete a person another site also holds;\n * - a DETACH changes who a record is shared with, which only an org-wide\n * member may do (`scopeUnchanged()` in the rules).\n *\n * ## The plan\n *\n * Letting a holder go is the CRM's \"Remove from this site\", included from\n * Starter (AGL-2851), so a detach is refused on a plan without the CRM. A\n * delete is not: removing a workspace's own record stays open on every plan.\n */\n\nimport {\n consentGroupForHost,\n CRM_COLLECTIONS,\n crmReadTokens,\n heldScopeTokens,\n isOrgWideMember,\n memberScopeTokens,\n planContactDetach,\n type PluginApiHandler,\n seenOnlyThroughGrant,\n visibleToTokens,\n} from '@aglyn/aglyn/server'\nimport {\n firebaseAdmin,\n removeContactKeepingRefusals,\n restampCrmListFieldsOf,\n} from '@aglyn/tenant-data-admin'\nimport {\n type ContactRemoveOutcome,\n type ContactRemoveResponse,\n CRM_CONTACT_REMOVE_MAX,\n} from '../model/contact-remove'\nimport { typed } from './contact-profile'\nimport { readCrmRouteScope } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\nimport { authorizeCrmWriter, canReach, type Writer } from './task-routes'\nimport { contactPrimaryGroup } from '../model/contact-holder'\nimport { sweepDealContactRoles } from './deal-contact-roles'\nimport { clearReportsToOf, contactFacetHolders } from './contact-reports-to'\n\n/** What the suite gate names for a detach on a plan without the CRM. */\nexport const CONTACT_DETACH_SUITE_ACT = 'Removing a contact from one site'\n\n/**\n * What a site is told for a contact it sees only because it was SHARED with\n * it (AGL-3336): it holds nothing there to let go of, and taking the share\n * away is a manager's unshare, not a removal.\n */\nexport const CONTACT_REMOVE_SHARED_REFUSAL =\n 'This contact was shared with this site by another site. An owner or ' +\n 'admin can stop sharing it from its Sharing card.'\n\n/** A contact read at the organization level that no site has captured. */\nexport const CONTACT_REMOVE_NO_HOLDER =\n 'No site holds this contact yet, so there is no site to remove it from.'\n\n/** What a site collaborator is told for a person another site also holds. */\nexport const CONTACT_REMOVE_SCOPED_REFUSAL =\n 'Other sites hold this contact too, and your access is limited to ' +\n 'specific sites. Ask an organization administrator to remove it.'\n\n/** How many contacts are removed at once — each in its own transaction. */\nconst CONCURRENCY = 20\n\nconst refused = (contactId: string, error: string): ContactRemoveOutcome => ({\n contactId,\n ok: false,\n error,\n})\n\n/** The ids a body names, deduplicated — or `null` when any is unreadable or there are too many. */\nfunction readContactIds(value: unknown): string[] | null {\n if (!Array.isArray(value) || !value.length || value.length > CRM_CONTACT_REMOVE_MAX) {\n return null\n }\n const ids = value.map((id) => typed(id, 200))\n if (ids.some((id) => !id || id.includes('/'))) return null\n return [...new Set(ids)]\n}\n\n/** Under a site, whether the caller's access reaches it. */\nfunction reachesSite(writer: Writer, hostId: string): boolean {\n return (\n writer.staff ||\n isOrgWideMember(writer.member) ||\n Boolean((writer.member?.hostAccess as Record<string, unknown> | undefined)?.[hostId])\n )\n}\n\n/** Whether the caller's reach is the whole organization. */\nconst orgWide = (writer: Writer): boolean =>\n writer.staff || writer.level === 'org' || isOrgWideMember(writer.member)\n\nexport const crmContactRemoveHandler: 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 = (req.body ?? {}) as Record<string, unknown>\n const scope = readCrmRouteScope(body)\n if (!scope) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const contactIds = readContactIds(body['contactIds'])\n if (!contactIds) {\n res.status(400).json({ error: `Name between 1 and ${CRM_CONTACT_REMOVE_MAX} contacts.` })\n return\n }\n\n try {\n const writer = await authorizeCrmWriter(req, scope, { suiteAct: null })\n if (writer.ok === false) {\n res.status(writer.status).json(writer.body)\n return\n }\n if (scope.level === 'site' && !reachesSite(writer, scope.hostId)) {\n res.status(403).json({ error: 'Your access to this organization does not reach this site.' })\n return\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const contacts = firestore.collection('orgs').doc(writer.orgId).collection('contacts')\n const siteGroup =\n scope.level === 'site' ? consentGroupForHost(writer.org, scope.hostId) : null\n const siteTokens = siteGroup ? crmReadTokens(siteGroup) : null\n const suite = crmSuiteRefusal(writer.org, CONTACT_DETACH_SUITE_ACT)\n const callerTokens = new Set<string>(memberScopeTokens(writer.member))\n const nowMs = Date.now()\n\n /*\n * Decided inside each contact's transaction, against the document as it\n * was read there: the reach, the holder letting go, and what that means.\n */\n const decide = (contact: Record<string, unknown>) => {\n const visibleTo = contact['visibleTo'] as string[] | undefined\n if (!canReach(writer, visibleTo)) return { refused: 'That contact is not visible to you.' }\n if (siteTokens && !visibleToTokens(visibleTo, siteTokens)) {\n return { refused: 'That contact is not visible to this site.' }\n }\n if (siteGroup && siteGroup.hostIds.every((hostId) => seenOnlyThroughGrant(contact, hostId))) {\n return { refused: CONTACT_REMOVE_SHARED_REFUSAL }\n }\n const group = siteGroup ?? contactPrimaryGroup(contact, writer.org)\n if (!group.groupId || !group.hostIds.length) return { refused: CONTACT_REMOVE_NO_HOLDER }\n const plan = planContactDetach(contact, group)\n if (plan.action === 'delete') {\n // The HOLDERS: a site the contact was only shared with holds\n // nothing, so it never stands between a holder and its delete.\n const holders = heldScopeTokens(contact)\n if (!orgWide(writer) && !holders.every((token) => callerTokens.has(token))) {\n return { refused: CONTACT_REMOVE_SCOPED_REFUSAL }\n }\n return plan\n }\n if (!orgWide(writer)) return { refused: CONTACT_REMOVE_SCOPED_REFUSAL }\n if (suite) return { refused: suite.body.error }\n return plan\n }\n\n const outcomes = new Map<string, ContactRemoveOutcome>()\n const detached: FirebaseFirestore.DocumentReference[] = []\n /*\n * The holders whose facets may point at each removed contact as their\n * reports-to (AGL-3537), as decided: every holder of a deleted contact,\n * and the one letting go of a detached one — which no longer holds the\n * person it would point at.\n */\n const lettingGo = new Map<string, string[]>()\n const deciding = (contactId: string) => (contact: Record<string, unknown>) => {\n const decided = decide(contact)\n if (!('refused' in decided)) {\n lettingGo.set(\n contactId,\n decided.action === 'delete'\n ? contactFacetHolders(contact)\n : [(siteGroup ?? contactPrimaryGroup(contact, writer.org)).groupId],\n )\n }\n return decided\n }\n for (let at = 0; at < contactIds.length; at += CONCURRENCY) {\n await Promise.all(\n contactIds.slice(at, at + CONCURRENCY).map(async (contactId) => {\n const contactRef = contacts.doc(contactId)\n try {\n const removal = await removeContactKeepingRefusals({\n contactRef,\n decide: deciding(contactId),\n nowMs,\n })\n if (removal.outcome === 'missing') {\n outcomes.set(contactId, refused(contactId, 'That contact no longer exists.'))\n } else if (removal.outcome === 'refused') {\n outcomes.set(contactId, refused(contactId, removal.error))\n } else if (removal.outcome === 'detached') {\n detached.push(contactRef)\n outcomes.set(contactId, { contactId, ok: true, removed: 'detached' })\n // The holder letting go points at the person no more (AGL-3537).\n await clearReportsToOf(firestore, contacts, lettingGo.get(contactId) ?? [], contactId)\n } else {\n outcomes.set(contactId, { contactId, ok: true, removed: 'deleted' })\n // Nobody reports to a deleted person (AGL-3537)…\n await clearReportsToOf(firestore, contacts, lettingGo.get(contactId) ?? [], contactId)\n // …and they leave every deal's contact roles (AGL-3521).\n await sweepDealContactRoles(\n firestore,\n firestore.collection('orgs').doc(writer.orgId).collection(CRM_COLLECTIONS.deals),\n contactId,\n null,\n '[crm] contact-remove',\n )\n }\n } catch (error) {\n console.error('[crm] contact-remove could not remove a contact', contactId, error)\n outcomes.set(contactId, refused(contactId, 'The contact could not be removed.'))\n }\n }),\n )\n }\n // A detached row still carries the facet keys and scoped search tokens of\n // the holder that let it go, which the Contacts list filters by (AGL-3321).\n // Its own catch: the contacts are removed by now, and a list stamp that\n // could not move is not a removal that failed.\n if (detached.length) {\n await restampCrmListFieldsOf(detached, 'contacts').catch((error: unknown) => {\n console.error('[crm] contact-remove could not restamp list fields', error)\n })\n }\n\n const answer: ContactRemoveResponse = {\n ok: true,\n results: contactIds.map(\n (contactId) =>\n outcomes.get(contactId) ?? refused(contactId, 'The contact could not be removed.'),\n ),\n }\n res.status(200).json(answer)\n } catch (error) {\n console.error('[crm] contact-remove failed', error)\n res.status(500).json({ error: 'The contacts could not be removed.' })\n }\n}\n"],"names":["consentGroupForHost","CRM_COLLECTIONS","crmReadTokens","heldScopeTokens","isOrgWideMember","memberScopeTokens","planContactDetach","seenOnlyThroughGrant","visibleToTokens","firebaseAdmin","removeContactKeepingRefusals","restampCrmListFieldsOf","CRM_CONTACT_REMOVE_MAX","typed","readCrmRouteScope","crmSuiteRefusal","authorizeCrmWriter","canReach","contactPrimaryGroup","sweepDealContactRoles","clearReportsToOf","contactFacetHolders","CONTACT_DETACH_SUITE_ACT","CONTACT_REMOVE_SHARED_REFUSAL","CONTACT_REMOVE_NO_HOLDER","CONTACT_REMOVE_SCOPED_REFUSAL","CONCURRENCY","refused","contactId","error","ok","readContactIds","value","Array","isArray","length","ids","map","id","some","includes","Set","reachesSite","writer","hostId","staff","member","Boolean","hostAccess","orgWide","level","crmContactRemoveHandler","req","res","method","setHeader","status","json","body","scope","contactIds","suiteAct","firestore","app","contacts","collection","doc","orgId","siteGroup","org","siteTokens","suite","callerTokens","nowMs","Date","now","decide","contact","visibleTo","hostIds","every","group","groupId","plan","action","holders","token","has","outcomes","Map","detached","lettingGo","deciding","decided","set","at","Promise","all","slice","contactRef","removal","outcome","push","removed","get","deals","console","catch","answer","results"],"mappings":"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"}
|