@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
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_without_properties_loose";
|
|
3
|
+
/**
|
|
4
|
+
* @license
|
|
5
|
+
* Copyright 2026 Aglyn LLC
|
|
6
|
+
*
|
|
7
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
8
|
+
* you may not use this file except in compliance with the License.
|
|
9
|
+
* You may obtain a copy of the License at
|
|
10
|
+
*
|
|
11
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
12
|
+
*
|
|
13
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
14
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
15
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
16
|
+
* See the License for the specific language governing permissions and
|
|
17
|
+
* limitations under the License.
|
|
18
|
+
*/ import { upsertHostContact } from "@aglyn/tenant-data-admin";
|
|
19
|
+
import { assignOwnerForCapture } from "./assign-contact-owner.js";
|
|
20
|
+
import { associateCompanyByDomain } from "./associate-company-by-domain.js";
|
|
21
|
+
import { emitHostEvent } from "@aglyn/tenant-runtime/emit-host-event";
|
|
22
|
+
import { contactCaptureActor } from "@aglyn/tenant-runtime/capture-actor";
|
|
23
|
+
/**
|
|
24
|
+
* THE contact capture door for a server path (AGL-2605).
|
|
25
|
+
*
|
|
26
|
+
* `upsertHostContact` writes the contact and reports, through its
|
|
27
|
+
* `onCreated` hook, when the write made a NEW person. This wrapper is the
|
|
28
|
+
* one place that hook is bound to `contactCreated`, and it is the function
|
|
29
|
+
* every server door calls — the forms route, the membership and newsletter
|
|
30
|
+
* handlers, the order and booking webhooks. A door that calls
|
|
31
|
+
* `upsertHostContact` directly still captures the contact and fires nothing,
|
|
32
|
+
* which is a contact no automation can welcome; `capture-host-contact.spec`
|
|
33
|
+
* scans for exactly that call.
|
|
34
|
+
*
|
|
35
|
+
* ## Why the binding lives here and not in the data library
|
|
36
|
+
*
|
|
37
|
+
* What listens for the event — the automation engine among them — imports
|
|
38
|
+
* the data library for its Firestore handle, its org helpers and its
|
|
39
|
+
* senders. The data library emitting an event would import the listeners
|
|
40
|
+
* back, which is a cycle, and the module boundaries (`scope:data` depends on
|
|
41
|
+
* data and util only) refuse it in any case. So the lower layer reports a
|
|
42
|
+
* fact and this layer, which already knows how to announce one, announces
|
|
43
|
+
* it. The alternative — a sink the runtime registers into when its module
|
|
44
|
+
* happens to be imported — would fire only in a process that had loaded the
|
|
45
|
+
* runtime, and a Stripe webhook that created a contact in a process that had
|
|
46
|
+
* not would announce nothing with no error anywhere. The listeners themselves
|
|
47
|
+
* are registered by a call made at boot (`host-event-listeners.ts`), so every
|
|
48
|
+
* process has them before its first request.
|
|
49
|
+
*
|
|
50
|
+
* Fire-and-forget in the same sense the capture itself is: the hook's
|
|
51
|
+
* failure is caught inside `upsertHostContact`, so a runner that throws
|
|
52
|
+
* costs the door nothing, and this function never rejects.
|
|
53
|
+
*
|
|
54
|
+
* ## The company, before the announcement (AGL-2613)
|
|
55
|
+
*
|
|
56
|
+
* A new person with a work email address is linked to the company at that
|
|
57
|
+
* domain here, in the same hook, BEFORE `contactCreated` goes out — so an
|
|
58
|
+
* automation that reads the new contact finds them already filed, rather
|
|
59
|
+
* than racing a link that lands a moment later. Only when the door did not
|
|
60
|
+
* name a company itself: the console's drawer and an import row that
|
|
61
|
+
* carried one have written it into the facet, and the capture must not
|
|
62
|
+
* second-guess a person's choice with a domain match. The association never
|
|
63
|
+
* rejects, and its own catch keeps a failed lookup from costing the event.
|
|
64
|
+
*
|
|
65
|
+
* ## The owner, on the same terms (AGL-2618)
|
|
66
|
+
*
|
|
67
|
+
* Then the org's assignment rules and the site's default owner decide who
|
|
68
|
+
* follows the new person up — again before `contactCreated`, so an
|
|
69
|
+
* automation's "create a task for the owner" finds one — and again only
|
|
70
|
+
* when the door named none: a drawer, an import column or a conversion
|
|
71
|
+
* that picked an owner has said whose the record is. The pass reads the
|
|
72
|
+
* form and the tags off the same options the data library was handed,
|
|
73
|
+
* because the created-report carries the identity and not the routing.
|
|
74
|
+
* It never rejects either; a record it could not assign is one somebody
|
|
75
|
+
* assigns by hand, which is what every record was before this existed.
|
|
76
|
+
*
|
|
77
|
+
* What this does NOT do is close a lead (AGL-3232): a lead is the CRM's
|
|
78
|
+
* record, and which relationship closes one is the CRM's rule, answered in
|
|
79
|
+
* its capture writer for the doors that report through the capture seam.
|
|
80
|
+
* The order doors call this directly and close nothing — a buyer with an
|
|
81
|
+
* open lead is a lead the rep converts, as in Salesforce.
|
|
82
|
+
*/ export async function captureHostContact(captureOptions) {
|
|
83
|
+
const { actor } = captureOptions, options = _object_without_properties_loose(captureOptions, [
|
|
84
|
+
"actor"
|
|
85
|
+
]);
|
|
86
|
+
return upsertHostContact(_extends({}, options, {
|
|
87
|
+
onCreated: async (created)=>{
|
|
88
|
+
var _options_facet, _options_facet1;
|
|
89
|
+
if (!((_options_facet = options.facet) == null ? void 0 : _options_facet.companyId)) {
|
|
90
|
+
await associateCompanyByDomain(created).catch((error)=>{
|
|
91
|
+
console.error('captureHostContact company association failed', error);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
if (!((_options_facet1 = options.facet) == null ? void 0 : _options_facet1.ownerUid)) {
|
|
95
|
+
var _options_interaction_formId;
|
|
96
|
+
await assignOwnerForCapture({
|
|
97
|
+
hostId: created.hostId,
|
|
98
|
+
contactId: created.contactId,
|
|
99
|
+
email: created.email,
|
|
100
|
+
source: created.source,
|
|
101
|
+
formId: (_options_interaction_formId = options.interaction.formId) != null ? _options_interaction_formId : null,
|
|
102
|
+
tags: options.tags
|
|
103
|
+
}).catch((error)=>{
|
|
104
|
+
console.error('captureHostContact owner assignment failed', error);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
const cause = contactCaptureActor(created.source, created.email, actor);
|
|
108
|
+
await emitHostEvent(created.hostId, 'contactCreated', contactCreatedPayload(created, options.interaction.formId), cause ? {
|
|
109
|
+
actor: cause
|
|
110
|
+
} : {});
|
|
111
|
+
}
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The `contactCreated` payload, as the scalars an event may carry.
|
|
116
|
+
*
|
|
117
|
+
* `HostEventPayload` holds strings, numbers and booleans and nothing
|
|
118
|
+
* nested, because the payload seeds an expression scope and a condition
|
|
119
|
+
* editor whose operators compare strings. So `campaignIds` rides as one
|
|
120
|
+
* comma-joined string — `contains` still finds a campaign in it — and is
|
|
121
|
+
* present only when the capture had campaigns, so `notEmpty` on it reads
|
|
122
|
+
* as "came in through a campaign form". `name` and `lifecycleStage` are
|
|
123
|
+
* always present, empty when the door had none, so a condition on either
|
|
124
|
+
* never sees a missing key: `lifecycleStage == "lead"` is the filter that
|
|
125
|
+
* picks the form captures out of the sign-ups (AGL-2612).
|
|
126
|
+
*
|
|
127
|
+
* `formId` rides the same way `campaignIds` does — present only when the
|
|
128
|
+
* capture came through a form — because the created-report carries the
|
|
129
|
+
* person's identity and not the routing, and the form is routing: it is
|
|
130
|
+
* read off the interaction the door was handed. Present, it is what lets a
|
|
131
|
+
* condition say `formId` equals this form and no other (AGL-2626); absent,
|
|
132
|
+
* `notEmpty` on it reads as "came in through a form".
|
|
133
|
+
*/ export function contactCreatedPayload(created, formId) {
|
|
134
|
+
var _created_name, _created_lifecycleStage;
|
|
135
|
+
const form = String(formId != null ? formId : '').trim();
|
|
136
|
+
return _extends({
|
|
137
|
+
contactId: created.contactId,
|
|
138
|
+
email: created.email,
|
|
139
|
+
name: (_created_name = created.name) != null ? _created_name : '',
|
|
140
|
+
source: created.source,
|
|
141
|
+
hostId: created.hostId,
|
|
142
|
+
lifecycleStage: (_created_lifecycleStage = created.lifecycleStage) != null ? _created_lifecycleStage : ''
|
|
143
|
+
}, created.campaignIds.length ? {
|
|
144
|
+
campaignIds: created.campaignIds.join(',')
|
|
145
|
+
} : {}, form ? {
|
|
146
|
+
formId: form
|
|
147
|
+
} : {});
|
|
148
|
+
}
|
|
149
|
+
export default captureHostContact;
|
|
150
|
+
|
|
151
|
+
//# sourceMappingURL=capture-host-contact.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/capture-host-contact.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 type HostContactCreated,\n upsertHostContact,\n type UpsertHostContactOptions,\n type UpsertHostContactVerdict,\n} from '@aglyn/tenant-data-admin'\nimport { assignOwnerForCapture } from './assign-contact-owner'\nimport { associateCompanyByDomain } from './associate-company-by-domain'\nimport { emitHostEvent } from '@aglyn/tenant-runtime/emit-host-event'\nimport { contactCaptureActor } from '@aglyn/tenant-runtime/capture-actor'\nimport type { HostEventActor, HostEventPayload } from '@aglyn/tenant-runtime/host-event-listeners'\n\n\n/**\n * THE contact capture door for a server path (AGL-2605).\n *\n * `upsertHostContact` writes the contact and reports, through its\n * `onCreated` hook, when the write made a NEW person. This wrapper is the\n * one place that hook is bound to `contactCreated`, and it is the function\n * every server door calls — the forms route, the membership and newsletter\n * handlers, the order and booking webhooks. A door that calls\n * `upsertHostContact` directly still captures the contact and fires nothing,\n * which is a contact no automation can welcome; `capture-host-contact.spec`\n * scans for exactly that call.\n *\n * ## Why the binding lives here and not in the data library\n *\n * What listens for the event — the automation engine among them — imports\n * the data library for its Firestore handle, its org helpers and its\n * senders. The data library emitting an event would import the listeners\n * back, which is a cycle, and the module boundaries (`scope:data` depends on\n * data and util only) refuse it in any case. So the lower layer reports a\n * fact and this layer, which already knows how to announce one, announces\n * it. The alternative — a sink the runtime registers into when its module\n * happens to be imported — would fire only in a process that had loaded the\n * runtime, and a Stripe webhook that created a contact in a process that had\n * not would announce nothing with no error anywhere. The listeners themselves\n * are registered by a call made at boot (`host-event-listeners.ts`), so every\n * process has them before its first request.\n *\n * Fire-and-forget in the same sense the capture itself is: the hook's\n * failure is caught inside `upsertHostContact`, so a runner that throws\n * costs the door nothing, and this function never rejects.\n *\n * ## The company, before the announcement (AGL-2613)\n *\n * A new person with a work email address is linked to the company at that\n * domain here, in the same hook, BEFORE `contactCreated` goes out — so an\n * automation that reads the new contact finds them already filed, rather\n * than racing a link that lands a moment later. Only when the door did not\n * name a company itself: the console's drawer and an import row that\n * carried one have written it into the facet, and the capture must not\n * second-guess a person's choice with a domain match. The association never\n * rejects, and its own catch keeps a failed lookup from costing the event.\n *\n * ## The owner, on the same terms (AGL-2618)\n *\n * Then the org's assignment rules and the site's default owner decide who\n * follows the new person up — again before `contactCreated`, so an\n * automation's \"create a task for the owner\" finds one — and again only\n * when the door named none: a drawer, an import column or a conversion\n * that picked an owner has said whose the record is. The pass reads the\n * form and the tags off the same options the data library was handed,\n * because the created-report carries the identity and not the routing.\n * It never rejects either; a record it could not assign is one somebody\n * assigns by hand, which is what every record was before this existed.\n *\n * What this does NOT do is close a lead (AGL-3232): a lead is the CRM's\n * record, and which relationship closes one is the CRM's rule, answered in\n * its capture writer for the doors that report through the capture seam.\n * The order doors call this directly and close nothing — a buyer with an\n * open lead is a lead the rep converts, as in Salesforce.\n */\nexport async function captureHostContact(\n captureOptions: Omit<UpsertHostContactOptions, 'onCreated'> & {\n /** Who caused the capture, when the door knows (AGL-3376). */\n actor?: HostEventActor\n },\n): Promise<UpsertHostContactVerdict> {\n const { actor, ...options } = captureOptions\n return upsertHostContact({\n ...options,\n onCreated: async (created) => {\n if (!options.facet?.companyId) {\n await associateCompanyByDomain(created).catch((error: unknown) => {\n console.error('captureHostContact company association failed', error)\n })\n }\n if (!options.facet?.ownerUid) {\n await assignOwnerForCapture({\n hostId: created.hostId,\n contactId: created.contactId,\n email: created.email,\n source: created.source,\n formId: options.interaction.formId ?? null,\n tags: options.tags,\n }).catch((error: unknown) => {\n console.error('captureHostContact owner assignment failed', error)\n })\n }\n const cause = contactCaptureActor(created.source, created.email, actor)\n await emitHostEvent(\n created.hostId,\n 'contactCreated',\n contactCreatedPayload(created, options.interaction.formId),\n cause ? { actor: cause } : {},\n )\n },\n })\n}\n\n/**\n * The `contactCreated` payload, as the scalars an event may carry.\n *\n * `HostEventPayload` holds strings, numbers and booleans and nothing\n * nested, because the payload seeds an expression scope and a condition\n * editor whose operators compare strings. So `campaignIds` rides as one\n * comma-joined string — `contains` still finds a campaign in it — and is\n * present only when the capture had campaigns, so `notEmpty` on it reads\n * as \"came in through a campaign form\". `name` and `lifecycleStage` are\n * always present, empty when the door had none, so a condition on either\n * never sees a missing key: `lifecycleStage == \"lead\"` is the filter that\n * picks the form captures out of the sign-ups (AGL-2612).\n *\n * `formId` rides the same way `campaignIds` does — present only when the\n * capture came through a form — because the created-report carries the\n * person's identity and not the routing, and the form is routing: it is\n * read off the interaction the door was handed. Present, it is what lets a\n * condition say `formId` equals this form and no other (AGL-2626); absent,\n * `notEmpty` on it reads as \"came in through a form\".\n */\nexport function contactCreatedPayload(\n created: HostContactCreated,\n formId?: string | null,\n): HostEventPayload {\n const form = String(formId ?? '').trim()\n return {\n contactId: created.contactId,\n email: created.email,\n name: created.name ?? '',\n source: created.source,\n hostId: created.hostId,\n lifecycleStage: created.lifecycleStage ?? '',\n ...(created.campaignIds.length\n ? { campaignIds: created.campaignIds.join(',') }\n : {}),\n ...(form ? { formId: form } : {}),\n }\n}\n\nexport default captureHostContact\n"],"names":["upsertHostContact","assignOwnerForCapture","associateCompanyByDomain","emitHostEvent","contactCaptureActor","captureHostContact","captureOptions","actor","options","onCreated","created","facet","companyId","catch","error","console","ownerUid","hostId","contactId","email","source","formId","interaction","tags","cause","contactCreatedPayload","form","String","trim","name","lifecycleStage","campaignIds","length","join"],"mappings":";;AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAEEA,iBAAiB,QAGZ,2BAA0B;AACjC,SAASC,qBAAqB,QAAQ,4BAAwB;AAC9D,SAASC,wBAAwB,QAAQ,mCAA+B;AACxE,SAASC,aAAa,QAAQ,wCAAuC;AACrE,SAASC,mBAAmB,QAAQ,sCAAqC;AAIzE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2DC,GACD,OAAO,eAAeC,mBACpBC,cAGC;IAED,MAAM,EAAEC,KAAK,EAAc,GAAGD,gBAAZE,2CAAYF;;;IAC9B,OAAON,kBAAkB,aACpBQ;QACHC,WAAW,OAAOC;gBACXF,gBAKAA;YALL,IAAI,GAACA,iBAAAA,QAAQG,KAAK,qBAAbH,eAAeI,SAAS,GAAE;gBAC7B,MAAMV,yBAAyBQ,SAASG,KAAK,CAAC,CAACC;oBAC7CC,QAAQD,KAAK,CAAC,iDAAiDA;gBACjE;YACF;YACA,IAAI,GAACN,kBAAAA,QAAQG,KAAK,qBAAbH,gBAAeQ,QAAQ,GAAE;oBAMlBR;gBALV,MAAMP,sBAAsB;oBAC1BgB,QAAQP,QAAQO,MAAM;oBACtBC,WAAWR,QAAQQ,SAAS;oBAC5BC,OAAOT,QAAQS,KAAK;oBACpBC,QAAQV,QAAQU,MAAM;oBACtBC,MAAM,GAAEb,8BAAAA,QAAQc,WAAW,CAACD,MAAM,YAA1Bb,8BAA8B;oBACtCe,MAAMf,QAAQe,IAAI;gBACpB,GAAGV,KAAK,CAAC,CAACC;oBACRC,QAAQD,KAAK,CAAC,8CAA8CA;gBAC9D;YACF;YACA,MAAMU,QAAQpB,oBAAoBM,QAAQU,MAAM,EAAEV,QAAQS,KAAK,EAAEZ;YACjE,MAAMJ,cACJO,QAAQO,MAAM,EACd,kBACAQ,sBAAsBf,SAASF,QAAQc,WAAW,CAACD,MAAM,GACzDG,QAAQ;gBAAEjB,OAAOiB;YAAM,IAAI,CAAC;QAEhC;;AAEJ;AAEA;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,SAASC,sBACdf,OAA2B,EAC3BW,MAAsB;QAMdX,eAGUA;IAPlB,MAAMgB,OAAOC,OAAON,iBAAAA,SAAU,IAAIO,IAAI;IACtC,OAAO;QACLV,WAAWR,QAAQQ,SAAS;QAC5BC,OAAOT,QAAQS,KAAK;QACpBU,IAAI,GAAEnB,gBAAAA,QAAQmB,IAAI,YAAZnB,gBAAgB;QACtBU,QAAQV,QAAQU,MAAM;QACtBH,QAAQP,QAAQO,MAAM;QACtBa,cAAc,GAAEpB,0BAAAA,QAAQoB,cAAc,YAAtBpB,0BAA0B;OACtCA,QAAQqB,WAAW,CAACC,MAAM,GAC1B;QAAED,aAAarB,QAAQqB,WAAW,CAACE,IAAI,CAAC;IAAK,IAC7C,CAAC,GACDP,OAAO;QAAEL,QAAQK;IAAK,IAAI,CAAC;AAEnC;AAEA,eAAerB,mBAAkB"}
|
|
@@ -0,0 +1,25 @@
|
|
|
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
|
+
* Clear `parentCompanyId` on at most `limit` companies under `companyId`
|
|
19
|
+
* (AGL-3514), and say whether more remain. Shared by `crm/company-delete`
|
|
20
|
+
* and the REST resource's delete, which calls it until none remain.
|
|
21
|
+
*/
|
|
22
|
+
export declare function detachChildCompanies(firestore: FirebaseFirestore.Firestore, companies: FirebaseFirestore.CollectionReference, companyId: string, limit?: number): Promise<{
|
|
23
|
+
detached: number;
|
|
24
|
+
moreRemain: boolean;
|
|
25
|
+
}>;
|
|
@@ -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":"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"}
|
|
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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"}
|