@aglyn/plugins-crm 1.0.0-beta.218 → 1.0.0-beta.220
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +14 -14
- package/src/index.d.ts +1 -0
- package/src/index.js.map +1 -1
- package/src/lib/components/companies-bulk-bar.js +12 -4
- package/src/lib/components/companies-bulk-bar.js.map +1 -1
- package/src/lib/components/company-detail-page.js +11 -0
- package/src/lib/components/company-detail-page.js.map +1 -1
- package/src/lib/components/company-edit-drawer.js +12 -2
- package/src/lib/components/company-edit-drawer.js.map +1 -1
- package/src/lib/components/company-import-drawer.d.ts +1 -1
- package/src/lib/components/company-picker.js +10 -3
- package/src/lib/components/company-picker.js.map +1 -1
- package/src/lib/components/contact-associations-card.js +4 -3
- package/src/lib/components/contact-associations-card.js.map +1 -1
- package/src/lib/components/contact-detail-page.d.ts +4 -4
- package/src/lib/components/contact-detail-page.js +50 -53
- package/src/lib/components/contact-detail-page.js.map +1 -1
- package/src/lib/components/contact-list-columns.d.ts +3 -0
- package/src/lib/components/contact-list-columns.js +21 -5
- package/src/lib/components/contact-list-columns.js.map +1 -1
- package/src/lib/components/contact-merge-dialog.js +2 -1
- package/src/lib/components/contact-merge-dialog.js.map +1 -1
- package/src/lib/components/contact-timeline-card.js +4 -3
- package/src/lib/components/contact-timeline-card.js.map +1 -1
- package/src/lib/components/contacts-bulk-bar.d.ts +3 -3
- package/src/lib/components/contacts-bulk-bar.js +24 -51
- package/src/lib/components/contacts-bulk-bar.js.map +1 -1
- package/src/lib/components/contacts-section.js +9 -3
- package/src/lib/components/contacts-section.js.map +1 -1
- package/src/lib/components/crm-booking-zone.d.ts +8 -2
- package/src/lib/components/crm-booking-zone.js.map +1 -1
- package/src/lib/components/crm-export-all-button.d.ts +1 -1
- package/src/lib/components/crm-export-all-button.js.map +1 -1
- package/src/lib/components/crm-glance-card.js +2 -1
- package/src/lib/components/crm-glance-card.js.map +1 -1
- package/src/lib/components/crm-send-email-dialog.js +9 -6
- package/src/lib/components/crm-send-email-dialog.js.map +1 -1
- package/src/lib/components/csv-import-drawer.js +2 -2
- package/src/lib/components/csv-import-drawer.js.map +1 -1
- package/src/lib/components/deal-detail-page.js +18 -2
- package/src/lib/components/deal-detail-page.js.map +1 -1
- package/src/lib/components/deal-edit-drawer.js +10 -0
- package/src/lib/components/deal-edit-drawer.js.map +1 -1
- package/src/lib/components/deal-products-card.js +27 -16
- package/src/lib/components/deal-products-card.js.map +1 -1
- package/src/lib/components/deals-bulk-bar.js +13 -5
- package/src/lib/components/deals-bulk-bar.js.map +1 -1
- package/src/lib/components/lead-campaigns-card.d.ts +4 -4
- package/src/lib/components/lead-campaigns-card.js +15 -8
- package/src/lib/components/lead-campaigns-card.js.map +1 -1
- package/src/lib/components/lead-detail-page.js +25 -5
- package/src/lib/components/lead-detail-page.js.map +1 -1
- package/src/lib/components/lead-properties-card.js +10 -0
- package/src/lib/components/lead-properties-card.js.map +1 -1
- package/src/lib/components/lead-status-chip.js +3 -1
- package/src/lib/components/lead-status-chip.js.map +1 -1
- package/src/lib/components/lead-surfaces-note.js +4 -5
- package/src/lib/components/lead-surfaces-note.js.map +1 -1
- package/src/lib/components/lead-unqualify-dialog.js +6 -0
- package/src/lib/components/lead-unqualify-dialog.js.map +1 -1
- package/src/lib/components/leads-bulk-bar.js +34 -11
- package/src/lib/components/leads-bulk-bar.js.map +1 -1
- package/src/lib/components/leads-section.js +41 -8
- package/src/lib/components/leads-section.js.map +1 -1
- package/src/lib/components/new-lead-drawer.js +5 -4
- package/src/lib/components/new-lead-drawer.js.map +1 -1
- package/src/lib/components/org-lead-surfaces-note.js +6 -2
- package/src/lib/components/org-lead-surfaces-note.js.map +1 -1
- package/src/lib/components/recipes-card.js +9 -4
- package/src/lib/components/recipes-card.js.map +1 -1
- package/src/lib/components/record-sharing-card.d.ts +57 -0
- package/src/lib/components/record-sharing-card.js +244 -0
- package/src/lib/components/record-sharing-card.js.map +1 -0
- package/src/lib/components/reports/activity-card.js +4 -3
- package/src/lib/components/reports/activity-card.js.map +1 -1
- package/src/lib/components/reports/closed-deals-card.js +4 -3
- package/src/lib/components/reports/closed-deals-card.js.map +1 -1
- package/src/lib/components/reports/contacts-mix-card.js +3 -2
- package/src/lib/components/reports/contacts-mix-card.js.map +1 -1
- package/src/lib/components/reports/contacts-trend-card.js +2 -1
- package/src/lib/components/reports/contacts-trend-card.js.map +1 -1
- package/src/lib/components/reports/forecast-card.js +3 -2
- package/src/lib/components/reports/forecast-card.js.map +1 -1
- package/src/lib/components/reports/lead-funnel-card.js +8 -6
- package/src/lib/components/reports/lead-funnel-card.js.map +1 -1
- package/src/lib/components/reports/pipeline-card.js +4 -3
- package/src/lib/components/reports/pipeline-card.js.map +1 -1
- package/src/lib/components/reports/report-format.d.ts +1 -1
- package/src/lib/components/reports/report-format.js.map +1 -1
- package/src/lib/components/reports/report-scope.d.ts +1 -1
- package/src/lib/components/reports/report-scope.js.map +1 -1
- package/src/lib/components/reports/source-conversion-card.js +2 -1
- package/src/lib/components/reports/source-conversion-card.js.map +1 -1
- package/src/lib/components/reports/tasks-card.js +2 -1
- package/src/lib/components/reports/tasks-card.js.map +1 -1
- package/src/lib/components/reports/won-lost-by-owner-card.js +3 -2
- package/src/lib/components/reports/won-lost-by-owner-card.js.map +1 -1
- package/src/lib/components/reports-section.js +4 -4
- package/src/lib/components/reports-section.js.map +1 -1
- package/src/lib/components/settings-section.js +7 -0
- package/src/lib/components/settings-section.js.map +1 -1
- package/src/lib/components/share-records-dialog.d.ts +28 -0
- package/src/lib/components/share-records-dialog.js +184 -0
- package/src/lib/components/share-records-dialog.js.map +1 -0
- package/src/lib/components/sharing-rule-drawer.d.ts +37 -0
- package/src/lib/components/sharing-rule-drawer.js +374 -0
- package/src/lib/components/sharing-rule-drawer.js.map +1 -0
- package/src/lib/components/sharing-rules-card.d.ts +24 -0
- package/src/lib/components/sharing-rules-card.js +287 -0
- package/src/lib/components/sharing-rules-card.js.map +1 -0
- package/src/lib/components/use-crm-api.d.ts +1 -1
- package/src/lib/components/use-crm-api.js.map +1 -1
- package/src/lib/components/use-site-route-context.d.ts +25 -0
- package/src/lib/components/use-site-route-context.js +58 -0
- package/src/lib/components/use-site-route-context.js.map +1 -0
- package/src/lib/constants/api-routes.d.ts +31 -0
- package/src/lib/constants/api-routes.js +27 -1
- package/src/lib/constants/api-routes.js.map +1 -1
- package/src/lib/constants/bundle-common.d.ts +2 -0
- package/src/lib/constants/bundle-common.js +1 -0
- package/src/lib/constants/bundle-common.js.map +1 -1
- package/src/lib/declarations.console-server.d.ts +41 -0
- package/src/lib/declarations.console-server.js +115 -0
- package/src/lib/declarations.console-server.js.map +1 -0
- package/src/lib/declarations.server.d.ts +12 -0
- package/src/lib/declarations.server.js +84 -1
- package/src/lib/declarations.server.js.map +1 -1
- package/src/lib/hooks/use-contact-remove.d.ts +35 -0
- package/src/lib/hooks/use-contact-remove.js +77 -0
- package/src/lib/hooks/use-contact-remove.js.map +1 -0
- package/src/lib/hooks/use-crm-campaigns.d.ts +2 -2
- package/src/lib/hooks/use-crm-campaigns.js +3 -3
- package/src/lib/hooks/use-crm-campaigns.js.map +1 -1
- package/src/lib/hooks/use-crm-sharing.d.ts +30 -0
- package/src/lib/hooks/use-crm-sharing.js +109 -0
- package/src/lib/hooks/use-crm-sharing.js.map +1 -0
- package/src/lib/model/campaign-filing-activity.d.ts +1 -1
- package/src/lib/model/campaign-filing-activity.js +1 -1
- package/src/lib/model/campaign-filing-activity.js.map +1 -1
- package/src/lib/model/companies-csv.d.ts +1 -1
- package/src/lib/model/companies-csv.js +2 -2
- package/src/lib/model/companies-csv.js.map +1 -1
- package/src/lib/model/contact-holder.d.ts +68 -0
- package/src/lib/model/contact-holder.js +129 -0
- package/src/lib/model/contact-holder.js.map +1 -0
- package/src/lib/model/contact-interaction-links.d.ts +46 -0
- package/src/lib/model/contact-interaction-links.js +65 -0
- package/src/lib/model/contact-interaction-links.js.map +1 -0
- package/src/lib/model/contact-merge.d.ts +136 -0
- package/src/lib/model/contact-merge.js +412 -0
- package/src/lib/model/contact-merge.js.map +1 -0
- package/src/lib/model/contact-record.d.ts +7 -1
- package/src/lib/model/contact-record.js +7 -6
- package/src/lib/model/contact-record.js.map +1 -1
- package/src/lib/model/contact-remove.d.ts +48 -0
- package/src/lib/model/contact-remove.js +22 -0
- package/src/lib/model/contact-remove.js.map +1 -0
- package/src/lib/model/contacts-bulk-writes.d.ts +2 -71
- package/src/lib/model/contacts-bulk-writes.js +2 -82
- package/src/lib/model/contacts-bulk-writes.js.map +1 -1
- package/src/lib/model/contacts-csv.d.ts +2 -2
- package/src/lib/model/contacts-csv.js +2 -2
- package/src/lib/model/contacts-csv.js.map +1 -1
- package/src/lib/model/crm-csv.d.ts +239 -0
- package/src/lib/model/crm-csv.js +467 -0
- package/src/lib/model/crm-csv.js.map +1 -0
- package/src/lib/model/crm-digest.d.ts +227 -0
- package/src/lib/model/crm-digest.js +336 -0
- package/src/lib/model/crm-digest.js.map +1 -0
- package/src/lib/model/crm-import.d.ts +7 -1
- package/src/lib/model/crm-import.js +2 -1
- package/src/lib/model/crm-import.js.map +1 -1
- package/src/lib/model/crm-inbound.d.ts +162 -0
- package/src/lib/model/crm-inbound.js +208 -0
- package/src/lib/model/crm-inbound.js.map +1 -0
- package/src/lib/model/crm-lead-import.d.ts +1 -1
- package/src/lib/model/crm-lead-import.js +4 -4
- package/src/lib/model/crm-lead-import.js.map +1 -1
- package/src/lib/model/crm-record-routes.d.ts +5 -0
- package/src/lib/model/crm-record-routes.js +7 -0
- package/src/lib/model/crm-record-routes.js.map +1 -1
- package/src/lib/model/crm-reports.d.ts +467 -0
- package/src/lib/model/crm-reports.js +667 -0
- package/src/lib/model/crm-reports.js.map +1 -0
- package/src/lib/model/crm-routes.d.ts +6 -5
- package/src/lib/model/crm-routes.js +7 -7
- package/src/lib/model/crm-routes.js.map +1 -1
- package/src/lib/model/crm-search-sources.d.ts +15 -0
- package/src/lib/model/crm-search-sources.js +162 -0
- package/src/lib/model/crm-search-sources.js.map +1 -0
- package/src/lib/model/crm-sharing-api.d.ts +32 -0
- package/src/lib/model/crm-sharing-api.js +59 -0
- package/src/lib/model/crm-sharing-api.js.map +1 -0
- package/src/lib/model/crm-sharing.d.ts +333 -0
- package/src/lib/model/crm-sharing.js +603 -0
- package/src/lib/model/crm-sharing.js.map +1 -0
- package/src/lib/model/deals-csv.d.ts +1 -1
- package/src/lib/model/deals-csv.js +4 -5
- package/src/lib/model/deals-csv.js.map +1 -1
- package/src/lib/model/lead-filters.d.ts +5 -4
- package/src/lib/model/lead-filters.js +7 -5
- package/src/lib/model/lead-filters.js.map +1 -1
- package/src/lib/model/leads-csv.d.ts +1 -1
- package/src/lib/model/leads-csv.js +1 -1
- package/src/lib/model/leads-csv.js.map +1 -1
- package/src/lib/model/tasks-csv.d.ts +1 -1
- package/src/lib/model/tasks-csv.js +1 -1
- package/src/lib/model/tasks-csv.js.map +1 -1
- package/src/lib/plan-entitlements.d.ts +75 -0
- package/src/lib/plan-entitlements.js +56 -0
- package/src/lib/plan-entitlements.js.map +1 -0
- package/src/lib/plugin.js +39 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/server/api-v1/contacts-merge.d.ts +20 -0
- package/src/lib/server/api-v1/contacts-merge.js +120 -0
- package/src/lib/server/api-v1/contacts-merge.js.map +1 -0
- package/src/lib/server/api-v1/contacts.d.ts +18 -0
- package/src/lib/server/api-v1/contacts.js +1076 -0
- package/src/lib/server/api-v1/contacts.js.map +1 -0
- package/src/lib/server/api-v1/crm-activities.d.ts +18 -0
- package/src/lib/server/api-v1/crm-activities.js +269 -0
- package/src/lib/server/api-v1/crm-activities.js.map +1 -0
- package/src/lib/server/api-v1/crm-companies.d.ts +18 -0
- package/src/lib/server/api-v1/crm-companies.js +391 -0
- package/src/lib/server/api-v1/crm-companies.js.map +1 -0
- package/src/lib/server/api-v1/crm-deals.d.ts +18 -0
- package/src/lib/server/api-v1/crm-deals.js +631 -0
- package/src/lib/server/api-v1/crm-deals.js.map +1 -0
- package/src/lib/server/api-v1/crm-email-templates.d.ts +18 -0
- package/src/lib/server/api-v1/crm-email-templates.js +336 -0
- package/src/lib/server/api-v1/crm-email-templates.js.map +1 -0
- package/src/lib/server/api-v1/crm-leads.d.ts +18 -0
- package/src/lib/server/api-v1/crm-leads.js +833 -0
- package/src/lib/server/api-v1/crm-leads.js.map +1 -0
- package/src/lib/server/api-v1/crm-pipelines.d.ts +76 -0
- package/src/lib/server/api-v1/crm-pipelines.js +146 -0
- package/src/lib/server/api-v1/crm-pipelines.js.map +1 -0
- package/src/lib/server/api-v1/crm-shared.d.ts +271 -0
- package/src/lib/server/api-v1/crm-shared.js +436 -0
- package/src/lib/server/api-v1/crm-shared.js.map +1 -0
- package/src/lib/server/api-v1/crm-tasks.d.ts +18 -0
- package/src/lib/server/api-v1/crm-tasks.js +398 -0
- package/src/lib/server/api-v1/crm-tasks.js.map +1 -0
- package/src/lib/server/api-v1/openapi.d.ts +29 -0
- package/src/lib/server/api-v1/openapi.js +985 -0
- package/src/lib/server/api-v1/openapi.js.map +1 -0
- package/src/lib/server/api-v1/usage.d.ts +26 -0
- package/src/lib/server/api-v1/usage.js +73 -0
- package/src/lib/server/api-v1/usage.js.map +1 -0
- package/src/lib/server/campaign-filing-activity.d.ts +1 -1
- package/src/lib/server/campaign-filing-activity.js +7 -11
- package/src/lib/server/campaign-filing-activity.js.map +1 -1
- package/src/lib/server/capture-contact.js +15 -4
- package/src/lib/server/capture-contact.js.map +1 -1
- package/src/lib/server/consent-group-participant.js +1 -1
- package/src/lib/server/consent-group-participant.js.map +1 -1
- package/src/lib/server/contact-email-engagement.d.ts +33 -0
- package/src/lib/server/contact-email-engagement.js +146 -0
- package/src/lib/server/contact-email-engagement.js.map +1 -0
- package/src/lib/server/contact-lifecycle-floor.d.ts +89 -0
- package/src/lib/server/contact-lifecycle-floor.js +91 -0
- package/src/lib/server/contact-lifecycle-floor.js.map +1 -0
- package/src/lib/server/contact-remove.d.ts +75 -0
- package/src/lib/server/contact-remove.js +244 -0
- package/src/lib/server/contact-remove.js.map +1 -0
- package/src/lib/server/contact-update.js +4 -3
- package/src/lib/server/contact-update.js.map +1 -1
- package/src/lib/server/contacts-import.d.ts +47 -11
- package/src/lib/server/contacts-import.js +171 -71
- package/src/lib/server/contacts-import.js.map +1 -1
- package/src/lib/server/contacts-merge.d.ts +1 -1
- package/src/lib/server/contacts-merge.js +3 -2
- package/src/lib/server/contacts-merge.js.map +1 -1
- package/src/lib/server/container-detach.d.ts +31 -0
- package/src/lib/server/container-detach.js +115 -0
- package/src/lib/server/container-detach.js.map +1 -0
- package/src/lib/server/convert-host-lead.d.ts +189 -0
- package/src/lib/server/convert-host-lead.js +518 -0
- package/src/lib/server/convert-host-lead.js.map +1 -0
- package/src/lib/server/crm-inbound-email.d.ts +162 -0
- package/src/lib/server/crm-inbound-email.js +259 -0
- package/src/lib/server/crm-inbound-email.js.map +1 -0
- package/src/lib/server/crm-records-meter.d.ts +50 -0
- package/src/lib/server/crm-records-meter.js +126 -0
- package/src/lib/server/crm-records-meter.js.map +1 -0
- package/src/lib/server/crm-sharing.d.ts +108 -0
- package/src/lib/server/crm-sharing.js +790 -0
- package/src/lib/server/crm-sharing.js.map +1 -0
- package/src/lib/server/daily-digest-route.d.ts +101 -0
- package/src/lib/server/daily-digest-route.js +578 -0
- package/src/lib/server/daily-digest-route.js.map +1 -0
- package/src/lib/server/export-route.d.ts +60 -0
- package/src/lib/server/export-route.js +447 -0
- package/src/lib/server/export-route.js.map +1 -0
- package/src/lib/server/inbound-address.js +5 -3
- package/src/lib/server/inbound-address.js.map +1 -1
- package/src/lib/server/inbound-route.d.ts +74 -0
- package/src/lib/server/inbound-route.js +260 -0
- package/src/lib/server/inbound-route.js.map +1 -0
- package/src/lib/server/lead-campaign-carry.js +3 -3
- package/src/lib/server/lead-campaign-carry.js.map +1 -1
- package/src/lib/server/lead-convert.d.ts +1 -1
- package/src/lib/server/lead-convert.js +2 -2
- package/src/lib/server/lead-convert.js.map +1 -1
- package/src/lib/server/lead-create.d.ts +1 -1
- package/src/lib/server/lead-create.js +10 -15
- package/src/lib/server/lead-create.js.map +1 -1
- package/src/lib/server/lead-nurturing.d.ts +23 -0
- package/src/lib/server/lead-nurturing.js +82 -0
- package/src/lib/server/lead-nurturing.js.map +1 -0
- package/src/lib/server/leads-import.js +7 -7
- package/src/lib/server/leads-import.js.map +1 -1
- package/src/lib/server/merge-contacts.d.ts +55 -0
- package/src/lib/server/merge-contacts.js +274 -0
- package/src/lib/server/merge-contacts.js.map +1 -0
- package/src/lib/server/pipeline-record-index.d.ts +36 -0
- package/src/lib/server/pipeline-record-index.js +87 -0
- package/src/lib/server/pipeline-record-index.js.map +1 -0
- package/src/lib/server/record-email-state.js +23 -0
- package/src/lib/server/record-email-state.js.map +1 -1
- package/src/lib/server/record-facts.js +2 -1
- package/src/lib/server/record-facts.js.map +1 -1
- package/src/lib/server/record-timeline.d.ts +12 -0
- package/src/lib/server/record-timeline.js +127 -15
- package/src/lib/server/record-timeline.js.map +1 -1
- package/src/lib/server/task-reminders-route.d.ts +82 -0
- package/src/lib/server/task-reminders-route.js +499 -0
- package/src/lib/server/task-reminders-route.js.map +1 -0
- package/src/lib/server/task-routes.d.ts +2 -0
- package/src/lib/server/task-routes.js +22 -3
- package/src/lib/server/task-routes.js.map +1 -1
- package/src/lib/server-deal-stage.js +2 -1
- package/src/lib/server-deal-stage.js.map +1 -1
- package/src/lib/server.d.ts +5 -2
- package/src/lib/server.js +47 -2
- package/src/lib/server.js.map +1 -1
- package/src/lib/testing/stand-in-site-record-routes.d.ts +26 -0
- package/src/lib/testing/stand-in-site-record-routes.js +55 -0
- package/src/lib/testing/stand-in-site-record-routes.js.map +1 -0
- package/src/lib/usage-axes.d.ts +35 -0
- package/src/lib/usage-axes.js +75 -0
- package/src/lib/usage-axes.js.map +1 -0
- package/src/lib/components/use-emails-hub-path.d.ts +0 -24
- package/src/lib/components/use-emails-hub-path.js +0 -59
- package/src/lib/components/use-emails-hub-path.js.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/consent-group-participant.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 CRM'S SHARE OF A CONSENT GROUP CHANGE (AGL-3320).\n *\n * Two things the CRM keeps are read through the CURRENT consent group, so a\n * change to the declaration moves under them unless they move too:\n *\n * - **C4 — a contact's per-site refusal.** `marketingConsentByHost.{site}`\n * holding `marketingConsent: false` refuses mail from every site of that\n * site's group (`readMarketingBasis`). When two sites stop being one\n * sender, each keeps the refusals the other held: the refusal is written\n * onto the receiving site's entry by dotted path, with the one it\n * replaced kept as `supersededEntry`. A refusal is carried whenever it was\n * given — over-carrying is the safe direction — and one the receiving site\n * already holds is left alone. Only contacts carry per-site refusals;\n * leads and list memberships carry grants alone.\n * - **The holder records**, `facets.{groupId}` — a profile, an owner, a\n * stage, notes, order figures and a timeline, keyed by the group that\n * keeps them. Every writer keys by the declaration in force, so after the\n * flip a site's records must be under its new key:\n * - a MOVE folds every old key's facet into the new key's, with the\n * existing target first and then the sources in the order the person\n * met their sites, through `mergeContactFacet` — the survivor's\n * values win, lists union, figures sum — and deletes each source in\n * the same write, so a figure is summed exactly once;\n * - a SPLIT copies the facet, without its figures and with only the\n * timeline entries of the successor's own sites, to each successor\n * that met the person itself, merged under whatever the successor\n * already holds. The figures follow the capture only when every site\n * that met the person lands under one key; otherwise they stay with the\n * surviving key, or — when the key dissolves — are dropped and counted.\n *\n * ## Run twice, write once\n *\n * The executor runs `carry` before the flip and again as the catch-up, and\n * the sweep runs everything once more. A carried refusal is skipped once the\n * receiving entry refuses; a MOVE or a dissolving SPLIT deletes its source in\n * the write that folds it, so a second pass finds nothing — except a\n * straggler a long job wrote under the old key after the first pass, which is\n * exactly what the sweep exists to fold in. A SPLIT whose key survives stays\n * a live key, so each copy is stamped with the change that made it\n * (`copiedByChange`), and a pass that finds the stamp does not copy again.\n */\n\nimport { mergeContactFacet } from '@aglyn/aglyn/app-utils/contact-merge'\nimport type {\n ConsentGroupChangePlan,\n ConsentGroupChangePreviewLine,\n ConsentGroupHolderMove,\n ConsentGroupHolderSplit,\n} from '@aglyn/aglyn/app-utils/consent-group-change'\nimport {\n CONTACT_FACETS_FIELD,\n type ContactInteraction,\n interactionsForGroup,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport {\n COMPANY_CONTACTS_COUNT_FIELD,\n CONTACT_COMPANY_IDS_FIELD,\n CRM_COLLECTIONS,\n} from '@aglyn/aglyn/app-utils/crm'\nimport {\n CAPTURED_BY_HOST_FIELD,\n CONSENT_GROUP_ID_FIELD,\n MARKETING_CONSENT_BY_HOST_FIELD,\n MARKETING_CONSENT_FIELD,\n MARKETING_CONSENT_SOURCE_FIELD,\n} from '@aglyn/aglyn/app-utils/marketing-consent'\nimport type {\n ConsentGroupChangeParticipant,\n ConsentGroupChangeRunRequest,\n ConsentGroupChangeRunResult,\n} from '@aglyn/aglyn/plugin-manager/plugin-consent-group-change'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { restampCrmListFieldsAt } from '@aglyn/tenant-data-admin/server/crm-records'\nimport { FieldPath, FieldValue } from 'firebase-admin/firestore'\nimport { summarizeConsentGroupChange } from '../model/consent-group-summary'\n\n/** Contacts one page of the refusal carry reads. */\nexport const REFUSAL_PAGE = 300\n\n/** Contacts one page of the holder pass reads. */\nexport const HOLDER_PAGE = 200\n\n/**\n * The most records a preview reads to answer a count no aggregation can:\n * past it, the line says so with a `null` count rather than guess.\n */\nexport const PREVIEW_SCAN_LIMIT = 5_000\n\n/**\n * The figures a facet sums, and the instants that go with them — what\n * `mergeContactFacet` adds up or takes the earliest or latest of. A split\n * cannot divide one person's purchases between two holders, so a copy\n * leaves them behind.\n */\nexport const HOLDER_FIGURE_FIELDS = [\n 'ltvCents',\n 'ordersCount',\n 'refundedCents',\n 'refundedOrdersCount',\n 'firstPurchaseAtMs',\n 'lastPurchaseAtMs',\n 'lastRefundAtMs',\n 'lastEmailEngagementAtMs',\n] as const\n\n/** The stamp a split's copy carries: `{ [sourceKey]: changeId }`. */\nexport const COPIED_BY_CHANGE_FIELD = 'copiedByChange'\n\ntype Doc = Record<string, unknown>\n\n/** The error codes that mean the contact was written since it was read. */\nconst RACED = new Set([5, 9])\nconst codeOf = (error: unknown): number | undefined => (error as { code?: number } | null)?.code\n\nfunction mapOf(value: unknown): Doc {\n return value && typeof value === 'object' && !Array.isArray(value) ? (value as Doc) : {}\n}\n\n/** The sites that met this person, in the order they did — then the legacy `hostId`. */\nfunction captureOrder(contact: Doc): string[] {\n const raw = contact[CAPTURED_BY_HOST_FIELD]\n const ordered = Array.isArray(raw)\n ? raw.map((id) => String(id ?? '').trim()).filter(Boolean)\n : []\n const legacy = typeof contact['hostId'] === 'string' ? (contact['hostId'] as string) : ''\n return legacy && !ordered.includes(legacy) ? [...ordered, legacy] : ordered\n}\n\nfunction withoutFigures(facet: Doc): Doc {\n const out: Doc = { ...facet }\n for (const field of HOLDER_FIGURE_FIELDS) delete out[field]\n return out\n}\n\nfunction figuresOf(facet: Doc): Doc {\n const out: Doc = {}\n for (const field of HOLDER_FIGURE_FIELDS) {\n if (field in facet) out[field] = facet[field]\n }\n return out\n}\n\n/** What one contact's holder records become, as a dotted `update()` patch. */\nexport interface HolderPatch {\n patch: Doc\n combined: number\n copied: number\n figuresDropped: number\n /** Each company whose contacts count moves, by how much. */\n companyCounts: Array<{ companyId: string; delta: 1 | -1 }>\n}\n\n/**\n * The holder records of one contact after the plan's flows — or `null` when\n * nothing moves.\n *\n * `stragglersOnly` is the sweep: only the flows that delete their source\n * (MOVE, and a SPLIT whose key dissolves) run, because only those can have\n * left anything behind that a later writer recreated. A key that survived a\n * split is a live key after the flip, and its new writes belong to it.\n */\nexport function planHolderRecords(\n contact: Doc,\n plan: Pick<ConsentGroupChangePlan, 'flows'>,\n changeId: string,\n options: { stragglersOnly?: boolean } = {},\n): HolderPatch | null {\n const facets = mapOf(contact[CONTACT_FACETS_FIELD]) as Record<string, Doc>\n const captured = captureOrder(contact)\n const next: Record<string, Doc | null> = {}\n const current = (key: string): Doc | undefined =>\n key in next ? (next[key] ?? undefined) : facets[key]\n let combined = 0\n let copied = 0\n let figuresDropped = 0\n\n const splits = plan.flows.filter(\n (flow): flow is ConsentGroupHolderSplit =>\n flow.kind === 'split' && !(options.stragglersOnly && flow.survives),\n )\n const copies: Array<{ key: string; facet: Doc }> = []\n for (const split of splits) {\n const source = facets[split.from]\n if (!source || typeof source !== 'object') continue\n if (\n split.survives &&\n split.successors.some(\n (successor) => mapOf(facets[successor.key]?.[COPIED_BY_CHANGE_FIELD])[split.from] === changeId,\n )\n ) {\n continue\n }\n const keyOf = (hostId: string) =>\n split.stayHostIds.includes(hostId)\n ? split.from\n : (split.successors.find((successor) => successor.hostIds.includes(hostId))?.key ?? null)\n let eligible = split.successors.filter((successor) =>\n successor.hostIds.some((hostId) => captured.includes(hostId)),\n )\n if (!split.survives && !eligible.length) eligible = split.successors\n const landing = [\n ...new Set(\n captured\n .filter((hostId) => split.fromHostIds.includes(hostId))\n .map(keyOf)\n .filter((key): key is string => key !== null),\n ),\n ]\n const figuresTo =\n landing.length === 1 && landing[0] !== split.from && eligible.some((s) => s.key === landing[0])\n ? landing[0]\n : null\n for (const successor of eligible) {\n const copy: Doc = {\n ...withoutFigures(source),\n interactions: interactionsForGroup(\n (source['interactions'] ?? []) as ContactInteraction[],\n successor.hostIds,\n ),\n ...(successor.key === figuresTo ? figuresOf(source) : {}),\n }\n if (split.survives) copy[COPIED_BY_CHANGE_FIELD] = { [split.from]: changeId }\n copies.push({ key: successor.key, facet: copy })\n }\n if (split.survives) {\n if (figuresTo) next[split.from] = withoutFigures(current(split.from) ?? source)\n } else {\n next[split.from] = null\n const figures = figuresOf(source)\n if (!figuresTo && Object.keys(figures).some((field) => field !== 'lastEmailEngagementAtMs')) {\n figuresDropped += 1\n }\n }\n }\n\n const moves = new Map<string, ConsentGroupHolderMove[]>()\n for (const flow of plan.flows) {\n if (flow.kind !== 'move' || !facets[flow.from]) continue\n moves.set(flow.to, [...(moves.get(flow.to) ?? []), flow])\n }\n const metAt = (hostIds: readonly string[]) => {\n const indexes = hostIds.map((hostId) => captured.indexOf(hostId)).filter((index) => index >= 0)\n return indexes.length ? Math.min(...indexes) : Number.POSITIVE_INFINITY\n }\n for (const [target, sources] of moves) {\n const ordered = [...sources].sort(\n (a, b) => metAt(a.fromHostIds) - metAt(b.fromHostIds) || (a.from < b.from ? -1 : a.from > b.from ? 1 : 0),\n )\n let folded = current(target)\n // A person is COMBINED when two or more records become one; a lone\n // record under a new key is only moved.\n if (ordered.length + (folded ? 1 : 0) >= 2) combined += 1\n for (const source of ordered) {\n const facet = facets[source.from]\n folded = folded ? mergeContactFacet(folded, facet) : { ...facet }\n next[source.from] = null\n }\n if (folded) next[target] = folded\n }\n\n for (const { key, facet } of copies) {\n const existing = current(key)\n const merged = existing ? mergeContactFacet(existing, facet) : facet\n // The stamps are a union, never the survivor's alone: the merge keeps a\n // map the survivor already has, and a lost stamp would copy again.\n const stamps = {\n ...mapOf(existing?.[COPIED_BY_CHANGE_FIELD]),\n ...mapOf(facet[COPIED_BY_CHANGE_FIELD]),\n }\n if (Object.keys(stamps).length) merged[COPIED_BY_CHANGE_FIELD] = stamps\n next[key] = merged\n copied += 1\n }\n\n const keys = Object.keys(next)\n if (!keys.length) return null\n\n /*\n * The company mirror follows the facets: an id that no facet names any\n * more leaves it, and one a facet newly names joins it. Ids the mirror held\n * without a facet naming them — links older than the facets — are left.\n */\n const named = (source: Record<string, Doc | null | undefined>) =>\n new Set(\n Object.values(source)\n .map((facet) => (facet ? facet['companyId'] : null))\n .filter((id): id is string => typeof id === 'string' && id !== ''),\n )\n const after: Record<string, Doc | null> = { ...facets, ...next }\n const before = named(facets)\n const now = named(after)\n const mirror = Array.isArray(contact[CONTACT_COMPANY_IDS_FIELD])\n ? (contact[CONTACT_COMPANY_IDS_FIELD] as unknown[]).filter(\n (id): id is string => typeof id === 'string' && id !== '',\n )\n : []\n const companyCounts: HolderPatch['companyCounts'] = []\n const leaving = [...before].filter((id) => !now.has(id))\n const joining = [...now].filter((id) => !before.has(id))\n for (const id of leaving) if (mirror.includes(id)) companyCounts.push({ companyId: id, delta: -1 })\n for (const id of joining) if (!mirror.includes(id)) companyCounts.push({ companyId: id, delta: 1 })\n\n const patch: Doc = {}\n for (const key of keys) {\n patch[`${CONTACT_FACETS_FIELD}.${key}`] = next[key] === null ? FieldValue.delete() : next[key]\n }\n if (companyCounts.length) {\n patch[CONTACT_COMPANY_IDS_FIELD] = [\n ...mirror.filter((id) => !leaving.includes(id)),\n ...joining.filter((id) => !mirror.includes(id)),\n ]\n }\n patch['updatedAt'] = FieldValue.serverTimestamp()\n return { patch, combined, copied, figuresDropped, companyCounts }\n}\n\n/**\n * The refusal `from` holds, carried onto `to` — or `null` when `to` already\n * refuses or `from` does not.\n */\nexport function planRefusalCarry(\n contact: Doc,\n carry: { toHostId: string; fromHostId: string },\n changeId: string,\n): Doc | null {\n const byHost = mapOf(contact[MARKETING_CONSENT_BY_HOST_FIELD])\n const source = mapOf(byHost[carry.fromHostId])\n if (source[MARKETING_CONSENT_FIELD] !== false) return null\n const target = byHost[carry.toHostId]\n if (mapOf(target)[MARKETING_CONSENT_FIELD] === false) return null\n const entry: Doc = {\n [MARKETING_CONSENT_FIELD]: false,\n ...(source['marketingConsentAtMs'] !== undefined\n ? { marketingConsentAtMs: source['marketingConsentAtMs'] }\n : {}),\n ...(source[MARKETING_CONSENT_SOURCE_FIELD] !== undefined\n ? { [MARKETING_CONSENT_SOURCE_FIELD]: source[MARKETING_CONSENT_SOURCE_FIELD] }\n : {}),\n carriedFromHostId: carry.fromHostId,\n carriedByChangeId: changeId,\n ...(target && typeof target === 'object' ? { supersededEntry: target } : {}),\n }\n return {\n [`${MARKETING_CONSENT_BY_HOST_FIELD}.${carry.toHostId}`]: entry,\n updatedAt: FieldValue.serverTimestamp(),\n }\n}\n\n/*==========================================\n * THE PASSES\n *=========================================*/\n\ninterface Cursor {\n /** `refusals` walks the carries; `holders` walks every contact. */\n stage: 'refusals' | 'holders'\n /** The carry being walked, in `refusals`. */\n carry: number\n after: string | null\n}\n\nfunction readCursor(raw: string | null, first: Cursor['stage']): Cursor {\n if (raw) {\n try {\n const parsed = JSON.parse(raw) as Cursor\n if (parsed && (parsed.stage === 'refusals' || parsed.stage === 'holders')) return parsed\n } catch {\n // A cursor this participant did not write starts the phase over, which\n // every pass is safe to do.\n }\n }\n return { stage: first, carry: 0, after: null }\n}\n\nexport interface ConsentGroupParticipantOptions {\n firestore?: () => FirebaseFirestore.Firestore\n now?: () => number\n}\n\n/** One contact write, guarded by the read it was computed from. */\ninterface Pending {\n snapshot: FirebaseFirestore.QueryDocumentSnapshot | FirebaseFirestore.DocumentSnapshot\n patch: Doc\n after?: () => Promise<void>\n}\n\n/**\n * Writes a page's patches, each with a `lastUpdateTime` precondition, and\n * recomputes the ones that raced once from a fresh read. Answers how many\n * were written.\n */\nasync function writeGuarded(\n firestore: FirebaseFirestore.Firestore,\n pending: Pending[],\n recompute: (snapshot: FirebaseFirestore.DocumentSnapshot) => Pending | null,\n): Promise<number> {\n let written = 0\n let due = pending\n for (let round = 0; round < 2 && due.length; round += 1) {\n const writer = firestore.bulkWriter()\n const outcomes = due.map((entry) =>\n writer\n .update(entry.snapshot.ref, entry.patch, {\n lastUpdateTime: entry.snapshot.updateTime as FirebaseFirestore.Timestamp,\n })\n .then(\n () => ({ entry, error: null as unknown }),\n (error: unknown) => ({ entry, error: error ?? new Error('The write failed') }),\n ),\n )\n await writer.close()\n const raced: Pending[] = []\n for (const { entry, error } of await Promise.all(outcomes)) {\n if (error === null) {\n written += 1\n await entry.after?.()\n continue\n }\n const code = codeOf(error)\n if (code === undefined || !RACED.has(code)) throw error\n raced.push(entry)\n }\n if (!raced.length) return written\n const fresh = await firestore.getAll(...raced.map((entry) => entry.snapshot.ref))\n due = fresh\n .filter((snapshot) => snapshot.exists)\n .map(recompute)\n .filter((entry): entry is Pending => entry !== null)\n }\n if (due.length) {\n throw new Error(`[crm] ${due.length} contact(s) kept changing under the consent group change`)\n }\n return written\n}\n\n/**\n * The CRM's participant. `firestore` and `now` are injectable for the specs;\n * the defaults are the Admin SDK's and the wall clock.\n */\nexport function createConsentGroupParticipant(\n options: ConsentGroupParticipantOptions = {},\n): ConsentGroupChangeParticipant {\n const db = () => (options.firestore ?? (() => firebaseAdmin.app().firestore()))()\n const now = options.now ?? Date.now\n const contactsOf = (orgId: string) => db().collection('orgs').doc(orgId).collection('contacts')\n\n async function countOf(query: FirebaseFirestore.Query): Promise<number> {\n return Number((await query.count().get()).data().count) || 0\n }\n\n /** One page of one carry's refusals. */\n async function refusalPage(\n request: ConsentGroupChangeRunRequest,\n cursor: Cursor,\n counts: Record<string, number>,\n ): Promise<Cursor | null> {\n const carries = request.plan.carries\n if (cursor.carry >= carries.length) return null\n const carry = carries[cursor.carry]\n let query = contactsOf(request.orgId)\n .where(`${MARKETING_CONSENT_BY_HOST_FIELD}.${carry.fromHostId}.${MARKETING_CONSENT_FIELD}`, '==', false)\n .orderBy(FieldPath.documentId())\n .limit(REFUSAL_PAGE)\n if (cursor.after) query = query.startAfter(cursor.after)\n const page = await query.get()\n const plan = (snapshot: FirebaseFirestore.DocumentSnapshot): Pending | null => {\n const patch = planRefusalCarry((snapshot.data() ?? {}) as Doc, carry, request.changeId)\n return patch ? { snapshot, patch } : null\n }\n const pending = page.docs.map(plan).filter((entry): entry is Pending => entry !== null)\n counts['refusals'] = (counts['refusals'] ?? 0) +\n (request.dryRun ? pending.length : await writeGuarded(db(), pending, plan))\n if (page.size < REFUSAL_PAGE) return { stage: 'refusals', carry: cursor.carry + 1, after: null }\n return { stage: 'refusals', carry: cursor.carry, after: page.docs[page.docs.length - 1].id }\n }\n\n /** One page of the holder pass over every contact. */\n async function holderPage(\n request: ConsentGroupChangeRunRequest,\n cursor: Cursor,\n counts: Record<string, number>,\n ): Promise<Cursor | null> {\n let query = contactsOf(request.orgId).orderBy(FieldPath.documentId()).limit(HOLDER_PAGE)\n if (cursor.after) query = query.startAfter(cursor.after)\n const page = await query.get()\n const companies = db().collection('orgs').doc(request.orgId).collection(CRM_COLLECTIONS.companies)\n const stragglersOnly = request.phase === 'sweep'\n const tally = { combined: 0, copied: 0, figuresDropped: 0 }\n const count = (planned: HolderPatch) => {\n tally.combined += planned.combined\n tally.copied += planned.copied\n tally.figuresDropped += planned.figuresDropped\n }\n const plan = (snapshot: FirebaseFirestore.DocumentSnapshot): (Pending & { planned: HolderPatch }) | null => {\n const planned = planHolderRecords((snapshot.data() ?? {}) as Doc, request.plan, request.changeId, {\n stragglersOnly,\n })\n if (!planned) return null\n return {\n snapshot,\n planned,\n patch: planned.patch,\n after: async () => {\n count(planned)\n // The holders moved, and with them the facet keys the Contacts\n // list filters by (AGL-3321).\n await restampCrmListFieldsAt(snapshot.ref, 'contacts')\n // A count that could not move is logged and left: the company's own\n // page takes the live aggregate, which corrects it.\n await Promise.all(\n planned.companyCounts.map((change) =>\n companies\n .doc(change.companyId)\n .update({ [COMPANY_CONTACTS_COUNT_FIELD]: FieldValue.increment(change.delta) })\n .catch((error: unknown) => {\n console.error('[crm] company contacts count could not move', change.companyId, error)\n }),\n ),\n )\n },\n }\n }\n const pending = page.docs\n .map(plan)\n .filter((entry): entry is Pending & { planned: HolderPatch } => entry !== null)\n if (request.dryRun) for (const entry of pending) count(entry.planned)\n else await writeGuarded(db(), pending, plan)\n counts['combined'] = (counts['combined'] ?? 0) + tally.combined\n counts['copied'] = (counts['copied'] ?? 0) + tally.copied\n counts['figuresDropped'] = (counts['figuresDropped'] ?? 0) + tally.figuresDropped\n if (page.size < HOLDER_PAGE) return null\n return { stage: 'holders', carry: 0, after: page.docs[page.docs.length - 1].id }\n }\n\n return {\n async run(request): Promise<ConsentGroupChangeRunResult> {\n const counts: Record<string, number> = {}\n const hasHolderFlows = request.plan.flows.length > 0\n // `carry` walks the refusals; `rehome` the holders; `sweep` both.\n let cursor: Cursor | null = readCursor(\n request.cursor,\n request.phase === 'rehome' ? 'holders' : 'refusals',\n )\n while (cursor && now() < request.deadlineMs) {\n if (cursor.stage === 'refusals') {\n const next: Cursor | null = await refusalPage(request, cursor, counts)\n cursor =\n next ??\n (request.phase === 'sweep' && hasHolderFlows\n ? { stage: 'holders', carry: 0, after: null }\n : null)\n continue\n }\n cursor = hasHolderFlows ? await holderPage(request, cursor, counts) : null\n }\n return { done: cursor === null, cursor: cursor ? JSON.stringify(cursor) : null, counts }\n },\n\n async preview({ orgId, plan }): Promise<ConsentGroupChangePreviewLine[]> {\n const contacts = contactsOf(orgId)\n const lines: ConsentGroupChangePreviewLine[] = []\n const moves = plan.flows.filter((flow): flow is ConsentGroupHolderMove => flow.kind === 'move')\n const splits = plan.flows.filter((flow): flow is ConsentGroupHolderSplit => flow.kind === 'split')\n const people = (count: number | null) => (count === 1 ? 'person' : 'people')\n const figure = (count: number | null) => (count === null ? 'some' : String(count))\n\n /*\n * `crm.combine`: the people whose records under two or more of the\n * folding keys become one. No aggregation can ask \"two or more\", so the\n * records under each source key are read — bounded, and `null` past\n * the bound — and put through the same fold the re-home runs.\n */\n if (moves.length) {\n const seen = new Map<string, Doc>()\n let bounded = true\n for (const move of moves) {\n const page = await contacts\n .where(`${CONTACT_FACETS_FIELD}.${move.from}`, '!=', null)\n .limit(PREVIEW_SCAN_LIMIT + 1)\n .get()\n if (page.size > PREVIEW_SCAN_LIMIT) bounded = false\n for (const doc of page.docs) seen.set(doc.id, (doc.data() ?? {}) as Doc)\n }\n const combine = bounded\n ? [...seen.values()].reduce(\n (total, contact) =>\n total + (planHolderRecords(contact, { flows: moves }, '')?.combined ?? 0),\n 0,\n )\n : null\n lines.push({\n id: 'crm.combine',\n text: `The CRM records these sites keep about the same person will be combined for ${figure(combine)} ${people(combine)}.`,\n count: combine,\n severity: 'info',\n })\n }\n\n if (splits.length) {\n /*\n * `crm.copy`: the people a separating site takes a copy for. When the\n * key survives, those are the people the separating sites met\n * themselves; when it dissolves, everybody it holds, since a record\n * nobody met goes to every successor.\n */\n let copy = 0\n for (const split of splits) {\n if (!split.survives) {\n copy += await countOf(contacts.where(`${CONTACT_FACETS_FIELD}.${split.from}`, '!=', null))\n continue\n }\n const leavers = split.successors.flatMap((successor) => successor.hostIds)\n for (let index = 0; index < leavers.length; index += 30) {\n copy += await countOf(\n contacts.where(CAPTURED_BY_HOST_FIELD, 'array-contains-any', leavers.slice(index, index + 30)),\n )\n }\n }\n lines.push({\n id: 'crm.copy',\n text: `The separating sites keep a copy of the CRM record for the ${copy} ${people(copy)} they met themselves.`,\n count: copy,\n severity: 'info',\n })\n\n /*\n * `crm.grants` and `crm.visibility`, per separating site: the people\n * who signed up to the group through it and still hold that grant,\n * and the people it can see without having met them.\n */\n let grants = 0\n let visible = 0\n for (const split of splits) {\n for (const hostId of split.successors.flatMap((successor) => successor.hostIds)) {\n grants += await countOf(\n contacts\n .where(`${MARKETING_CONSENT_BY_HOST_FIELD}.${hostId}.${CONSENT_GROUP_ID_FIELD}`, '==', split.from)\n .where(`${MARKETING_CONSENT_BY_HOST_FIELD}.${hostId}.${MARKETING_CONSENT_FIELD}`, '==', true),\n )\n const seen = await countOf(contacts.where('visibleTo', 'array-contains', `host:${hostId}`))\n const met = await countOf(contacts.where(CAPTURED_BY_HOST_FIELD, 'array-contains', hostId))\n visible += Math.max(0, seen - met)\n }\n }\n lines.push({\n id: 'crm.grants',\n text: `The separating sites can still email the ${grants} ${people(grants)} who signed up to the group while they were part of it.`,\n count: grants,\n severity: 'info',\n })\n lines.push({\n id: 'crm.visibility',\n text: `The separating sites will still see ${visible} ${people(visible)} they met only through the group's other sites, without their CRM details.`,\n count: visible,\n severity: 'info',\n })\n\n /*\n * `crm.figures`: on a dissolve, the people whose order totals cannot\n * follow one site and are dropped. Read like the combine — the people\n * with orders under the key, bounded — and decided by the same split.\n */\n const dissolving = splits.filter((split) => !split.survives)\n if (dissolving.length) {\n let figures: number | null = 0\n for (const split of dissolving) {\n const page = await contacts\n .where(`${CONTACT_FACETS_FIELD}.${split.from}.ordersCount`, '>', 0)\n .limit(PREVIEW_SCAN_LIMIT + 1)\n .get()\n if (page.size > PREVIEW_SCAN_LIMIT || figures === null) {\n figures = null\n continue\n }\n for (const doc of page.docs) {\n figures +=\n planHolderRecords((doc.data() ?? {}) as Doc, { flows: [split] }, '')?.figuresDropped ?? 0\n }\n }\n lines.push({\n id: 'crm.figures',\n text: `Order totals for ${figure(figures)} ${people(figures)} who bought on more than one of these sites can't be split and will be removed.`,\n count: figures,\n severity: 'warning',\n })\n }\n }\n\n /*\n * `crm.refusals`: the refusals C4 would write — each separating pair's\n * declined consents, less the ones the receiving site already holds.\n */\n if (plan.carries.length) {\n let refusals = 0\n for (const carry of plan.carries) {\n const declined = (hostId: string) =>\n `${MARKETING_CONSENT_BY_HOST_FIELD}.${hostId}.${MARKETING_CONSENT_FIELD}`\n const from = contacts.where(declined(carry.fromHostId), '==', false)\n refusals += Math.max(\n 0,\n (await countOf(from)) - (await countOf(from.where(declined(carry.toHostId), '==', false))),\n )\n }\n lines.push({\n id: 'crm.refusals',\n text: `${refusals} declined ${refusals === 1 ? 'consent' : 'consents'} will be copied to the sites that separate.`,\n count: refusals,\n severity: 'info',\n })\n }\n return lines\n },\n\n summarize: summarizeConsentGroupChange,\n }\n}\n\n/** The participant the CRM registers, on the Admin SDK and the wall clock. */\nexport const consentGroupParticipant = createConsentGroupParticipant()\n"],"names":["mergeContactFacet","CONTACT_FACETS_FIELD","interactionsForGroup","COMPANY_CONTACTS_COUNT_FIELD","CONTACT_COMPANY_IDS_FIELD","CRM_COLLECTIONS","CAPTURED_BY_HOST_FIELD","CONSENT_GROUP_ID_FIELD","MARKETING_CONSENT_BY_HOST_FIELD","MARKETING_CONSENT_FIELD","MARKETING_CONSENT_SOURCE_FIELD","firebaseAdmin","restampCrmListFieldsAt","FieldPath","FieldValue","summarizeConsentGroupChange","REFUSAL_PAGE","HOLDER_PAGE","PREVIEW_SCAN_LIMIT","HOLDER_FIGURE_FIELDS","COPIED_BY_CHANGE_FIELD","RACED","Set","codeOf","error","code","mapOf","value","Array","isArray","captureOrder","contact","raw","ordered","map","id","String","trim","filter","Boolean","legacy","includes","withoutFigures","facet","out","field","figuresOf","planHolderRecords","plan","changeId","options","facets","captured","next","current","key","undefined","combined","copied","figuresDropped","splits","flows","flow","kind","stragglersOnly","survives","copies","split","source","from","successors","some","successor","keyOf","hostId","stayHostIds","find","hostIds","eligible","length","landing","fromHostIds","figuresTo","s","copy","interactions","push","figures","Object","keys","moves","Map","set","to","get","metAt","indexes","indexOf","index","Math","min","Number","POSITIVE_INFINITY","target","sources","sort","a","b","folded","existing","merged","stamps","named","values","after","before","now","mirror","companyCounts","leaving","has","joining","companyId","delta","patch","delete","serverTimestamp","planRefusalCarry","carry","byHost","fromHostId","toHostId","entry","marketingConsentAtMs","carriedFromHostId","carriedByChangeId","supersededEntry","updatedAt","readCursor","first","parsed","JSON","parse","stage","writeGuarded","firestore","pending","recompute","written","due","round","writer","bulkWriter","outcomes","update","snapshot","ref","lastUpdateTime","updateTime","then","Error","close","raced","Promise","all","fresh","getAll","exists","createConsentGroupParticipant","db","app","Date","contactsOf","orgId","collection","doc","countOf","query","count","data","refusalPage","request","cursor","counts","carries","where","orderBy","documentId","limit","startAfter","page","docs","dryRun","size","holderPage","companies","phase","tally","planned","change","increment","catch","console","run","hasHolderFlows","deadlineMs","done","stringify","preview","contacts","lines","people","figure","seen","bounded","move","combine","reduce","total","text","severity","leavers","flatMap","slice","grants","visible","met","max","dissolving","refusals","declined","summarize","consentGroupParticipant"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/consent-group-participant.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 CRM'S SHARE OF A CONSENT GROUP CHANGE (AGL-3320).\n *\n * Two things the CRM keeps are read through the CURRENT consent group, so a\n * change to the declaration moves under them unless they move too:\n *\n * - **C4 — a contact's per-site refusal.** `marketingConsentByHost.{site}`\n * holding `marketingConsent: false` refuses mail from every site of that\n * site's group (`readMarketingBasis`). When two sites stop being one\n * sender, each keeps the refusals the other held: the refusal is written\n * onto the receiving site's entry by dotted path, with the one it\n * replaced kept as `supersededEntry`. A refusal is carried whenever it was\n * given — over-carrying is the safe direction — and one the receiving site\n * already holds is left alone. Only contacts carry per-site refusals;\n * leads and list memberships carry grants alone.\n * - **The holder records**, `facets.{groupId}` — a profile, an owner, a\n * stage, notes, order figures and a timeline, keyed by the group that\n * keeps them. Every writer keys by the declaration in force, so after the\n * flip a site's records must be under its new key:\n * - a MOVE folds every old key's facet into the new key's, with the\n * existing target first and then the sources in the order the person\n * met their sites, through `mergeContactFacet` — the survivor's\n * values win, lists union, figures sum — and deletes each source in\n * the same write, so a figure is summed exactly once;\n * - a SPLIT copies the facet, without its figures and with only the\n * timeline entries of the successor's own sites, to each successor\n * that met the person itself, merged under whatever the successor\n * already holds. The figures follow the capture only when every site\n * that met the person lands under one key; otherwise they stay with the\n * surviving key, or — when the key dissolves — are dropped and counted.\n *\n * ## Run twice, write once\n *\n * The executor runs `carry` before the flip and again as the catch-up, and\n * the sweep runs everything once more. A carried refusal is skipped once the\n * receiving entry refuses; a MOVE or a dissolving SPLIT deletes its source in\n * the write that folds it, so a second pass finds nothing — except a\n * straggler a long job wrote under the old key after the first pass, which is\n * exactly what the sweep exists to fold in. A SPLIT whose key survives stays\n * a live key, so each copy is stamped with the change that made it\n * (`copiedByChange`), and a pass that finds the stamp does not copy again.\n */\n\nimport { mergeContactFacet } from '../model/contact-merge'\nimport type {\n ConsentGroupChangePlan,\n ConsentGroupChangePreviewLine,\n ConsentGroupHolderMove,\n ConsentGroupHolderSplit,\n} from '@aglyn/aglyn/app-utils/consent-group-change'\nimport {\n CONTACT_FACETS_FIELD,\n type ContactInteraction,\n interactionsForGroup,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport {\n COMPANY_CONTACTS_COUNT_FIELD,\n CONTACT_COMPANY_IDS_FIELD,\n CRM_COLLECTIONS,\n} from '@aglyn/aglyn/app-utils/crm'\nimport {\n CAPTURED_BY_HOST_FIELD,\n CONSENT_GROUP_ID_FIELD,\n MARKETING_CONSENT_BY_HOST_FIELD,\n MARKETING_CONSENT_FIELD,\n MARKETING_CONSENT_SOURCE_FIELD,\n} from '@aglyn/aglyn/app-utils/marketing-consent'\nimport type {\n ConsentGroupChangeParticipant,\n ConsentGroupChangeRunRequest,\n ConsentGroupChangeRunResult,\n} from '@aglyn/aglyn/plugin-manager/plugin-consent-group-change'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { restampCrmListFieldsAt } from '@aglyn/tenant-data-admin/server/crm-records'\nimport { FieldPath, FieldValue } from 'firebase-admin/firestore'\nimport { summarizeConsentGroupChange } from '../model/consent-group-summary'\n\n/** Contacts one page of the refusal carry reads. */\nexport const REFUSAL_PAGE = 300\n\n/** Contacts one page of the holder pass reads. */\nexport const HOLDER_PAGE = 200\n\n/**\n * The most records a preview reads to answer a count no aggregation can:\n * past it, the line says so with a `null` count rather than guess.\n */\nexport const PREVIEW_SCAN_LIMIT = 5_000\n\n/**\n * The figures a facet sums, and the instants that go with them — what\n * `mergeContactFacet` adds up or takes the earliest or latest of. A split\n * cannot divide one person's purchases between two holders, so a copy\n * leaves them behind.\n */\nexport const HOLDER_FIGURE_FIELDS = [\n 'ltvCents',\n 'ordersCount',\n 'refundedCents',\n 'refundedOrdersCount',\n 'firstPurchaseAtMs',\n 'lastPurchaseAtMs',\n 'lastRefundAtMs',\n 'lastEmailEngagementAtMs',\n] as const\n\n/** The stamp a split's copy carries: `{ [sourceKey]: changeId }`. */\nexport const COPIED_BY_CHANGE_FIELD = 'copiedByChange'\n\ntype Doc = Record<string, unknown>\n\n/** The error codes that mean the contact was written since it was read. */\nconst RACED = new Set([5, 9])\nconst codeOf = (error: unknown): number | undefined => (error as { code?: number } | null)?.code\n\nfunction mapOf(value: unknown): Doc {\n return value && typeof value === 'object' && !Array.isArray(value) ? (value as Doc) : {}\n}\n\n/** The sites that met this person, in the order they did — then the legacy `hostId`. */\nfunction captureOrder(contact: Doc): string[] {\n const raw = contact[CAPTURED_BY_HOST_FIELD]\n const ordered = Array.isArray(raw)\n ? raw.map((id) => String(id ?? '').trim()).filter(Boolean)\n : []\n const legacy = typeof contact['hostId'] === 'string' ? (contact['hostId'] as string) : ''\n return legacy && !ordered.includes(legacy) ? [...ordered, legacy] : ordered\n}\n\nfunction withoutFigures(facet: Doc): Doc {\n const out: Doc = { ...facet }\n for (const field of HOLDER_FIGURE_FIELDS) delete out[field]\n return out\n}\n\nfunction figuresOf(facet: Doc): Doc {\n const out: Doc = {}\n for (const field of HOLDER_FIGURE_FIELDS) {\n if (field in facet) out[field] = facet[field]\n }\n return out\n}\n\n/** What one contact's holder records become, as a dotted `update()` patch. */\nexport interface HolderPatch {\n patch: Doc\n combined: number\n copied: number\n figuresDropped: number\n /** Each company whose contacts count moves, by how much. */\n companyCounts: Array<{ companyId: string; delta: 1 | -1 }>\n}\n\n/**\n * The holder records of one contact after the plan's flows — or `null` when\n * nothing moves.\n *\n * `stragglersOnly` is the sweep: only the flows that delete their source\n * (MOVE, and a SPLIT whose key dissolves) run, because only those can have\n * left anything behind that a later writer recreated. A key that survived a\n * split is a live key after the flip, and its new writes belong to it.\n */\nexport function planHolderRecords(\n contact: Doc,\n plan: Pick<ConsentGroupChangePlan, 'flows'>,\n changeId: string,\n options: { stragglersOnly?: boolean } = {},\n): HolderPatch | null {\n const facets = mapOf(contact[CONTACT_FACETS_FIELD]) as Record<string, Doc>\n const captured = captureOrder(contact)\n const next: Record<string, Doc | null> = {}\n const current = (key: string): Doc | undefined =>\n key in next ? (next[key] ?? undefined) : facets[key]\n let combined = 0\n let copied = 0\n let figuresDropped = 0\n\n const splits = plan.flows.filter(\n (flow): flow is ConsentGroupHolderSplit =>\n flow.kind === 'split' && !(options.stragglersOnly && flow.survives),\n )\n const copies: Array<{ key: string; facet: Doc }> = []\n for (const split of splits) {\n const source = facets[split.from]\n if (!source || typeof source !== 'object') continue\n if (\n split.survives &&\n split.successors.some(\n (successor) => mapOf(facets[successor.key]?.[COPIED_BY_CHANGE_FIELD])[split.from] === changeId,\n )\n ) {\n continue\n }\n const keyOf = (hostId: string) =>\n split.stayHostIds.includes(hostId)\n ? split.from\n : (split.successors.find((successor) => successor.hostIds.includes(hostId))?.key ?? null)\n let eligible = split.successors.filter((successor) =>\n successor.hostIds.some((hostId) => captured.includes(hostId)),\n )\n if (!split.survives && !eligible.length) eligible = split.successors\n const landing = [\n ...new Set(\n captured\n .filter((hostId) => split.fromHostIds.includes(hostId))\n .map(keyOf)\n .filter((key): key is string => key !== null),\n ),\n ]\n const figuresTo =\n landing.length === 1 && landing[0] !== split.from && eligible.some((s) => s.key === landing[0])\n ? landing[0]\n : null\n for (const successor of eligible) {\n const copy: Doc = {\n ...withoutFigures(source),\n interactions: interactionsForGroup(\n (source['interactions'] ?? []) as ContactInteraction[],\n successor.hostIds,\n ),\n ...(successor.key === figuresTo ? figuresOf(source) : {}),\n }\n if (split.survives) copy[COPIED_BY_CHANGE_FIELD] = { [split.from]: changeId }\n copies.push({ key: successor.key, facet: copy })\n }\n if (split.survives) {\n if (figuresTo) next[split.from] = withoutFigures(current(split.from) ?? source)\n } else {\n next[split.from] = null\n const figures = figuresOf(source)\n if (!figuresTo && Object.keys(figures).some((field) => field !== 'lastEmailEngagementAtMs')) {\n figuresDropped += 1\n }\n }\n }\n\n const moves = new Map<string, ConsentGroupHolderMove[]>()\n for (const flow of plan.flows) {\n if (flow.kind !== 'move' || !facets[flow.from]) continue\n moves.set(flow.to, [...(moves.get(flow.to) ?? []), flow])\n }\n const metAt = (hostIds: readonly string[]) => {\n const indexes = hostIds.map((hostId) => captured.indexOf(hostId)).filter((index) => index >= 0)\n return indexes.length ? Math.min(...indexes) : Number.POSITIVE_INFINITY\n }\n for (const [target, sources] of moves) {\n const ordered = [...sources].sort(\n (a, b) => metAt(a.fromHostIds) - metAt(b.fromHostIds) || (a.from < b.from ? -1 : a.from > b.from ? 1 : 0),\n )\n let folded = current(target)\n // A person is COMBINED when two or more records become one; a lone\n // record under a new key is only moved.\n if (ordered.length + (folded ? 1 : 0) >= 2) combined += 1\n for (const source of ordered) {\n const facet = facets[source.from]\n folded = folded ? mergeContactFacet(folded, facet) : { ...facet }\n next[source.from] = null\n }\n if (folded) next[target] = folded\n }\n\n for (const { key, facet } of copies) {\n const existing = current(key)\n const merged = existing ? mergeContactFacet(existing, facet) : facet\n // The stamps are a union, never the survivor's alone: the merge keeps a\n // map the survivor already has, and a lost stamp would copy again.\n const stamps = {\n ...mapOf(existing?.[COPIED_BY_CHANGE_FIELD]),\n ...mapOf(facet[COPIED_BY_CHANGE_FIELD]),\n }\n if (Object.keys(stamps).length) merged[COPIED_BY_CHANGE_FIELD] = stamps\n next[key] = merged\n copied += 1\n }\n\n const keys = Object.keys(next)\n if (!keys.length) return null\n\n /*\n * The company mirror follows the facets: an id that no facet names any\n * more leaves it, and one a facet newly names joins it. Ids the mirror held\n * without a facet naming them — links older than the facets — are left.\n */\n const named = (source: Record<string, Doc | null | undefined>) =>\n new Set(\n Object.values(source)\n .map((facet) => (facet ? facet['companyId'] : null))\n .filter((id): id is string => typeof id === 'string' && id !== ''),\n )\n const after: Record<string, Doc | null> = { ...facets, ...next }\n const before = named(facets)\n const now = named(after)\n const mirror = Array.isArray(contact[CONTACT_COMPANY_IDS_FIELD])\n ? (contact[CONTACT_COMPANY_IDS_FIELD] as unknown[]).filter(\n (id): id is string => typeof id === 'string' && id !== '',\n )\n : []\n const companyCounts: HolderPatch['companyCounts'] = []\n const leaving = [...before].filter((id) => !now.has(id))\n const joining = [...now].filter((id) => !before.has(id))\n for (const id of leaving) if (mirror.includes(id)) companyCounts.push({ companyId: id, delta: -1 })\n for (const id of joining) if (!mirror.includes(id)) companyCounts.push({ companyId: id, delta: 1 })\n\n const patch: Doc = {}\n for (const key of keys) {\n patch[`${CONTACT_FACETS_FIELD}.${key}`] = next[key] === null ? FieldValue.delete() : next[key]\n }\n if (companyCounts.length) {\n patch[CONTACT_COMPANY_IDS_FIELD] = [\n ...mirror.filter((id) => !leaving.includes(id)),\n ...joining.filter((id) => !mirror.includes(id)),\n ]\n }\n patch['updatedAt'] = FieldValue.serverTimestamp()\n return { patch, combined, copied, figuresDropped, companyCounts }\n}\n\n/**\n * The refusal `from` holds, carried onto `to` — or `null` when `to` already\n * refuses or `from` does not.\n */\nexport function planRefusalCarry(\n contact: Doc,\n carry: { toHostId: string; fromHostId: string },\n changeId: string,\n): Doc | null {\n const byHost = mapOf(contact[MARKETING_CONSENT_BY_HOST_FIELD])\n const source = mapOf(byHost[carry.fromHostId])\n if (source[MARKETING_CONSENT_FIELD] !== false) return null\n const target = byHost[carry.toHostId]\n if (mapOf(target)[MARKETING_CONSENT_FIELD] === false) return null\n const entry: Doc = {\n [MARKETING_CONSENT_FIELD]: false,\n ...(source['marketingConsentAtMs'] !== undefined\n ? { marketingConsentAtMs: source['marketingConsentAtMs'] }\n : {}),\n ...(source[MARKETING_CONSENT_SOURCE_FIELD] !== undefined\n ? { [MARKETING_CONSENT_SOURCE_FIELD]: source[MARKETING_CONSENT_SOURCE_FIELD] }\n : {}),\n carriedFromHostId: carry.fromHostId,\n carriedByChangeId: changeId,\n ...(target && typeof target === 'object' ? { supersededEntry: target } : {}),\n }\n return {\n [`${MARKETING_CONSENT_BY_HOST_FIELD}.${carry.toHostId}`]: entry,\n updatedAt: FieldValue.serverTimestamp(),\n }\n}\n\n/*==========================================\n * THE PASSES\n *=========================================*/\n\ninterface Cursor {\n /** `refusals` walks the carries; `holders` walks every contact. */\n stage: 'refusals' | 'holders'\n /** The carry being walked, in `refusals`. */\n carry: number\n after: string | null\n}\n\nfunction readCursor(raw: string | null, first: Cursor['stage']): Cursor {\n if (raw) {\n try {\n const parsed = JSON.parse(raw) as Cursor\n if (parsed && (parsed.stage === 'refusals' || parsed.stage === 'holders')) return parsed\n } catch {\n // A cursor this participant did not write starts the phase over, which\n // every pass is safe to do.\n }\n }\n return { stage: first, carry: 0, after: null }\n}\n\nexport interface ConsentGroupParticipantOptions {\n firestore?: () => FirebaseFirestore.Firestore\n now?: () => number\n}\n\n/** One contact write, guarded by the read it was computed from. */\ninterface Pending {\n snapshot: FirebaseFirestore.QueryDocumentSnapshot | FirebaseFirestore.DocumentSnapshot\n patch: Doc\n after?: () => Promise<void>\n}\n\n/**\n * Writes a page's patches, each with a `lastUpdateTime` precondition, and\n * recomputes the ones that raced once from a fresh read. Answers how many\n * were written.\n */\nasync function writeGuarded(\n firestore: FirebaseFirestore.Firestore,\n pending: Pending[],\n recompute: (snapshot: FirebaseFirestore.DocumentSnapshot) => Pending | null,\n): Promise<number> {\n let written = 0\n let due = pending\n for (let round = 0; round < 2 && due.length; round += 1) {\n const writer = firestore.bulkWriter()\n const outcomes = due.map((entry) =>\n writer\n .update(entry.snapshot.ref, entry.patch, {\n lastUpdateTime: entry.snapshot.updateTime as FirebaseFirestore.Timestamp,\n })\n .then(\n () => ({ entry, error: null as unknown }),\n (error: unknown) => ({ entry, error: error ?? new Error('The write failed') }),\n ),\n )\n await writer.close()\n const raced: Pending[] = []\n for (const { entry, error } of await Promise.all(outcomes)) {\n if (error === null) {\n written += 1\n await entry.after?.()\n continue\n }\n const code = codeOf(error)\n if (code === undefined || !RACED.has(code)) throw error\n raced.push(entry)\n }\n if (!raced.length) return written\n const fresh = await firestore.getAll(...raced.map((entry) => entry.snapshot.ref))\n due = fresh\n .filter((snapshot) => snapshot.exists)\n .map(recompute)\n .filter((entry): entry is Pending => entry !== null)\n }\n if (due.length) {\n throw new Error(`[crm] ${due.length} contact(s) kept changing under the consent group change`)\n }\n return written\n}\n\n/**\n * The CRM's participant. `firestore` and `now` are injectable for the specs;\n * the defaults are the Admin SDK's and the wall clock.\n */\nexport function createConsentGroupParticipant(\n options: ConsentGroupParticipantOptions = {},\n): ConsentGroupChangeParticipant {\n const db = () => (options.firestore ?? (() => firebaseAdmin.app().firestore()))()\n const now = options.now ?? Date.now\n const contactsOf = (orgId: string) => db().collection('orgs').doc(orgId).collection('contacts')\n\n async function countOf(query: FirebaseFirestore.Query): Promise<number> {\n return Number((await query.count().get()).data().count) || 0\n }\n\n /** One page of one carry's refusals. */\n async function refusalPage(\n request: ConsentGroupChangeRunRequest,\n cursor: Cursor,\n counts: Record<string, number>,\n ): Promise<Cursor | null> {\n const carries = request.plan.carries\n if (cursor.carry >= carries.length) return null\n const carry = carries[cursor.carry]\n let query = contactsOf(request.orgId)\n .where(`${MARKETING_CONSENT_BY_HOST_FIELD}.${carry.fromHostId}.${MARKETING_CONSENT_FIELD}`, '==', false)\n .orderBy(FieldPath.documentId())\n .limit(REFUSAL_PAGE)\n if (cursor.after) query = query.startAfter(cursor.after)\n const page = await query.get()\n const plan = (snapshot: FirebaseFirestore.DocumentSnapshot): Pending | null => {\n const patch = planRefusalCarry((snapshot.data() ?? {}) as Doc, carry, request.changeId)\n return patch ? { snapshot, patch } : null\n }\n const pending = page.docs.map(plan).filter((entry): entry is Pending => entry !== null)\n counts['refusals'] = (counts['refusals'] ?? 0) +\n (request.dryRun ? pending.length : await writeGuarded(db(), pending, plan))\n if (page.size < REFUSAL_PAGE) return { stage: 'refusals', carry: cursor.carry + 1, after: null }\n return { stage: 'refusals', carry: cursor.carry, after: page.docs[page.docs.length - 1].id }\n }\n\n /** One page of the holder pass over every contact. */\n async function holderPage(\n request: ConsentGroupChangeRunRequest,\n cursor: Cursor,\n counts: Record<string, number>,\n ): Promise<Cursor | null> {\n let query = contactsOf(request.orgId).orderBy(FieldPath.documentId()).limit(HOLDER_PAGE)\n if (cursor.after) query = query.startAfter(cursor.after)\n const page = await query.get()\n const companies = db().collection('orgs').doc(request.orgId).collection(CRM_COLLECTIONS.companies)\n const stragglersOnly = request.phase === 'sweep'\n const tally = { combined: 0, copied: 0, figuresDropped: 0 }\n const count = (planned: HolderPatch) => {\n tally.combined += planned.combined\n tally.copied += planned.copied\n tally.figuresDropped += planned.figuresDropped\n }\n const plan = (snapshot: FirebaseFirestore.DocumentSnapshot): (Pending & { planned: HolderPatch }) | null => {\n const planned = planHolderRecords((snapshot.data() ?? {}) as Doc, request.plan, request.changeId, {\n stragglersOnly,\n })\n if (!planned) return null\n return {\n snapshot,\n planned,\n patch: planned.patch,\n after: async () => {\n count(planned)\n // The holders moved, and with them the facet keys the Contacts\n // list filters by (AGL-3321).\n await restampCrmListFieldsAt(snapshot.ref, 'contacts')\n // A count that could not move is logged and left: the company's own\n // page takes the live aggregate, which corrects it.\n await Promise.all(\n planned.companyCounts.map((change) =>\n companies\n .doc(change.companyId)\n .update({ [COMPANY_CONTACTS_COUNT_FIELD]: FieldValue.increment(change.delta) })\n .catch((error: unknown) => {\n console.error('[crm] company contacts count could not move', change.companyId, error)\n }),\n ),\n )\n },\n }\n }\n const pending = page.docs\n .map(plan)\n .filter((entry): entry is Pending & { planned: HolderPatch } => entry !== null)\n if (request.dryRun) for (const entry of pending) count(entry.planned)\n else await writeGuarded(db(), pending, plan)\n counts['combined'] = (counts['combined'] ?? 0) + tally.combined\n counts['copied'] = (counts['copied'] ?? 0) + tally.copied\n counts['figuresDropped'] = (counts['figuresDropped'] ?? 0) + tally.figuresDropped\n if (page.size < HOLDER_PAGE) return null\n return { stage: 'holders', carry: 0, after: page.docs[page.docs.length - 1].id }\n }\n\n return {\n async run(request): Promise<ConsentGroupChangeRunResult> {\n const counts: Record<string, number> = {}\n const hasHolderFlows = request.plan.flows.length > 0\n // `carry` walks the refusals; `rehome` the holders; `sweep` both.\n let cursor: Cursor | null = readCursor(\n request.cursor,\n request.phase === 'rehome' ? 'holders' : 'refusals',\n )\n while (cursor && now() < request.deadlineMs) {\n if (cursor.stage === 'refusals') {\n const next: Cursor | null = await refusalPage(request, cursor, counts)\n cursor =\n next ??\n (request.phase === 'sweep' && hasHolderFlows\n ? { stage: 'holders', carry: 0, after: null }\n : null)\n continue\n }\n cursor = hasHolderFlows ? await holderPage(request, cursor, counts) : null\n }\n return { done: cursor === null, cursor: cursor ? JSON.stringify(cursor) : null, counts }\n },\n\n async preview({ orgId, plan }): Promise<ConsentGroupChangePreviewLine[]> {\n const contacts = contactsOf(orgId)\n const lines: ConsentGroupChangePreviewLine[] = []\n const moves = plan.flows.filter((flow): flow is ConsentGroupHolderMove => flow.kind === 'move')\n const splits = plan.flows.filter((flow): flow is ConsentGroupHolderSplit => flow.kind === 'split')\n const people = (count: number | null) => (count === 1 ? 'person' : 'people')\n const figure = (count: number | null) => (count === null ? 'some' : String(count))\n\n /*\n * `crm.combine`: the people whose records under two or more of the\n * folding keys become one. No aggregation can ask \"two or more\", so the\n * records under each source key are read — bounded, and `null` past\n * the bound — and put through the same fold the re-home runs.\n */\n if (moves.length) {\n const seen = new Map<string, Doc>()\n let bounded = true\n for (const move of moves) {\n const page = await contacts\n .where(`${CONTACT_FACETS_FIELD}.${move.from}`, '!=', null)\n .limit(PREVIEW_SCAN_LIMIT + 1)\n .get()\n if (page.size > PREVIEW_SCAN_LIMIT) bounded = false\n for (const doc of page.docs) seen.set(doc.id, (doc.data() ?? {}) as Doc)\n }\n const combine = bounded\n ? [...seen.values()].reduce(\n (total, contact) =>\n total + (planHolderRecords(contact, { flows: moves }, '')?.combined ?? 0),\n 0,\n )\n : null\n lines.push({\n id: 'crm.combine',\n text: `The CRM records these sites keep about the same person will be combined for ${figure(combine)} ${people(combine)}.`,\n count: combine,\n severity: 'info',\n })\n }\n\n if (splits.length) {\n /*\n * `crm.copy`: the people a separating site takes a copy for. When the\n * key survives, those are the people the separating sites met\n * themselves; when it dissolves, everybody it holds, since a record\n * nobody met goes to every successor.\n */\n let copy = 0\n for (const split of splits) {\n if (!split.survives) {\n copy += await countOf(contacts.where(`${CONTACT_FACETS_FIELD}.${split.from}`, '!=', null))\n continue\n }\n const leavers = split.successors.flatMap((successor) => successor.hostIds)\n for (let index = 0; index < leavers.length; index += 30) {\n copy += await countOf(\n contacts.where(CAPTURED_BY_HOST_FIELD, 'array-contains-any', leavers.slice(index, index + 30)),\n )\n }\n }\n lines.push({\n id: 'crm.copy',\n text: `The separating sites keep a copy of the CRM record for the ${copy} ${people(copy)} they met themselves.`,\n count: copy,\n severity: 'info',\n })\n\n /*\n * `crm.grants` and `crm.visibility`, per separating site: the people\n * who signed up to the group through it and still hold that grant,\n * and the people it can see without having met them.\n */\n let grants = 0\n let visible = 0\n for (const split of splits) {\n for (const hostId of split.successors.flatMap((successor) => successor.hostIds)) {\n grants += await countOf(\n contacts\n .where(`${MARKETING_CONSENT_BY_HOST_FIELD}.${hostId}.${CONSENT_GROUP_ID_FIELD}`, '==', split.from)\n .where(`${MARKETING_CONSENT_BY_HOST_FIELD}.${hostId}.${MARKETING_CONSENT_FIELD}`, '==', true),\n )\n const seen = await countOf(contacts.where('visibleTo', 'array-contains', `host:${hostId}`))\n const met = await countOf(contacts.where(CAPTURED_BY_HOST_FIELD, 'array-contains', hostId))\n visible += Math.max(0, seen - met)\n }\n }\n lines.push({\n id: 'crm.grants',\n text: `The separating sites can still email the ${grants} ${people(grants)} who signed up to the group while they were part of it.`,\n count: grants,\n severity: 'info',\n })\n lines.push({\n id: 'crm.visibility',\n text: `The separating sites will still see ${visible} ${people(visible)} they met only through the group's other sites, without their CRM details.`,\n count: visible,\n severity: 'info',\n })\n\n /*\n * `crm.figures`: on a dissolve, the people whose order totals cannot\n * follow one site and are dropped. Read like the combine — the people\n * with orders under the key, bounded — and decided by the same split.\n */\n const dissolving = splits.filter((split) => !split.survives)\n if (dissolving.length) {\n let figures: number | null = 0\n for (const split of dissolving) {\n const page = await contacts\n .where(`${CONTACT_FACETS_FIELD}.${split.from}.ordersCount`, '>', 0)\n .limit(PREVIEW_SCAN_LIMIT + 1)\n .get()\n if (page.size > PREVIEW_SCAN_LIMIT || figures === null) {\n figures = null\n continue\n }\n for (const doc of page.docs) {\n figures +=\n planHolderRecords((doc.data() ?? {}) as Doc, { flows: [split] }, '')?.figuresDropped ?? 0\n }\n }\n lines.push({\n id: 'crm.figures',\n text: `Order totals for ${figure(figures)} ${people(figures)} who bought on more than one of these sites can't be split and will be removed.`,\n count: figures,\n severity: 'warning',\n })\n }\n }\n\n /*\n * `crm.refusals`: the refusals C4 would write — each separating pair's\n * declined consents, less the ones the receiving site already holds.\n */\n if (plan.carries.length) {\n let refusals = 0\n for (const carry of plan.carries) {\n const declined = (hostId: string) =>\n `${MARKETING_CONSENT_BY_HOST_FIELD}.${hostId}.${MARKETING_CONSENT_FIELD}`\n const from = contacts.where(declined(carry.fromHostId), '==', false)\n refusals += Math.max(\n 0,\n (await countOf(from)) - (await countOf(from.where(declined(carry.toHostId), '==', false))),\n )\n }\n lines.push({\n id: 'crm.refusals',\n text: `${refusals} declined ${refusals === 1 ? 'consent' : 'consents'} will be copied to the sites that separate.`,\n count: refusals,\n severity: 'info',\n })\n }\n return lines\n },\n\n summarize: summarizeConsentGroupChange,\n }\n}\n\n/** The participant the CRM registers, on the Admin SDK and the wall clock. */\nexport const consentGroupParticipant = createConsentGroupParticipant()\n"],"names":["mergeContactFacet","CONTACT_FACETS_FIELD","interactionsForGroup","COMPANY_CONTACTS_COUNT_FIELD","CONTACT_COMPANY_IDS_FIELD","CRM_COLLECTIONS","CAPTURED_BY_HOST_FIELD","CONSENT_GROUP_ID_FIELD","MARKETING_CONSENT_BY_HOST_FIELD","MARKETING_CONSENT_FIELD","MARKETING_CONSENT_SOURCE_FIELD","firebaseAdmin","restampCrmListFieldsAt","FieldPath","FieldValue","summarizeConsentGroupChange","REFUSAL_PAGE","HOLDER_PAGE","PREVIEW_SCAN_LIMIT","HOLDER_FIGURE_FIELDS","COPIED_BY_CHANGE_FIELD","RACED","Set","codeOf","error","code","mapOf","value","Array","isArray","captureOrder","contact","raw","ordered","map","id","String","trim","filter","Boolean","legacy","includes","withoutFigures","facet","out","field","figuresOf","planHolderRecords","plan","changeId","options","facets","captured","next","current","key","undefined","combined","copied","figuresDropped","splits","flows","flow","kind","stragglersOnly","survives","copies","split","source","from","successors","some","successor","keyOf","hostId","stayHostIds","find","hostIds","eligible","length","landing","fromHostIds","figuresTo","s","copy","interactions","push","figures","Object","keys","moves","Map","set","to","get","metAt","indexes","indexOf","index","Math","min","Number","POSITIVE_INFINITY","target","sources","sort","a","b","folded","existing","merged","stamps","named","values","after","before","now","mirror","companyCounts","leaving","has","joining","companyId","delta","patch","delete","serverTimestamp","planRefusalCarry","carry","byHost","fromHostId","toHostId","entry","marketingConsentAtMs","carriedFromHostId","carriedByChangeId","supersededEntry","updatedAt","readCursor","first","parsed","JSON","parse","stage","writeGuarded","firestore","pending","recompute","written","due","round","writer","bulkWriter","outcomes","update","snapshot","ref","lastUpdateTime","updateTime","then","Error","close","raced","Promise","all","fresh","getAll","exists","createConsentGroupParticipant","db","app","Date","contactsOf","orgId","collection","doc","countOf","query","count","data","refusalPage","request","cursor","counts","carries","where","orderBy","documentId","limit","startAfter","page","docs","dryRun","size","holderPage","companies","phase","tally","planned","change","increment","catch","console","run","hasHolderFlows","deadlineMs","done","stringify","preview","contacts","lines","people","figure","seen","bounded","move","combine","reduce","total","text","severity","leavers","flatMap","slice","grants","visible","met","max","dissolving","refusals","declined","summarize","consentGroupParticipant"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyCC,GAED,SAASA,iBAAiB,QAAQ,4BAAwB;AAO1D,SACEC,oBAAoB,EAEpBC,oBAAoB,QACf,kCAAiC;AACxC,SACEC,4BAA4B,EAC5BC,yBAAyB,EACzBC,eAAe,QACV,6BAA4B;AACnC,SACEC,sBAAsB,EACtBC,sBAAsB,EACtBC,+BAA+B,EAC/BC,uBAAuB,EACvBC,8BAA8B,QACzB,2CAA0C;AAMjD,SAASC,aAAa,QAAQ,iDAAgD;AAC9E,SAASC,sBAAsB,QAAQ,8CAA6C;AACpF,SAASC,SAAS,EAAEC,UAAU,QAAQ,2BAA0B;AAChE,SAASC,2BAA2B,QAAQ,oCAAgC;AAE5E,kDAAkD,GAClD,OAAO,MAAMC,eAAe,IAAG;AAE/B,gDAAgD,GAChD,OAAO,MAAMC,cAAc,IAAG;AAE9B;;;CAGC,GACD,OAAO,MAAMC,qBAAqB,KAAK;AAEvC;;;;;CAKC,GACD,OAAO,MAAMC,uBAAuB;IAClC;IACA;IACA;IACA;IACA;IACA;IACA;IACA;CACD,CAAS;AAEV,mEAAmE,GACnE,OAAO,MAAMC,yBAAyB,iBAAgB;AAItD,yEAAyE,GACzE,MAAMC,QAAQ,IAAIC,IAAI;IAAC;IAAG;CAAE;AAC5B,MAAMC,SAAS,CAACC,QAAwCA,yBAAD,AAACA,MAAoCC,IAAI;AAEhG,SAASC,MAAMC,KAAc;IAC3B,OAAOA,SAAS,OAAOA,UAAU,YAAY,CAACC,MAAMC,OAAO,CAACF,SAAUA,QAAgB,CAAC;AACzF;AAEA,sFAAsF,GACtF,SAASG,aAAaC,OAAY;IAChC,MAAMC,MAAMD,OAAO,CAACzB,uBAAuB;IAC3C,MAAM2B,UAAUL,MAAMC,OAAO,CAACG,OAC1BA,IAAIE,GAAG,CAAC,CAACC,KAAOC,OAAOD,aAAAA,KAAM,IAAIE,IAAI,IAAIC,MAAM,CAACC,WAChD,EAAE;IACN,MAAMC,SAAS,OAAOT,OAAO,CAAC,SAAS,KAAK,WAAYA,OAAO,CAAC,SAAS,GAAc;IACvF,OAAOS,UAAU,CAACP,QAAQQ,QAAQ,CAACD,UAAU;WAAIP;QAASO;KAAO,GAAGP;AACtE;AAEA,SAASS,eAAeC,KAAU;IAChC,MAAMC,MAAW,aAAKD;IACtB,KAAK,MAAME,SAAS1B,qBAAsB,OAAOyB,GAAG,CAACC,MAAM;IAC3D,OAAOD;AACT;AAEA,SAASE,UAAUH,KAAU;IAC3B,MAAMC,MAAW,CAAC;IAClB,KAAK,MAAMC,SAAS1B,qBAAsB;QACxC,IAAI0B,SAASF,OAAOC,GAAG,CAACC,MAAM,GAAGF,KAAK,CAACE,MAAM;IAC/C;IACA,OAAOD;AACT;AAYA;;;;;;;;CAQC,GACD,OAAO,SAASG,kBACdhB,OAAY,EACZiB,IAA2C,EAC3CC,QAAgB,EAChBC,UAAwC,CAAC,CAAC;IAE1C,MAAMC,SAASzB,MAAMK,OAAO,CAAC9B,qBAAqB;IAClD,MAAMmD,WAAWtB,aAAaC;IAC9B,MAAMsB,OAAmC,CAAC;IAC1C,MAAMC,UAAU,CAACC;YACAF;eAAfE,OAAOF,QAAQA,YAAAA,IAAI,CAACE,IAAI,YAATF,YAAaG,YAAaL,MAAM,CAACI,IAAI;;IACtD,IAAIE,WAAW;IACf,IAAIC,SAAS;IACb,IAAIC,iBAAiB;IAErB,MAAMC,SAASZ,KAAKa,KAAK,CAACvB,MAAM,CAC9B,CAACwB,OACCA,KAAKC,IAAI,KAAK,WAAW,CAAEb,CAAAA,QAAQc,cAAc,IAAIF,KAAKG,QAAQ,AAAD;IAErE,MAAMC,SAA6C,EAAE;IACrD,KAAK,MAAMC,SAASP,OAAQ;QAC1B,MAAMQ,SAASjB,MAAM,CAACgB,MAAME,IAAI,CAAC;QACjC,IAAI,CAACD,UAAU,OAAOA,WAAW,UAAU;QAC3C,IACED,MAAMF,QAAQ,IACdE,MAAMG,UAAU,CAACC,IAAI,CACnB,CAACC;gBAAoBrB;mBAANzB,OAAMyB,wBAAAA,MAAM,CAACqB,UAAUjB,GAAG,CAAC,qBAArBJ,qBAAuB,CAAC/B,uBAAuB,CAAC,CAAC+C,MAAME,IAAI,CAAC,KAAKpB;YAExF;YACA;QACF;QACA,MAAMwB,QAAQ,CAACC;;gBAGRP;mBAFLA,MAAMQ,WAAW,CAAClC,QAAQ,CAACiC,UACvBP,MAAME,IAAI,YACTF,yBAAAA,MAAMG,UAAU,CAACM,IAAI,CAAC,CAACJ,YAAcA,UAAUK,OAAO,CAACpC,QAAQ,CAACiC,6BAAhEP,uBAA0EZ,GAAG,mBAAI;;QACxF,IAAIuB,WAAWX,MAAMG,UAAU,CAAChC,MAAM,CAAC,CAACkC,YACtCA,UAAUK,OAAO,CAACN,IAAI,CAAC,CAACG,SAAWtB,SAASX,QAAQ,CAACiC;QAEvD,IAAI,CAACP,MAAMF,QAAQ,IAAI,CAACa,SAASC,MAAM,EAAED,WAAWX,MAAMG,UAAU;QACpE,MAAMU,UAAU;eACX,IAAI1D,IACL8B,SACGd,MAAM,CAAC,CAACoC,SAAWP,MAAMc,WAAW,CAACxC,QAAQ,CAACiC,SAC9CxC,GAAG,CAACuC,OACJnC,MAAM,CAAC,CAACiB,MAAuBA,QAAQ;SAE7C;QACD,MAAM2B,YACJF,QAAQD,MAAM,KAAK,KAAKC,OAAO,CAAC,EAAE,KAAKb,MAAME,IAAI,IAAIS,SAASP,IAAI,CAAC,CAACY,IAAMA,EAAE5B,GAAG,KAAKyB,OAAO,CAAC,EAAE,IAC1FA,OAAO,CAAC,EAAE,GACV;QACN,KAAK,MAAMR,aAAaM,SAAU;gBAI3BV;YAHL,MAAMgB,OAAY,aACb1C,eAAe0B;gBAClBiB,cAAcnF,sBACXkE,uBAAAA,MAAM,CAAC,eAAe,YAAtBA,uBAA0B,EAAE,EAC7BI,UAAUK,OAAO;eAEfL,UAAUjB,GAAG,KAAK2B,YAAYpC,UAAUsB,UAAU,CAAC;YAEzD,IAAID,MAAMF,QAAQ,EAAEmB,IAAI,CAAChE,uBAAuB,GAAG;gBAAE,CAAC+C,MAAME,IAAI,CAAC,EAAEpB;YAAS;YAC5EiB,OAAOoB,IAAI,CAAC;gBAAE/B,KAAKiB,UAAUjB,GAAG;gBAAEZ,OAAOyC;YAAK;QAChD;QACA,IAAIjB,MAAMF,QAAQ,EAAE;gBAC+BX;YAAjD,IAAI4B,WAAW7B,IAAI,CAACc,MAAME,IAAI,CAAC,GAAG3B,gBAAeY,WAAAA,QAAQa,MAAME,IAAI,aAAlBf,WAAuBc;QAC1E,OAAO;YACLf,IAAI,CAACc,MAAME,IAAI,CAAC,GAAG;YACnB,MAAMkB,UAAUzC,UAAUsB;YAC1B,IAAI,CAACc,aAAaM,OAAOC,IAAI,CAACF,SAAShB,IAAI,CAAC,CAAC1B,QAAUA,UAAU,4BAA4B;gBAC3Fc,kBAAkB;YACpB;QACF;IACF;IAEA,MAAM+B,QAAQ,IAAIC;IAClB,KAAK,MAAM7B,QAAQd,KAAKa,KAAK,CAAE;YAEL6B;QADxB,IAAI5B,KAAKC,IAAI,KAAK,UAAU,CAACZ,MAAM,CAACW,KAAKO,IAAI,CAAC,EAAE;QAChDqB,MAAME,GAAG,CAAC9B,KAAK+B,EAAE,EAAE;gBAAKH,aAAAA,MAAMI,GAAG,CAAChC,KAAK+B,EAAE,aAAjBH,aAAsB,EAAE;YAAG5B;SAAK;IAC1D;IACA,MAAMiC,QAAQ,CAAClB;QACb,MAAMmB,UAAUnB,QAAQ3C,GAAG,CAAC,CAACwC,SAAWtB,SAAS6C,OAAO,CAACvB,SAASpC,MAAM,CAAC,CAAC4D,QAAUA,SAAS;QAC7F,OAAOF,QAAQjB,MAAM,GAAGoB,KAAKC,GAAG,IAAIJ,WAAWK,OAAOC,iBAAiB;IACzE;IACA,KAAK,MAAM,CAACC,QAAQC,QAAQ,IAAId,MAAO;QACrC,MAAMzD,UAAU;eAAIuE;SAAQ,CAACC,IAAI,CAC/B,CAACC,GAAGC,IAAMZ,MAAMW,EAAEzB,WAAW,IAAIc,MAAMY,EAAE1B,WAAW,KAAMyB,CAAAA,EAAErC,IAAI,GAAGsC,EAAEtC,IAAI,GAAG,CAAC,IAAIqC,EAAErC,IAAI,GAAGsC,EAAEtC,IAAI,GAAG,IAAI,CAAA;QAEzG,IAAIuC,SAAStD,QAAQiD;QACrB,mEAAmE;QACnE,wCAAwC;QACxC,IAAItE,QAAQ8C,MAAM,GAAI6B,CAAAA,SAAS,IAAI,CAAA,KAAM,GAAGnD,YAAY;QACxD,KAAK,MAAMW,UAAUnC,QAAS;YAC5B,MAAMU,QAAQQ,MAAM,CAACiB,OAAOC,IAAI,CAAC;YACjCuC,SAASA,SAAS5G,kBAAkB4G,QAAQjE,SAAS,aAAKA;YAC1DU,IAAI,CAACe,OAAOC,IAAI,CAAC,GAAG;QACtB;QACA,IAAIuC,QAAQvD,IAAI,CAACkD,OAAO,GAAGK;IAC7B;IAEA,KAAK,MAAM,EAAErD,GAAG,EAAEZ,KAAK,EAAE,IAAIuB,OAAQ;QACnC,MAAM2C,WAAWvD,QAAQC;QACzB,MAAMuD,SAASD,WAAW7G,kBAAkB6G,UAAUlE,SAASA;QAC/D,wEAAwE;QACxE,mEAAmE;QACnE,MAAMoE,SAAS,aACVrF,MAAMmF,4BAAAA,QAAU,CAACzF,uBAAuB,GACxCM,MAAMiB,KAAK,CAACvB,uBAAuB;QAExC,IAAIoE,OAAOC,IAAI,CAACsB,QAAQhC,MAAM,EAAE+B,MAAM,CAAC1F,uBAAuB,GAAG2F;QACjE1D,IAAI,CAACE,IAAI,GAAGuD;QACZpD,UAAU;IACZ;IAEA,MAAM+B,OAAOD,OAAOC,IAAI,CAACpC;IACzB,IAAI,CAACoC,KAAKV,MAAM,EAAE,OAAO;IAEzB;;;;GAIC,GACD,MAAMiC,QAAQ,CAAC5C,SACb,IAAI9C,IACFkE,OAAOyB,MAAM,CAAC7C,QACXlC,GAAG,CAAC,CAACS,QAAWA,QAAQA,KAAK,CAAC,YAAY,GAAG,MAC7CL,MAAM,CAAC,CAACH,KAAqB,OAAOA,OAAO,YAAYA,OAAO;IAErE,MAAM+E,QAAoC,aAAK/D,QAAWE;IAC1D,MAAM8D,SAASH,MAAM7D;IACrB,MAAMiE,MAAMJ,MAAME;IAClB,MAAMG,SAASzF,MAAMC,OAAO,CAACE,OAAO,CAAC3B,0BAA0B,IAC3D,AAAC2B,OAAO,CAAC3B,0BAA0B,CAAekC,MAAM,CACtD,CAACH,KAAqB,OAAOA,OAAO,YAAYA,OAAO,MAEzD,EAAE;IACN,MAAMmF,gBAA8C,EAAE;IACtD,MAAMC,UAAU;WAAIJ;KAAO,CAAC7E,MAAM,CAAC,CAACH,KAAO,CAACiF,IAAII,GAAG,CAACrF;IACpD,MAAMsF,UAAU;WAAIL;KAAI,CAAC9E,MAAM,CAAC,CAACH,KAAO,CAACgF,OAAOK,GAAG,CAACrF;IACpD,KAAK,MAAMA,MAAMoF,QAAS,IAAIF,OAAO5E,QAAQ,CAACN,KAAKmF,cAAchC,IAAI,CAAC;QAAEoC,WAAWvF;QAAIwF,OAAO,CAAC;IAAE;IACjG,KAAK,MAAMxF,MAAMsF,QAAS,IAAI,CAACJ,OAAO5E,QAAQ,CAACN,KAAKmF,cAAchC,IAAI,CAAC;QAAEoC,WAAWvF;QAAIwF,OAAO;IAAE;IAEjG,MAAMC,QAAa,CAAC;IACpB,KAAK,MAAMrE,OAAOkC,KAAM;QACtBmC,KAAK,CAAC,GAAG3H,qBAAqB,CAAC,EAAEsD,KAAK,CAAC,GAAGF,IAAI,CAACE,IAAI,KAAK,OAAOzC,WAAW+G,MAAM,KAAKxE,IAAI,CAACE,IAAI;IAChG;IACA,IAAI+D,cAAcvC,MAAM,EAAE;QACxB6C,KAAK,CAACxH,0BAA0B,GAAG;eAC9BiH,OAAO/E,MAAM,CAAC,CAACH,KAAO,CAACoF,QAAQ9E,QAAQ,CAACN;eACxCsF,QAAQnF,MAAM,CAAC,CAACH,KAAO,CAACkF,OAAO5E,QAAQ,CAACN;SAC5C;IACH;IACAyF,KAAK,CAAC,YAAY,GAAG9G,WAAWgH,eAAe;IAC/C,OAAO;QAAEF;QAAOnE;QAAUC;QAAQC;QAAgB2D;IAAc;AAClE;AAEA;;;CAGC,GACD,OAAO,SAASS,iBACdhG,OAAY,EACZiG,KAA+C,EAC/C/E,QAAgB;IAEhB,MAAMgF,SAASvG,MAAMK,OAAO,CAACvB,gCAAgC;IAC7D,MAAM4D,SAAS1C,MAAMuG,MAAM,CAACD,MAAME,UAAU,CAAC;IAC7C,IAAI9D,MAAM,CAAC3D,wBAAwB,KAAK,OAAO,OAAO;IACtD,MAAM8F,SAAS0B,MAAM,CAACD,MAAMG,QAAQ,CAAC;IACrC,IAAIzG,MAAM6E,OAAO,CAAC9F,wBAAwB,KAAK,OAAO,OAAO;IAC7D,MAAM2H,QAAa;QACjB,CAAC3H,wBAAwB,EAAE;OACvB2D,MAAM,CAAC,uBAAuB,KAAKZ,YACnC;QAAE6E,sBAAsBjE,MAAM,CAAC,uBAAuB;IAAC,IACvD,CAAC,GACDA,MAAM,CAAC1D,+BAA+B,KAAK8C,YAC3C;QAAE,CAAC9C,+BAA+B,EAAE0D,MAAM,CAAC1D,+BAA+B;IAAC,IAC3E,CAAC;QACL4H,mBAAmBN,MAAME,UAAU;QACnCK,mBAAmBtF;OACfsD,UAAU,OAAOA,WAAW,WAAW;QAAEiC,iBAAiBjC;IAAO,IAAI,CAAC;IAE5E,OAAO;QACL,CAAC,GAAG/F,gCAAgC,CAAC,EAAEwH,MAAMG,QAAQ,EAAE,CAAC,EAAEC;QAC1DK,WAAW3H,WAAWgH,eAAe;IACvC;AACF;AAcA,SAASY,WAAW1G,GAAkB,EAAE2G,KAAsB;IAC5D,IAAI3G,KAAK;QACP,IAAI;YACF,MAAM4G,SAASC,KAAKC,KAAK,CAAC9G;YAC1B,IAAI4G,UAAWA,CAAAA,OAAOG,KAAK,KAAK,cAAcH,OAAOG,KAAK,KAAK,SAAQ,GAAI,OAAOH;QACpF,EAAE,eAAM;QACN,uEAAuE;QACvE,4BAA4B;QAC9B;IACF;IACA,OAAO;QAAEG,OAAOJ;QAAOX,OAAO;QAAGd,OAAO;IAAK;AAC/C;AAcA;;;;CAIC,GACD,eAAe8B,aACbC,SAAsC,EACtCC,OAAkB,EAClBC,SAA2E;IAE3E,IAAIC,UAAU;IACd,IAAIC,MAAMH;IACV,IAAK,IAAII,QAAQ,GAAGA,QAAQ,KAAKD,IAAItE,MAAM,EAAEuE,SAAS,EAAG;QACvD,MAAMC,SAASN,UAAUO,UAAU;QACnC,MAAMC,WAAWJ,IAAInH,GAAG,CAAC,CAACkG,QACxBmB,OACGG,MAAM,CAACtB,MAAMuB,QAAQ,CAACC,GAAG,EAAExB,MAAMR,KAAK,EAAE;gBACvCiC,gBAAgBzB,MAAMuB,QAAQ,CAACG,UAAU;YAC3C,GACCC,IAAI,CACH,IAAO,CAAA;oBAAE3B;oBAAO5G,OAAO;gBAAgB,CAAA,GACvC,CAACA,QAAoB,CAAA;oBAAE4G;oBAAO5G,KAAK,EAAEA,gBAAAA,QAAS,IAAIwI,MAAM;gBAAoB,CAAA;QAGlF,MAAMT,OAAOU,KAAK;QAClB,MAAMC,QAAmB,EAAE;QAC3B,KAAK,MAAM,EAAE9B,KAAK,EAAE5G,KAAK,EAAE,IAAI,CAAA,MAAM2I,QAAQC,GAAG,CAACX,SAAQ,EAAG;YAC1D,IAAIjI,UAAU,MAAM;gBAClB4H,WAAW;gBACX,OAAMhB,MAAMlB,KAAK,oBAAXkB,MAAMlB,KAAK,MAAXkB;gBACN;YACF;YACA,MAAM3G,OAAOF,OAAOC;YACpB,IAAIC,SAAS+B,aAAa,CAACnC,MAAMmG,GAAG,CAAC/F,OAAO,MAAMD;YAClD0I,MAAM5E,IAAI,CAAC8C;QACb;QACA,IAAI,CAAC8B,MAAMnF,MAAM,EAAE,OAAOqE;QAC1B,MAAMiB,QAAQ,MAAMpB,UAAUqB,MAAM,IAAIJ,MAAMhI,GAAG,CAAC,CAACkG,QAAUA,MAAMuB,QAAQ,CAACC,GAAG;QAC/EP,MAAMgB,MACH/H,MAAM,CAAC,CAACqH,WAAaA,SAASY,MAAM,EACpCrI,GAAG,CAACiH,WACJ7G,MAAM,CAAC,CAAC8F,QAA4BA,UAAU;IACnD;IACA,IAAIiB,IAAItE,MAAM,EAAE;QACd,MAAM,IAAIiF,MAAM,CAAC,MAAM,EAAEX,IAAItE,MAAM,CAAC,wDAAwD,CAAC;IAC/F;IACA,OAAOqE;AACT;AAEA;;;CAGC,GACD,OAAO,SAASoB,8BACdtH,UAA0C,CAAC,CAAC;QAGhCA;IADZ,MAAMuH,KAAK;YAAOvH;eAAD,EAACA,qBAAAA,QAAQ+F,SAAS,YAAjB/F,qBAAsB,IAAMvC,cAAc+J,GAAG,GAAGzB,SAAS;;IAC3E,MAAM7B,OAAMlE,eAAAA,QAAQkE,GAAG,YAAXlE,eAAeyH,KAAKvD,GAAG;IACnC,MAAMwD,aAAa,CAACC,QAAkBJ,KAAKK,UAAU,CAAC,QAAQC,GAAG,CAACF,OAAOC,UAAU,CAAC;IAEpF,eAAeE,QAAQC,KAA8B;QACnD,OAAO5E,OAAO,AAAC,CAAA,MAAM4E,MAAMC,KAAK,GAAGpF,GAAG,EAAC,EAAGqF,IAAI,GAAGD,KAAK,KAAK;IAC7D;IAEA,sCAAsC,GACtC,eAAeE,YACbC,OAAqC,EACrCC,MAAc,EACdC,MAA8B;YAgBRA;QAdtB,MAAMC,UAAUH,QAAQrI,IAAI,CAACwI,OAAO;QACpC,IAAIF,OAAOtD,KAAK,IAAIwD,QAAQzG,MAAM,EAAE,OAAO;QAC3C,MAAMiD,QAAQwD,OAAO,CAACF,OAAOtD,KAAK,CAAC;QACnC,IAAIiD,QAAQL,WAAWS,QAAQR,KAAK,EACjCY,KAAK,CAAC,GAAGjL,gCAAgC,CAAC,EAAEwH,MAAME,UAAU,CAAC,CAAC,EAAEzH,yBAAyB,EAAE,MAAM,OACjGiL,OAAO,CAAC7K,UAAU8K,UAAU,IAC5BC,KAAK,CAAC5K;QACT,IAAIsK,OAAOpE,KAAK,EAAE+D,QAAQA,MAAMY,UAAU,CAACP,OAAOpE,KAAK;QACvD,MAAM4E,OAAO,MAAMb,MAAMnF,GAAG;QAC5B,MAAM9C,OAAO,CAAC2G;gBACoBA;YAAhC,MAAM/B,QAAQG,kBAAkB4B,iBAAAA,SAASwB,IAAI,cAAbxB,iBAAmB,CAAC,GAAW3B,OAAOqD,QAAQpI,QAAQ;YACtF,OAAO2E,QAAQ;gBAAE+B;gBAAU/B;YAAM,IAAI;QACvC;QACA,MAAMsB,UAAU4C,KAAKC,IAAI,CAAC7J,GAAG,CAACc,MAAMV,MAAM,CAAC,CAAC8F,QAA4BA,UAAU;QAClFmD,MAAM,CAAC,WAAW,GAAG,EAACA,mBAAAA,MAAM,CAAC,WAAW,YAAlBA,mBAAsB,KACzCF,CAAAA,QAAQW,MAAM,GAAG9C,QAAQnE,MAAM,GAAG,MAAMiE,aAAayB,MAAMvB,SAASlG,KAAI;QAC3E,IAAI8I,KAAKG,IAAI,GAAGjL,cAAc,OAAO;YAAE+H,OAAO;YAAYf,OAAOsD,OAAOtD,KAAK,GAAG;YAAGd,OAAO;QAAK;QAC/F,OAAO;YAAE6B,OAAO;YAAYf,OAAOsD,OAAOtD,KAAK;YAAEd,OAAO4E,KAAKC,IAAI,CAACD,KAAKC,IAAI,CAAChH,MAAM,GAAG,EAAE,CAAC5C,EAAE;QAAC;IAC7F;IAEA,oDAAoD,GACpD,eAAe+J,WACbb,OAAqC,EACrCC,MAAc,EACdC,MAA8B;YA+CRA,kBACFA,gBACQA;QA/C5B,IAAIN,QAAQL,WAAWS,QAAQR,KAAK,EAAEa,OAAO,CAAC7K,UAAU8K,UAAU,IAAIC,KAAK,CAAC3K;QAC5E,IAAIqK,OAAOpE,KAAK,EAAE+D,QAAQA,MAAMY,UAAU,CAACP,OAAOpE,KAAK;QACvD,MAAM4E,OAAO,MAAMb,MAAMnF,GAAG;QAC5B,MAAMqG,YAAY1B,KAAKK,UAAU,CAAC,QAAQC,GAAG,CAACM,QAAQR,KAAK,EAAEC,UAAU,CAACzK,gBAAgB8L,SAAS;QACjG,MAAMnI,iBAAiBqH,QAAQe,KAAK,KAAK;QACzC,MAAMC,QAAQ;YAAE5I,UAAU;YAAGC,QAAQ;YAAGC,gBAAgB;QAAE;QAC1D,MAAMuH,QAAQ,CAACoB;YACbD,MAAM5I,QAAQ,IAAI6I,QAAQ7I,QAAQ;YAClC4I,MAAM3I,MAAM,IAAI4I,QAAQ5I,MAAM;YAC9B2I,MAAM1I,cAAc,IAAI2I,QAAQ3I,cAAc;QAChD;QACA,MAAMX,OAAO,CAAC2G;gBACuBA;YAAnC,MAAM2C,UAAUvJ,mBAAmB4G,iBAAAA,SAASwB,IAAI,cAAbxB,iBAAmB,CAAC,GAAW0B,QAAQrI,IAAI,EAAEqI,QAAQpI,QAAQ,EAAE;gBAChGe;YACF;YACA,IAAI,CAACsI,SAAS,OAAO;YACrB,OAAO;gBACL3C;gBACA2C;gBACA1E,OAAO0E,QAAQ1E,KAAK;gBACpBV,OAAO;oBACLgE,MAAMoB;oBACN,+DAA+D;oBAC/D,8BAA8B;oBAC9B,MAAM1L,uBAAuB+I,SAASC,GAAG,EAAE;oBAC3C,oEAAoE;oBACpE,oDAAoD;oBACpD,MAAMO,QAAQC,GAAG,CACfkC,QAAQhF,aAAa,CAACpF,GAAG,CAAC,CAACqK,SACzBJ,UACGpB,GAAG,CAACwB,OAAO7E,SAAS,EACpBgC,MAAM,CAAC;4BAAE,CAACvJ,6BAA6B,EAAEW,WAAW0L,SAAS,CAACD,OAAO5E,KAAK;wBAAE,GAC5E8E,KAAK,CAAC,CAACjL;4BACNkL,QAAQlL,KAAK,CAAC,+CAA+C+K,OAAO7E,SAAS,EAAElG;wBACjF;gBAGR;YACF;QACF;QACA,MAAM0H,UAAU4C,KAAKC,IAAI,CACtB7J,GAAG,CAACc,MACJV,MAAM,CAAC,CAAC8F,QAAuDA,UAAU;QAC5E,IAAIiD,QAAQW,MAAM,EAAE,KAAK,MAAM5D,SAASc,QAASgC,MAAM9C,MAAMkE,OAAO;aAC/D,MAAMtD,aAAayB,MAAMvB,SAASlG;QACvCuI,MAAM,CAAC,WAAW,GAAG,EAACA,mBAAAA,MAAM,CAAC,WAAW,YAAlBA,mBAAsB,KAAKc,MAAM5I,QAAQ;QAC/D8H,MAAM,CAAC,SAAS,GAAG,EAACA,iBAAAA,MAAM,CAAC,SAAS,YAAhBA,iBAAoB,KAAKc,MAAM3I,MAAM;QACzD6H,MAAM,CAAC,iBAAiB,GAAG,EAACA,yBAAAA,MAAM,CAAC,iBAAiB,YAAxBA,yBAA4B,KAAKc,MAAM1I,cAAc;QACjF,IAAImI,KAAKG,IAAI,GAAGhL,aAAa,OAAO;QACpC,OAAO;YAAE8H,OAAO;YAAWf,OAAO;YAAGd,OAAO4E,KAAKC,IAAI,CAACD,KAAKC,IAAI,CAAChH,MAAM,GAAG,EAAE,CAAC5C,EAAE;QAAC;IACjF;IAEA,OAAO;QACL,MAAMwK,KAAItB,OAAO;YACf,MAAME,SAAiC,CAAC;YACxC,MAAMqB,iBAAiBvB,QAAQrI,IAAI,CAACa,KAAK,CAACkB,MAAM,GAAG;YACnD,kEAAkE;YAClE,IAAIuG,SAAwB5C,WAC1B2C,QAAQC,MAAM,EACdD,QAAQe,KAAK,KAAK,WAAW,YAAY;YAE3C,MAAOd,UAAUlE,QAAQiE,QAAQwB,UAAU,CAAE;gBAC3C,IAAIvB,OAAOvC,KAAK,KAAK,YAAY;oBAC/B,MAAM1F,OAAsB,MAAM+H,YAAYC,SAASC,QAAQC;oBAC/DD,SACEjI,eAAAA,OACCgI,QAAQe,KAAK,KAAK,WAAWQ,iBAC1B;wBAAE7D,OAAO;wBAAWf,OAAO;wBAAGd,OAAO;oBAAK,IAC1C;oBACN;gBACF;gBACAoE,SAASsB,iBAAiB,MAAMV,WAAWb,SAASC,QAAQC,UAAU;YACxE;YACA,OAAO;gBAAEuB,MAAMxB,WAAW;gBAAMA,QAAQA,SAASzC,KAAKkE,SAAS,CAACzB,UAAU;gBAAMC;YAAO;QACzF;QAEA,MAAMyB,SAAQ,EAAEnC,KAAK,EAAE7H,IAAI,EAAE;YAC3B,MAAMiK,WAAWrC,WAAWC;YAC5B,MAAMqC,QAAyC,EAAE;YACjD,MAAMxH,QAAQ1C,KAAKa,KAAK,CAACvB,MAAM,CAAC,CAACwB,OAAyCA,KAAKC,IAAI,KAAK;YACxF,MAAMH,SAASZ,KAAKa,KAAK,CAACvB,MAAM,CAAC,CAACwB,OAA0CA,KAAKC,IAAI,KAAK;YAC1F,MAAMoJ,SAAS,CAACjC,QAA0BA,UAAU,IAAI,WAAW;YACnE,MAAMkC,SAAS,CAAClC,QAA0BA,UAAU,OAAO,SAAS9I,OAAO8I;YAE3E;;;;;OAKC,GACD,IAAIxF,MAAMX,MAAM,EAAE;gBAChB,MAAMsI,OAAO,IAAI1H;gBACjB,IAAI2H,UAAU;gBACd,KAAK,MAAMC,QAAQ7H,MAAO;wBAMuBqF;oBAL/C,MAAMe,OAAO,MAAMmB,SAChBxB,KAAK,CAAC,GAAGxL,qBAAqB,CAAC,EAAEsN,KAAKlJ,IAAI,EAAE,EAAE,MAAM,MACpDuH,KAAK,CAAC1K,qBAAqB,GAC3B4E,GAAG;oBACN,IAAIgG,KAAKG,IAAI,GAAG/K,oBAAoBoM,UAAU;oBAC9C,KAAK,MAAMvC,OAAOe,KAAKC,IAAI,CAAEsB,KAAKzH,GAAG,CAACmF,IAAI5I,EAAE,GAAG4I,YAAAA,IAAII,IAAI,cAARJ,YAAc,CAAC;gBAChE;gBACA,MAAMyC,UAAUF,UACZ;uBAAID,KAAKpG,MAAM;iBAAG,CAACwG,MAAM,CACvB,CAACC,OAAO3L;;wBACGgB;2BAAT2K,kBAAS3K,qBAAAA,kBAAkBhB,SAAS;wBAAE8B,OAAO6B;oBAAM,GAAG,wBAA7C3C,mBAAkDU,QAAQ,mBAAI;mBACzE,KAEF;gBACJyJ,MAAM5H,IAAI,CAAC;oBACTnD,IAAI;oBACJwL,MAAM,CAAC,4EAA4E,EAAEP,OAAOI,SAAS,CAAC,EAAEL,OAAOK,SAAS,CAAC,CAAC;oBAC1HtC,OAAOsC;oBACPI,UAAU;gBACZ;YACF;YAEA,IAAIhK,OAAOmB,MAAM,EAAE;gBACjB;;;;;SAKC,GACD,IAAIK,OAAO;gBACX,KAAK,MAAMjB,SAASP,OAAQ;oBAC1B,IAAI,CAACO,MAAMF,QAAQ,EAAE;wBACnBmB,QAAQ,MAAM4F,QAAQiC,SAASxB,KAAK,CAAC,GAAGxL,qBAAqB,CAAC,EAAEkE,MAAME,IAAI,EAAE,EAAE,MAAM;wBACpF;oBACF;oBACA,MAAMwJ,UAAU1J,MAAMG,UAAU,CAACwJ,OAAO,CAAC,CAACtJ,YAAcA,UAAUK,OAAO;oBACzE,IAAK,IAAIqB,QAAQ,GAAGA,QAAQ2H,QAAQ9I,MAAM,EAAEmB,SAAS,GAAI;wBACvDd,QAAQ,MAAM4F,QACZiC,SAASxB,KAAK,CAACnL,wBAAwB,sBAAsBuN,QAAQE,KAAK,CAAC7H,OAAOA,QAAQ;oBAE9F;gBACF;gBACAgH,MAAM5H,IAAI,CAAC;oBACTnD,IAAI;oBACJwL,MAAM,CAAC,2DAA2D,EAAEvI,KAAK,CAAC,EAAE+H,OAAO/H,MAAM,qBAAqB,CAAC;oBAC/G8F,OAAO9F;oBACPwI,UAAU;gBACZ;gBAEA;;;;SAIC,GACD,IAAII,SAAS;gBACb,IAAIC,UAAU;gBACd,KAAK,MAAM9J,SAASP,OAAQ;oBAC1B,KAAK,MAAMc,UAAUP,MAAMG,UAAU,CAACwJ,OAAO,CAAC,CAACtJ,YAAcA,UAAUK,OAAO,EAAG;wBAC/EmJ,UAAU,MAAMhD,QACdiC,SACGxB,KAAK,CAAC,GAAGjL,gCAAgC,CAAC,EAAEkE,OAAO,CAAC,EAAEnE,wBAAwB,EAAE,MAAM4D,MAAME,IAAI,EAChGoH,KAAK,CAAC,GAAGjL,gCAAgC,CAAC,EAAEkE,OAAO,CAAC,EAAEjE,yBAAyB,EAAE,MAAM;wBAE5F,MAAM4M,OAAO,MAAMrC,QAAQiC,SAASxB,KAAK,CAAC,aAAa,kBAAkB,CAAC,KAAK,EAAE/G,QAAQ;wBACzF,MAAMwJ,MAAM,MAAMlD,QAAQiC,SAASxB,KAAK,CAACnL,wBAAwB,kBAAkBoE;wBACnFuJ,WAAW9H,KAAKgI,GAAG,CAAC,GAAGd,OAAOa;oBAChC;gBACF;gBACAhB,MAAM5H,IAAI,CAAC;oBACTnD,IAAI;oBACJwL,MAAM,CAAC,yCAAyC,EAAEK,OAAO,CAAC,EAAEb,OAAOa,QAAQ,uDAAuD,CAAC;oBACnI9C,OAAO8C;oBACPJ,UAAU;gBACZ;gBACAV,MAAM5H,IAAI,CAAC;oBACTnD,IAAI;oBACJwL,MAAM,CAAC,oCAAoC,EAAEM,QAAQ,CAAC,EAAEd,OAAOc,SAAS,0EAA0E,CAAC;oBACnJ/C,OAAO+C;oBACPL,UAAU;gBACZ;gBAEA;;;;SAIC,GACD,MAAMQ,aAAaxK,OAAOtB,MAAM,CAAC,CAAC6B,QAAU,CAACA,MAAMF,QAAQ;gBAC3D,IAAImK,WAAWrJ,MAAM,EAAE;oBACrB,IAAIQ,UAAyB;oBAC7B,KAAK,MAAMpB,SAASiK,WAAY;wBAC9B,MAAMtC,OAAO,MAAMmB,SAChBxB,KAAK,CAAC,GAAGxL,qBAAqB,CAAC,EAAEkE,MAAME,IAAI,CAAC,YAAY,CAAC,EAAE,KAAK,GAChEuH,KAAK,CAAC1K,qBAAqB,GAC3B4E,GAAG;wBACN,IAAIgG,KAAKG,IAAI,GAAG/K,sBAAsBqE,YAAY,MAAM;4BACtDA,UAAU;4BACV;wBACF;wBACA,KAAK,MAAMwF,OAAOe,KAAKC,IAAI,CAAE;sCAENhB;gCAAnBhI;4BADFwC,oBACExC,qBAAAA,mBAAmBgI,aAAAA,IAAII,IAAI,cAARJ,aAAc,CAAC,GAAW;gCAAElH,OAAO;oCAACM;iCAAM;4BAAC,GAAG,wBAAjEpB,mBAAsEY,cAAc,mBAAI;wBAC5F;oBACF;oBACAuJ,MAAM5H,IAAI,CAAC;wBACTnD,IAAI;wBACJwL,MAAM,CAAC,iBAAiB,EAAEP,OAAO7H,SAAS,CAAC,EAAE4H,OAAO5H,SAAS,+EAA+E,CAAC;wBAC7I2F,OAAO3F;wBACPqI,UAAU;oBACZ;gBACF;YACF;YAEA;;;OAGC,GACD,IAAI5K,KAAKwI,OAAO,CAACzG,MAAM,EAAE;gBACvB,IAAIsJ,WAAW;gBACf,KAAK,MAAMrG,SAAShF,KAAKwI,OAAO,CAAE;oBAChC,MAAM8C,WAAW,CAAC5J,SAChB,GAAGlE,gCAAgC,CAAC,EAAEkE,OAAO,CAAC,EAAEjE,yBAAyB;oBAC3E,MAAM4D,OAAO4I,SAASxB,KAAK,CAAC6C,SAAStG,MAAME,UAAU,GAAG,MAAM;oBAC9DmG,YAAYlI,KAAKgI,GAAG,CAClB,GACA,AAAC,MAAMnD,QAAQ3G,QAAU,MAAM2G,QAAQ3G,KAAKoH,KAAK,CAAC6C,SAAStG,MAAMG,QAAQ,GAAG,MAAM;gBAEtF;gBACA+E,MAAM5H,IAAI,CAAC;oBACTnD,IAAI;oBACJwL,MAAM,GAAGU,SAAS,UAAU,EAAEA,aAAa,IAAI,YAAY,WAAW,2CAA2C,CAAC;oBAClHnD,OAAOmD;oBACPT,UAAU;gBACZ;YACF;YACA,OAAOV;QACT;QAEAqB,WAAWxN;IACb;AACF;AAEA,4EAA4E,GAC5E,OAAO,MAAMyN,0BAA0BhE,gCAA+B"}
|
|
@@ -0,0 +1,33 @@
|
|
|
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 { PluginRecordEmailEvent } from '@aglyn/aglyn/plugin-manager/plugin-record-email-state';
|
|
18
|
+
/** The facet field the stamp lives under. */
|
|
19
|
+
export declare const CONTACT_EMAIL_ENGAGEMENT_FIELD = "lastEmailEngagementAtMs";
|
|
20
|
+
/**
|
|
21
|
+
* Stamps the sending site's contact facet for every person these events
|
|
22
|
+
* say engaged for the first time with a message — the CRM's answer on the
|
|
23
|
+
* core's record email-state seam (`stampRecordEmailEngagement`).
|
|
24
|
+
*
|
|
25
|
+
* @param hostId the site the campaign went out from — the `hostId` tag the
|
|
26
|
+
* send stamped, which is the only tenant identity a delivery event carries.
|
|
27
|
+
* @returns how many contact documents were written.
|
|
28
|
+
*/
|
|
29
|
+
export declare function recordContactEmailEngagement(args: {
|
|
30
|
+
hostId: string;
|
|
31
|
+
events: readonly PluginRecordEmailEvent[];
|
|
32
|
+
firestore?: any;
|
|
33
|
+
}): Promise<number>;
|
|
@@ -0,0 +1,146 @@
|
|
|
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
|
+
* THE CONTACT'S OWN ENGAGEMENT STAMP (AGL-2616):
|
|
18
|
+
* `facets.{groupId}.lastEmailEngagementAtMs` on `orgs/{orgId}/contacts`.
|
|
19
|
+
*
|
|
20
|
+
* ## What it answers
|
|
21
|
+
*
|
|
22
|
+
* "When did this person last read one of OUR campaigns?" — on the record
|
|
23
|
+
* page as "Last engaged", as a column on the list, and as the audience rule
|
|
24
|
+
* `engagedWithinDays`. The per-person rollup on `emailDeliveries/{key}`
|
|
25
|
+
* already answers "when did this address last read anything from anybody",
|
|
26
|
+
* which is the right question for a sunset that refuses to mail cold
|
|
27
|
+
* addresses and the wrong one for a re-engagement audience: it moves when
|
|
28
|
+
* the person opens a receipt, an invite, or a sibling business's newsletter
|
|
29
|
+
* on the same shared row. This stamp moves only when they open or click a
|
|
30
|
+
* campaign this holder's own sites sent.
|
|
31
|
+
*
|
|
32
|
+
* ## What one webhook event costs
|
|
33
|
+
*
|
|
34
|
+
* The same bound the rollup keeps: only an event that is the FIRST of its
|
|
35
|
+
* type for its message reaches here, which `recordEmailDeliveryEvent`'s
|
|
36
|
+
* transaction already decided. A reader opening one newsletter six times is
|
|
37
|
+
* one transaction, and a replayed event finds its type already recorded and
|
|
38
|
+
* contributes nothing. Inside that bound the cost per person is one org
|
|
39
|
+
* resolution and one group resolution per batch, then one keyed query and
|
|
40
|
+
* at most one update per person.
|
|
41
|
+
*
|
|
42
|
+
* ## Forward-only, in a transaction
|
|
43
|
+
*
|
|
44
|
+
* Provider events are unordered and at-least-once, so an old instant
|
|
45
|
+
* arriving late must not overwrite a fresh stamp — the same reasoning
|
|
46
|
+
* `recordPersonEngagement` gives. The query for the contact rides inside
|
|
47
|
+
* the transaction so the compare-and-write is atomic against a second event
|
|
48
|
+
* for the same person landing in another instance.
|
|
49
|
+
*
|
|
50
|
+
* ## Only a contact this site may see, and only this holder's facet
|
|
51
|
+
*
|
|
52
|
+
* The contact row is shared by every site in the org. The stamp is written
|
|
53
|
+
* under the sending site's consent group, never at the top of the document,
|
|
54
|
+
* and only onto a row whose `visibleTo` admits the site — a person another
|
|
55
|
+
* holder captured and this site never met is not this site's contact, and a
|
|
56
|
+
* write onto their row would mint a facet for a holder that does not hold
|
|
57
|
+
* them. `updatedAt` is left alone: an open is something the person did, not
|
|
58
|
+
* an edit the team made, and a list sorted on recency must not reshuffle on
|
|
59
|
+
* every mailbox prefetch.
|
|
60
|
+
*
|
|
61
|
+
* ## Never throws
|
|
62
|
+
*
|
|
63
|
+
* Best-effort for the reason everything on the webhook path is: a stamp that
|
|
64
|
+
* failed loses a fact a page can live without, and a stamp that threw would
|
|
65
|
+
* lose the provider's acknowledgement and teach it to retry the whole event.
|
|
66
|
+
*/ import { contactFacetPath, normalizeContactEmail, readContactFacet, visibleToHost } from "@aglyn/aglyn/server";
|
|
67
|
+
import { findContactByEmail } from "@aglyn/tenant-data-admin/server/contact-email-index";
|
|
68
|
+
import { firebaseAdmin } from "@aglyn/tenant-data-admin/server/firebase-admin";
|
|
69
|
+
import { consentGroupForSite, orgDataCollectionForHost } from "@aglyn/tenant-data-admin/server/organizations";
|
|
70
|
+
/** The facet field the stamp lives under. */ export const CONTACT_EMAIL_ENGAGEMENT_FIELD = 'lastEmailEngagementAtMs';
|
|
71
|
+
/** The event types that count as a person engaging with a campaign. */ const ENGAGEMENT_TYPES = new Set([
|
|
72
|
+
'opened',
|
|
73
|
+
'clicked'
|
|
74
|
+
]);
|
|
75
|
+
/**
|
|
76
|
+
* Stamps the sending site's contact facet for every person these events
|
|
77
|
+
* say engaged for the first time with a message — the CRM's answer on the
|
|
78
|
+
* core's record email-state seam (`stampRecordEmailEngagement`).
|
|
79
|
+
*
|
|
80
|
+
* @param hostId the site the campaign went out from — the `hostId` tag the
|
|
81
|
+
* send stamped, which is the only tenant identity a delivery event carries.
|
|
82
|
+
* @returns how many contact documents were written.
|
|
83
|
+
*/ export async function recordContactEmailEngagement(args) {
|
|
84
|
+
var _args_hostId, _args_firestore;
|
|
85
|
+
const hostId = String((_args_hostId = args.hostId) != null ? _args_hostId : '');
|
|
86
|
+
if (!hostId) return 0;
|
|
87
|
+
/** Address → the newest engagement instant in this batch. */ const byEmail = new Map();
|
|
88
|
+
for (const outcome of args.events){
|
|
89
|
+
var _byEmail_get;
|
|
90
|
+
if (!outcome.firstOfType) continue;
|
|
91
|
+
if (!ENGAGEMENT_TYPES.has(outcome.type)) continue;
|
|
92
|
+
const email = normalizeContactEmail(outcome.to);
|
|
93
|
+
const at = Number(outcome.at);
|
|
94
|
+
if (!email || !Number.isFinite(at) || at <= 0) continue;
|
|
95
|
+
byEmail.set(email, Math.max((_byEmail_get = byEmail.get(email)) != null ? _byEmail_get : 0, at));
|
|
96
|
+
}
|
|
97
|
+
if (!byEmail.size) return 0;
|
|
98
|
+
let contactsRef;
|
|
99
|
+
let groupId;
|
|
100
|
+
try {
|
|
101
|
+
contactsRef = await orgDataCollectionForHost(hostId, 'contacts');
|
|
102
|
+
groupId = (await consentGroupForSite(hostId)).groupId;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
console.error('[contact-email-engagement] site could not be resolved', hostId, error);
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
const db = (_args_firestore = args.firestore) != null ? _args_firestore : firebaseAdmin.app().firestore();
|
|
108
|
+
const field = contactFacetPath(groupId, CONTACT_EMAIL_ENGAGEMENT_FIELD);
|
|
109
|
+
let written = 0;
|
|
110
|
+
for (const [email, at] of byEmail){
|
|
111
|
+
try {
|
|
112
|
+
await db.runTransaction(async (transaction)=>{
|
|
113
|
+
var _snapshot_data, _readContactFacet_CONTACT_EMAIL_ENGAGEMENT_FIELD;
|
|
114
|
+
/*
|
|
115
|
+
* The same unscoped lookup the capture door makes — one human is one
|
|
116
|
+
* row whichever site met them, and through the address index
|
|
117
|
+
* (AGL-2633) the row is found under an address a merge folded into
|
|
118
|
+
* it too — followed by the scope check the capture door's
|
|
119
|
+
* `visibleTo` write is the source of. Read THROUGH the transaction:
|
|
120
|
+
* the stamp below is a compare-and-set against the instant this
|
|
121
|
+
* read saw.
|
|
122
|
+
*/ const snapshot = await findContactByEmail(contactsRef, email, {
|
|
123
|
+
transaction
|
|
124
|
+
});
|
|
125
|
+
if (!snapshot) return;
|
|
126
|
+
const data = (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {};
|
|
127
|
+
if (!visibleToHost(data['visibleTo'], hostId)) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const stored = Number((_readContactFacet_CONTACT_EMAIL_ENGAGEMENT_FIELD = readContactFacet(data, groupId)[CONTACT_EMAIL_ENGAGEMENT_FIELD]) != null ? _readContactFacet_CONTACT_EMAIL_ENGAGEMENT_FIELD : 0);
|
|
131
|
+
// Nothing moved forward, so nothing is written — the out-of-order
|
|
132
|
+
// and the replayed event are the ordinary cases this skips.
|
|
133
|
+
if (Number.isFinite(stored) && stored >= at) return;
|
|
134
|
+
transaction.update(snapshot.ref, {
|
|
135
|
+
[field]: at
|
|
136
|
+
});
|
|
137
|
+
written += 1;
|
|
138
|
+
});
|
|
139
|
+
} catch (error) {
|
|
140
|
+
console.error('[contact-email-engagement] stamp failed', hostId, error);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return written;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
//# sourceMappingURL=contact-email-engagement.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/contact-email-engagement.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 CONTACT'S OWN ENGAGEMENT STAMP (AGL-2616):\n * `facets.{groupId}.lastEmailEngagementAtMs` on `orgs/{orgId}/contacts`.\n *\n * ## What it answers\n *\n * \"When did this person last read one of OUR campaigns?\" — on the record\n * page as \"Last engaged\", as a column on the list, and as the audience rule\n * `engagedWithinDays`. The per-person rollup on `emailDeliveries/{key}`\n * already answers \"when did this address last read anything from anybody\",\n * which is the right question for a sunset that refuses to mail cold\n * addresses and the wrong one for a re-engagement audience: it moves when\n * the person opens a receipt, an invite, or a sibling business's newsletter\n * on the same shared row. This stamp moves only when they open or click a\n * campaign this holder's own sites sent.\n *\n * ## What one webhook event costs\n *\n * The same bound the rollup keeps: only an event that is the FIRST of its\n * type for its message reaches here, which `recordEmailDeliveryEvent`'s\n * transaction already decided. A reader opening one newsletter six times is\n * one transaction, and a replayed event finds its type already recorded and\n * contributes nothing. Inside that bound the cost per person is one org\n * resolution and one group resolution per batch, then one keyed query and\n * at most one update per person.\n *\n * ## Forward-only, in a transaction\n *\n * Provider events are unordered and at-least-once, so an old instant\n * arriving late must not overwrite a fresh stamp — the same reasoning\n * `recordPersonEngagement` gives. The query for the contact rides inside\n * the transaction so the compare-and-write is atomic against a second event\n * for the same person landing in another instance.\n *\n * ## Only a contact this site may see, and only this holder's facet\n *\n * The contact row is shared by every site in the org. The stamp is written\n * under the sending site's consent group, never at the top of the document,\n * and only onto a row whose `visibleTo` admits the site — a person another\n * holder captured and this site never met is not this site's contact, and a\n * write onto their row would mint a facet for a holder that does not hold\n * them. `updatedAt` is left alone: an open is something the person did, not\n * an edit the team made, and a list sorted on recency must not reshuffle on\n * every mailbox prefetch.\n *\n * ## Never throws\n *\n * Best-effort for the reason everything on the webhook path is: a stamp that\n * failed loses a fact a page can live without, and a stamp that threw would\n * lose the provider's acknowledgement and teach it to retry the whole event.\n */\n\nimport {\n contactFacetPath,\n normalizeContactEmail,\n readContactFacet,\n visibleToHost,\n} from '@aglyn/aglyn/server'\nimport type { PluginRecordEmailEvent } from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\nimport { findContactByEmail } from '@aglyn/tenant-data-admin/server/contact-email-index'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport {\n consentGroupForSite,\n orgDataCollectionForHost,\n} from '@aglyn/tenant-data-admin/server/organizations'\n\n/** The facet field the stamp lives under. */\nexport const CONTACT_EMAIL_ENGAGEMENT_FIELD = 'lastEmailEngagementAtMs'\n\n/** The event types that count as a person engaging with a campaign. */\nconst ENGAGEMENT_TYPES: ReadonlySet<string> = new Set([\n 'opened',\n 'clicked',\n])\n\n/**\n * Stamps the sending site's contact facet for every person these events\n * say engaged for the first time with a message — the CRM's answer on the\n * core's record email-state seam (`stampRecordEmailEngagement`).\n *\n * @param hostId the site the campaign went out from — the `hostId` tag the\n * send stamped, which is the only tenant identity a delivery event carries.\n * @returns how many contact documents were written.\n */\nexport async function recordContactEmailEngagement(args: {\n hostId: string\n events: readonly PluginRecordEmailEvent[]\n firestore?: any\n}): Promise<number> {\n const hostId = String(args.hostId ?? '')\n if (!hostId) return 0\n\n /** Address → the newest engagement instant in this batch. */\n const byEmail = new Map<string, number>()\n for (const outcome of args.events) {\n if (!outcome.firstOfType) continue\n if (!ENGAGEMENT_TYPES.has(outcome.type)) continue\n const email = normalizeContactEmail(outcome.to)\n const at = Number(outcome.at)\n if (!email || !Number.isFinite(at) || at <= 0) continue\n byEmail.set(email, Math.max(byEmail.get(email) ?? 0, at))\n }\n if (!byEmail.size) return 0\n\n let contactsRef: FirebaseFirestore.CollectionReference\n let groupId: string\n try {\n contactsRef = await orgDataCollectionForHost(hostId, 'contacts')\n groupId = (await consentGroupForSite(hostId)).groupId\n } catch (error) {\n console.error('[contact-email-engagement] site could not be resolved', hostId, error)\n return 0\n }\n\n const db = args.firestore ?? firebaseAdmin.app().firestore()\n const field = contactFacetPath(groupId, CONTACT_EMAIL_ENGAGEMENT_FIELD)\n let written = 0\n for (const [email, at] of byEmail) {\n try {\n await db.runTransaction(async (transaction: any) => {\n /*\n * The same unscoped lookup the capture door makes — one human is one\n * row whichever site met them, and through the address index\n * (AGL-2633) the row is found under an address a merge folded into\n * it too — followed by the scope check the capture door's\n * `visibleTo` write is the source of. Read THROUGH the transaction:\n * the stamp below is a compare-and-set against the instant this\n * read saw.\n */\n const snapshot = await findContactByEmail(contactsRef, email, { transaction })\n if (!snapshot) return\n const data = (snapshot.data() ?? {}) as Record<string, unknown>\n if (!visibleToHost(data['visibleTo'] as string[] | undefined, hostId)) {\n return\n }\n const stored = Number(\n readContactFacet(data, groupId)[CONTACT_EMAIL_ENGAGEMENT_FIELD] ?? 0,\n )\n // Nothing moved forward, so nothing is written — the out-of-order\n // and the replayed event are the ordinary cases this skips.\n if (Number.isFinite(stored) && stored >= at) return\n transaction.update(snapshot.ref, { [field]: at })\n written += 1\n })\n } catch (error) {\n console.error('[contact-email-engagement] stamp failed', hostId, error)\n }\n }\n return written\n}\n"],"names":["contactFacetPath","normalizeContactEmail","readContactFacet","visibleToHost","findContactByEmail","firebaseAdmin","consentGroupForSite","orgDataCollectionForHost","CONTACT_EMAIL_ENGAGEMENT_FIELD","ENGAGEMENT_TYPES","Set","recordContactEmailEngagement","args","hostId","String","byEmail","Map","outcome","events","firstOfType","has","type","email","to","at","Number","isFinite","set","Math","max","get","size","contactsRef","groupId","error","console","db","firestore","app","field","written","runTransaction","transaction","snapshot","data","stored","update","ref"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkDC,GAED,SACEA,gBAAgB,EAChBC,qBAAqB,EACrBC,gBAAgB,EAChBC,aAAa,QACR,sBAAqB;AAE5B,SAASC,kBAAkB,QAAQ,sDAAqD;AACxF,SAASC,aAAa,QAAQ,iDAAgD;AAC9E,SACEC,mBAAmB,EACnBC,wBAAwB,QACnB,gDAA+C;AAEtD,2CAA2C,GAC3C,OAAO,MAAMC,iCAAiC,0BAAyB;AAEvE,qEAAqE,GACrE,MAAMC,mBAAwC,IAAIC,IAAI;IACpD;IACA;CACD;AAED;;;;;;;;CAQC,GACD,OAAO,eAAeC,6BAA6BC,IAIlD;QACuBA,cAyBXA;IAzBX,MAAMC,SAASC,QAAOF,eAAAA,KAAKC,MAAM,YAAXD,eAAe;IACrC,IAAI,CAACC,QAAQ,OAAO;IAEpB,2DAA2D,GAC3D,MAAME,UAAU,IAAIC;IACpB,KAAK,MAAMC,WAAWL,KAAKM,MAAM,CAAE;YAMLH;QAL5B,IAAI,CAACE,QAAQE,WAAW,EAAE;QAC1B,IAAI,CAACV,iBAAiBW,GAAG,CAACH,QAAQI,IAAI,GAAG;QACzC,MAAMC,QAAQrB,sBAAsBgB,QAAQM,EAAE;QAC9C,MAAMC,KAAKC,OAAOR,QAAQO,EAAE;QAC5B,IAAI,CAACF,SAAS,CAACG,OAAOC,QAAQ,CAACF,OAAOA,MAAM,GAAG;QAC/CT,QAAQY,GAAG,CAACL,OAAOM,KAAKC,GAAG,EAACd,eAAAA,QAAQe,GAAG,CAACR,kBAAZP,eAAsB,GAAGS;IACvD;IACA,IAAI,CAACT,QAAQgB,IAAI,EAAE,OAAO;IAE1B,IAAIC;IACJ,IAAIC;IACJ,IAAI;QACFD,cAAc,MAAMzB,yBAAyBM,QAAQ;QACrDoB,UAAU,AAAC,CAAA,MAAM3B,oBAAoBO,OAAM,EAAGoB,OAAO;IACvD,EAAE,OAAOC,OAAO;QACdC,QAAQD,KAAK,CAAC,yDAAyDrB,QAAQqB;QAC/E,OAAO;IACT;IAEA,MAAME,MAAKxB,kBAAAA,KAAKyB,SAAS,YAAdzB,kBAAkBP,cAAciC,GAAG,GAAGD,SAAS;IAC1D,MAAME,QAAQvC,iBAAiBiC,SAASzB;IACxC,IAAIgC,UAAU;IACd,KAAK,MAAM,CAAClB,OAAOE,GAAG,IAAIT,QAAS;QACjC,IAAI;YACF,MAAMqB,GAAGK,cAAc,CAAC,OAAOC;oBAYfC,gBAKZzC;gBAhBF;;;;;;;;SAQC,GACD,MAAMyC,WAAW,MAAMvC,mBAAmB4B,aAAaV,OAAO;oBAAEoB;gBAAY;gBAC5E,IAAI,CAACC,UAAU;gBACf,MAAMC,QAAQD,iBAAAA,SAASC,IAAI,cAAbD,iBAAmB,CAAC;gBAClC,IAAI,CAACxC,cAAcyC,IAAI,CAAC,YAAY,EAA0B/B,SAAS;oBACrE;gBACF;gBACA,MAAMgC,SAASpB,QACbvB,mDAAAA,iBAAiB0C,MAAMX,QAAQ,CAACzB,+BAA+B,YAA/DN,mDAAmE;gBAErE,kEAAkE;gBAClE,4DAA4D;gBAC5D,IAAIuB,OAAOC,QAAQ,CAACmB,WAAWA,UAAUrB,IAAI;gBAC7CkB,YAAYI,MAAM,CAACH,SAASI,GAAG,EAAE;oBAAE,CAACR,MAAM,EAAEf;gBAAG;gBAC/CgB,WAAW;YACb;QACF,EAAE,OAAON,OAAO;YACdC,QAAQD,KAAK,CAAC,2CAA2CrB,QAAQqB;QACnE;IACF;IACA,OAAOM;AACT"}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* A person's lifecycle stage, FLOORED at what just happened to them
|
|
19
|
+
* (AGL-2641).
|
|
20
|
+
*
|
|
21
|
+
* The capture doors floor a stage inside `upsertHostContact`: an order
|
|
22
|
+
* names `customer` as its `initialLifecycleStage`, and the door fills an
|
|
23
|
+
* empty stage or advances an earlier one and never moves anybody back. A
|
|
24
|
+
* won deal is the same fact as a purchase — the business has decided this
|
|
25
|
+
* person bought — but it arrives at a record that already exists, through a
|
|
26
|
+
* writer that is not a capture door: the deal-stage route and the REST
|
|
27
|
+
* deals resource. This is the door's rule for them, over a contact document
|
|
28
|
+
* they already hold a reference to.
|
|
29
|
+
*
|
|
30
|
+
* The write goes into ONE holder's facet, because a stage is one business's
|
|
31
|
+
* reading of a person and the row is shared by every site in the org. The
|
|
32
|
+
* holder is the site the deal was made on, resolved to its consent group
|
|
33
|
+
* the way every other facet writer resolves it; a deal that names no site
|
|
34
|
+
* falls back to the site that captured the person, which is the one
|
|
35
|
+
* holder a contact always has. A contact that names none either is left
|
|
36
|
+
* alone and the caller told, rather than written into a facet nobody reads.
|
|
37
|
+
*
|
|
38
|
+
* The write is skipped when it would change nothing — a customer stays a
|
|
39
|
+
* customer, an evangelist stays an evangelist, `other` is never overwritten
|
|
40
|
+
* — so a caller can announce `contactStageChanged` exactly when `advanced`
|
|
41
|
+
* says the stage moved, and an automation listening for the change never
|
|
42
|
+
* hears one that did not happen.
|
|
43
|
+
*/
|
|
44
|
+
import { type ContactLifecycleStage } from '@aglyn/aglyn/server';
|
|
45
|
+
export type ContactLifecycleFloor = {
|
|
46
|
+
/** The stage was raised to the floor. */
|
|
47
|
+
outcome: 'advanced';
|
|
48
|
+
contactId: string;
|
|
49
|
+
/** The address on the row, for the event a caller announces. */
|
|
50
|
+
email: string;
|
|
51
|
+
/** The site whose facet was written. */
|
|
52
|
+
hostId: string;
|
|
53
|
+
groupId: string;
|
|
54
|
+
/** The stage before the write, or `''` for a person who had none. */
|
|
55
|
+
previousStage: ContactLifecycleStage | '';
|
|
56
|
+
lifecycleStage: ContactLifecycleStage;
|
|
57
|
+
} | {
|
|
58
|
+
/** The person already held the floor or a later stage; nothing was written. */
|
|
59
|
+
outcome: 'held';
|
|
60
|
+
contactId: string;
|
|
61
|
+
email: string;
|
|
62
|
+
hostId: string;
|
|
63
|
+
groupId: string;
|
|
64
|
+
lifecycleStage: ContactLifecycleStage;
|
|
65
|
+
} | {
|
|
66
|
+
/** No such contact. */
|
|
67
|
+
outcome: 'missing';
|
|
68
|
+
contactId: string;
|
|
69
|
+
} | {
|
|
70
|
+
/** Neither the caller nor the contact names a site, so there is no facet to write. */
|
|
71
|
+
outcome: 'unheld';
|
|
72
|
+
contactId: string;
|
|
73
|
+
};
|
|
74
|
+
export interface ContactLifecycleFloorOptions {
|
|
75
|
+
contactRef: FirebaseFirestore.DocumentReference;
|
|
76
|
+
/**
|
|
77
|
+
* The org the contact belongs to, for the consent groups — the same
|
|
78
|
+
* document every facet writer resolves the holder through.
|
|
79
|
+
*/
|
|
80
|
+
org: Record<string, unknown> | null | undefined;
|
|
81
|
+
/**
|
|
82
|
+
* The site the act happened on, whose facet takes the stage. `null` for a
|
|
83
|
+
* record no site captured; the contact's own capturing site is used then.
|
|
84
|
+
*/
|
|
85
|
+
hostId: string | null | undefined;
|
|
86
|
+
/** The EARLIEST stage that describes what happened — see `advanceContactLifecycleStage`. */
|
|
87
|
+
floor: ContactLifecycleStage;
|
|
88
|
+
}
|
|
89
|
+
export declare function floorContactLifecycleStage(options: ContactLifecycleFloorOptions): Promise<ContactLifecycleFloor>;
|