@aglyn/tenant-data-admin 1.0.0-beta.143
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/LICENSE +201 -0
- package/README.md +7 -0
- package/jest.integration.config.d.ts +39 -0
- package/jest.integration.setup.d.ts +17 -0
- package/package.json +51 -0
- package/src/index.d.ts +17 -0
- package/src/index.js +18 -0
- package/src/index.js.map +1 -0
- package/src/lib/render-cache.d.ts +184 -0
- package/src/lib/render-cache.js +198 -0
- package/src/lib/render-cache.js.map +1 -0
- package/src/lib/server/account-addresses.d.ts +160 -0
- package/src/lib/server/account-addresses.js +311 -0
- package/src/lib/server/account-addresses.js.map +1 -0
- package/src/lib/server/account-emails.d.ts +219 -0
- package/src/lib/server/account-emails.js +640 -0
- package/src/lib/server/account-emails.js.map +1 -0
- package/src/lib/server/admin-audit.d.ts +180 -0
- package/src/lib/server/admin-audit.js +233 -0
- package/src/lib/server/admin-audit.js.map +1 -0
- package/src/lib/server/analytics-retention.d.ts +63 -0
- package/src/lib/server/analytics-retention.js +66 -0
- package/src/lib/server/analytics-retention.js.map +1 -0
- package/src/lib/server/api-http.d.ts +159 -0
- package/src/lib/server/api-http.js +230 -0
- package/src/lib/server/api-http.js.map +1 -0
- package/src/lib/server/api-keys.d.ts +117 -0
- package/src/lib/server/api-keys.js +298 -0
- package/src/lib/server/api-keys.js.map +1 -0
- package/src/lib/server/auth-handoff.d.ts +165 -0
- package/src/lib/server/auth-handoff.js +482 -0
- package/src/lib/server/auth-handoff.js.map +1 -0
- package/src/lib/server/auth-pools.d.ts +246 -0
- package/src/lib/server/auth-pools.js +551 -0
- package/src/lib/server/auth-pools.js.map +1 -0
- package/src/lib/server/billing-addons.d.ts +171 -0
- package/src/lib/server/billing-addons.js +277 -0
- package/src/lib/server/billing-addons.js.map +1 -0
- package/src/lib/server/campaign-attribution-store.d.ts +55 -0
- package/src/lib/server/campaign-attribution-store.js +95 -0
- package/src/lib/server/campaign-attribution-store.js.map +1 -0
- package/src/lib/server/campaign-conversion-attribution.d.ts +205 -0
- package/src/lib/server/campaign-conversion-attribution.js +279 -0
- package/src/lib/server/campaign-conversion-attribution.js.map +1 -0
- package/src/lib/server/client-error-report.d.ts +301 -0
- package/src/lib/server/client-error-report.js +755 -0
- package/src/lib/server/client-error-report.js.map +1 -0
- package/src/lib/server/connect-account-status.d.ts +74 -0
- package/src/lib/server/connect-account-status.js +94 -0
- package/src/lib/server/connect-account-status.js.map +1 -0
- package/src/lib/server/connect-payout-failure.d.ts +103 -0
- package/src/lib/server/connect-payout-failure.js +127 -0
- package/src/lib/server/connect-payout-failure.js.map +1 -0
- package/src/lib/server/console-api-rate-limit.d.ts +81 -0
- package/src/lib/server/console-api-rate-limit.js +111 -0
- package/src/lib/server/console-api-rate-limit.js.map +1 -0
- package/src/lib/server/console-domains.d.ts +326 -0
- package/src/lib/server/console-domains.js +779 -0
- package/src/lib/server/console-domains.js.map +1 -0
- package/src/lib/server/consume-once.d.ts +91 -0
- package/src/lib/server/consume-once.js +96 -0
- package/src/lib/server/consume-once.js.map +1 -0
- package/src/lib/server/contact-company-link.d.ts +85 -0
- package/src/lib/server/contact-company-link.js +105 -0
- package/src/lib/server/contact-company-link.js.map +1 -0
- package/src/lib/server/contact-email-engagement.d.ts +32 -0
- package/src/lib/server/contact-email-engagement.js +145 -0
- package/src/lib/server/contact-email-engagement.js.map +1 -0
- package/src/lib/server/contact-email-index.d.ts +56 -0
- package/src/lib/server/contact-email-index.js +158 -0
- package/src/lib/server/contact-email-index.js.map +1 -0
- package/src/lib/server/contact-lifecycle-floor.d.ts +89 -0
- package/src/lib/server/contact-lifecycle-floor.js +88 -0
- package/src/lib/server/contact-lifecycle-floor.js.map +1 -0
- package/src/lib/server/contact-merge.d.ts +55 -0
- package/src/lib/server/contact-merge.js +279 -0
- package/src/lib/server/contact-merge.js.map +1 -0
- package/src/lib/server/contact-suppression.d.ts +153 -0
- package/src/lib/server/contact-suppression.js +259 -0
- package/src/lib/server/contact-suppression.js.map +1 -0
- package/src/lib/server/crm-booking-activity.d.ts +86 -0
- package/src/lib/server/crm-booking-activity.js +251 -0
- package/src/lib/server/crm-booking-activity.js.map +1 -0
- package/src/lib/server/crm-email-activity.d.ts +112 -0
- package/src/lib/server/crm-email-activity.js +143 -0
- package/src/lib/server/crm-email-activity.js.map +1 -0
- package/src/lib/server/crm-inbound-email.d.ts +145 -0
- package/src/lib/server/crm-inbound-email.js +287 -0
- package/src/lib/server/crm-inbound-email.js.map +1 -0
- package/src/lib/server/crm-next-activity.d.ts +74 -0
- package/src/lib/server/crm-next-activity.js +194 -0
- package/src/lib/server/crm-next-activity.js.map +1 -0
- package/src/lib/server/crm-records.d.ts +144 -0
- package/src/lib/server/crm-records.js +218 -0
- package/src/lib/server/crm-records.js.map +1 -0
- package/src/lib/server/csp-aggregate.d.ts +62 -0
- package/src/lib/server/csp-aggregate.js +276 -0
- package/src/lib/server/csp-aggregate.js.map +1 -0
- package/src/lib/server/data-storage-gate.d.ts +66 -0
- package/src/lib/server/data-storage-gate.js +48 -0
- package/src/lib/server/data-storage-gate.js.map +1 -0
- package/src/lib/server/dataset-live-pages.d.ts +179 -0
- package/src/lib/server/dataset-live-pages.js +421 -0
- package/src/lib/server/dataset-live-pages.js.map +1 -0
- package/src/lib/server/dns-probe.d.ts +53 -0
- package/src/lib/server/dns-probe.js +184 -0
- package/src/lib/server/dns-probe.js.map +1 -0
- package/src/lib/server/document-id.d.ts +29 -0
- package/src/lib/server/document-id.js +100 -0
- package/src/lib/server/document-id.js.map +1 -0
- package/src/lib/server/domain-provider-vercel.d.ts +33 -0
- package/src/lib/server/domain-provider-vercel.js +298 -0
- package/src/lib/server/domain-provider-vercel.js.map +1 -0
- package/src/lib/server/domain-provider-webhook.d.ts +73 -0
- package/src/lib/server/domain-provider-webhook.js +223 -0
- package/src/lib/server/domain-provider-webhook.js.map +1 -0
- package/src/lib/server/domain-provider-wildcard.d.ts +54 -0
- package/src/lib/server/domain-provider-wildcard.js +163 -0
- package/src/lib/server/domain-provider-wildcard.js.map +1 -0
- package/src/lib/server/domain-provider.d.ts +197 -0
- package/src/lib/server/domain-provider.js +221 -0
- package/src/lib/server/domain-provider.js.map +1 -0
- package/src/lib/server/duplicate-activity.d.ts +54 -0
- package/src/lib/server/duplicate-activity.js +67 -0
- package/src/lib/server/duplicate-activity.js.map +1 -0
- package/src/lib/server/duplicate-resource.d.ts +55 -0
- package/src/lib/server/duplicate-resource.js +474 -0
- package/src/lib/server/duplicate-resource.js.map +1 -0
- package/src/lib/server/dynamic-list-materialize.d.ts +153 -0
- package/src/lib/server/dynamic-list-materialize.js +581 -0
- package/src/lib/server/dynamic-list-materialize.js.map +1 -0
- package/src/lib/server/edit-access-authz.d.ts +56 -0
- package/src/lib/server/edit-access-authz.js +107 -0
- package/src/lib/server/edit-access-authz.js.map +1 -0
- package/src/lib/server/edit-access-token.d.ts +72 -0
- package/src/lib/server/edit-access-token.js +116 -0
- package/src/lib/server/edit-access-token.js.map +1 -0
- package/src/lib/server/edit-hint-token.d.ts +83 -0
- package/src/lib/server/edit-hint-token.js +119 -0
- package/src/lib/server/edit-hint-token.js.map +1 -0
- package/src/lib/server/email-delivery-log.d.ts +509 -0
- package/src/lib/server/email-delivery-log.js +1043 -0
- package/src/lib/server/email-delivery-log.js.map +1 -0
- package/src/lib/server/email-flow-gate.d.ts +80 -0
- package/src/lib/server/email-flow-gate.js +153 -0
- package/src/lib/server/email-flow-gate.js.map +1 -0
- package/src/lib/server/email-marketing-gate.d.ts +333 -0
- package/src/lib/server/email-marketing-gate.js +639 -0
- package/src/lib/server/email-marketing-gate.js.map +1 -0
- package/src/lib/server/email-metering.d.ts +293 -0
- package/src/lib/server/email-metering.js +359 -0
- package/src/lib/server/email-metering.js.map +1 -0
- package/src/lib/server/email-revenue-attribution.d.ts +185 -0
- package/src/lib/server/email-revenue-attribution.js +300 -0
- package/src/lib/server/email-revenue-attribution.js.map +1 -0
- package/src/lib/server/email-send-rate.d.ts +232 -0
- package/src/lib/server/email-send-rate.js +417 -0
- package/src/lib/server/email-send-rate.js.map +1 -0
- package/src/lib/server/email-sender-reputation.d.ts +317 -0
- package/src/lib/server/email-sender-reputation.js +464 -0
- package/src/lib/server/email-sender-reputation.js.map +1 -0
- package/src/lib/server/email-suppression.d.ts +490 -0
- package/src/lib/server/email-suppression.js +648 -0
- package/src/lib/server/email-suppression.js.map +1 -0
- package/src/lib/server/email-topic-confirmation.d.ts +150 -0
- package/src/lib/server/email-topic-confirmation.js +272 -0
- package/src/lib/server/email-topic-confirmation.js.map +1 -0
- package/src/lib/server/email-unsubscribe-link.d.ts +174 -0
- package/src/lib/server/email-unsubscribe-link.js +236 -0
- package/src/lib/server/email-unsubscribe-link.js.map +1 -0
- package/src/lib/server/erase-person.d.ts +116 -0
- package/src/lib/server/erase-person.js +272 -0
- package/src/lib/server/erase-person.js.map +1 -0
- package/src/lib/server/erase.d.ts +458 -0
- package/src/lib/server/erase.js +1506 -0
- package/src/lib/server/erase.js.map +1 -0
- package/src/lib/server/firebase-admin.d.ts +71 -0
- package/src/lib/server/firebase-admin.js +343 -0
- package/src/lib/server/firebase-admin.js.map +1 -0
- package/src/lib/server/form-dataset-binding-token.d.ts +35 -0
- package/src/lib/server/form-dataset-binding-token.js +107 -0
- package/src/lib/server/form-dataset-binding-token.js.map +1 -0
- package/src/lib/server/free-workspace-cap.d.ts +201 -0
- package/src/lib/server/free-workspace-cap.js +404 -0
- package/src/lib/server/free-workspace-cap.js.map +1 -0
- package/src/lib/server/ga4-measurement-protocol.d.ts +256 -0
- package/src/lib/server/ga4-measurement-protocol.js +558 -0
- package/src/lib/server/ga4-measurement-protocol.js.map +1 -0
- package/src/lib/server/host-email-tokens.d.ts +47 -0
- package/src/lib/server/host-email-tokens.js +72 -0
- package/src/lib/server/host-email-tokens.js.map +1 -0
- package/src/lib/server/host-memberships.d.ts +54 -0
- package/src/lib/server/host-memberships.js +194 -0
- package/src/lib/server/host-memberships.js.map +1 -0
- package/src/lib/server/host-sending-domain.d.ts +292 -0
- package/src/lib/server/host-sending-domain.js +451 -0
- package/src/lib/server/host-sending-domain.js.map +1 -0
- package/src/lib/server/host-visitor-records.d.ts +150 -0
- package/src/lib/server/host-visitor-records.js +262 -0
- package/src/lib/server/host-visitor-records.js.map +1 -0
- package/src/lib/server/id-token-refusal.d.ts +28 -0
- package/src/lib/server/id-token-refusal.js +111 -0
- package/src/lib/server/id-token-refusal.js.map +1 -0
- package/src/lib/server/legal-acceptance.d.ts +214 -0
- package/src/lib/server/legal-acceptance.js +305 -0
- package/src/lib/server/legal-acceptance.js.map +1 -0
- package/src/lib/server/list-members.d.ts +211 -0
- package/src/lib/server/list-members.js +176 -0
- package/src/lib/server/list-members.js.map +1 -0
- package/src/lib/server/live-page-usage.d.ts +202 -0
- package/src/lib/server/live-page-usage.js +300 -0
- package/src/lib/server/live-page-usage.js.map +1 -0
- package/src/lib/server/lockdown.d.ts +313 -0
- package/src/lib/server/lockdown.js +643 -0
- package/src/lib/server/lockdown.js.map +1 -0
- package/src/lib/server/media-cdn-rate-limit.d.ts +188 -0
- package/src/lib/server/media-cdn-rate-limit.js +204 -0
- package/src/lib/server/media-cdn-rate-limit.js.map +1 -0
- package/src/lib/server/media-delivery.d.ts +267 -0
- package/src/lib/server/media-delivery.js +485 -0
- package/src/lib/server/media-delivery.js.map +1 -0
- package/src/lib/server/media-download-tokens.d.ts +161 -0
- package/src/lib/server/media-download-tokens.js +386 -0
- package/src/lib/server/media-download-tokens.js.map +1 -0
- package/src/lib/server/media-quarantine.d.ts +127 -0
- package/src/lib/server/media-quarantine.js +170 -0
- package/src/lib/server/media-quarantine.js.map +1 -0
- package/src/lib/server/media-signing.d.ts +126 -0
- package/src/lib/server/media-signing.js +159 -0
- package/src/lib/server/media-signing.js.map +1 -0
- package/src/lib/server/media-storage-path.d.ts +96 -0
- package/src/lib/server/media-storage-path.js +111 -0
- package/src/lib/server/media-storage-path.js.map +1 -0
- package/src/lib/server/media-strong-digest.d.ts +85 -0
- package/src/lib/server/media-strong-digest.js +190 -0
- package/src/lib/server/media-strong-digest.js.map +1 -0
- package/src/lib/server/media-tombstone.d.ts +276 -0
- package/src/lib/server/media-tombstone.js +421 -0
- package/src/lib/server/media-tombstone.js.map +1 -0
- package/src/lib/server/media-variants.d.ts +309 -0
- package/src/lib/server/media-variants.js +428 -0
- package/src/lib/server/media-variants.js.map +1 -0
- package/src/lib/server/member-email-aliases.d.ts +180 -0
- package/src/lib/server/member-email-aliases.js +420 -0
- package/src/lib/server/member-email-aliases.js.map +1 -0
- package/src/lib/server/member-photo.d.ts +88 -0
- package/src/lib/server/member-photo.js +185 -0
- package/src/lib/server/member-photo.js.map +1 -0
- package/src/lib/server/membership-recover-throttle.d.ts +211 -0
- package/src/lib/server/membership-recover-throttle.js +231 -0
- package/src/lib/server/membership-recover-throttle.js.map +1 -0
- package/src/lib/server/notifications.d.ts +55 -0
- package/src/lib/server/notifications.js +134 -0
- package/src/lib/server/notifications.js.map +1 -0
- package/src/lib/server/org-billing.d.ts +97 -0
- package/src/lib/server/org-billing.js +236 -0
- package/src/lib/server/org-billing.js.map +1 -0
- package/src/lib/server/organizations.d.ts +837 -0
- package/src/lib/server/organizations.js +1814 -0
- package/src/lib/server/organizations.js.map +1 -0
- package/src/lib/server/password-reset-throttle.d.ts +84 -0
- package/src/lib/server/password-reset-throttle.js +102 -0
- package/src/lib/server/password-reset-throttle.js.map +1 -0
- package/src/lib/server/personal-data-export.d.ts +241 -0
- package/src/lib/server/personal-data-export.js +858 -0
- package/src/lib/server/personal-data-export.js.map +1 -0
- package/src/lib/server/platform-domain-names.d.ts +62 -0
- package/src/lib/server/platform-domain-names.js +197 -0
- package/src/lib/server/platform-domain-names.js.map +1 -0
- package/src/lib/server/platform-marketing-consent.d.ts +133 -0
- package/src/lib/server/platform-marketing-consent.js +209 -0
- package/src/lib/server/platform-marketing-consent.js.map +1 -0
- package/src/lib/server/rate-limit-store.d.ts +702 -0
- package/src/lib/server/rate-limit-store.js +1220 -0
- package/src/lib/server/rate-limit-store.js.map +1 -0
- package/src/lib/server/realm-plugins.d.ts +67 -0
- package/src/lib/server/realm-plugins.js +250 -0
- package/src/lib/server/realm-plugins.js.map +1 -0
- package/src/lib/server/recaptcha-allowlist.d.ts +90 -0
- package/src/lib/server/recaptcha-allowlist.js +409 -0
- package/src/lib/server/recaptcha-allowlist.js.map +1 -0
- package/src/lib/server/release-flags.d.ts +105 -0
- package/src/lib/server/release-flags.js +183 -0
- package/src/lib/server/release-flags.js.map +1 -0
- package/src/lib/server/replay-erasures.d.ts +126 -0
- package/src/lib/server/replay-erasures.js +217 -0
- package/src/lib/server/replay-erasures.js.map +1 -0
- package/src/lib/server/resolve-people.d.ts +66 -0
- package/src/lib/server/resolve-people.js +112 -0
- package/src/lib/server/resolve-people.js.map +1 -0
- package/src/lib/server/safe-equal.d.ts +45 -0
- package/src/lib/server/safe-equal.js +58 -0
- package/src/lib/server/safe-equal.js.map +1 -0
- package/src/lib/server/sending-domain-debt.d.ts +127 -0
- package/src/lib/server/sending-domain-debt.js +268 -0
- package/src/lib/server/sending-domain-debt.js.map +1 -0
- package/src/lib/server/sending-domain-recheck.d.ts +151 -0
- package/src/lib/server/sending-domain-recheck.js +253 -0
- package/src/lib/server/sending-domain-recheck.js.map +1 -0
- package/src/lib/server/sending-domains.d.ts +364 -0
- package/src/lib/server/sending-domains.js +683 -0
- package/src/lib/server/sending-domains.js.map +1 -0
- package/src/lib/server/serve-media-cdn.d.ts +433 -0
- package/src/lib/server/serve-media-cdn.js +1370 -0
- package/src/lib/server/serve-media-cdn.js.map +1 -0
- package/src/lib/server/serve-plugin-fetch.d.ts +61 -0
- package/src/lib/server/serve-plugin-fetch.js +294 -0
- package/src/lib/server/serve-plugin-fetch.js.map +1 -0
- package/src/lib/server/sms-keywords.d.ts +63 -0
- package/src/lib/server/sms-keywords.js +146 -0
- package/src/lib/server/sms-keywords.js.map +1 -0
- package/src/lib/server/sso-break-glass-owners.d.ts +91 -0
- package/src/lib/server/sso-break-glass-owners.js +227 -0
- package/src/lib/server/sso-break-glass-owners.js.map +1 -0
- package/src/lib/server/sso-domain-policy.d.ts +140 -0
- package/src/lib/server/sso-domain-policy.js +239 -0
- package/src/lib/server/sso-domain-policy.js.map +1 -0
- package/src/lib/server/sso-drift-logic.d.ts +106 -0
- package/src/lib/server/sso-drift-logic.js +95 -0
- package/src/lib/server/sso-drift-logic.js.map +1 -0
- package/src/lib/server/sso-enforcement.d.ts +204 -0
- package/src/lib/server/sso-enforcement.js +309 -0
- package/src/lib/server/sso-enforcement.js.map +1 -0
- package/src/lib/server/sso-provisioning.d.ts +320 -0
- package/src/lib/server/sso-provisioning.js +715 -0
- package/src/lib/server/sso-provisioning.js.map +1 -0
- package/src/lib/server/staff-alert-email.d.ts +41 -0
- package/src/lib/server/staff-alert-email.js +63 -0
- package/src/lib/server/staff-alert-email.js.map +1 -0
- package/src/lib/server/stripe-account-mode.d.ts +156 -0
- package/src/lib/server/stripe-account-mode.js +162 -0
- package/src/lib/server/stripe-account-mode.js.map +1 -0
- package/src/lib/server/tenant-write-lockdown.d.ts +131 -0
- package/src/lib/server/tenant-write-lockdown.js +176 -0
- package/src/lib/server/tenant-write-lockdown.js.map +1 -0
- package/src/lib/server/test-firestore.d.ts +45 -0
- package/src/lib/server/test-firestore.js +124 -0
- package/src/lib/server/test-firestore.js.map +1 -0
- package/src/lib/server/token-revocation.d.ts +140 -0
- package/src/lib/server/token-revocation.js +255 -0
- package/src/lib/server/token-revocation.js.map +1 -0
- package/src/lib/server/update-existing.d.ts +32 -0
- package/src/lib/server/update-existing.js +73 -0
- package/src/lib/server/update-existing.js.map +1 -0
- package/src/lib/server/upload-cors-reconcile.d.ts +118 -0
- package/src/lib/server/upload-cors-reconcile.js +234 -0
- package/src/lib/server/upload-cors-reconcile.js.map +1 -0
- package/src/lib/server/upsert-contact.d.ts +262 -0
- package/src/lib/server/upsert-contact.js +546 -0
- package/src/lib/server/upsert-contact.js.map +1 -0
- package/src/lib/server/usage-invoice.d.ts +203 -0
- package/src/lib/server/usage-invoice.js +397 -0
- package/src/lib/server/usage-invoice.js.map +1 -0
- package/src/lib/server/user-profiles.d.ts +151 -0
- package/src/lib/server/user-profiles.js +364 -0
- package/src/lib/server/user-profiles.js.map +1 -0
- package/src/lib/server/vercel-drain-signature.d.ts +30 -0
- package/src/lib/server/vercel-drain-signature.js +61 -0
- package/src/lib/server/vercel-drain-signature.js.map +1 -0
- package/src/lib/server/vercel-log-drain.d.ts +259 -0
- package/src/lib/server/vercel-log-drain.js +479 -0
- package/src/lib/server/vercel-log-drain.js.map +1 -0
- package/src/lib/server/verify-email-cooldown.d.ts +102 -0
- package/src/lib/server/verify-email-cooldown.js +114 -0
- package/src/lib/server/verify-email-cooldown.js.map +1 -0
- package/src/lib/server/visitor-write-rate-limit.d.ts +87 -0
- package/src/lib/server/visitor-write-rate-limit.js +117 -0
- package/src/lib/server/visitor-write-rate-limit.js.map +1 -0
- package/src/lib/server/workspace-domains.d.ts +156 -0
- package/src/lib/server/workspace-domains.js +271 -0
- package/src/lib/server/workspace-domains.js.map +1 -0
- package/src/tenancy.d.ts +115 -0
- package/src/tenancy.js +142 -0
- package/src/tenancy.js.map +1 -0
|
@@ -0,0 +1,55 @@
|
|
|
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 { HostActivityActor } from '@aglyn/aglyn/app-utils/activity-presenter';
|
|
18
|
+
export interface MergeContactsOptions {
|
|
19
|
+
firestore: FirebaseFirestore.Firestore;
|
|
20
|
+
/** `orgs/{orgId}` — the org whose contacts both records are. */
|
|
21
|
+
orgRef: FirebaseFirestore.DocumentReference;
|
|
22
|
+
/** The record that stays, and keeps its address as the identity. */
|
|
23
|
+
survivorId: string;
|
|
24
|
+
/** The record folded in and deleted. */
|
|
25
|
+
mergedId: string;
|
|
26
|
+
actor: HostActivityActor;
|
|
27
|
+
/**
|
|
28
|
+
* The site whose console did the work: where the activity feed entry
|
|
29
|
+
* lands, and the site a timeline note is filed under. `null` for a door
|
|
30
|
+
* with no site — the REST API — which logs no host entry.
|
|
31
|
+
*/
|
|
32
|
+
hostId: string | null;
|
|
33
|
+
/** The actor's display name for the timeline note, when the door knows it. */
|
|
34
|
+
actorName?: string | null;
|
|
35
|
+
}
|
|
36
|
+
export interface MergeContactsRepointed {
|
|
37
|
+
deals: number;
|
|
38
|
+
tasks: number;
|
|
39
|
+
activities: number;
|
|
40
|
+
leads: number;
|
|
41
|
+
}
|
|
42
|
+
export type MergeContactsResult = {
|
|
43
|
+
ok: true;
|
|
44
|
+
survivorId: string;
|
|
45
|
+
survivorEmail: string;
|
|
46
|
+
mergedId: string;
|
|
47
|
+
mergedEmail: string;
|
|
48
|
+
/** Every address the survivor answers to now, primary first. */
|
|
49
|
+
emails: string[];
|
|
50
|
+
repointed: MergeContactsRepointed;
|
|
51
|
+
} | {
|
|
52
|
+
ok: false;
|
|
53
|
+
reason: 'same-record' | 'survivor-missing' | 'merged-missing';
|
|
54
|
+
};
|
|
55
|
+
export declare function mergeContacts(options: MergeContactsOptions): Promise<MergeContactsResult>;
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ /**
|
|
18
|
+
* MERGING TWO CONTACTS — the write (AGL-2625).
|
|
19
|
+
*
|
|
20
|
+
* `planContactMerge` says what the survivor becomes; this performs it, and
|
|
21
|
+
* moves everything that pointed at the merged record. One function behind
|
|
22
|
+
* two doors — the console's `crm/contacts-merge` route and
|
|
23
|
+
* `POST /v1/contacts/{id}/merge` — so the REST caller and the record page
|
|
24
|
+
* cannot merge two different ways.
|
|
25
|
+
*
|
|
26
|
+
* ## The order is the crash-safety argument
|
|
27
|
+
*
|
|
28
|
+
* 1. Every deal, task and activity naming the merged contact is repointed
|
|
29
|
+
* at the survivor, and every lead converted into it. Batched, bounded,
|
|
30
|
+
* idempotent: a re-run finds nothing left to move.
|
|
31
|
+
* 2. Then, in ONE transaction: the survivor takes the plan, every address
|
|
32
|
+
* it now answers to is indexed at it, and the merged document is
|
|
33
|
+
* deleted — with both records re-read inside the transaction, so two
|
|
34
|
+
* merges racing on one pair cannot both succeed and a plan is never
|
|
35
|
+
* computed on a document that changed under it.
|
|
36
|
+
*
|
|
37
|
+
* Children first because a process that dies between the two steps leaves
|
|
38
|
+
* children pointing at a record that EXISTS — the survivor — while the merged
|
|
39
|
+
* document is still there for a retry to fold in. The other order would
|
|
40
|
+
* leave deals pointing at a deleted contact, which is a record nobody can
|
|
41
|
+
* open from anywhere.
|
|
42
|
+
*
|
|
43
|
+
* ## What it does not move
|
|
44
|
+
*
|
|
45
|
+
* List memberships, suppression entries and the delivery log are keyed by
|
|
46
|
+
* ADDRESS, not by contact id, and stay under the merged address; a capture
|
|
47
|
+
* on that address resolves to the survivor through the index, which is the
|
|
48
|
+
* fact those keys need. A company's contacts count drops only for a company
|
|
49
|
+
* both records named — the survivor still names the rest.
|
|
50
|
+
*/ import { CONTACT_EMAIL_INDEX_COLLECTION, contactEmails } from "@aglyn/aglyn/app-utils/contacts";
|
|
51
|
+
import { planContactMerge } from "@aglyn/aglyn/app-utils/contact-merge";
|
|
52
|
+
import { CRM_COLLECTIONS } from "@aglyn/aglyn/app-utils/crm";
|
|
53
|
+
import { personKey } from "@aglyn/aglyn/app-utils/person-key";
|
|
54
|
+
import { FieldValue } from "firebase-admin/firestore";
|
|
55
|
+
import { settleCompanyContactsCounts } from "./contact-company-link.js";
|
|
56
|
+
import { recomputeCrmNextTaskAt } from "./crm-next-activity.js";
|
|
57
|
+
import { logHostActivity } from "./organizations.js";
|
|
58
|
+
/** How many pointing rows one repoint pass reads — and one batch writes. */ const REPOINT_PAGE = 400;
|
|
59
|
+
/** A ceiling on passes, so a query that keeps answering cannot loop forever. */ const REPOINT_PASSES = 25;
|
|
60
|
+
/**
|
|
61
|
+
* Point every row of `collection` whose `contactId` is `from` at `to`.
|
|
62
|
+
*
|
|
63
|
+
* Page, batch, repeat: each pass reads the rows still pointing at the
|
|
64
|
+
* merged record — the ones a previous pass moved no longer match — so the
|
|
65
|
+
* loop ends when a pass finds nothing. `updatedAt` moves with the pointer
|
|
66
|
+
* because an integration syncing by `updatedAfter` has to see the change.
|
|
67
|
+
*/ async function repointContactId(firestore, collection, from, to) {
|
|
68
|
+
let moved = 0;
|
|
69
|
+
for(let pass = 0; pass < REPOINT_PASSES; pass += 1){
|
|
70
|
+
const page = await collection.where('contactId', '==', from).limit(REPOINT_PAGE).get();
|
|
71
|
+
if (page.empty) break;
|
|
72
|
+
const batch = firestore.batch();
|
|
73
|
+
for (const row of page.docs){
|
|
74
|
+
batch.update(row.ref, {
|
|
75
|
+
contactId: to,
|
|
76
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
await batch.commit();
|
|
80
|
+
moved += page.size;
|
|
81
|
+
if (page.size < REPOINT_PAGE) break;
|
|
82
|
+
}
|
|
83
|
+
return moved;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The leads converted into the merged contact, restamped at the survivor.
|
|
87
|
+
*
|
|
88
|
+
* A lead lives at `hosts/{hostId}/leads/{personKey(email)}`, so there is no
|
|
89
|
+
* query: each site that met either record is asked for a lead under each of
|
|
90
|
+
* the merged record's addresses, and one that names the merged contact is
|
|
91
|
+
* moved. Bounded by sites times addresses, both small.
|
|
92
|
+
*/ async function repointLeads(firestore, hostIds, emails, from, to) {
|
|
93
|
+
let moved = 0;
|
|
94
|
+
for (const hostId of hostIds){
|
|
95
|
+
for (const email of emails){
|
|
96
|
+
const key = personKey(email);
|
|
97
|
+
if (!key) continue;
|
|
98
|
+
const leadRef = firestore.collection('hosts').doc(hostId).collection('leads').doc(key);
|
|
99
|
+
const lead = await leadRef.get();
|
|
100
|
+
if (!lead.exists || lead.get('convertedContactId') !== from) continue;
|
|
101
|
+
await leadRef.update({
|
|
102
|
+
convertedContactId: to,
|
|
103
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
104
|
+
});
|
|
105
|
+
moved += 1;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return moved;
|
|
109
|
+
}
|
|
110
|
+
function strings(value) {
|
|
111
|
+
return Array.isArray(value) ? value.filter((item)=>typeof item === 'string' && item !== '') : [];
|
|
112
|
+
}
|
|
113
|
+
export async function mergeContacts(options) {
|
|
114
|
+
var _mergedBefore_data, _survivorBefore_data, _contactEmails_, _plan_emails_;
|
|
115
|
+
const { firestore, orgRef, survivorId, mergedId, actor, hostId } = options;
|
|
116
|
+
if (!survivorId || !mergedId || survivorId === mergedId) {
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
reason: 'same-record'
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const contactsRef = orgRef.collection('contacts');
|
|
123
|
+
const survivorRef = contactsRef.doc(survivorId);
|
|
124
|
+
const mergedRef = contactsRef.doc(mergedId);
|
|
125
|
+
const [survivorBefore, mergedBefore] = await Promise.all([
|
|
126
|
+
survivorRef.get(),
|
|
127
|
+
mergedRef.get()
|
|
128
|
+
]);
|
|
129
|
+
if (!survivorBefore.exists) return {
|
|
130
|
+
ok: false,
|
|
131
|
+
reason: 'survivor-missing'
|
|
132
|
+
};
|
|
133
|
+
if (!mergedBefore.exists) return {
|
|
134
|
+
ok: false,
|
|
135
|
+
reason: 'merged-missing'
|
|
136
|
+
};
|
|
137
|
+
const mergedData = (_mergedBefore_data = mergedBefore.data()) != null ? _mergedBefore_data : {};
|
|
138
|
+
const survivorData = (_survivorBefore_data = survivorBefore.data()) != null ? _survivorBefore_data : {};
|
|
139
|
+
/*==========================================
|
|
140
|
+
* 1. THE CHILDREN — before the parent, for the reason the header gives.
|
|
141
|
+
*=========================================*/ const [deals, tasks, activities] = await Promise.all([
|
|
142
|
+
repointContactId(firestore, orgRef.collection(CRM_COLLECTIONS.deals), mergedId, survivorId),
|
|
143
|
+
repointContactId(firestore, orgRef.collection(CRM_COLLECTIONS.tasks), mergedId, survivorId),
|
|
144
|
+
repointContactId(firestore, orgRef.collection(CRM_COLLECTIONS.activities), mergedId, survivorId)
|
|
145
|
+
]);
|
|
146
|
+
const leadHosts = [
|
|
147
|
+
...new Set([
|
|
148
|
+
...strings(mergedData['capturedByHostIds']),
|
|
149
|
+
...strings(survivorData['capturedByHostIds']),
|
|
150
|
+
...strings([
|
|
151
|
+
mergedData['hostId'],
|
|
152
|
+
survivorData['hostId'],
|
|
153
|
+
hostId
|
|
154
|
+
])
|
|
155
|
+
])
|
|
156
|
+
];
|
|
157
|
+
const leads = await repointLeads(firestore, leadHosts, contactEmails(mergedData), mergedId, survivorId);
|
|
158
|
+
/*==========================================
|
|
159
|
+
* 2. THE SWAP — one transaction over both documents and the index.
|
|
160
|
+
*=========================================*/ const index = orgRef.collection(CONTACT_EMAIL_INDEX_COLLECTION);
|
|
161
|
+
const swapped = await firestore.runTransaction(async (transaction)=>{
|
|
162
|
+
var _survivor_data, _merged_data, _survivor_data1;
|
|
163
|
+
const [survivor, merged] = await Promise.all([
|
|
164
|
+
transaction.get(survivorRef),
|
|
165
|
+
transaction.get(mergedRef)
|
|
166
|
+
]);
|
|
167
|
+
if (!survivor.exists) return {
|
|
168
|
+
reason: 'survivor-missing'
|
|
169
|
+
};
|
|
170
|
+
if (!merged.exists) return {
|
|
171
|
+
reason: 'merged-missing'
|
|
172
|
+
};
|
|
173
|
+
const plan = planContactMerge((_survivor_data = survivor.data()) != null ? _survivor_data : {}, (_merged_data = merged.data()) != null ? _merged_data : {});
|
|
174
|
+
transaction.set(survivorRef, _extends({}, plan.survivor, {
|
|
175
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
176
|
+
}), {
|
|
177
|
+
merge: true
|
|
178
|
+
});
|
|
179
|
+
for (const email of plan.emails){
|
|
180
|
+
const key = personKey(email);
|
|
181
|
+
if (!key) continue;
|
|
182
|
+
transaction.set(index.doc(key), {
|
|
183
|
+
email,
|
|
184
|
+
contactId: survivorId,
|
|
185
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
186
|
+
}, {
|
|
187
|
+
merge: true
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
transaction.delete(mergedRef);
|
|
191
|
+
return {
|
|
192
|
+
plan,
|
|
193
|
+
survivor: (_survivor_data1 = survivor.data()) != null ? _survivor_data1 : {}
|
|
194
|
+
};
|
|
195
|
+
});
|
|
196
|
+
if ('reason' in swapped) return {
|
|
197
|
+
ok: false,
|
|
198
|
+
reason: swapped.reason
|
|
199
|
+
};
|
|
200
|
+
const { plan } = swapped;
|
|
201
|
+
/*
|
|
202
|
+
* The companies both records named counted two people and now count one
|
|
203
|
+
* (AGL-2613). Settled after the swap the way the capture settles its
|
|
204
|
+
* counts: a figure that could not move is logged, and the company page's
|
|
205
|
+
* live aggregate corrects it.
|
|
206
|
+
*/ await settleCompanyContactsCounts(orgRef.collection(CRM_COLLECTIONS.companies), {
|
|
207
|
+
companyId: null,
|
|
208
|
+
mirror: null,
|
|
209
|
+
counts: plan.companyCounts
|
|
210
|
+
});
|
|
211
|
+
/*
|
|
212
|
+
* The survivor now owns the merged record's open tasks too, so its next
|
|
213
|
+
* activity is the earliest of both (AGL-2661). Settled the same way: a
|
|
214
|
+
* figure that could not move is logged, and the Fields section's
|
|
215
|
+
* recompute corrects it.
|
|
216
|
+
*/ await recomputeCrmNextTaskAt(firestore, orgRef.id, [
|
|
217
|
+
{
|
|
218
|
+
contactId: survivorId
|
|
219
|
+
}
|
|
220
|
+
]).catch((error)=>{
|
|
221
|
+
console.error('contact merge next activity could not be recomputed', survivorId, error);
|
|
222
|
+
});
|
|
223
|
+
const mergedEmail = (_contactEmails_ = contactEmails(mergedData)[0]) != null ? _contactEmails_ : '';
|
|
224
|
+
const survivorEmail = (_plan_emails_ = plan.emails[0]) != null ? _plan_emails_ : '';
|
|
225
|
+
// The canonical name — the one identity every holder shares — for the
|
|
226
|
+
// feed; the address stands in for a person nobody has named.
|
|
227
|
+
const survivorName = typeof swapped.survivor['name'] === 'string' ? swapped.survivor['name'] : '';
|
|
228
|
+
/*
|
|
229
|
+
* THE TIMELINE NOTE, on the survivor: the merge is an event in this
|
|
230
|
+
* person's history, and the record page is where a reader looks for one.
|
|
231
|
+
* Filed under the calling site, or the survivor's first site for a door
|
|
232
|
+
* with none; its own catch, because a note that could not be written must
|
|
233
|
+
* not read as a merge that did not happen.
|
|
234
|
+
*/ try {
|
|
235
|
+
var _survivorData_hostId;
|
|
236
|
+
await orgRef.collection(CRM_COLLECTIONS.activities).add(_extends({
|
|
237
|
+
kind: 'note',
|
|
238
|
+
body: `Merged with ${mergedEmail}`,
|
|
239
|
+
atMs: Date.now(),
|
|
240
|
+
byUid: actor.uid
|
|
241
|
+
}, options.actorName || actor.email ? {
|
|
242
|
+
byName: options.actorName || actor.email
|
|
243
|
+
} : {}, {
|
|
244
|
+
contactId: survivorId,
|
|
245
|
+
visibleTo: plan.visibleTo,
|
|
246
|
+
hostId: hostId != null ? hostId : String((_survivorData_hostId = survivorData['hostId']) != null ? _survivorData_hostId : ''),
|
|
247
|
+
createdByUid: actor.uid,
|
|
248
|
+
createdAt: FieldValue.serverTimestamp(),
|
|
249
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
250
|
+
}));
|
|
251
|
+
} catch (error) {
|
|
252
|
+
console.error('[contact-merge] timeline note failed', survivorId, error);
|
|
253
|
+
}
|
|
254
|
+
// The audit line (AGL-2622), in the feed of the site whose console did
|
|
255
|
+
// the work. A door with no site — the API — has no host feed to write.
|
|
256
|
+
if (hostId) {
|
|
257
|
+
await logHostActivity(hostId, actor, `Merged with ${mergedEmail}`, {
|
|
258
|
+
type: 'contact',
|
|
259
|
+
id: survivorId,
|
|
260
|
+
name: survivorName || survivorEmail
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
ok: true,
|
|
265
|
+
survivorId,
|
|
266
|
+
survivorEmail,
|
|
267
|
+
mergedId,
|
|
268
|
+
mergedEmail,
|
|
269
|
+
emails: plan.emails,
|
|
270
|
+
repointed: {
|
|
271
|
+
deals,
|
|
272
|
+
tasks,
|
|
273
|
+
activities,
|
|
274
|
+
leads
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
//# sourceMappingURL=contact-merge.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/contact-merge.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 * MERGING TWO CONTACTS — the write (AGL-2625).\n *\n * `planContactMerge` says what the survivor becomes; this performs it, and\n * moves everything that pointed at the merged record. One function behind\n * two doors — the console's `crm/contacts-merge` route and\n * `POST /v1/contacts/{id}/merge` — so the REST caller and the record page\n * cannot merge two different ways.\n *\n * ## The order is the crash-safety argument\n *\n * 1. Every deal, task and activity naming the merged contact is repointed\n * at the survivor, and every lead converted into it. Batched, bounded,\n * idempotent: a re-run finds nothing left to move.\n * 2. Then, in ONE transaction: the survivor takes the plan, every address\n * it now answers to is indexed at it, and the merged document is\n * deleted — with both records re-read inside the transaction, so two\n * merges racing on one pair cannot both succeed and a plan is never\n * computed on a document that changed under it.\n *\n * Children first because a process that dies between the two steps leaves\n * children pointing at a record that EXISTS — the survivor — while the merged\n * document is still there for a retry to fold in. The other order would\n * leave deals pointing at a deleted contact, which is a record nobody can\n * open from anywhere.\n *\n * ## What it does not move\n *\n * List memberships, suppression entries and the delivery log are keyed by\n * ADDRESS, not by contact id, and stay under the merged address; a capture\n * on that address resolves to the survivor through the index, which is the\n * fact those keys need. A company's contacts count drops only for a company\n * both records named — the survivor still names the rest.\n */\n\nimport {\n CONTACT_EMAIL_INDEX_COLLECTION,\n contactEmails,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport type { HostActivityActor } from '@aglyn/aglyn/app-utils/activity-presenter'\nimport { planContactMerge } from '@aglyn/aglyn/app-utils/contact-merge'\nimport { CRM_COLLECTIONS } from '@aglyn/aglyn/app-utils/crm'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { settleCompanyContactsCounts } from './contact-company-link'\nimport { recomputeCrmNextTaskAt } from './crm-next-activity'\nimport { logHostActivity } from './organizations'\n\n/** How many pointing rows one repoint pass reads — and one batch writes. */\nconst REPOINT_PAGE = 400\n/** A ceiling on passes, so a query that keeps answering cannot loop forever. */\nconst REPOINT_PASSES = 25\n\nexport interface MergeContactsOptions {\n firestore: FirebaseFirestore.Firestore\n /** `orgs/{orgId}` — the org whose contacts both records are. */\n orgRef: FirebaseFirestore.DocumentReference\n /** The record that stays, and keeps its address as the identity. */\n survivorId: string\n /** The record folded in and deleted. */\n mergedId: string\n actor: HostActivityActor\n /**\n * The site whose console did the work: where the activity feed entry\n * lands, and the site a timeline note is filed under. `null` for a door\n * with no site — the REST API — which logs no host entry.\n */\n hostId: string | null\n /** The actor's display name for the timeline note, when the door knows it. */\n actorName?: string | null\n}\n\nexport interface MergeContactsRepointed {\n deals: number\n tasks: number\n activities: number\n leads: number\n}\n\nexport type MergeContactsResult =\n | {\n ok: true\n survivorId: string\n survivorEmail: string\n mergedId: string\n mergedEmail: string\n /** Every address the survivor answers to now, primary first. */\n emails: string[]\n repointed: MergeContactsRepointed\n }\n | { ok: false; reason: 'same-record' | 'survivor-missing' | 'merged-missing' }\n\n/**\n * Point every row of `collection` whose `contactId` is `from` at `to`.\n *\n * Page, batch, repeat: each pass reads the rows still pointing at the\n * merged record — the ones a previous pass moved no longer match — so the\n * loop ends when a pass finds nothing. `updatedAt` moves with the pointer\n * because an integration syncing by `updatedAfter` has to see the change.\n */\nasync function repointContactId(\n firestore: FirebaseFirestore.Firestore,\n collection: FirebaseFirestore.CollectionReference,\n from: string,\n to: string,\n): Promise<number> {\n let moved = 0\n for (let pass = 0; pass < REPOINT_PASSES; pass += 1) {\n const page = await collection\n .where('contactId', '==', from)\n .limit(REPOINT_PAGE)\n .get()\n if (page.empty) break\n const batch = firestore.batch()\n for (const row of page.docs) {\n batch.update(row.ref, {\n contactId: to,\n updatedAt: FieldValue.serverTimestamp(),\n })\n }\n await batch.commit()\n moved += page.size\n if (page.size < REPOINT_PAGE) break\n }\n return moved\n}\n\n/**\n * The leads converted into the merged contact, restamped at the survivor.\n *\n * A lead lives at `hosts/{hostId}/leads/{personKey(email)}`, so there is no\n * query: each site that met either record is asked for a lead under each of\n * the merged record's addresses, and one that names the merged contact is\n * moved. Bounded by sites times addresses, both small.\n */\nasync function repointLeads(\n firestore: FirebaseFirestore.Firestore,\n hostIds: readonly string[],\n emails: readonly string[],\n from: string,\n to: string,\n): Promise<number> {\n let moved = 0\n for (const hostId of hostIds) {\n for (const email of emails) {\n const key = personKey(email)\n if (!key) continue\n const leadRef = firestore\n .collection('hosts')\n .doc(hostId)\n .collection('leads')\n .doc(key)\n const lead = await leadRef.get()\n if (!lead.exists || lead.get('convertedContactId') !== from) continue\n await leadRef.update({\n convertedContactId: to,\n updatedAt: FieldValue.serverTimestamp(),\n })\n moved += 1\n }\n }\n return moved\n}\n\nfunction strings(value: unknown): string[] {\n return Array.isArray(value)\n ? value.filter((item): item is string => typeof item === 'string' && item !== '')\n : []\n}\n\nexport async function mergeContacts(\n options: MergeContactsOptions,\n): Promise<MergeContactsResult> {\n const { firestore, orgRef, survivorId, mergedId, actor, hostId } = options\n if (!survivorId || !mergedId || survivorId === mergedId) {\n return { ok: false, reason: 'same-record' }\n }\n const contactsRef = orgRef.collection('contacts')\n const survivorRef = contactsRef.doc(survivorId)\n const mergedRef = contactsRef.doc(mergedId)\n const [survivorBefore, mergedBefore] = await Promise.all([\n survivorRef.get(),\n mergedRef.get(),\n ])\n if (!survivorBefore.exists) return { ok: false, reason: 'survivor-missing' }\n if (!mergedBefore.exists) return { ok: false, reason: 'merged-missing' }\n const mergedData = (mergedBefore.data() ?? {}) as Record<string, unknown>\n const survivorData = (survivorBefore.data() ?? {}) as Record<string, unknown>\n\n /*==========================================\n * 1. THE CHILDREN — before the parent, for the reason the header gives.\n *=========================================*/\n const [deals, tasks, activities] = await Promise.all([\n repointContactId(firestore, orgRef.collection(CRM_COLLECTIONS.deals), mergedId, survivorId),\n repointContactId(firestore, orgRef.collection(CRM_COLLECTIONS.tasks), mergedId, survivorId),\n repointContactId(\n firestore,\n orgRef.collection(CRM_COLLECTIONS.activities),\n mergedId,\n survivorId,\n ),\n ])\n const leadHosts = [\n ...new Set([\n ...strings(mergedData['capturedByHostIds']),\n ...strings(survivorData['capturedByHostIds']),\n ...strings([mergedData['hostId'], survivorData['hostId'], hostId]),\n ]),\n ]\n const leads = await repointLeads(\n firestore,\n leadHosts,\n contactEmails(mergedData),\n mergedId,\n survivorId,\n )\n\n /*==========================================\n * 2. THE SWAP — one transaction over both documents and the index.\n *=========================================*/\n const index = orgRef.collection(CONTACT_EMAIL_INDEX_COLLECTION)\n const swapped = await firestore.runTransaction(async (transaction) => {\n const [survivor, merged] = await Promise.all([\n transaction.get(survivorRef),\n transaction.get(mergedRef),\n ])\n if (!survivor.exists) return { reason: 'survivor-missing' as const }\n if (!merged.exists) return { reason: 'merged-missing' as const }\n const plan = planContactMerge(\n (survivor.data() ?? {}) as Record<string, unknown>,\n (merged.data() ?? {}) as Record<string, unknown>,\n )\n transaction.set(\n survivorRef,\n { ...plan.survivor, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n for (const email of plan.emails) {\n const key = personKey(email)\n if (!key) continue\n transaction.set(\n index.doc(key),\n { email, contactId: survivorId, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n }\n transaction.delete(mergedRef)\n return { plan, survivor: (survivor.data() ?? {}) as Record<string, unknown> }\n })\n if ('reason' in swapped) return { ok: false, reason: swapped.reason }\n const { plan } = swapped\n\n /*\n * The companies both records named counted two people and now count one\n * (AGL-2613). Settled after the swap the way the capture settles its\n * counts: a figure that could not move is logged, and the company page's\n * live aggregate corrects it.\n */\n await settleCompanyContactsCounts(orgRef.collection(CRM_COLLECTIONS.companies), {\n companyId: null,\n mirror: null,\n counts: plan.companyCounts,\n })\n /*\n * The survivor now owns the merged record's open tasks too, so its next\n * activity is the earliest of both (AGL-2661). Settled the same way: a\n * figure that could not move is logged, and the Fields section's\n * recompute corrects it.\n */\n await recomputeCrmNextTaskAt(firestore, orgRef.id, [{ contactId: survivorId }]).catch(\n (error: unknown) => {\n console.error('contact merge next activity could not be recomputed', survivorId, error)\n },\n )\n\n const mergedEmail = contactEmails(mergedData)[0] ?? ''\n const survivorEmail = plan.emails[0] ?? ''\n // The canonical name — the one identity every holder shares — for the\n // feed; the address stands in for a person nobody has named.\n const survivorName =\n typeof swapped.survivor['name'] === 'string' ? swapped.survivor['name'] : ''\n\n /*\n * THE TIMELINE NOTE, on the survivor: the merge is an event in this\n * person's history, and the record page is where a reader looks for one.\n * Filed under the calling site, or the survivor's first site for a door\n * with none; its own catch, because a note that could not be written must\n * not read as a merge that did not happen.\n */\n try {\n await orgRef.collection(CRM_COLLECTIONS.activities).add({\n kind: 'note',\n body: `Merged with ${mergedEmail}`,\n atMs: Date.now(),\n byUid: actor.uid,\n ...(options.actorName || actor.email\n ? { byName: options.actorName || actor.email }\n : {}),\n contactId: survivorId,\n visibleTo: plan.visibleTo,\n hostId: hostId ?? String(survivorData['hostId'] ?? ''),\n createdByUid: actor.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n } catch (error) {\n console.error('[contact-merge] timeline note failed', survivorId, error)\n }\n\n // The audit line (AGL-2622), in the feed of the site whose console did\n // the work. A door with no site — the API — has no host feed to write.\n if (hostId) {\n await logHostActivity(hostId, actor, `Merged with ${mergedEmail}`, {\n type: 'contact',\n id: survivorId,\n name: survivorName || survivorEmail,\n })\n }\n\n return {\n ok: true,\n survivorId,\n survivorEmail,\n mergedId,\n mergedEmail,\n emails: plan.emails,\n repointed: { deals, tasks, activities, leads },\n }\n}\n"],"names":["CONTACT_EMAIL_INDEX_COLLECTION","contactEmails","planContactMerge","CRM_COLLECTIONS","personKey","FieldValue","settleCompanyContactsCounts","recomputeCrmNextTaskAt","logHostActivity","REPOINT_PAGE","REPOINT_PASSES","repointContactId","firestore","collection","from","to","moved","pass","page","where","limit","get","empty","batch","row","docs","update","ref","contactId","updatedAt","serverTimestamp","commit","size","repointLeads","hostIds","emails","hostId","email","key","leadRef","doc","lead","exists","convertedContactId","strings","value","Array","isArray","filter","item","mergeContacts","options","mergedBefore","survivorBefore","plan","orgRef","survivorId","mergedId","actor","ok","reason","contactsRef","survivorRef","mergedRef","Promise","all","mergedData","data","survivorData","deals","tasks","activities","leadHosts","Set","leads","index","swapped","runTransaction","transaction","survivor","merged","set","merge","delete","companies","companyId","mirror","counts","companyCounts","id","catch","error","console","mergedEmail","survivorEmail","survivorName","add","kind","body","atMs","Date","now","byUid","uid","actorName","byName","visibleTo","String","createdByUid","createdAt","type","name","repointed"],"mappings":"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"}
|
|
@@ -0,0 +1,153 @@
|
|
|
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
|
+
export declare const CONTACT_SUPPRESSIONS_COLLECTION = "contactSuppressions";
|
|
18
|
+
/**
|
|
19
|
+
* The channels §11 names. Kept as an explicit list rather than a boolean
|
|
20
|
+
* because they are governed differently — a STOP text is a texting opt-out by
|
|
21
|
+
* statute, and reading it as a calling opt-out too would be us inventing a
|
|
22
|
+
* request the person did not make. Callers that mean both say both.
|
|
23
|
+
*/
|
|
24
|
+
export declare const CONTACT_CHANNELS: readonly ["calls", "texts"];
|
|
25
|
+
export type ContactChannel = (typeof CONTACT_CHANNELS)[number];
|
|
26
|
+
/** Where the request came in. Every route §11 offers, plus staff-initiated. */
|
|
27
|
+
export type ContactSuppressionSource =
|
|
28
|
+
/** Reply STOP (or another CTIA keyword) to a marketing text. */
|
|
29
|
+
'sms-keyword'
|
|
30
|
+
/** Emailed privacy@aglyn.com and a human recorded it. */
|
|
31
|
+
| 'email'
|
|
32
|
+
/** Said so on a call and a human recorded it. */
|
|
33
|
+
| 'verbal'
|
|
34
|
+
/** The user asked us to delete the number they had on file. */
|
|
35
|
+
| 'erasure-request'
|
|
36
|
+
/** Recorded by staff for any other reason (a complaint, a bounce, a court). */
|
|
37
|
+
| 'staff';
|
|
38
|
+
export interface SuppressPhoneContactInput {
|
|
39
|
+
/** Anything `normalizePhone` accepts; stored as E.164. */
|
|
40
|
+
phoneNumber: string;
|
|
41
|
+
/** Defaults to BOTH channels — the conservative reading of an ambiguous ask. */
|
|
42
|
+
channels?: ContactChannel[];
|
|
43
|
+
source: ContactSuppressionSource;
|
|
44
|
+
/** The account the request was matched to, when it was matched to one. */
|
|
45
|
+
uid?: string | null;
|
|
46
|
+
/** Staff member who took the email or the call. */
|
|
47
|
+
recordedByUid?: string | null;
|
|
48
|
+
/** Free text: what they actually said, the ticket id, the case number. */
|
|
49
|
+
note?: string | null;
|
|
50
|
+
/**
|
|
51
|
+
* True only when the person also asked us to stop holding the number.
|
|
52
|
+
* `seedUserProfile` reads this to refuse an IdP re-assertion, so it is the
|
|
53
|
+
* difference between "do not call me" and "do not keep my number".
|
|
54
|
+
*/
|
|
55
|
+
erasePhoneOnFile?: boolean;
|
|
56
|
+
/** Injectable for tests; defaults to the admin app's Firestore. */
|
|
57
|
+
firestore?: any;
|
|
58
|
+
}
|
|
59
|
+
export interface ContactSuppressionRecord {
|
|
60
|
+
phoneNumber: string;
|
|
61
|
+
channels: ContactChannel[];
|
|
62
|
+
source: ContactSuppressionSource;
|
|
63
|
+
uid: string | null;
|
|
64
|
+
recordedByUid: string | null;
|
|
65
|
+
note: string | null;
|
|
66
|
+
erasePhoneOnFile: boolean;
|
|
67
|
+
/** Non-null once the person opted back in. A revoked record does not suppress. */
|
|
68
|
+
revokedAt: unknown | null;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Document id for an E.164 number: the digits, with the `+` dropped.
|
|
72
|
+
*
|
|
73
|
+
* The `+` is a legal Firestore id character, but it is also the one character
|
|
74
|
+
* that means "space" in a form-encoded query string, so a number carried
|
|
75
|
+
* through a URL somewhere down the line would silently become a different
|
|
76
|
+
* number. Dropping it here costs nothing — the full E.164 is stored in the
|
|
77
|
+
* document — and removes the whole class of bug in advance.
|
|
78
|
+
*
|
|
79
|
+
* @returns the id, or `null` when the input is not a number we can be sure of.
|
|
80
|
+
* Never a best-guess id: suppressing the wrong subscriber and
|
|
81
|
+
* believing you suppressed the right one is worse than refusing.
|
|
82
|
+
*/
|
|
83
|
+
export declare function contactSuppressionKey(phoneNumber: string | null | undefined): string | null;
|
|
84
|
+
/**
|
|
85
|
+
* Record a do-not-contact request. Idempotent by document id, so a retried
|
|
86
|
+
* request or a second STOP cannot produce a second record.
|
|
87
|
+
*
|
|
88
|
+
* Merging rather than replacing, and the channel list UNIONS with whatever is
|
|
89
|
+
* already recorded: someone who opted out of texts in March and calls in June
|
|
90
|
+
* is opted out of both in July. Narrowing a suppression is `releasePhoneContact`
|
|
91
|
+
* and nothing else — no write on this path may ever make a person MORE
|
|
92
|
+
* reachable than they already were.
|
|
93
|
+
*
|
|
94
|
+
* A previously revoked record is un-revoked, because a fresh request is a
|
|
95
|
+
* fresh request.
|
|
96
|
+
*
|
|
97
|
+
* @throws when the number cannot be normalized. Deliberately NOT best-effort:
|
|
98
|
+
* a silently dropped opt-out is the exact failure this issue exists to
|
|
99
|
+
* fix, so the caller has to see it.
|
|
100
|
+
*/
|
|
101
|
+
export declare function suppressPhoneContact(input: SuppressPhoneContactInput): Promise<{
|
|
102
|
+
phoneNumber: string;
|
|
103
|
+
channels: ContactChannel[];
|
|
104
|
+
created: boolean;
|
|
105
|
+
}>;
|
|
106
|
+
/**
|
|
107
|
+
* Opt back in (a START/UNSTOP reply, or a staff correction of a mis-recorded
|
|
108
|
+
* opt-out). The record is kept and marked revoked, never deleted.
|
|
109
|
+
*
|
|
110
|
+
* IMPORTANT — this does NOT create consent. Clearing a suppression only
|
|
111
|
+
* removes a prohibition; it does not manufacture the prior express written
|
|
112
|
+
* consent that TCPA requires before a marketing call or text may be placed at
|
|
113
|
+
* all. That mechanism does not exist yet (AGL-1564, and the privacy benchmark
|
|
114
|
+
* items 9–10), so after this call the correct number of marketing messages to
|
|
115
|
+
* send is still zero.
|
|
116
|
+
*
|
|
117
|
+
* @returns false when there was no record to revoke.
|
|
118
|
+
*/
|
|
119
|
+
export declare function releasePhoneContact(input: {
|
|
120
|
+
phoneNumber: string;
|
|
121
|
+
releasedByUid?: string | null;
|
|
122
|
+
note?: string | null;
|
|
123
|
+
firestore?: any;
|
|
124
|
+
}): Promise<boolean>;
|
|
125
|
+
/**
|
|
126
|
+
* The dial-time gate. Every outbound marketing call or text MUST pass through
|
|
127
|
+
* this before the number reaches a provider.
|
|
128
|
+
*
|
|
129
|
+
* FAILS CLOSED. A read that throws — rules, quota, a cold Firestore — answers
|
|
130
|
+
* `true`, meaning "treat as suppressed". The alternative is a list outage that
|
|
131
|
+
* silently turns into a round of calls to people who asked us never to call
|
|
132
|
+
* again, which is a statutory violation per call rather than a delayed
|
|
133
|
+
* campaign. Never "fix" a flaky read here by returning false.
|
|
134
|
+
*/
|
|
135
|
+
export declare function isPhoneContactSuppressed(phoneNumber: string | null | undefined, channel?: ContactChannel, injectedFirestore?: any): Promise<boolean>;
|
|
136
|
+
/**
|
|
137
|
+
* Read one record, for the staff surface and for `seedUserProfile`'s
|
|
138
|
+
* re-assertion guard.
|
|
139
|
+
*/
|
|
140
|
+
export declare function getContactSuppression(phoneNumber: string | null | undefined, injectedFirestore?: any): Promise<(ContactSuppressionRecord & {
|
|
141
|
+
$id: string;
|
|
142
|
+
}) | null>;
|
|
143
|
+
/**
|
|
144
|
+
* The staff queue, newest first. Ordered by `updatedAt` so a re-recorded or
|
|
145
|
+
* revoked entry surfaces again — the operator's question is "what changed",
|
|
146
|
+
* not "what was first written".
|
|
147
|
+
*/
|
|
148
|
+
export declare function listContactSuppressions(options?: {
|
|
149
|
+
limit?: number;
|
|
150
|
+
firestore?: any;
|
|
151
|
+
}): Promise<Array<ContactSuppressionRecord & {
|
|
152
|
+
$id: string;
|
|
153
|
+
}>>;
|