@aglyn/plugins-crm 1.0.0-beta.221 → 1.0.0-beta.223
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-next-activity-column.js +1 -1
- package/src/lib/components/crm-next-activity-column.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/pipeline-card.js +2 -1
- package/src/lib/components/reports/pipeline-card.js.map +1 -1
- 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 +97 -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 +17 -2
- 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-next-activity.d.ts +107 -0
- package/src/lib/model/crm-next-activity.js +140 -0
- package/src/lib/model/crm-next-activity.js.map +1 -0
- package/src/lib/model/crm-recipes.d.ts +40 -0
- package/src/lib/model/crm-recipes.js +259 -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/crm-task-reminders.d.ts +152 -0
- package/src/lib/model/crm-task-reminders.js +161 -0
- package/src/lib/model/crm-task-reminders.js.map +1 -0
- 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/next-activity.d.ts +1 -1
- package/src/lib/model/next-activity.js.map +1 -1
- 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 +78 -34
- 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 +188 -0
- package/src/lib/server/assign-contact-owner.js +446 -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 +140 -0
- package/src/lib/server/automation-steps.js +669 -0
- package/src/lib/server/automation-steps.js.map +1 -0
- package/src/lib/server/capture-contact.d.ts +17 -0
- package/src/lib/server/capture-contact.js +95 -26
- 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-email-activity.d.ts +118 -0
- package/src/lib/server/crm-email-activity.js +148 -0
- package/src/lib/server/crm-email-activity.js.map +1 -0
- package/src/lib/server/crm-inbound-email.js +1 -1
- package/src/lib/server/crm-inbound-email.js.map +1 -1
- package/src/lib/server/crm-next-activity.d.ts +74 -0
- package/src/lib/server/crm-next-activity.js +194 -0
- package/src/lib/server/crm-next-activity.js.map +1 -0
- 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/email-send.js +2 -1
- package/src/lib/server/email-send.js.map +1 -1
- 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 +23 -5
- package/src/lib/server/merge-contacts.js.map +1 -1
- package/src/lib/server/next-activity-routes.js +2 -1
- package/src/lib/server/next-activity-routes.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 +22 -8
- 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 +40 -3
- 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-reminders-route.js +2 -1
- package/src/lib/server/task-reminders-route.js.map +1 -1
- package/src/lib/server/task-routes.js +33 -17
- 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
|
@@ -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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"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* A CONTACT'S REPORTS-TO (AGL-3515): Salesforce's Reports To, the person's
|
|
19
|
+
* manager, as another contact's id in the holder's facet.
|
|
20
|
+
*
|
|
21
|
+
* The pointer is per holder like the rest of the profile, so a chain is
|
|
22
|
+
* walked through ONE holder's facets: who reports to whom is one business's
|
|
23
|
+
* knowledge of an org chart, and another holder's guess at it is not part
|
|
24
|
+
* of this one's chain.
|
|
25
|
+
*/
|
|
26
|
+
/** The longest chain a loop check walks — far past any real org chart. */
|
|
27
|
+
export declare const CONTACT_REPORTS_TO_DEPTH = 25;
|
|
28
|
+
export declare const CONTACT_REPORTS_TO_SELF_REFUSAL = "A contact cannot report to themselves.";
|
|
29
|
+
export declare const CONTACT_REPORTS_TO_LOOP_REFUSAL = "That would make a loop: the person picked already reports, through others, to this contact.";
|
|
30
|
+
/** A contact document by id, or `null` when there is none. */
|
|
31
|
+
export type ContactReader = (contactId: string) => Promise<Record<string, unknown> | null>;
|
|
32
|
+
/**
|
|
33
|
+
* Whether making `contactId` report to `managerId` closes a loop: the
|
|
34
|
+
* manager's own chain, through `groupId`'s facets, reaches `contactId`.
|
|
35
|
+
* A chain longer than {@link CONTACT_REPORTS_TO_DEPTH} is treated as a loop,
|
|
36
|
+
* because a walk that cannot finish cannot prove there is none.
|
|
37
|
+
*/
|
|
38
|
+
export declare function contactReportsToLoops(read: ContactReader, contactId: string, managerId: string, groupId: string): Promise<boolean>;
|
|
39
|
+
/** A reader over one org's contacts that reads each document once. */
|
|
40
|
+
export declare function cachedContactReader(contacts: FirebaseFirestore.CollectionReference): ContactReader;
|
|
41
|
+
/**
|
|
42
|
+
* Every contact that reports to `from`, in any of `groupIds`' facets,
|
|
43
|
+
* pointed at `to` — or, with `to` null, cleared. A contact that would
|
|
44
|
+
* then report to itself is cleared instead. Answers how many pointers moved.
|
|
45
|
+
*
|
|
46
|
+
* Queried per holder, because the pointer lives at
|
|
47
|
+
* `facets.{groupId}.reportsToContactId` and a query names one path. Each
|
|
48
|
+
* failure is logged and the sweep goes on: a stale pointer is read as
|
|
49
|
+
* "nobody" by every surface, which is the safe way for one to be left.
|
|
50
|
+
*/
|
|
51
|
+
export declare function repointContactReportsTo(firestore: FirebaseFirestore.Firestore, contacts: FirebaseFirestore.CollectionReference, groupIds: Iterable<string>, from: string, to: string | null, label: string): Promise<number>;
|
|
52
|
+
/** The holders a contact document carries a facet for — every facet a pointer at it can sit in. */
|
|
53
|
+
export declare function contactFacetHolders(contact: Record<string, unknown>): string[];
|
|
54
|
+
/**
|
|
55
|
+
* Every reports-to in `groupIds`' facets that names `contactId`, cleared —
|
|
56
|
+
* a contact deleted, or let go by those holders (AGL-3537). A holder can
|
|
57
|
+
* point only at a contact it holds, so the holders of the removed record
|
|
58
|
+
* are every facet a pointer can sit in; the sweep is `repointContactReportsTo`
|
|
59
|
+
* to nobody, which logs a failure and goes on rather than failing the removal.
|
|
60
|
+
*/
|
|
61
|
+
export declare function clearReportsToOf(firestore: FirebaseFirestore.Firestore, contacts: FirebaseFirestore.CollectionReference, groupIds: Iterable<string>, contactId: string): Promise<number>;
|
|
@@ -0,0 +1,114 @@
|
|
|
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 { CONTACT_FACETS_FIELD, contactFacetPath, readContactFacet } from "@aglyn/aglyn/app-utils/contacts";
|
|
17
|
+
import { FieldValue } from "firebase-admin/firestore";
|
|
18
|
+
/**
|
|
19
|
+
* A CONTACT'S REPORTS-TO (AGL-3515): Salesforce's Reports To, the person's
|
|
20
|
+
* manager, as another contact's id in the holder's facet.
|
|
21
|
+
*
|
|
22
|
+
* The pointer is per holder like the rest of the profile, so a chain is
|
|
23
|
+
* walked through ONE holder's facets: who reports to whom is one business's
|
|
24
|
+
* knowledge of an org chart, and another holder's guess at it is not part
|
|
25
|
+
* of this one's chain.
|
|
26
|
+
*/ /** The longest chain a loop check walks — far past any real org chart. */ export const CONTACT_REPORTS_TO_DEPTH = 25;
|
|
27
|
+
export const CONTACT_REPORTS_TO_SELF_REFUSAL = 'A contact cannot report to themselves.';
|
|
28
|
+
export const CONTACT_REPORTS_TO_LOOP_REFUSAL = 'That would make a loop: the person picked already reports, through others, to this contact.';
|
|
29
|
+
/**
|
|
30
|
+
* Whether making `contactId` report to `managerId` closes a loop: the
|
|
31
|
+
* manager's own chain, through `groupId`'s facets, reaches `contactId`.
|
|
32
|
+
* A chain longer than {@link CONTACT_REPORTS_TO_DEPTH} is treated as a loop,
|
|
33
|
+
* because a walk that cannot finish cannot prove there is none.
|
|
34
|
+
*/ export async function contactReportsToLoops(read, contactId, managerId, groupId) {
|
|
35
|
+
let current = managerId;
|
|
36
|
+
const seen = new Set();
|
|
37
|
+
for(let step = 0; current && step < CONTACT_REPORTS_TO_DEPTH; step += 1){
|
|
38
|
+
if (current === contactId) return true;
|
|
39
|
+
// A loop above the contact that does not pass through it is someone
|
|
40
|
+
// else's to untangle, and does not stop this write.
|
|
41
|
+
if (seen.has(current)) return false;
|
|
42
|
+
seen.add(current);
|
|
43
|
+
const doc = await read(current);
|
|
44
|
+
if (!doc) return false;
|
|
45
|
+
current = readContactFacet(doc, groupId).reportsToContactId || undefined;
|
|
46
|
+
}
|
|
47
|
+
return Boolean(current);
|
|
48
|
+
}
|
|
49
|
+
/** A reader over one org's contacts that reads each document once. */ export function cachedContactReader(contacts) {
|
|
50
|
+
const cache = new Map();
|
|
51
|
+
return (contactId)=>{
|
|
52
|
+
let pending = cache.get(contactId);
|
|
53
|
+
if (!pending) {
|
|
54
|
+
pending = contacts.doc(contactId).get().then((snapshot)=>{
|
|
55
|
+
var _snapshot_data;
|
|
56
|
+
return snapshot.exists ? (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {} : null;
|
|
57
|
+
});
|
|
58
|
+
cache.set(contactId, pending);
|
|
59
|
+
}
|
|
60
|
+
return pending;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** How many pointing rows one pass reads and one batch writes. */ const PAGE = 400;
|
|
64
|
+
/** A ceiling on passes per holder. */ const PASSES = 25;
|
|
65
|
+
/**
|
|
66
|
+
* Every contact that reports to `from`, in any of `groupIds`' facets,
|
|
67
|
+
* pointed at `to` — or, with `to` null, cleared. A contact that would
|
|
68
|
+
* then report to itself is cleared instead. Answers how many pointers moved.
|
|
69
|
+
*
|
|
70
|
+
* Queried per holder, because the pointer lives at
|
|
71
|
+
* `facets.{groupId}.reportsToContactId` and a query names one path. Each
|
|
72
|
+
* failure is logged and the sweep goes on: a stale pointer is read as
|
|
73
|
+
* "nobody" by every surface, which is the safe way for one to be left.
|
|
74
|
+
*/ export async function repointContactReportsTo(firestore, contacts, groupIds, from, to, label) {
|
|
75
|
+
let moved = 0;
|
|
76
|
+
for (const groupId of new Set(groupIds)){
|
|
77
|
+
if (!groupId) continue;
|
|
78
|
+
const path = contactFacetPath(groupId, 'reportsToContactId');
|
|
79
|
+
try {
|
|
80
|
+
for(let pass = 0; pass < PASSES; pass += 1){
|
|
81
|
+
const page = await contacts.where(path, '==', from).limit(PAGE).get();
|
|
82
|
+
if (page.empty) break;
|
|
83
|
+
const batch = firestore.batch();
|
|
84
|
+
for (const row of page.docs){
|
|
85
|
+
batch.update(row.ref, {
|
|
86
|
+
[path]: to && row.id !== to ? to : FieldValue.delete(),
|
|
87
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
await batch.commit();
|
|
91
|
+
moved += page.size;
|
|
92
|
+
if (page.size < PAGE) break;
|
|
93
|
+
}
|
|
94
|
+
} catch (error) {
|
|
95
|
+
console.error(`${label}: reports-to sweep failed for a holder`, error);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return moved;
|
|
99
|
+
}
|
|
100
|
+
/** The holders a contact document carries a facet for — every facet a pointer at it can sit in. */ export function contactFacetHolders(contact) {
|
|
101
|
+
const facets = contact[CONTACT_FACETS_FIELD];
|
|
102
|
+
return facets && typeof facets === 'object' && !Array.isArray(facets) ? Object.keys(facets) : [];
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Every reports-to in `groupIds`' facets that names `contactId`, cleared —
|
|
106
|
+
* a contact deleted, or let go by those holders (AGL-3537). A holder can
|
|
107
|
+
* point only at a contact it holds, so the holders of the removed record
|
|
108
|
+
* are every facet a pointer can sit in; the sweep is `repointContactReportsTo`
|
|
109
|
+
* to nobody, which logs a failure and goes on rather than failing the removal.
|
|
110
|
+
*/ export function clearReportsToOf(firestore, contacts, groupIds, contactId) {
|
|
111
|
+
return repointContactReportsTo(firestore, contacts, groupIds, contactId, null, 'contact delete');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
//# sourceMappingURL=contact-reports-to.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/contact-reports-to.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n CONTACT_FACETS_FIELD,\n contactFacetPath,\n readContactFacet,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/**\n * A CONTACT'S REPORTS-TO (AGL-3515): Salesforce's Reports To, the person's\n * manager, as another contact's id in the holder's facet.\n *\n * The pointer is per holder like the rest of the profile, so a chain is\n * walked through ONE holder's facets: who reports to whom is one business's\n * knowledge of an org chart, and another holder's guess at it is not part\n * of this one's chain.\n */\n\n/** The longest chain a loop check walks — far past any real org chart. */\nexport const CONTACT_REPORTS_TO_DEPTH = 25\n\nexport const CONTACT_REPORTS_TO_SELF_REFUSAL = 'A contact cannot report to themselves.'\n\nexport const CONTACT_REPORTS_TO_LOOP_REFUSAL =\n 'That would make a loop: the person picked already reports, through others, to this contact.'\n\n/** A contact document by id, or `null` when there is none. */\nexport type ContactReader = (contactId: string) => Promise<Record<string, unknown> | null>\n\n/**\n * Whether making `contactId` report to `managerId` closes a loop: the\n * manager's own chain, through `groupId`'s facets, reaches `contactId`.\n * A chain longer than {@link CONTACT_REPORTS_TO_DEPTH} is treated as a loop,\n * because a walk that cannot finish cannot prove there is none.\n */\nexport async function contactReportsToLoops(\n read: ContactReader,\n contactId: string,\n managerId: string,\n groupId: string,\n): Promise<boolean> {\n let current: string | undefined = managerId\n const seen = new Set<string>()\n for (let step = 0; current && step < CONTACT_REPORTS_TO_DEPTH; step += 1) {\n if (current === contactId) return true\n // A loop above the contact that does not pass through it is someone\n // else's to untangle, and does not stop this write.\n if (seen.has(current)) return false\n seen.add(current)\n const doc = await read(current)\n if (!doc) return false\n current = readContactFacet(doc, groupId).reportsToContactId || undefined\n }\n return Boolean(current)\n}\n\n/** A reader over one org's contacts that reads each document once. */\nexport function cachedContactReader(\n contacts: FirebaseFirestore.CollectionReference,\n): ContactReader {\n const cache = new Map<string, Promise<Record<string, unknown> | null>>()\n return (contactId) => {\n let pending = cache.get(contactId)\n if (!pending) {\n pending = contacts\n .doc(contactId)\n .get()\n .then((snapshot) =>\n snapshot.exists ? ((snapshot.data() ?? {}) as Record<string, unknown>) : null,\n )\n cache.set(contactId, pending)\n }\n return pending\n }\n}\n\n/** How many pointing rows one pass reads and one batch writes. */\nconst PAGE = 400\n/** A ceiling on passes per holder. */\nconst PASSES = 25\n\n/**\n * Every contact that reports to `from`, in any of `groupIds`' facets,\n * pointed at `to` — or, with `to` null, cleared. A contact that would\n * then report to itself is cleared instead. Answers how many pointers moved.\n *\n * Queried per holder, because the pointer lives at\n * `facets.{groupId}.reportsToContactId` and a query names one path. Each\n * failure is logged and the sweep goes on: a stale pointer is read as\n * \"nobody\" by every surface, which is the safe way for one to be left.\n */\nexport async function repointContactReportsTo(\n firestore: FirebaseFirestore.Firestore,\n contacts: FirebaseFirestore.CollectionReference,\n groupIds: Iterable<string>,\n from: string,\n to: string | null,\n label: string,\n): Promise<number> {\n let moved = 0\n for (const groupId of new Set(groupIds)) {\n if (!groupId) continue\n const path = contactFacetPath(groupId, 'reportsToContactId')\n try {\n for (let pass = 0; pass < PASSES; pass += 1) {\n const page = await contacts.where(path, '==', from).limit(PAGE).get()\n if (page.empty) break\n const batch = firestore.batch()\n for (const row of page.docs) {\n batch.update(row.ref, {\n [path]: to && row.id !== to ? to : FieldValue.delete(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n }\n await batch.commit()\n moved += page.size\n if (page.size < PAGE) break\n }\n } catch (error) {\n console.error(`${label}: reports-to sweep failed for a holder`, error)\n }\n }\n return moved\n}\n\n/** The holders a contact document carries a facet for — every facet a pointer at it can sit in. */\nexport function contactFacetHolders(contact: Record<string, unknown>): string[] {\n const facets = contact[CONTACT_FACETS_FIELD]\n return facets && typeof facets === 'object' && !Array.isArray(facets) ? Object.keys(facets) : []\n}\n\n/**\n * Every reports-to in `groupIds`' facets that names `contactId`, cleared —\n * a contact deleted, or let go by those holders (AGL-3537). A holder can\n * point only at a contact it holds, so the holders of the removed record\n * are every facet a pointer can sit in; the sweep is `repointContactReportsTo`\n * to nobody, which logs a failure and goes on rather than failing the removal.\n */\nexport function clearReportsToOf(\n firestore: FirebaseFirestore.Firestore,\n contacts: FirebaseFirestore.CollectionReference,\n groupIds: Iterable<string>,\n contactId: string,\n): Promise<number> {\n return repointContactReportsTo(firestore, contacts, groupIds, contactId, null, 'contact delete')\n}\n"],"names":["CONTACT_FACETS_FIELD","contactFacetPath","readContactFacet","FieldValue","CONTACT_REPORTS_TO_DEPTH","CONTACT_REPORTS_TO_SELF_REFUSAL","CONTACT_REPORTS_TO_LOOP_REFUSAL","contactReportsToLoops","read","contactId","managerId","groupId","current","seen","Set","step","has","add","doc","reportsToContactId","undefined","Boolean","cachedContactReader","contacts","cache","Map","pending","get","then","snapshot","exists","data","set","PAGE","PASSES","repointContactReportsTo","firestore","groupIds","from","to","label","moved","path","pass","page","where","limit","empty","batch","row","docs","update","ref","id","delete","updatedAt","serverTimestamp","commit","size","error","console","contactFacetHolders","contact","facets","Array","isArray","Object","keys","clearReportsToOf"],"mappings":"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"}
|
|
@@ -69,8 +69,10 @@
|
|
|
69
69
|
* company deleted in between cannot fail the save of the people at it — the
|
|
70
70
|
* company page's live aggregate is what corrects a count that did not move.
|
|
71
71
|
*/
|
|
72
|
-
import { type PluginApiHandler } from '@aglyn/aglyn/server';
|
|
72
|
+
import { type ContactCompanyLinkPlan, type ContactCustomValue, type PluginApiHandler } from '@aglyn/aglyn/server';
|
|
73
73
|
import { type ContactUpdateFields } from '../model/contact-update';
|
|
74
|
+
/** The sentence a `name` that contradicts the holder's first and last names is refused with. */
|
|
75
|
+
export declare const CONTACT_COMPOSED_NAME_REFUSAL = "This person's name is made of their first and last name \u2014 change those instead.";
|
|
74
76
|
/**
|
|
75
77
|
* The act a request is refused for on a plan without the CRM: the first
|
|
76
78
|
* field it carries, in the order the route reads them. Every field is the
|
|
@@ -89,4 +91,43 @@ export declare function readContactUpdateFields(input: unknown): {
|
|
|
89
91
|
ok: false;
|
|
90
92
|
error: string;
|
|
91
93
|
};
|
|
94
|
+
/**
|
|
95
|
+
* Salesforce's standard contact fields (AGL-3515) a create may carry beside
|
|
96
|
+
* the identity — every one `upsertHostContact` writes into the capturing
|
|
97
|
+
* facet. The reports-to is not one: it names another record, which only
|
|
98
|
+
* this route checks the holder can see.
|
|
99
|
+
*/
|
|
100
|
+
export declare const CONTACT_CREATE_PROFILE_FIELDS: readonly ["salutation", "firstName", "lastName", "department", "mobilePhone", "homePhone", "otherPhone", "fax", "birthdate", "assistantName", "assistantPhone", "otherAddress", "doNotCall"];
|
|
101
|
+
/**
|
|
102
|
+
* Those fields of a create's body, read by the same rules an update reads
|
|
103
|
+
* them with — or the sentence refusing one. A body naming none of them
|
|
104
|
+
* reads as no fields.
|
|
105
|
+
*/
|
|
106
|
+
export declare function readContactCreateProfile(body: Record<string, unknown>): {
|
|
107
|
+
ok: true;
|
|
108
|
+
fields: ContactUpdateFields;
|
|
109
|
+
} | {
|
|
110
|
+
ok: false;
|
|
111
|
+
error: string;
|
|
112
|
+
};
|
|
113
|
+
/** One contact's planned write, and the company counts it moves. */
|
|
114
|
+
export interface ContactWrite {
|
|
115
|
+
contactId: string;
|
|
116
|
+
ref: FirebaseFirestore.DocumentReference;
|
|
117
|
+
update: Record<string, unknown>;
|
|
118
|
+
counts: ContactCompanyLinkPlan['counts'];
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* One contact's update for one holder, or the sentence refusing the row.
|
|
122
|
+
* `update` is `null` when the document already says everything asked — a tag
|
|
123
|
+
* the row already carries, a link already made.
|
|
124
|
+
*/
|
|
125
|
+
export declare function contactPatch(contact: Record<string, unknown>, groupId: string, fields: ContactUpdateFields, custom: Record<string, ContactCustomValue> | undefined, linkedCompanyName: string): {
|
|
126
|
+
update: Record<string, unknown> | null;
|
|
127
|
+
counts: ContactCompanyLinkPlan['counts'];
|
|
128
|
+
} | {
|
|
129
|
+
refused: string;
|
|
130
|
+
};
|
|
131
|
+
/** Each company's contacts count moved once, by the sum of what the written links moved. */
|
|
132
|
+
export declare function settleCompanyCounts(companies: FirebaseFirestore.CollectionReference, writes: readonly ContactWrite[]): Promise<void>;
|
|
92
133
|
export declare const crmContactUpdateHandler: PluginApiHandler;
|