@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,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import { type CrmPicklist, type CrmPicklistId, type CrmTaskPicklists } from '@aglyn/aglyn/server';
|
|
18
|
+
/** An org's list for one picklist as every server door judges against it (AGL-3510). */
|
|
19
|
+
export declare function readCrmPicklist(firestore: FirebaseFirestore.Firestore, orgId: string, id: CrmPicklistId): Promise<CrmPicklist>;
|
|
20
|
+
/** An org's Status, Priority and Type lists for tasks (AGL-3517), the three read together. */
|
|
21
|
+
export declare function readCrmTaskPicklists(firestore: FirebaseFirestore.Firestore, orgId: string): Promise<CrmTaskPicklists>;
|
|
22
|
+
/**
|
|
23
|
+
* What a record write stores under picklist `id`, from what the request
|
|
24
|
+
* said about the field:
|
|
25
|
+
*
|
|
26
|
+
* - `undefined` (not named): nothing — except on a CREATE, which starts
|
|
27
|
+
* from the list's default when it has one, as Salesforce's does.
|
|
28
|
+
* - `null` (cleared): the clear.
|
|
29
|
+
* - a label: {@link judgeCrmPicklistValue} — an active value's label, the
|
|
30
|
+
* record's own current value kept, and on a restricted picklist anything
|
|
31
|
+
* else refused naming the values the list allows.
|
|
32
|
+
*
|
|
33
|
+
* `write` undefined means leave the field alone.
|
|
34
|
+
*/
|
|
35
|
+
export declare function resolveCrmPicklistWrite(id: CrmPicklistId, picklist: CrmPicklist, requested: string | null | undefined, options: {
|
|
36
|
+
current?: unknown;
|
|
37
|
+
created: boolean;
|
|
38
|
+
}): {
|
|
39
|
+
ok: true;
|
|
40
|
+
write: string | null | undefined;
|
|
41
|
+
} | {
|
|
42
|
+
ok: false;
|
|
43
|
+
error: string;
|
|
44
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
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 { CRM_COLLECTIONS, CRM_TASK_PICKLIST_IDS, crmPicklistDefaultLabel, effectiveCrmPicklist, effectiveCrmTaskPicklists, judgeCrmPicklistValue } from "@aglyn/aglyn/server";
|
|
17
|
+
/** An org's list for one picklist as every server door judges against it (AGL-3510). */ export async function readCrmPicklist(firestore, orgId, id) {
|
|
18
|
+
const snapshot = await firestore.collection('orgs').doc(orgId).collection(CRM_COLLECTIONS.picklists).doc(id).get();
|
|
19
|
+
return effectiveCrmPicklist(id, snapshot.data());
|
|
20
|
+
}
|
|
21
|
+
/** An org's Status, Priority and Type lists for tasks (AGL-3517), the three read together. */ export async function readCrmTaskPicklists(firestore, orgId) {
|
|
22
|
+
const lists = firestore.collection('orgs').doc(orgId).collection(CRM_COLLECTIONS.picklists);
|
|
23
|
+
const [status, priority, type] = await Promise.all([
|
|
24
|
+
lists.doc(CRM_TASK_PICKLIST_IDS.status).get(),
|
|
25
|
+
lists.doc(CRM_TASK_PICKLIST_IDS.priority).get(),
|
|
26
|
+
lists.doc(CRM_TASK_PICKLIST_IDS.type).get()
|
|
27
|
+
]);
|
|
28
|
+
return effectiveCrmTaskPicklists({
|
|
29
|
+
status: status == null ? void 0 : status.data(),
|
|
30
|
+
priority: priority == null ? void 0 : priority.data(),
|
|
31
|
+
type: type == null ? void 0 : type.data()
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* What a record write stores under picklist `id`, from what the request
|
|
36
|
+
* said about the field:
|
|
37
|
+
*
|
|
38
|
+
* - `undefined` (not named): nothing — except on a CREATE, which starts
|
|
39
|
+
* from the list's default when it has one, as Salesforce's does.
|
|
40
|
+
* - `null` (cleared): the clear.
|
|
41
|
+
* - a label: {@link judgeCrmPicklistValue} — an active value's label, the
|
|
42
|
+
* record's own current value kept, and on a restricted picklist anything
|
|
43
|
+
* else refused naming the values the list allows.
|
|
44
|
+
*
|
|
45
|
+
* `write` undefined means leave the field alone.
|
|
46
|
+
*/ export function resolveCrmPicklistWrite(id, picklist, requested, options) {
|
|
47
|
+
if (requested === undefined) {
|
|
48
|
+
const fallback = options.created ? crmPicklistDefaultLabel(picklist) : null;
|
|
49
|
+
return {
|
|
50
|
+
ok: true,
|
|
51
|
+
write: fallback != null ? fallback : undefined
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
if (requested === null) return {
|
|
55
|
+
ok: true,
|
|
56
|
+
write: null
|
|
57
|
+
};
|
|
58
|
+
const judged = judgeCrmPicklistValue(id, picklist, requested, options.current);
|
|
59
|
+
return judged.ok === false ? judged : {
|
|
60
|
+
ok: true,
|
|
61
|
+
write: judged.value
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
//# sourceMappingURL=read-picklist.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/read-picklist.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 CRM_COLLECTIONS,\n CRM_TASK_PICKLIST_IDS,\n type CrmPicklist,\n type CrmPicklistId,\n type CrmTaskPicklists,\n crmPicklistDefaultLabel,\n effectiveCrmPicklist,\n effectiveCrmTaskPicklists,\n judgeCrmPicklistValue,\n} from '@aglyn/aglyn/server'\n\n/** An org's list for one picklist as every server door judges against it (AGL-3510). */\nexport async function readCrmPicklist(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n id: CrmPicklistId,\n): Promise<CrmPicklist> {\n const snapshot = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.picklists)\n .doc(id)\n .get()\n return effectiveCrmPicklist(id, snapshot.data())\n}\n\n/** An org's Status, Priority and Type lists for tasks (AGL-3517), the three read together. */\nexport async function readCrmTaskPicklists(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<CrmTaskPicklists> {\n const lists = firestore.collection('orgs').doc(orgId).collection(CRM_COLLECTIONS.picklists)\n const [status, priority, type] = await Promise.all([\n lists.doc(CRM_TASK_PICKLIST_IDS.status).get(),\n lists.doc(CRM_TASK_PICKLIST_IDS.priority).get(),\n lists.doc(CRM_TASK_PICKLIST_IDS.type).get(),\n ])\n return effectiveCrmTaskPicklists({\n status: status?.data(),\n priority: priority?.data(),\n type: type?.data(),\n })\n}\n\n/**\n * What a record write stores under picklist `id`, from what the request\n * said about the field:\n *\n * - `undefined` (not named): nothing — except on a CREATE, which starts\n * from the list's default when it has one, as Salesforce's does.\n * - `null` (cleared): the clear.\n * - a label: {@link judgeCrmPicklistValue} — an active value's label, the\n * record's own current value kept, and on a restricted picklist anything\n * else refused naming the values the list allows.\n *\n * `write` undefined means leave the field alone.\n */\nexport function resolveCrmPicklistWrite(\n id: CrmPicklistId,\n picklist: CrmPicklist,\n requested: string | null | undefined,\n options: { current?: unknown; created: boolean },\n): { ok: true; write: string | null | undefined } | { ok: false; error: string } {\n if (requested === undefined) {\n const fallback = options.created ? crmPicklistDefaultLabel(picklist) : null\n return { ok: true, write: fallback ?? undefined }\n }\n if (requested === null) return { ok: true, write: null }\n const judged = judgeCrmPicklistValue(id, picklist, requested, options.current)\n return judged.ok === false ? judged : { ok: true, write: judged.value }\n}\n"],"names":["CRM_COLLECTIONS","CRM_TASK_PICKLIST_IDS","crmPicklistDefaultLabel","effectiveCrmPicklist","effectiveCrmTaskPicklists","judgeCrmPicklistValue","readCrmPicklist","firestore","orgId","id","snapshot","collection","doc","picklists","get","data","readCrmTaskPicklists","lists","status","priority","type","Promise","all","resolveCrmPicklistWrite","picklist","requested","options","undefined","fallback","created","ok","write","judged","current","value"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,eAAe,EACfC,qBAAqB,EAIrBC,uBAAuB,EACvBC,oBAAoB,EACpBC,yBAAyB,EACzBC,qBAAqB,QAChB,sBAAqB;AAE5B,sFAAsF,GACtF,OAAO,eAAeC,gBACpBC,SAAsC,EACtCC,KAAa,EACbC,EAAiB;IAEjB,MAAMC,WAAW,MAAMH,UACpBI,UAAU,CAAC,QACXC,GAAG,CAACJ,OACJG,UAAU,CAACX,gBAAgBa,SAAS,EACpCD,GAAG,CAACH,IACJK,GAAG;IACN,OAAOX,qBAAqBM,IAAIC,SAASK,IAAI;AAC/C;AAEA,4FAA4F,GAC5F,OAAO,eAAeC,qBACpBT,SAAsC,EACtCC,KAAa;IAEb,MAAMS,QAAQV,UAAUI,UAAU,CAAC,QAAQC,GAAG,CAACJ,OAAOG,UAAU,CAACX,gBAAgBa,SAAS;IAC1F,MAAM,CAACK,QAAQC,UAAUC,KAAK,GAAG,MAAMC,QAAQC,GAAG,CAAC;QACjDL,MAAML,GAAG,CAACX,sBAAsBiB,MAAM,EAAEJ,GAAG;QAC3CG,MAAML,GAAG,CAACX,sBAAsBkB,QAAQ,EAAEL,GAAG;QAC7CG,MAAML,GAAG,CAACX,sBAAsBmB,IAAI,EAAEN,GAAG;KAC1C;IACD,OAAOV,0BAA0B;QAC/Bc,MAAM,EAAEA,0BAAAA,OAAQH,IAAI;QACpBI,QAAQ,EAAEA,4BAAAA,SAAUJ,IAAI;QACxBK,IAAI,EAAEA,wBAAAA,KAAML,IAAI;IAClB;AACF;AAEA;;;;;;;;;;;;CAYC,GACD,OAAO,SAASQ,wBACdd,EAAiB,EACjBe,QAAqB,EACrBC,SAAoC,EACpCC,OAAgD;IAEhD,IAAID,cAAcE,WAAW;QAC3B,MAAMC,WAAWF,QAAQG,OAAO,GAAG3B,wBAAwBsB,YAAY;QACvE,OAAO;YAAEM,IAAI;YAAMC,KAAK,EAAEH,mBAAAA,WAAYD;QAAU;IAClD;IACA,IAAIF,cAAc,MAAM,OAAO;QAAEK,IAAI;QAAMC,OAAO;IAAK;IACvD,MAAMC,SAAS3B,sBAAsBI,IAAIe,UAAUC,WAAWC,QAAQO,OAAO;IAC7E,OAAOD,OAAOF,EAAE,KAAK,QAAQE,SAAS;QAAEF,IAAI;QAAMC,OAAOC,OAAOE,KAAK;IAAC;AACxE"}
|
|
@@ -47,11 +47,14 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
47
47
|
* host role that creates actions today), so a site-level caller gets the
|
|
48
48
|
* same answers; the org variant (`orgId` in the body) is the one the org
|
|
49
49
|
* hub uses.
|
|
50
|
-
*/ import { ACTIONS_MAX_PER_HOST, checkEntitlement, createResourceUid,
|
|
50
|
+
*/ import { ACTIONS_MAX_PER_HOST, checkEntitlement, createResourceUid, hostRoleCanWrite, planLabelGrantingFeature, siteInteractionDocument } from "@aglyn/aglyn/server";
|
|
51
|
+
import { interactionRecipeStamp } from "@aglyn/aglyn/plugin-manager/interaction-recipes";
|
|
52
|
+
import { validateStoredInteraction } from "@aglyn/aglyn/plugin-manager/interaction-step-checks";
|
|
51
53
|
import { firebaseAdmin, getOrgForHost, logHostActivity, logOrgActivity } from "@aglyn/tenant-data-admin";
|
|
52
54
|
import { isRefusedIdToken } from "@aglyn/tenant-data-admin/server/id-token-refusal";
|
|
53
55
|
import { FieldValue } from "firebase-admin/firestore";
|
|
54
56
|
import { CRM_API_ROUTES } from "../constants/api-routes.js";
|
|
57
|
+
import { crmActionRecipe } from "../model/crm-recipes.js";
|
|
55
58
|
import { authorizeOrgCaller, orgHostIds, readCrmRouteScope } from "./org-caller.js";
|
|
56
59
|
import { crmSuiteRefusal } from "./suite-gate.js";
|
|
57
60
|
export const CRM_RECIPE_INSTALL_ROUTE = CRM_API_ROUTES.recipeInstall;
|
|
@@ -148,9 +151,11 @@ function typed(value, max) {
|
|
|
148
151
|
for (const row of rows.docs){
|
|
149
152
|
if (row.get('deletedAt') != null) continue;
|
|
150
153
|
live += 1;
|
|
151
|
-
const stamp =
|
|
154
|
+
const stamp = interactionRecipeStamp(row.data());
|
|
152
155
|
if (stamp === undefined) unstamped += 1;
|
|
153
|
-
else if (stamp && !installed.has(stamp))
|
|
156
|
+
else if (stamp && !installed.has(stamp)) {
|
|
157
|
+
installed.set(stamp, row.id);
|
|
158
|
+
}
|
|
154
159
|
}
|
|
155
160
|
return {
|
|
156
161
|
installed,
|
|
@@ -198,6 +203,7 @@ const stampsQuery = (host)=>host.collection('actions').select('recipe', 'deleted
|
|
|
198
203
|
}
|
|
199
204
|
const formId = typed(body.formId, 200);
|
|
200
205
|
try {
|
|
206
|
+
var _recipe_picks;
|
|
201
207
|
const firestore = firebaseAdmin.app().firestore();
|
|
202
208
|
let writer;
|
|
203
209
|
if (scope.level === 'org') {
|
|
@@ -257,8 +263,9 @@ const stampsQuery = (host)=>host.collection('actions').select('recipe', 'deleted
|
|
|
257
263
|
}
|
|
258
264
|
const hostRef = writer.host.ref;
|
|
259
265
|
let form;
|
|
260
|
-
if (recipe.
|
|
266
|
+
if (((_recipe_picks = recipe.picks) == null ? void 0 : _recipe_picks.kind) === 'form') {
|
|
261
267
|
var _snapshot_get;
|
|
268
|
+
var _snapshot_get1;
|
|
262
269
|
if (!formId) {
|
|
263
270
|
res.status(400).json({
|
|
264
271
|
error: 'Pick one of the site’s forms'
|
|
@@ -282,13 +289,20 @@ const stampsQuery = (host)=>host.collection('actions').select('recipe', 'deleted
|
|
|
282
289
|
}
|
|
283
290
|
form = {
|
|
284
291
|
id: snapshot.id,
|
|
285
|
-
name: String((_snapshot_get = snapshot.get('displayName')) != null ? _snapshot_get : '').trim() || snapshot.id
|
|
292
|
+
name: String((_snapshot_get = snapshot.get('displayName')) != null ? _snapshot_get : '').trim() || snapshot.id,
|
|
293
|
+
// The fact the Forms plugin's list source shares (AGL-3458): a
|
|
294
|
+
// lead-routed form makes leads, so its recipe listens for one.
|
|
295
|
+
facts: {
|
|
296
|
+
routesLeads: ((_snapshot_get1 = snapshot.get('routing')) == null ? void 0 : _snapshot_get1.lead) === true
|
|
297
|
+
}
|
|
286
298
|
};
|
|
287
299
|
}
|
|
288
300
|
const action = recipe.build(form ? {
|
|
289
|
-
form
|
|
301
|
+
picked: form
|
|
290
302
|
} : undefined);
|
|
291
|
-
|
|
303
|
+
// The automation editor's own checks of every step, registered from its
|
|
304
|
+
// declarations: a recipe installs only what that editor would save.
|
|
305
|
+
const problem = validateStoredInteraction(action);
|
|
292
306
|
if (problem) {
|
|
293
307
|
res.status(400).json({
|
|
294
308
|
error: problem
|
|
@@ -312,7 +326,7 @@ const stampsQuery = (host)=>host.collection('actions').select('recipe', 'deleted
|
|
|
312
326
|
error: `interactions and actions are capped at ${ACTIONS_MAX_PER_HOST} per site`
|
|
313
327
|
};
|
|
314
328
|
}
|
|
315
|
-
tx.create(hostRef.collection('actions').doc(actionId), _extends({},
|
|
329
|
+
tx.create(hostRef.collection('actions').doc(actionId), _extends({}, siteInteractionDocument(action), {
|
|
316
330
|
createdAt: FieldValue.serverTimestamp(),
|
|
317
331
|
updatedAt: FieldValue.serverTimestamp(),
|
|
318
332
|
createdBy: writer.uid
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/recipe-routes.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 * THE RECIPES, INSTALLED FROM THE ORGANIZATION (AGL-2639).\n *\n * On a site, a recipe opens the Actions editor prefilled and writes nothing\n * until the person saves — the editor is the writer, and the site's own\n * role is the authority. The organization-level CRM hub has no editor: it\n * mounts over every site with no site of its own, and an org running five\n * sites installed \"Welcome a new lead\" five times by hand. These two routes\n * are the org hub's door to a site's automations.\n *\n * `crm/recipe-install` writes the action a recipe builds into ONE site's\n * `hosts/{hostId}/actions`, as the site editor would have saved it — the\n * same builder, the same validator, the same stored shape — and refuses a\n * second install of the same recipe on the same site by the stamp the\n * builder puts on it. A server route rather than a client write for three\n * reasons the browser cannot supply: the document is created by a server\n * on every plan (the rules close `actions` to client creates, and the\n * per-site cap is counted here); the stamp is the server's word, not the\n * caller's; and the site is checked to be one the caller may manage —\n * at the org level, a site OF THE ORG the caller holds org-wide reach in,\n * so a host id typed into the body cannot reach a site under another\n * workspace.\n *\n * `crm/recipe-status` reads the stamps back for every site of the org, so\n * the card can say which sites carry which recipe. An action with no stamp\n * at all — saved before the stamp existed — is counted, not ignored: it\n * may well be a recipe, and the reader is told the site \"may already have\n * it\" rather than shown a clean slate.\n *\n * Both routes serve the site variant too (`hostId` alone, authorized by the\n * host role that creates actions today), so a site-level caller gets the\n * same answers; the org variant (`orgId` in the body) is the one the org\n * hub uses.\n */\n\nimport {\n ACTIONS_MAX_PER_HOST,\n type AglynOrganization,\n checkEntitlement,\n createResourceUid,\n crmActionRecipe,\n type CrmActionRecipeId,\n hostActionDocument,\n hostActionRecipeId,\n hostRoleCanWrite,\n planLabelGrantingFeature,\n type PluginApiHandler,\n type PluginApiRequest,\n validateHostAction,\n} from '@aglyn/aglyn/server'\nimport {\n firebaseAdmin,\n getOrgForHost,\n logHostActivity,\n logOrgActivity,\n} from '@aglyn/tenant-data-admin'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n CRM_API_ROUTES,\n type CrmRecipeInstallRequest,\n type CrmRecipeInstallResult,\n type CrmRecipeSiteStatus,\n} from '../constants/api-routes'\nimport { authorizeOrgCaller, orgHostIds, readCrmRouteScope } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\nexport const CRM_RECIPE_INSTALL_ROUTE = CRM_API_ROUTES.recipeInstall\nexport const CRM_RECIPE_STATUS_ROUTE = CRM_API_ROUTES.recipeStatus\n\ntype Refusal = { ok: false; status: number; error: string }\n\n/** A verified caller who may write a site's actions, and the site's org. */\ninterface SiteWriter {\n ok: true\n uid: string\n email: string | null\n staff: boolean\n orgId: string\n org: Partial<AglynOrganization>\n host: FirebaseFirestore.DocumentSnapshot\n}\n\nconst INSTALL_REFUSAL =\n 'Installing a recipe requires the data permission across the whole workspace'\nconst STATUS_REFUSAL =\n 'Reading which sites carry a recipe requires the data permission across the whole workspace'\n\nfunction typed(value: unknown, max: number): string {\n return String(value ?? '')\n .trim()\n .slice(0, max)\n}\n\n/**\n * The site variant's authority: the role on the host document that creates\n * an action through `/api/hosts/resources` today. Staff pass, as they pass\n * that route. Answers the host snapshot it read, so the caller reads it\n * once.\n */\nasync function authorizeSiteWriter(\n req: PluginApiRequest,\n hostId: string,\n): Promise<SiteWriter | 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 { ok: false, status: 401, error: 'Unauthenticated' }\n let decoded: { uid: string; email?: 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 { ok: false, status: 401, error: 'Unauthenticated' }\n console.error('[crm] the recipe site writer could not be verified', error)\n return { ok: false, status: 500, error: 'The sign-in could not be checked. Try again.' }\n }\n const staff = decoded.staff === true\n const host = await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .get()\n if (!host.exists) return { ok: false, status: 404, error: 'Unknown site' }\n const role = (host.get('memberRoles') ?? {})[decoded.uid]\n if (!staff && !hostRoleCanWrite(role)) {\n return { ok: false, status: 403, error: 'Editing requires the editor role' }\n }\n const resolved = await getOrgForHost(hostId)\n if (!resolved) return { ok: false, status: 404, error: 'Unknown site' }\n return {\n ok: true,\n uid: decoded.uid,\n email: decoded.email ?? null,\n staff,\n orgId: resolved.orgId,\n org: resolved.org,\n host,\n }\n}\n\n/**\n * The org variant's site check: the host document, when it belongs to the\n * organization the caller was admitted to. A site under another workspace\n * — or none — reads as unknown, so a probe learns nothing from the answer.\n */\nasync function siteOfOrg(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n orgId: string,\n): Promise<FirebaseFirestore.DocumentSnapshot | null> {\n const host = await firestore.collection('hosts').doc(hostId).get()\n return host.exists && host.get('orgId') === orgId ? host : null\n}\n\n/** How a site reads in a feed line: its display name, its subdomain, its id. */\nfunction siteLabel(host: FirebaseFirestore.DocumentSnapshot): string {\n return (\n String(host.get('displayName') ?? '').trim() ||\n String(host.get('subdomain') ?? '').trim() ||\n host.id\n )\n}\n\n/**\n * The stamps a site's LIVE actions carry, read with a field mask: the\n * install's duplicate check and the status sweep ask the same question,\n * and neither needs a trigger or a step list to answer it. A soft-deleted\n * action (`deletedAt`) is a freed slot and counts for nothing.\n */\nfunction readSiteStamps(rows: FirebaseFirestore.QuerySnapshot): {\n installed: Map<CrmActionRecipeId, string>\n unstamped: number\n live: number\n} {\n const installed = new Map<CrmActionRecipeId, string>()\n let unstamped = 0\n let live = 0\n for (const row of rows.docs) {\n if (row.get('deletedAt') != null) continue\n live += 1\n const stamp = hostActionRecipeId(row.data())\n if (stamp === undefined) unstamped += 1\n else if (stamp && !installed.has(stamp)) installed.set(stamp, row.id)\n }\n return { installed, unstamped, live }\n}\n\nconst stampsQuery = (host: FirebaseFirestore.DocumentReference) =>\n host.collection('actions').select('recipe', 'deletedAt')\n\n/**\n * `POST /api/crm/recipe-install`\n *\n * The body is a CrmRecipeInstallRequest: the site, the recipe, the form a\n * recipe that needs one is keyed on, and the org at the organization level.\n * Answers 201 with a CrmRecipeInstallResult once the action is written, and\n * 409 when a live action on the site already carries the stamp — naming it,\n * so the caller can say \"already installed\" rather than install it twice.\n *\n * The write is one transaction: the stamps are read, the duplicate and the\n * per-site cap are judged, and the document is created — all inside it, so\n * two installs racing for the same site cannot both land.\n */\nexport const crmRecipeInstallHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const body: Partial<CrmRecipeInstallRequest> =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const scope = readCrmRouteScope(body as Record<string, unknown>)\n const hostId = scope?.hostId ?? ''\n if (!scope || !hostId) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const recipe = crmActionRecipe(typed(body.recipeId, 64))\n if (!recipe) {\n res.status(400).json({ error: 'Pick a recipe' })\n return\n }\n const formId = typed(body.formId, 200)\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n let writer: SiteWriter\n if (scope.level === 'org') {\n const caller = await authorizeOrgCaller(req, scope.orgId, {\n needs: 'data.manage',\n refusal: INSTALL_REFUSAL,\n })\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n const host = await siteOfOrg(firestore, hostId, caller.orgId)\n if (!host) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n writer = {\n ok: true,\n uid: caller.uid,\n email: caller.email,\n staff: caller.staff,\n orgId: caller.orgId,\n org: caller.org,\n host,\n }\n } else {\n const caller = await authorizeSiteWriter(req, hostId)\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n writer = caller\n }\n\n // A recipe is a CRM automation first (AGL-2787): the suite is asked\n // before the actions builder, so a plan without either is told about\n // the one the recipe belongs to.\n const suite = crmSuiteRefusal(writer.org, 'A CRM recipe')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n\n // The gate the site's own Recipes menu applies, judged here from the\n // org document rather than trusted from the console: a recipe is an\n // action, and the plan that carries one carries the other.\n if (!checkEntitlement(writer.org, 'actions')) {\n res.status(403).json({\n error:\n `The actions builder requires the ${planLabelGrantingFeature('actions') ?? 'Pro'} ` +\n 'plan — see Billing to upgrade',\n })\n return\n }\n\n const hostRef = writer.host.ref\n let form: { id: string; name: string } | undefined\n if (recipe.needs === 'form') {\n if (!formId) {\n res.status(400).json({ error: 'Pick one of the site’s forms' })\n return\n }\n const snapshot = await hostRef.collection('forms').doc(formId).get()\n if (!snapshot.exists) {\n res.status(404).json({ error: 'Unknown form' })\n return\n }\n // An archived form collects nothing, so a recipe keyed on it would\n // never fire; the name falls back to the id the way every form list's does.\n if (snapshot.get('archivedAt') != null) {\n res.status(400).json({ error: 'That form is archived and collects nothing' })\n return\n }\n form = {\n id: snapshot.id,\n name: String(snapshot.get('displayName') ?? '').trim() || snapshot.id,\n }\n }\n\n const action = recipe.build(form ? { form } : undefined)\n const problem = validateHostAction(action)\n if (problem) {\n res.status(400).json({ error: problem })\n return\n }\n\n const actionId = createResourceUid()\n const outcome = await firestore.runTransaction(async (tx) => {\n const stamps = readSiteStamps(await tx.get(stampsQuery(hostRef)))\n const existing = stamps.installed.get(recipe.id)\n if (existing) {\n return {\n status: 409,\n error: 'Already installed on this site',\n actionId: existing,\n }\n }\n if (stamps.live >= ACTIONS_MAX_PER_HOST) {\n return {\n status: 403,\n error: `interactions and actions are capped at ${ACTIONS_MAX_PER_HOST} per site`,\n }\n }\n tx.create(hostRef.collection('actions').doc(actionId), {\n ...hostActionDocument(action),\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n createdBy: writer.uid,\n })\n return null\n })\n if (outcome) {\n res.status(outcome.status).json({\n error: outcome.error,\n ...(outcome.actionId ? { actionId: outcome.actionId } : {}),\n })\n return\n }\n\n const actor = { uid: writer.uid, email: writer.email }\n await logHostActivity(hostId, actor, 'Installed recipe', {\n type: 'content',\n id: actionId,\n name: action.name,\n })\n // The org hub's own feed line, for the act it performed (AGL-2634).\n if (scope.level === 'org') {\n await logOrgActivity(\n writer.orgId,\n actor,\n `Installed the “${recipe.title}” recipe on ${siteLabel(writer.host)}`,\n { type: 'host', id: hostId, name: siteLabel(writer.host) },\n )\n }\n const answer: CrmRecipeInstallResult = {\n ok: true,\n actionId,\n name: action.name,\n recipeId: recipe.id,\n }\n res.status(201).json(answer)\n } catch (error) {\n console.error('[crm] recipe install failed', hostId, error)\n res.status(500).json({ error: 'The recipe could not be installed' })\n }\n}\n\n/**\n * `POST /api/crm/recipe-status`\n *\n * The body names the organization (`orgId`) for every one of its sites, or\n * one site (`hostId`) for that site alone. Answers `ok: true` and `sites`,\n * one CrmRecipeSiteStatus per site swept.\n */\nexport const crmRecipeStatusHandler: 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: Record<string, unknown> =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const scope = readCrmRouteScope(body)\n if (!scope) {\n res.status(400).json({ error: 'Missing orgId or hostId' })\n return\n }\n try {\n const firestore = firebaseAdmin.app().firestore()\n let orgId: string | null = null\n let org: unknown\n if (scope.level === 'org') {\n const caller = await authorizeOrgCaller(req, scope.orgId, {\n needs: 'data.manage',\n refusal: STATUS_REFUSAL,\n })\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n orgId = caller.orgId\n org = caller.org\n } else {\n const caller = await authorizeSiteWriter(req, scope.hostId)\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n org = caller.org\n }\n // The plan after the caller, before any site is read (AGL-2851).\n const suite = crmSuiteRefusal(org, 'Reading which sites carry a CRM recipe')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n const hostIds = orgId ? await orgHostIds(firestore, orgId) : [scope.hostId]\n const sites = await Promise.all(\n hostIds.map(async (hostId): Promise<CrmRecipeSiteStatus> => {\n const stamps = readSiteStamps(\n await stampsQuery(firestore.collection('hosts').doc(hostId)).get(),\n )\n return {\n hostId,\n installed: [...stamps.installed.keys()],\n unstamped: stamps.unstamped,\n }\n }),\n )\n res.status(200).json({ ok: true, sites })\n } catch (error) {\n console.error('[crm] recipe status failed', error)\n res.status(500).json({ error: 'The recipe status could not be read' })\n }\n}\n"],"names":["ACTIONS_MAX_PER_HOST","checkEntitlement","createResourceUid","crmActionRecipe","hostActionDocument","hostActionRecipeId","hostRoleCanWrite","planLabelGrantingFeature","validateHostAction","firebaseAdmin","getOrgForHost","logHostActivity","logOrgActivity","isRefusedIdToken","FieldValue","CRM_API_ROUTES","authorizeOrgCaller","orgHostIds","readCrmRouteScope","crmSuiteRefusal","CRM_RECIPE_INSTALL_ROUTE","recipeInstall","CRM_RECIPE_STATUS_ROUTE","recipeStatus","INSTALL_REFUSAL","STATUS_REFUSAL","typed","value","max","String","trim","slice","authorizeSiteWriter","req","hostId","host","decoded","authorization","headers","idToken","startsWith","length","undefined","ok","status","error","app","auth","verifyIdToken","console","staff","firestore","collection","doc","get","exists","role","uid","resolved","email","orgId","org","siteOfOrg","siteLabel","id","readSiteStamps","rows","installed","Map","unstamped","live","row","docs","stamp","data","has","set","stampsQuery","select","crmRecipeInstallHandler","res","method","setHeader","json","body","JSON","parse","scope","recipe","recipeId","formId","writer","level","caller","needs","refusal","suite","hostRef","ref","form","snapshot","name","action","build","problem","actionId","outcome","runTransaction","tx","stamps","existing","create","createdAt","serverTimestamp","updatedAt","createdBy","actor","type","title","answer","crmRecipeStatusHandler","hostIds","sites","Promise","all","map","keys"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/recipe-routes.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 * THE RECIPES, INSTALLED FROM THE ORGANIZATION (AGL-2639).\n *\n * On a site, a recipe opens the Actions editor prefilled and writes nothing\n * until the person saves — the editor is the writer, and the site's own\n * role is the authority. The organization-level CRM hub has no editor: it\n * mounts over every site with no site of its own, and an org running five\n * sites installed \"Welcome a new lead\" five times by hand. These two routes\n * are the org hub's door to a site's automations.\n *\n * `crm/recipe-install` writes the action a recipe builds into ONE site's\n * `hosts/{hostId}/actions`, as the site editor would have saved it — the\n * same builder, the same validator, the same stored shape — and refuses a\n * second install of the same recipe on the same site by the stamp the\n * builder puts on it. A server route rather than a client write for three\n * reasons the browser cannot supply: the document is created by a server\n * on every plan (the rules close `actions` to client creates, and the\n * per-site cap is counted here); the stamp is the server's word, not the\n * caller's; and the site is checked to be one the caller may manage —\n * at the org level, a site OF THE ORG the caller holds org-wide reach in,\n * so a host id typed into the body cannot reach a site under another\n * workspace.\n *\n * `crm/recipe-status` reads the stamps back for every site of the org, so\n * the card can say which sites carry which recipe. An action with no stamp\n * at all — saved before the stamp existed — is counted, not ignored: it\n * may well be a recipe, and the reader is told the site \"may already have\n * it\" rather than shown a clean slate.\n *\n * Both routes serve the site variant too (`hostId` alone, authorized by the\n * host role that creates actions today), so a site-level caller gets the\n * same answers; the org variant (`orgId` in the body) is the one the org\n * hub uses.\n */\n\nimport {\n ACTIONS_MAX_PER_HOST,\n type AglynOrganization,\n checkEntitlement,\n createResourceUid,\n hostRoleCanWrite,\n planLabelGrantingFeature,\n type PluginApiHandler,\n type PluginApiRequest,\n siteInteractionDocument,\n} from '@aglyn/aglyn/server'\nimport { interactionRecipeStamp } from '@aglyn/aglyn/plugin-manager/interaction-recipes'\nimport { validateStoredInteraction } from '@aglyn/aglyn/plugin-manager/interaction-step-checks'\nimport {\n firebaseAdmin,\n getOrgForHost,\n logHostActivity,\n logOrgActivity,\n} from '@aglyn/tenant-data-admin'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n CRM_API_ROUTES,\n type CrmRecipeInstallRequest,\n type CrmRecipeInstallResult,\n type CrmRecipeSiteStatus,\n} from '../constants/api-routes'\nimport { crmActionRecipe, type CrmActionRecipeId } from '../model/crm-recipes'\nimport { authorizeOrgCaller, orgHostIds, readCrmRouteScope } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\nexport const CRM_RECIPE_INSTALL_ROUTE = CRM_API_ROUTES.recipeInstall\nexport const CRM_RECIPE_STATUS_ROUTE = CRM_API_ROUTES.recipeStatus\n\ntype Refusal = { ok: false; status: number; error: string }\n\n/** A verified caller who may write a site's actions, and the site's org. */\ninterface SiteWriter {\n ok: true\n uid: string\n email: string | null\n staff: boolean\n orgId: string\n org: Partial<AglynOrganization>\n host: FirebaseFirestore.DocumentSnapshot\n}\n\nconst INSTALL_REFUSAL =\n 'Installing a recipe requires the data permission across the whole workspace'\nconst STATUS_REFUSAL =\n 'Reading which sites carry a recipe requires the data permission across the whole workspace'\n\nfunction typed(value: unknown, max: number): string {\n return String(value ?? '')\n .trim()\n .slice(0, max)\n}\n\n/**\n * The site variant's authority: the role on the host document that creates\n * an action through `/api/hosts/resources` today. Staff pass, as they pass\n * that route. Answers the host snapshot it read, so the caller reads it\n * once.\n */\nasync function authorizeSiteWriter(\n req: PluginApiRequest,\n hostId: string,\n): Promise<SiteWriter | 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 { ok: false, status: 401, error: 'Unauthenticated' }\n let decoded: { uid: string; email?: 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 { ok: false, status: 401, error: 'Unauthenticated' }\n console.error('[crm] the recipe site writer could not be verified', error)\n return { ok: false, status: 500, error: 'The sign-in could not be checked. Try again.' }\n }\n const staff = decoded.staff === true\n const host = await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .get()\n if (!host.exists) return { ok: false, status: 404, error: 'Unknown site' }\n const role = (host.get('memberRoles') ?? {})[decoded.uid]\n if (!staff && !hostRoleCanWrite(role)) {\n return { ok: false, status: 403, error: 'Editing requires the editor role' }\n }\n const resolved = await getOrgForHost(hostId)\n if (!resolved) return { ok: false, status: 404, error: 'Unknown site' }\n return {\n ok: true,\n uid: decoded.uid,\n email: decoded.email ?? null,\n staff,\n orgId: resolved.orgId,\n org: resolved.org,\n host,\n }\n}\n\n/**\n * The org variant's site check: the host document, when it belongs to the\n * organization the caller was admitted to. A site under another workspace\n * — or none — reads as unknown, so a probe learns nothing from the answer.\n */\nasync function siteOfOrg(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n orgId: string,\n): Promise<FirebaseFirestore.DocumentSnapshot | null> {\n const host = await firestore.collection('hosts').doc(hostId).get()\n return host.exists && host.get('orgId') === orgId ? host : null\n}\n\n/** How a site reads in a feed line: its display name, its subdomain, its id. */\nfunction siteLabel(host: FirebaseFirestore.DocumentSnapshot): string {\n return (\n String(host.get('displayName') ?? '').trim() ||\n String(host.get('subdomain') ?? '').trim() ||\n host.id\n )\n}\n\n/**\n * The stamps a site's LIVE actions carry, read with a field mask: the\n * install's duplicate check and the status sweep ask the same question,\n * and neither needs a trigger or a step list to answer it. A soft-deleted\n * action (`deletedAt`) is a freed slot and counts for nothing.\n */\nfunction readSiteStamps(rows: FirebaseFirestore.QuerySnapshot): {\n installed: Map<CrmActionRecipeId, string>\n unstamped: number\n live: number\n} {\n const installed = new Map<CrmActionRecipeId, string>()\n let unstamped = 0\n let live = 0\n for (const row of rows.docs) {\n if (row.get('deletedAt') != null) continue\n live += 1\n const stamp = interactionRecipeStamp(row.data())\n if (stamp === undefined) unstamped += 1\n else if (stamp && !installed.has(stamp as CrmActionRecipeId)) {\n installed.set(stamp as CrmActionRecipeId, row.id)\n }\n }\n return { installed, unstamped, live }\n}\n\nconst stampsQuery = (host: FirebaseFirestore.DocumentReference) =>\n host.collection('actions').select('recipe', 'deletedAt')\n\n/**\n * `POST /api/crm/recipe-install`\n *\n * The body is a CrmRecipeInstallRequest: the site, the recipe, the form a\n * recipe that needs one is keyed on, and the org at the organization level.\n * Answers 201 with a CrmRecipeInstallResult once the action is written, and\n * 409 when a live action on the site already carries the stamp — naming it,\n * so the caller can say \"already installed\" rather than install it twice.\n *\n * The write is one transaction: the stamps are read, the duplicate and the\n * per-site cap are judged, and the document is created — all inside it, so\n * two installs racing for the same site cannot both land.\n */\nexport const crmRecipeInstallHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const body: Partial<CrmRecipeInstallRequest> =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const scope = readCrmRouteScope(body as Record<string, unknown>)\n const hostId = scope?.hostId ?? ''\n if (!scope || !hostId) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const recipe = crmActionRecipe(typed(body.recipeId, 64))\n if (!recipe) {\n res.status(400).json({ error: 'Pick a recipe' })\n return\n }\n const formId = typed(body.formId, 200)\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n let writer: SiteWriter\n if (scope.level === 'org') {\n const caller = await authorizeOrgCaller(req, scope.orgId, {\n needs: 'data.manage',\n refusal: INSTALL_REFUSAL,\n })\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n const host = await siteOfOrg(firestore, hostId, caller.orgId)\n if (!host) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n writer = {\n ok: true,\n uid: caller.uid,\n email: caller.email,\n staff: caller.staff,\n orgId: caller.orgId,\n org: caller.org,\n host,\n }\n } else {\n const caller = await authorizeSiteWriter(req, hostId)\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n writer = caller\n }\n\n // A recipe is a CRM automation first (AGL-2787): the suite is asked\n // before the actions builder, so a plan without either is told about\n // the one the recipe belongs to.\n const suite = crmSuiteRefusal(writer.org, 'A CRM recipe')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n\n // The gate the site's own Recipes menu applies, judged here from the\n // org document rather than trusted from the console: a recipe is an\n // action, and the plan that carries one carries the other.\n if (!checkEntitlement(writer.org, 'actions')) {\n res.status(403).json({\n error:\n `The actions builder requires the ${planLabelGrantingFeature('actions') ?? 'Pro'} ` +\n 'plan — see Billing to upgrade',\n })\n return\n }\n\n const hostRef = writer.host.ref\n let form: { id: string; name: string; facts: { routesLeads: boolean } } | undefined\n if (recipe.picks?.kind === 'form') {\n if (!formId) {\n res.status(400).json({ error: 'Pick one of the site’s forms' })\n return\n }\n const snapshot = await hostRef.collection('forms').doc(formId).get()\n if (!snapshot.exists) {\n res.status(404).json({ error: 'Unknown form' })\n return\n }\n // An archived form collects nothing, so a recipe keyed on it would\n // never fire; the name falls back to the id the way every form list's does.\n if (snapshot.get('archivedAt') != null) {\n res.status(400).json({ error: 'That form is archived and collects nothing' })\n return\n }\n form = {\n id: snapshot.id,\n name: String(snapshot.get('displayName') ?? '').trim() || snapshot.id,\n // The fact the Forms plugin's list source shares (AGL-3458): a\n // lead-routed form makes leads, so its recipe listens for one.\n facts: { routesLeads: snapshot.get('routing')?.lead === true },\n }\n }\n\n const action = recipe.build(form ? { picked: form } : undefined)\n // The automation editor's own checks of every step, registered from its\n // declarations: a recipe installs only what that editor would save.\n const problem = validateStoredInteraction(action)\n if (problem) {\n res.status(400).json({ error: problem })\n return\n }\n\n const actionId = createResourceUid()\n const outcome = await firestore.runTransaction(async (tx) => {\n const stamps = readSiteStamps(await tx.get(stampsQuery(hostRef)))\n const existing = stamps.installed.get(recipe.id)\n if (existing) {\n return {\n status: 409,\n error: 'Already installed on this site',\n actionId: existing,\n }\n }\n if (stamps.live >= ACTIONS_MAX_PER_HOST) {\n return {\n status: 403,\n error: `interactions and actions are capped at ${ACTIONS_MAX_PER_HOST} per site`,\n }\n }\n tx.create(hostRef.collection('actions').doc(actionId), {\n ...siteInteractionDocument(action),\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n createdBy: writer.uid,\n })\n return null\n })\n if (outcome) {\n res.status(outcome.status).json({\n error: outcome.error,\n ...(outcome.actionId ? { actionId: outcome.actionId } : {}),\n })\n return\n }\n\n const actor = { uid: writer.uid, email: writer.email }\n await logHostActivity(hostId, actor, 'Installed recipe', {\n type: 'content',\n id: actionId,\n name: action.name,\n })\n // The org hub's own feed line, for the act it performed (AGL-2634).\n if (scope.level === 'org') {\n await logOrgActivity(\n writer.orgId,\n actor,\n `Installed the “${recipe.title}” recipe on ${siteLabel(writer.host)}`,\n { type: 'host', id: hostId, name: siteLabel(writer.host) },\n )\n }\n const answer: CrmRecipeInstallResult = {\n ok: true,\n actionId,\n name: action.name,\n recipeId: recipe.id,\n }\n res.status(201).json(answer)\n } catch (error) {\n console.error('[crm] recipe install failed', hostId, error)\n res.status(500).json({ error: 'The recipe could not be installed' })\n }\n}\n\n/**\n * `POST /api/crm/recipe-status`\n *\n * The body names the organization (`orgId`) for every one of its sites, or\n * one site (`hostId`) for that site alone. Answers `ok: true` and `sites`,\n * one CrmRecipeSiteStatus per site swept.\n */\nexport const crmRecipeStatusHandler: 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: Record<string, unknown> =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const scope = readCrmRouteScope(body)\n if (!scope) {\n res.status(400).json({ error: 'Missing orgId or hostId' })\n return\n }\n try {\n const firestore = firebaseAdmin.app().firestore()\n let orgId: string | null = null\n let org: unknown\n if (scope.level === 'org') {\n const caller = await authorizeOrgCaller(req, scope.orgId, {\n needs: 'data.manage',\n refusal: STATUS_REFUSAL,\n })\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n orgId = caller.orgId\n org = caller.org\n } else {\n const caller = await authorizeSiteWriter(req, scope.hostId)\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n org = caller.org\n }\n // The plan after the caller, before any site is read (AGL-2851).\n const suite = crmSuiteRefusal(org, 'Reading which sites carry a CRM recipe')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n const hostIds = orgId ? await orgHostIds(firestore, orgId) : [scope.hostId]\n const sites = await Promise.all(\n hostIds.map(async (hostId): Promise<CrmRecipeSiteStatus> => {\n const stamps = readSiteStamps(\n await stampsQuery(firestore.collection('hosts').doc(hostId)).get(),\n )\n return {\n hostId,\n installed: [...stamps.installed.keys()],\n unstamped: stamps.unstamped,\n }\n }),\n )\n res.status(200).json({ ok: true, sites })\n } catch (error) {\n console.error('[crm] recipe status failed', error)\n res.status(500).json({ error: 'The recipe status could not be read' })\n }\n}\n"],"names":["ACTIONS_MAX_PER_HOST","checkEntitlement","createResourceUid","hostRoleCanWrite","planLabelGrantingFeature","siteInteractionDocument","interactionRecipeStamp","validateStoredInteraction","firebaseAdmin","getOrgForHost","logHostActivity","logOrgActivity","isRefusedIdToken","FieldValue","CRM_API_ROUTES","crmActionRecipe","authorizeOrgCaller","orgHostIds","readCrmRouteScope","crmSuiteRefusal","CRM_RECIPE_INSTALL_ROUTE","recipeInstall","CRM_RECIPE_STATUS_ROUTE","recipeStatus","INSTALL_REFUSAL","STATUS_REFUSAL","typed","value","max","String","trim","slice","authorizeSiteWriter","req","hostId","host","decoded","authorization","headers","idToken","startsWith","length","undefined","ok","status","error","app","auth","verifyIdToken","console","staff","firestore","collection","doc","get","exists","role","uid","resolved","email","orgId","org","siteOfOrg","siteLabel","id","readSiteStamps","rows","installed","Map","unstamped","live","row","docs","stamp","data","has","set","stampsQuery","select","crmRecipeInstallHandler","res","method","setHeader","json","body","JSON","parse","scope","recipe","recipeId","formId","writer","level","caller","needs","refusal","suite","hostRef","ref","form","picks","kind","snapshot","name","facts","routesLeads","lead","action","build","picked","problem","actionId","outcome","runTransaction","tx","stamps","existing","create","createdAt","serverTimestamp","updatedAt","createdBy","actor","type","title","answer","crmRecipeStatusHandler","hostIds","sites","Promise","all","map","keys"],"mappings":"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"}
|
|
@@ -20,7 +20,7 @@ import { findContactByEmail, firebaseAdmin } from "@aglyn/tenant-data-admin";
|
|
|
20
20
|
import { FieldValue } from "firebase-admin/firestore";
|
|
21
21
|
import { BUNDLE_ID } from "../constants/bundle-common.js";
|
|
22
22
|
import { recordContactEmailEngagement } from "./contact-email-engagement.js";
|
|
23
|
-
import { nurtureReachedLeads } from "./lead-nurturing.js";
|
|
23
|
+
import { nurtureReachedLeads, workRepliedLeads } from "./lead-nurturing.js";
|
|
24
24
|
const NONE = {
|
|
25
25
|
records: 0
|
|
26
26
|
};
|
|
@@ -144,6 +144,12 @@ export function createCrmRecordEmailStateWriter(deps) {
|
|
|
144
144
|
return {
|
|
145
145
|
records: await nurtureReachedLeads(deps.firestore(), request)
|
|
146
146
|
};
|
|
147
|
+
},
|
|
148
|
+
// A reply moves a lead nobody had worked to Working (AGL-3080).
|
|
149
|
+
async replied (request) {
|
|
150
|
+
return {
|
|
151
|
+
records: await workRepliedLeads(deps.firestore(), request)
|
|
152
|
+
};
|
|
147
153
|
}
|
|
148
154
|
};
|
|
149
155
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/record-email-state.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n registerPluginRecordEmailStateWriter,\n type PluginRecordEmailStateReport,\n type PluginRecordEmailStateRequest,\n type PluginRecordEmailStateWriter,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\nimport {\n CRM_EMAIL_STATUS_FIELD,\n CRM_EMAIL_STATUS_NONE,\n EMAIL_STATE_FIELD,\n nextEmailState,\n normalizeContactEmail,\n personKey,\n readEmailState,\n type EmailState,\n} from '@aglyn/aglyn/server'\nimport { findContactByEmail, firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport { recordContactEmailEngagement } from './contact-email-engagement'\nimport { nurtureReachedLeads } from './lead-nurturing'\n\n/**\n * THE CRM'S WRITER ON THE CORE'S RECORD EMAIL-STATE SEAM (AGL-3245).\n *\n * A sender filed a verdict on an address — the outreach runtime's bounce,\n * the campaign webhook's complaint, a member's do-not-contact mark, the\n * unsubscribe link — and says so here; this writes it onto every record\n * the address is, as `emailState`, so the record a person reads and the\n * lists a send consults agree.\n *\n * One address can be several records: the contact the organization holds\n * (found through the address index, `findContactByEmail`), and a lead on\n * each site the organization owns — leads are keyed by `personKey`, so a\n * lead is addressed directly under every host. All of them are stamped;\n * the verdict is about the address, not about which record a rep opened.\n * A caller that knows only the site reads the organization off it.\n *\n * The write keeps the STRONGER verdict (`nextEmailState`): a member's\n * do-not-contact mark is not undone by a later bounce, and `ok` lands only\n * when a caller forces it. `updatedAt` is left alone — a verdict is\n * something that happened to the person, not an edit the team made, and a\n * list sorted on recency must not reshuffle on a bounce.\n *\n * Never throws: the list is the control, and a record that could not be\n * stamped is a chip a page goes without, logged.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\nexport interface CrmRecordEmailStateDeps {\n firestore(): Firestore\n}\n\nconst NONE: PluginRecordEmailStateReport = { records: 0 }\n\n/**\n * Applies the verdict to one record; answers whether it moved. A read then\n * a merge rather than a transaction: two verdicts landing together each\n * compare against a state at least as old as their own, and the ranking\n * makes the order of two writes of the same rank immaterial.\n */\nasync function stampRecord(\n ref: FirebaseFirestore.DocumentReference,\n incoming: EmailState,\n force: boolean,\n withdraw = false,\n): Promise<boolean> {\n const snapshot = await ref.get()\n if (!snapshot.exists) return false\n const current = readEmailState(snapshot.data() as Record<string, unknown>)\n /*\n * A WITHDRAWAL takes back the one verdict it names (AGL-3328): the\n * deliverability check's \"Would bounce\" once the domain takes mail again.\n * Any other standing verdict — a bounce DNS cannot see — is left alone.\n */\n if (withdraw) {\n if (current?.status !== incoming.status) return false\n await ref.update({\n [EMAIL_STATE_FIELD]: FieldValue.delete(),\n [CRM_EMAIL_STATUS_FIELD]: CRM_EMAIL_STATUS_NONE,\n })\n return true\n }\n // The deliverability check saying again what the record already holds:\n // the date it was first found is the one worth keeping.\n if (incoming.source === 'check' && current?.status === incoming.status) return false\n const next = nextEmailState(current, incoming, { force })\n // The current verdict stood, or the same verdict arrived again — a second\n // run of the caller — and there is nothing to write.\n if (\n next === current ||\n (current &&\n next.status === current.status &&\n next.atMs === current.atMs &&\n next.source === current.source &&\n next.detail === current.detail)\n ) {\n return false\n }\n // `emailStatus` beside it: the verdict as the CRM lists filter by it\n // (AGL-3321) — a key a query can ask, where the state is a map.\n await ref.set(\n { [EMAIL_STATE_FIELD]: next, [CRM_EMAIL_STATUS_FIELD]: next.status },\n { merge: true },\n )\n return true\n}\n\n/** The organization a request names, or the one its site belongs to. */\nasync function resolveOrgId(firestore: Firestore, request: PluginRecordEmailStateRequest): Promise<string> {\n const named = String(request.orgId ?? '').trim()\n if (named) return named\n const hostId = String(request.hostId ?? '').trim()\n if (!hostId) return ''\n const host = await firestore.collection('hosts').doc(hostId).get()\n return host.exists ? String(host.get('orgId') ?? '') : ''\n}\n\nexport function createCrmRecordEmailStateWriter(deps: CrmRecordEmailStateDeps): PluginRecordEmailStateWriter {\n return {\n async stamp(request) {\n const email = normalizeContactEmail(request.email)\n const key = email ? personKey(email) : null\n if (!email || !key) return NONE\n const firestore = deps.firestore()\n let orgId: string\n try {\n orgId = await resolveOrgId(firestore, request)\n } catch (error) {\n console.error('[crm] the site’s organization could not be read for an email state', request.hostId, error)\n return NONE\n }\n if (!orgId) return NONE\n const state: EmailState = {\n status: request.state.status,\n atMs: Number.isFinite(request.state.atMs) && request.state.atMs > 0 ? request.state.atMs : Date.now(),\n source: request.state.source,\n detail: request.state.detail\n ? String(request.state.detail).replace(/\\s+/g, ' ').trim().slice(0, 500)\n : null,\n ...(request.state.enrollmentId ? { enrollmentId: request.state.enrollmentId } : {}),\n }\n const force = request.force === true\n const withdraw = request.withdraw === true\n let records = 0\n try {\n const contacts = firestore.collection('orgs').doc(orgId).collection('contacts')\n const contact = await findContactByEmail(contacts, email)\n if (contact && (await stampRecord(contact.ref, state, force, withdraw))) records += 1\n } catch (error) {\n console.error('[crm] the contact could not be stamped with an email state', orgId, error)\n }\n try {\n /*\n * ONE ORG ROW. A bounce or a do-not-contact mark is the platform's\n * verdict on the ADDRESS, and since AGL-3277 there is one document\n * carrying it — the per-site loop beside this had nothing left to\n * find once AGL-3276 emptied the host path.\n */\n if (await stampRecord(\n firestore.collection('orgs').doc(orgId).collection('leads').doc(key),\n state,\n force,\n withdraw,\n )) {\n records += 1\n }\n } catch (error) {\n console.error('[crm] the leads could not be stamped with an email state', orgId, error)\n }\n return { records }\n },\n async engaged(request) {\n try {\n const records = await recordContactEmailEngagement({\n hostId: request.hostId,\n events: request.events,\n firestore: deps.firestore(),\n })\n return { records }\n } catch (error) {\n console.error('[crm] the contacts could not be stamped with an engagement', request.hostId, error)\n return NONE\n }\n },\n // A delivered campaign moves the New leads it reached to Nurturing (AGL-3446).\n async reached(request) {\n return { records: await nurtureReachedLeads(deps.firestore(), request) }\n },\n }\n}\n\n/** The platform's own dependencies. Specs build their own. */\nexport function defaultCrmRecordEmailStateDeps(): CrmRecordEmailStateDeps {\n return { firestore: () => firebaseAdmin.app().firestore() }\n}\n\n/** Registers the CRM as the workspace's record system on the email-state seam. */\nexport function registerCrmRecordEmailStateWriter(\n deps: CrmRecordEmailStateDeps = defaultCrmRecordEmailStateDeps(),\n): void {\n registerPluginRecordEmailStateWriter(createCrmRecordEmailStateWriter(deps), { pluginId: BUNDLE_ID })\n}\n"],"names":["registerPluginRecordEmailStateWriter","CRM_EMAIL_STATUS_FIELD","CRM_EMAIL_STATUS_NONE","EMAIL_STATE_FIELD","nextEmailState","normalizeContactEmail","personKey","readEmailState","findContactByEmail","firebaseAdmin","FieldValue","BUNDLE_ID","recordContactEmailEngagement","nurtureReachedLeads","NONE","records","stampRecord","ref","incoming","force","withdraw","snapshot","get","exists","current","data","status","update","delete","source","next","atMs","detail","set","merge","resolveOrgId","firestore","request","host","named","String","orgId","trim","hostId","collection","doc","createCrmRecordEmailStateWriter","deps","stamp","email","key","error","console","state","Number","isFinite","Date","now","replace","slice","enrollmentId","contacts","contact","engaged","events","reached","defaultCrmRecordEmailStateDeps","app","registerCrmRecordEmailStateWriter","pluginId"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/record-email-state.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n registerPluginRecordEmailStateWriter,\n type PluginRecordEmailStateReport,\n type PluginRecordEmailStateRequest,\n type PluginRecordEmailStateWriter,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\nimport {\n CRM_EMAIL_STATUS_FIELD,\n CRM_EMAIL_STATUS_NONE,\n EMAIL_STATE_FIELD,\n nextEmailState,\n normalizeContactEmail,\n personKey,\n readEmailState,\n type EmailState,\n} from '@aglyn/aglyn/server'\nimport { findContactByEmail, firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport { recordContactEmailEngagement } from './contact-email-engagement'\nimport { nurtureReachedLeads, workRepliedLeads } from './lead-nurturing'\n\n/**\n * THE CRM'S WRITER ON THE CORE'S RECORD EMAIL-STATE SEAM (AGL-3245).\n *\n * A sender filed a verdict on an address — the outreach runtime's bounce,\n * the campaign webhook's complaint, a member's do-not-contact mark, the\n * unsubscribe link — and says so here; this writes it onto every record\n * the address is, as `emailState`, so the record a person reads and the\n * lists a send consults agree.\n *\n * One address can be several records: the contact the organization holds\n * (found through the address index, `findContactByEmail`), and a lead on\n * each site the organization owns — leads are keyed by `personKey`, so a\n * lead is addressed directly under every host. All of them are stamped;\n * the verdict is about the address, not about which record a rep opened.\n * A caller that knows only the site reads the organization off it.\n *\n * The write keeps the STRONGER verdict (`nextEmailState`): a member's\n * do-not-contact mark is not undone by a later bounce, and `ok` lands only\n * when a caller forces it. `updatedAt` is left alone — a verdict is\n * something that happened to the person, not an edit the team made, and a\n * list sorted on recency must not reshuffle on a bounce.\n *\n * Never throws: the list is the control, and a record that could not be\n * stamped is a chip a page goes without, logged.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\nexport interface CrmRecordEmailStateDeps {\n firestore(): Firestore\n}\n\nconst NONE: PluginRecordEmailStateReport = { records: 0 }\n\n/**\n * Applies the verdict to one record; answers whether it moved. A read then\n * a merge rather than a transaction: two verdicts landing together each\n * compare against a state at least as old as their own, and the ranking\n * makes the order of two writes of the same rank immaterial.\n */\nasync function stampRecord(\n ref: FirebaseFirestore.DocumentReference,\n incoming: EmailState,\n force: boolean,\n withdraw = false,\n): Promise<boolean> {\n const snapshot = await ref.get()\n if (!snapshot.exists) return false\n const current = readEmailState(snapshot.data() as Record<string, unknown>)\n /*\n * A WITHDRAWAL takes back the one verdict it names (AGL-3328): the\n * deliverability check's \"Would bounce\" once the domain takes mail again.\n * Any other standing verdict — a bounce DNS cannot see — is left alone.\n */\n if (withdraw) {\n if (current?.status !== incoming.status) return false\n await ref.update({\n [EMAIL_STATE_FIELD]: FieldValue.delete(),\n [CRM_EMAIL_STATUS_FIELD]: CRM_EMAIL_STATUS_NONE,\n })\n return true\n }\n // The deliverability check saying again what the record already holds:\n // the date it was first found is the one worth keeping.\n if (incoming.source === 'check' && current?.status === incoming.status) return false\n const next = nextEmailState(current, incoming, { force })\n // The current verdict stood, or the same verdict arrived again — a second\n // run of the caller — and there is nothing to write.\n if (\n next === current ||\n (current &&\n next.status === current.status &&\n next.atMs === current.atMs &&\n next.source === current.source &&\n next.detail === current.detail)\n ) {\n return false\n }\n // `emailStatus` beside it: the verdict as the CRM lists filter by it\n // (AGL-3321) — a key a query can ask, where the state is a map.\n await ref.set(\n { [EMAIL_STATE_FIELD]: next, [CRM_EMAIL_STATUS_FIELD]: next.status },\n { merge: true },\n )\n return true\n}\n\n/** The organization a request names, or the one its site belongs to. */\nasync function resolveOrgId(firestore: Firestore, request: PluginRecordEmailStateRequest): Promise<string> {\n const named = String(request.orgId ?? '').trim()\n if (named) return named\n const hostId = String(request.hostId ?? '').trim()\n if (!hostId) return ''\n const host = await firestore.collection('hosts').doc(hostId).get()\n return host.exists ? String(host.get('orgId') ?? '') : ''\n}\n\nexport function createCrmRecordEmailStateWriter(deps: CrmRecordEmailStateDeps): PluginRecordEmailStateWriter {\n return {\n async stamp(request) {\n const email = normalizeContactEmail(request.email)\n const key = email ? personKey(email) : null\n if (!email || !key) return NONE\n const firestore = deps.firestore()\n let orgId: string\n try {\n orgId = await resolveOrgId(firestore, request)\n } catch (error) {\n console.error('[crm] the site’s organization could not be read for an email state', request.hostId, error)\n return NONE\n }\n if (!orgId) return NONE\n const state: EmailState = {\n status: request.state.status,\n atMs: Number.isFinite(request.state.atMs) && request.state.atMs > 0 ? request.state.atMs : Date.now(),\n source: request.state.source,\n detail: request.state.detail\n ? String(request.state.detail).replace(/\\s+/g, ' ').trim().slice(0, 500)\n : null,\n ...(request.state.enrollmentId ? { enrollmentId: request.state.enrollmentId } : {}),\n }\n const force = request.force === true\n const withdraw = request.withdraw === true\n let records = 0\n try {\n const contacts = firestore.collection('orgs').doc(orgId).collection('contacts')\n const contact = await findContactByEmail(contacts, email)\n if (contact && (await stampRecord(contact.ref, state, force, withdraw))) records += 1\n } catch (error) {\n console.error('[crm] the contact could not be stamped with an email state', orgId, error)\n }\n try {\n /*\n * ONE ORG ROW. A bounce or a do-not-contact mark is the platform's\n * verdict on the ADDRESS, and since AGL-3277 there is one document\n * carrying it — the per-site loop beside this had nothing left to\n * find once AGL-3276 emptied the host path.\n */\n if (await stampRecord(\n firestore.collection('orgs').doc(orgId).collection('leads').doc(key),\n state,\n force,\n withdraw,\n )) {\n records += 1\n }\n } catch (error) {\n console.error('[crm] the leads could not be stamped with an email state', orgId, error)\n }\n return { records }\n },\n async engaged(request) {\n try {\n const records = await recordContactEmailEngagement({\n hostId: request.hostId,\n events: request.events,\n firestore: deps.firestore(),\n })\n return { records }\n } catch (error) {\n console.error('[crm] the contacts could not be stamped with an engagement', request.hostId, error)\n return NONE\n }\n },\n // A delivered campaign moves the New leads it reached to Nurturing (AGL-3446).\n async reached(request) {\n return { records: await nurtureReachedLeads(deps.firestore(), request) }\n },\n // A reply moves a lead nobody had worked to Working (AGL-3080).\n async replied(request) {\n return { records: await workRepliedLeads(deps.firestore(), request) }\n },\n }\n}\n\n/** The platform's own dependencies. Specs build their own. */\nexport function defaultCrmRecordEmailStateDeps(): CrmRecordEmailStateDeps {\n return { firestore: () => firebaseAdmin.app().firestore() }\n}\n\n/** Registers the CRM as the workspace's record system on the email-state seam. */\nexport function registerCrmRecordEmailStateWriter(\n deps: CrmRecordEmailStateDeps = defaultCrmRecordEmailStateDeps(),\n): void {\n registerPluginRecordEmailStateWriter(createCrmRecordEmailStateWriter(deps), { pluginId: BUNDLE_ID })\n}\n"],"names":["registerPluginRecordEmailStateWriter","CRM_EMAIL_STATUS_FIELD","CRM_EMAIL_STATUS_NONE","EMAIL_STATE_FIELD","nextEmailState","normalizeContactEmail","personKey","readEmailState","findContactByEmail","firebaseAdmin","FieldValue","BUNDLE_ID","recordContactEmailEngagement","nurtureReachedLeads","workRepliedLeads","NONE","records","stampRecord","ref","incoming","force","withdraw","snapshot","get","exists","current","data","status","update","delete","source","next","atMs","detail","set","merge","resolveOrgId","firestore","request","host","named","String","orgId","trim","hostId","collection","doc","createCrmRecordEmailStateWriter","deps","stamp","email","key","error","console","state","Number","isFinite","Date","now","replace","slice","enrollmentId","contacts","contact","engaged","events","reached","replied","defaultCrmRecordEmailStateDeps","app","registerCrmRecordEmailStateWriter","pluginId"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,oCAAoC,QAI/B,wDAAuD;AAC9D,SACEC,sBAAsB,EACtBC,qBAAqB,EACrBC,iBAAiB,EACjBC,cAAc,EACdC,qBAAqB,EACrBC,SAAS,EACTC,cAAc,QAET,sBAAqB;AAC5B,SAASC,kBAAkB,EAAEC,aAAa,QAAQ,2BAA0B;AAC5E,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,SAAS,QAAQ,gCAA4B;AACtD,SAASC,4BAA4B,QAAQ,gCAA4B;AACzE,SAASC,mBAAmB,EAAEC,gBAAgB,QAAQ,sBAAkB;AAkCxE,MAAMC,OAAqC;IAAEC,SAAS;AAAE;AAExD;;;;;CAKC,GACD,eAAeC,YACbC,GAAwC,EACxCC,QAAoB,EACpBC,KAAc,EACdC,WAAW,KAAK;IAEhB,MAAMC,WAAW,MAAMJ,IAAIK,GAAG;IAC9B,IAAI,CAACD,SAASE,MAAM,EAAE,OAAO;IAC7B,MAAMC,UAAUlB,eAAee,SAASI,IAAI;IAC5C;;;;GAIC,GACD,IAAIL,UAAU;QACZ,IAAII,CAAAA,2BAAAA,QAASE,MAAM,MAAKR,SAASQ,MAAM,EAAE,OAAO;QAChD,MAAMT,IAAIU,MAAM,CAAC;YACf,CAACzB,kBAAkB,EAAEO,WAAWmB,MAAM;YACtC,CAAC5B,uBAAuB,EAAEC;QAC5B;QACA,OAAO;IACT;IACA,uEAAuE;IACvE,wDAAwD;IACxD,IAAIiB,SAASW,MAAM,KAAK,WAAWL,CAAAA,2BAAAA,QAASE,MAAM,MAAKR,SAASQ,MAAM,EAAE,OAAO;IAC/E,MAAMI,OAAO3B,eAAeqB,SAASN,UAAU;QAAEC;IAAM;IACvD,0EAA0E;IAC1E,qDAAqD;IACrD,IACEW,SAASN,WACRA,WACCM,KAAKJ,MAAM,KAAKF,QAAQE,MAAM,IAC9BI,KAAKC,IAAI,KAAKP,QAAQO,IAAI,IAC1BD,KAAKD,MAAM,KAAKL,QAAQK,MAAM,IAC9BC,KAAKE,MAAM,KAAKR,QAAQQ,MAAM,EAChC;QACA,OAAO;IACT;IACA,qEAAqE;IACrE,gEAAgE;IAChE,MAAMf,IAAIgB,GAAG,CACX;QAAE,CAAC/B,kBAAkB,EAAE4B;QAAM,CAAC9B,uBAAuB,EAAE8B,KAAKJ,MAAM;IAAC,GACnE;QAAEQ,OAAO;IAAK;IAEhB,OAAO;AACT;AAEA,sEAAsE,GACtE,eAAeC,aAAaC,SAAoB,EAAEC,OAAsC;QACjEA,gBAECA,iBAGMC;IAL5B,MAAMC,QAAQC,QAAOH,iBAAAA,QAAQI,KAAK,YAAbJ,iBAAiB,IAAIK,IAAI;IAC9C,IAAIH,OAAO,OAAOA;IAClB,MAAMI,SAASH,QAAOH,kBAAAA,QAAQM,MAAM,YAAdN,kBAAkB,IAAIK,IAAI;IAChD,IAAI,CAACC,QAAQ,OAAO;IACpB,MAAML,OAAO,MAAMF,UAAUQ,UAAU,CAAC,SAASC,GAAG,CAACF,QAAQrB,GAAG;IAChE,OAAOgB,KAAKf,MAAM,GAAGiB,QAAOF,YAAAA,KAAKhB,GAAG,CAAC,oBAATgB,YAAqB,MAAM;AACzD;AAEA,OAAO,SAASQ,gCAAgCC,IAA6B;IAC3E,OAAO;QACL,MAAMC,OAAMX,OAAO;YACjB,MAAMY,QAAQ7C,sBAAsBiC,QAAQY,KAAK;YACjD,MAAMC,MAAMD,QAAQ5C,UAAU4C,SAAS;YACvC,IAAI,CAACA,SAAS,CAACC,KAAK,OAAOpC;YAC3B,MAAMsB,YAAYW,KAAKX,SAAS;YAChC,IAAIK;YACJ,IAAI;gBACFA,QAAQ,MAAMN,aAAaC,WAAWC;YACxC,EAAE,OAAOc,OAAO;gBACdC,QAAQD,KAAK,CAAC,sEAAsEd,QAAQM,MAAM,EAAEQ;gBACpG,OAAOrC;YACT;YACA,IAAI,CAAC2B,OAAO,OAAO3B;YACnB,MAAMuC,QAAoB;gBACxB3B,QAAQW,QAAQgB,KAAK,CAAC3B,MAAM;gBAC5BK,MAAMuB,OAAOC,QAAQ,CAAClB,QAAQgB,KAAK,CAACtB,IAAI,KAAKM,QAAQgB,KAAK,CAACtB,IAAI,GAAG,IAAIM,QAAQgB,KAAK,CAACtB,IAAI,GAAGyB,KAAKC,GAAG;gBACnG5B,QAAQQ,QAAQgB,KAAK,CAACxB,MAAM;gBAC5BG,QAAQK,QAAQgB,KAAK,CAACrB,MAAM,GACxBQ,OAAOH,QAAQgB,KAAK,CAACrB,MAAM,EAAE0B,OAAO,CAAC,QAAQ,KAAKhB,IAAI,GAAGiB,KAAK,CAAC,GAAG,OAClE;eACAtB,QAAQgB,KAAK,CAACO,YAAY,GAAG;gBAAEA,cAAcvB,QAAQgB,KAAK,CAACO,YAAY;YAAC,IAAI,CAAC;YAEnF,MAAMzC,QAAQkB,QAAQlB,KAAK,KAAK;YAChC,MAAMC,WAAWiB,QAAQjB,QAAQ,KAAK;YACtC,IAAIL,UAAU;YACd,IAAI;gBACF,MAAM8C,WAAWzB,UAAUQ,UAAU,CAAC,QAAQC,GAAG,CAACJ,OAAOG,UAAU,CAAC;gBACpE,MAAMkB,UAAU,MAAMvD,mBAAmBsD,UAAUZ;gBACnD,IAAIa,WAAY,MAAM9C,YAAY8C,QAAQ7C,GAAG,EAAEoC,OAAOlC,OAAOC,WAAYL,WAAW;YACtF,EAAE,OAAOoC,OAAO;gBACdC,QAAQD,KAAK,CAAC,8DAA8DV,OAAOU;YACrF;YACA,IAAI;gBACF;;;;;SAKC,GACD,IAAI,MAAMnC,YACRoB,UAAUQ,UAAU,CAAC,QAAQC,GAAG,CAACJ,OAAOG,UAAU,CAAC,SAASC,GAAG,CAACK,MAChEG,OACAlC,OACAC,WACC;oBACDL,WAAW;gBACb;YACF,EAAE,OAAOoC,OAAO;gBACdC,QAAQD,KAAK,CAAC,4DAA4DV,OAAOU;YACnF;YACA,OAAO;gBAAEpC;YAAQ;QACnB;QACA,MAAMgD,SAAQ1B,OAAO;YACnB,IAAI;gBACF,MAAMtB,UAAU,MAAMJ,6BAA6B;oBACjDgC,QAAQN,QAAQM,MAAM;oBACtBqB,QAAQ3B,QAAQ2B,MAAM;oBACtB5B,WAAWW,KAAKX,SAAS;gBAC3B;gBACA,OAAO;oBAAErB;gBAAQ;YACnB,EAAE,OAAOoC,OAAO;gBACdC,QAAQD,KAAK,CAAC,8DAA8Dd,QAAQM,MAAM,EAAEQ;gBAC5F,OAAOrC;YACT;QACF;QACA,+EAA+E;QAC/E,MAAMmD,SAAQ5B,OAAO;YACnB,OAAO;gBAAEtB,SAAS,MAAMH,oBAAoBmC,KAAKX,SAAS,IAAIC;YAAS;QACzE;QACA,gEAAgE;QAChE,MAAM6B,SAAQ7B,OAAO;YACnB,OAAO;gBAAEtB,SAAS,MAAMF,iBAAiBkC,KAAKX,SAAS,IAAIC;YAAS;QACtE;IACF;AACF;AAEA,4DAA4D,GAC5D,OAAO,SAAS8B;IACd,OAAO;QAAE/B,WAAW,IAAM5B,cAAc4D,GAAG,GAAGhC,SAAS;IAAG;AAC5D;AAEA,gFAAgF,GAChF,OAAO,SAASiC,kCACdtB,OAAgCoB,gCAAgC;IAEhEpE,qCAAqC+C,gCAAgCC,OAAO;QAAEuB,UAAU5D;IAAU;AACpG"}
|
|
@@ -16,12 +16,15 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { type PluginRecordFactsReader, type PluginRecordFactsRequest } from '@aglyn/aglyn/plugin-manager/plugin-record-facts';
|
|
18
18
|
import { type ConsentGroup } from '@aglyn/aglyn/server';
|
|
19
|
+
import type { ReleaseFlagKey } from '@aglyn/aglyn/app-utils/release-flags';
|
|
19
20
|
type Data = Record<string, unknown>;
|
|
20
21
|
type Refusal = {
|
|
21
22
|
ok: false;
|
|
22
23
|
status: 400 | 403 | 404;
|
|
23
24
|
error: string;
|
|
24
25
|
};
|
|
26
|
+
/** The release flag that lets a read answer the whole record (AGL-3520). */
|
|
27
|
+
export declare const CRM_ASSIST_WHOLE_RECORD_FLAG: ReleaseFlagKey;
|
|
25
28
|
/** Logged activities read per record before visibility and the timeline cut. */
|
|
26
29
|
export declare const CRM_FACTS_ACTIVITIES_READ = 40;
|
|
27
30
|
/** Tasks read per record, the record page's own window. */
|