@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,259 @@
|
|
|
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
|
+
* The do-not-contact list, `contactSuppressions/{nationalKey}` (AGL-1592).
|
|
19
|
+
*
|
|
20
|
+
* WHY THIS EXISTS
|
|
21
|
+
*
|
|
22
|
+
* Privacy Policy v4 §11 tells users they may opt out of marketing calls and
|
|
23
|
+
* texts, and may ask us to delete the phone number we hold. Neither had a
|
|
24
|
+
* mechanism. This is the first half — the durable record that makes "never
|
|
25
|
+
* contact me again" survivable. The second half (`forgetUserPhoneNumber`)
|
|
26
|
+
* lives in `user-profiles.ts`, next to the seed it has to defeat.
|
|
27
|
+
*
|
|
28
|
+
* THE TENSION, AND HOW IT IS RESOLVED
|
|
29
|
+
*
|
|
30
|
+
* "Delete my phone number" and "stop contacting me" are DIFFERENT REQUESTS
|
|
31
|
+
* WITH OPPOSITE DATA OUTCOMES, and §11 currently offers both in one sentence.
|
|
32
|
+
* A suppression list that forgets the identifier cannot suppress anything: the
|
|
33
|
+
* only way to guarantee a number is never dialled is to keep the number and
|
|
34
|
+
* check it before dialling. Forgetting it does not protect the person — it
|
|
35
|
+
* removes the one artefact that could have protected them, and the next list
|
|
36
|
+
* imported from a customer's CRM re-adds them silently.
|
|
37
|
+
*
|
|
38
|
+
* So the two requests are implemented as two operations:
|
|
39
|
+
*
|
|
40
|
+
* - "stop contacting me" → `suppressPhoneContact`. The number is RETAINED,
|
|
41
|
+
* here and only here. Whatever copy the profile
|
|
42
|
+
* holds is left alone; they did not ask us to
|
|
43
|
+
* drop it.
|
|
44
|
+
* - "delete my number" → `forgetUserPhoneNumber` (user-profiles.ts).
|
|
45
|
+
* The profile copy is cleared, the account is
|
|
46
|
+
* marked so an IdP cannot re-assert it, AND a
|
|
47
|
+
* suppression record is written here with
|
|
48
|
+
* `erasePhoneOnFile: true`.
|
|
49
|
+
*
|
|
50
|
+
* That last clause is the whole point: a number we no longer hold anywhere is
|
|
51
|
+
* a number we cannot recognise, and therefore a number we will happily dial
|
|
52
|
+
* the day it arrives from somewhere else. Deletion WITHOUT suppression is the
|
|
53
|
+
* outcome that gets the person called. So the minimal retained record is not
|
|
54
|
+
* a hedge against the deletion request — it is the only way to honour what the
|
|
55
|
+
* person actually wanted.
|
|
56
|
+
*
|
|
57
|
+
* This carve-out is the standard one and is not optional: CCPA §1798.105(d)
|
|
58
|
+
* lets a business keep what it needs to honour an opt-out, and the TSR's
|
|
59
|
+
* entity-specific do-not-call duty is unmeetable without a retained list.
|
|
60
|
+
* §11's current wording does not say so, and should — see the wording proposal
|
|
61
|
+
* recorded on AGL-1592.
|
|
62
|
+
*
|
|
63
|
+
* WHY KEYED ON THE NUMBER AND NOT ON A `uid`
|
|
64
|
+
*
|
|
65
|
+
* The number is what gets dialled. At dial time an outbound programme holds a
|
|
66
|
+
* phone number and often nothing else, so the check has to be answerable from
|
|
67
|
+
* the number alone — one `get()` by document id, no query, no composite index
|
|
68
|
+
* that could go missing and fail the lookup open.
|
|
69
|
+
*
|
|
70
|
+
* It is also the only key that spans accounts. One human can reach us as a
|
|
71
|
+
* self-serve signup AND as an SSO account in their employer's GCIP tenant,
|
|
72
|
+
* with a different `uid` in each and a third if they are ever reprovisioned.
|
|
73
|
+
* A `uid`-keyed record would suppress one of those and leave the same person
|
|
74
|
+
* reachable on the same handset through the others.
|
|
75
|
+
*
|
|
76
|
+
* WHY THE RECORD IS NOT HASHED
|
|
77
|
+
*
|
|
78
|
+
* A phone number is a low-entropy identifier — NANP is ~10^10 values, which a
|
|
79
|
+
* laptop enumerates against SHA-256 in minutes. Hashing it would buy the
|
|
80
|
+
* appearance of de-identification and none of the substance, while making the
|
|
81
|
+
* record unauditable by the person who has to honour it. The collection is
|
|
82
|
+
* Admin-SDK-only in rules (`read, write: if false`), which is strictly less
|
|
83
|
+
* exposed than `users/{uid}.phoneNumber` already is — that one is readable by
|
|
84
|
+
* its owner.
|
|
85
|
+
*
|
|
86
|
+
* WHY A REVOCATION IS A FIELD AND NOT A DELETE
|
|
87
|
+
*
|
|
88
|
+
* Same reasoning as the clickwrap acceptances: the record exists to be
|
|
89
|
+
* evidence. Deleting it on opt-in destroys the proof that the opt-out was
|
|
90
|
+
* honoured for the period it was in force. `revokedAt` is set instead, and
|
|
91
|
+
* every read treats a revoked record as not suppressed.
|
|
92
|
+
*/ import { normalizePhone } from "@aglyn/aglyn/server";
|
|
93
|
+
import { FieldValue } from "firebase-admin/firestore";
|
|
94
|
+
import firebaseAdmin from "./firebase-admin.js";
|
|
95
|
+
const firestore = ()=>firebaseAdmin.app().firestore();
|
|
96
|
+
export const CONTACT_SUPPRESSIONS_COLLECTION = 'contactSuppressions';
|
|
97
|
+
/**
|
|
98
|
+
* The channels §11 names. Kept as an explicit list rather than a boolean
|
|
99
|
+
* because they are governed differently — a STOP text is a texting opt-out by
|
|
100
|
+
* statute, and reading it as a calling opt-out too would be us inventing a
|
|
101
|
+
* request the person did not make. Callers that mean both say both.
|
|
102
|
+
*/ export const CONTACT_CHANNELS = [
|
|
103
|
+
'calls',
|
|
104
|
+
'texts'
|
|
105
|
+
];
|
|
106
|
+
/**
|
|
107
|
+
* Document id for an E.164 number: the digits, with the `+` dropped.
|
|
108
|
+
*
|
|
109
|
+
* The `+` is a legal Firestore id character, but it is also the one character
|
|
110
|
+
* that means "space" in a form-encoded query string, so a number carried
|
|
111
|
+
* through a URL somewhere down the line would silently become a different
|
|
112
|
+
* number. Dropping it here costs nothing — the full E.164 is stored in the
|
|
113
|
+
* document — and removes the whole class of bug in advance.
|
|
114
|
+
*
|
|
115
|
+
* @returns the id, or `null` when the input is not a number we can be sure of.
|
|
116
|
+
* Never a best-guess id: suppressing the wrong subscriber and
|
|
117
|
+
* believing you suppressed the right one is worse than refusing.
|
|
118
|
+
*/ export function contactSuppressionKey(phoneNumber) {
|
|
119
|
+
const e164 = normalizePhone(phoneNumber);
|
|
120
|
+
return e164 ? e164.slice(1) : null;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Record a do-not-contact request. Idempotent by document id, so a retried
|
|
124
|
+
* request or a second STOP cannot produce a second record.
|
|
125
|
+
*
|
|
126
|
+
* Merging rather than replacing, and the channel list UNIONS with whatever is
|
|
127
|
+
* already recorded: someone who opted out of texts in March and calls in June
|
|
128
|
+
* is opted out of both in July. Narrowing a suppression is `releasePhoneContact`
|
|
129
|
+
* and nothing else — no write on this path may ever make a person MORE
|
|
130
|
+
* reachable than they already were.
|
|
131
|
+
*
|
|
132
|
+
* A previously revoked record is un-revoked, because a fresh request is a
|
|
133
|
+
* fresh request.
|
|
134
|
+
*
|
|
135
|
+
* @throws when the number cannot be normalized. Deliberately NOT best-effort:
|
|
136
|
+
* a silently dropped opt-out is the exact failure this issue exists to
|
|
137
|
+
* fix, so the caller has to see it.
|
|
138
|
+
*/ export async function suppressPhoneContact(input) {
|
|
139
|
+
var _input_firestore, _ref, _input_uid, _input_recordedByUid, _input_note;
|
|
140
|
+
var _input_channels;
|
|
141
|
+
const e164 = normalizePhone(input.phoneNumber);
|
|
142
|
+
const key = e164 ? e164.slice(1) : null;
|
|
143
|
+
if (!e164 || !key) {
|
|
144
|
+
throw new Error(`[contact-suppression] cannot normalize phone number for suppression`);
|
|
145
|
+
}
|
|
146
|
+
const db = (_input_firestore = input.firestore) != null ? _input_firestore : firestore();
|
|
147
|
+
const ref = db.collection(CONTACT_SUPPRESSIONS_COLLECTION).doc(key);
|
|
148
|
+
const snapshot = await ref.get();
|
|
149
|
+
const requested = (((_input_channels = input.channels) == null ? void 0 : _input_channels.length) ? input.channels : CONTACT_CHANNELS).filter((channel)=>CONTACT_CHANNELS.includes(channel));
|
|
150
|
+
const existing = Array.isArray(snapshot.get('channels')) ? snapshot.get('channels') : [];
|
|
151
|
+
const channels = CONTACT_CHANNELS.filter((channel)=>requested.includes(channel) || existing.includes(channel));
|
|
152
|
+
await ref.set(_extends({
|
|
153
|
+
phoneNumber: e164,
|
|
154
|
+
channels,
|
|
155
|
+
source: input.source,
|
|
156
|
+
uid: (_ref = (_input_uid = input.uid) != null ? _input_uid : snapshot.get('uid')) != null ? _ref : null,
|
|
157
|
+
recordedByUid: (_input_recordedByUid = input.recordedByUid) != null ? _input_recordedByUid : null,
|
|
158
|
+
note: (_input_note = input.note) != null ? _input_note : null,
|
|
159
|
+
// Sticky: an erasure request recorded in March is still an erasure
|
|
160
|
+
// request after a plain "stop texting me" arrives in June. Only an
|
|
161
|
+
// explicit re-supply of the number clears it, and that happens on the
|
|
162
|
+
// profile, not here.
|
|
163
|
+
erasePhoneOnFile: input.erasePhoneOnFile === true || snapshot.get('erasePhoneOnFile') === true,
|
|
164
|
+
revokedAt: null,
|
|
165
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
166
|
+
}, snapshot.exists ? {} : {
|
|
167
|
+
createdAt: FieldValue.serverTimestamp()
|
|
168
|
+
}), {
|
|
169
|
+
merge: true
|
|
170
|
+
});
|
|
171
|
+
return {
|
|
172
|
+
phoneNumber: e164,
|
|
173
|
+
channels,
|
|
174
|
+
created: !snapshot.exists
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Opt back in (a START/UNSTOP reply, or a staff correction of a mis-recorded
|
|
179
|
+
* opt-out). The record is kept and marked revoked, never deleted.
|
|
180
|
+
*
|
|
181
|
+
* IMPORTANT — this does NOT create consent. Clearing a suppression only
|
|
182
|
+
* removes a prohibition; it does not manufacture the prior express written
|
|
183
|
+
* consent that TCPA requires before a marketing call or text may be placed at
|
|
184
|
+
* all. That mechanism does not exist yet (AGL-1564, and the privacy benchmark
|
|
185
|
+
* items 9–10), so after this call the correct number of marketing messages to
|
|
186
|
+
* send is still zero.
|
|
187
|
+
*
|
|
188
|
+
* @returns false when there was no record to revoke.
|
|
189
|
+
*/ export async function releasePhoneContact(input) {
|
|
190
|
+
var _input_firestore, _input_releasedByUid, _input_note;
|
|
191
|
+
const key = contactSuppressionKey(input.phoneNumber);
|
|
192
|
+
if (!key) return false;
|
|
193
|
+
const db = (_input_firestore = input.firestore) != null ? _input_firestore : firestore();
|
|
194
|
+
const ref = db.collection(CONTACT_SUPPRESSIONS_COLLECTION).doc(key);
|
|
195
|
+
const snapshot = await ref.get();
|
|
196
|
+
if (!snapshot.exists || snapshot.get('revokedAt')) return false;
|
|
197
|
+
await ref.set({
|
|
198
|
+
revokedAt: FieldValue.serverTimestamp(),
|
|
199
|
+
revokedByUid: (_input_releasedByUid = input.releasedByUid) != null ? _input_releasedByUid : null,
|
|
200
|
+
revokedNote: (_input_note = input.note) != null ? _input_note : null
|
|
201
|
+
}, {
|
|
202
|
+
merge: true
|
|
203
|
+
});
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* The dial-time gate. Every outbound marketing call or text MUST pass through
|
|
208
|
+
* this before the number reaches a provider.
|
|
209
|
+
*
|
|
210
|
+
* FAILS CLOSED. A read that throws — rules, quota, a cold Firestore — answers
|
|
211
|
+
* `true`, meaning "treat as suppressed". The alternative is a list outage that
|
|
212
|
+
* silently turns into a round of calls to people who asked us never to call
|
|
213
|
+
* again, which is a statutory violation per call rather than a delayed
|
|
214
|
+
* campaign. Never "fix" a flaky read here by returning false.
|
|
215
|
+
*/ export async function isPhoneContactSuppressed(phoneNumber, channel, injectedFirestore) {
|
|
216
|
+
const key = contactSuppressionKey(phoneNumber);
|
|
217
|
+
// An unrecognizable number is one we cannot check against the list, so it is
|
|
218
|
+
// one we must not dial. Same fail-closed rule.
|
|
219
|
+
if (!key) return true;
|
|
220
|
+
try {
|
|
221
|
+
const db = injectedFirestore != null ? injectedFirestore : firestore();
|
|
222
|
+
const snapshot = await db.collection(CONTACT_SUPPRESSIONS_COLLECTION).doc(key).get();
|
|
223
|
+
if (!snapshot.exists) return false;
|
|
224
|
+
if (snapshot.get('revokedAt')) return false;
|
|
225
|
+
if (!channel) return true;
|
|
226
|
+
const channels = snapshot.get('channels');
|
|
227
|
+
return Array.isArray(channels) ? channels.includes(channel) : true;
|
|
228
|
+
} catch (error) {
|
|
229
|
+
console.error('[contact-suppression] lookup failed; failing closed', error);
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Read one record, for the staff surface and for `seedUserProfile`'s
|
|
235
|
+
* re-assertion guard.
|
|
236
|
+
*/ export async function getContactSuppression(phoneNumber, injectedFirestore) {
|
|
237
|
+
const key = contactSuppressionKey(phoneNumber);
|
|
238
|
+
if (!key) return null;
|
|
239
|
+
const db = injectedFirestore != null ? injectedFirestore : firestore();
|
|
240
|
+
const snapshot = await db.collection(CONTACT_SUPPRESSIONS_COLLECTION).doc(key).get();
|
|
241
|
+
if (!snapshot.exists) return null;
|
|
242
|
+
return _extends({
|
|
243
|
+
$id: snapshot.id
|
|
244
|
+
}, snapshot.data());
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* The staff queue, newest first. Ordered by `updatedAt` so a re-recorded or
|
|
248
|
+
* revoked entry surfaces again — the operator's question is "what changed",
|
|
249
|
+
* not "what was first written".
|
|
250
|
+
*/ export async function listContactSuppressions(options) {
|
|
251
|
+
var _ref, _ref1;
|
|
252
|
+
const db = (_ref = options == null ? void 0 : options.firestore) != null ? _ref : firestore();
|
|
253
|
+
const snapshot = await db.collection(CONTACT_SUPPRESSIONS_COLLECTION).orderBy('updatedAt', 'desc').limit(Math.min(Math.max((_ref1 = options == null ? void 0 : options.limit) != null ? _ref1 : 100, 1), 500)).get();
|
|
254
|
+
return snapshot.docs.map((doc)=>_extends({
|
|
255
|
+
$id: doc.id
|
|
256
|
+
}, doc.data()));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
//# sourceMappingURL=contact-suppression.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/contact-suppression.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 do-not-contact list, `contactSuppressions/{nationalKey}` (AGL-1592).\n *\n * WHY THIS EXISTS\n *\n * Privacy Policy v4 §11 tells users they may opt out of marketing calls and\n * texts, and may ask us to delete the phone number we hold. Neither had a\n * mechanism. This is the first half — the durable record that makes \"never\n * contact me again\" survivable. The second half (`forgetUserPhoneNumber`)\n * lives in `user-profiles.ts`, next to the seed it has to defeat.\n *\n * THE TENSION, AND HOW IT IS RESOLVED\n *\n * \"Delete my phone number\" and \"stop contacting me\" are DIFFERENT REQUESTS\n * WITH OPPOSITE DATA OUTCOMES, and §11 currently offers both in one sentence.\n * A suppression list that forgets the identifier cannot suppress anything: the\n * only way to guarantee a number is never dialled is to keep the number and\n * check it before dialling. Forgetting it does not protect the person — it\n * removes the one artefact that could have protected them, and the next list\n * imported from a customer's CRM re-adds them silently.\n *\n * So the two requests are implemented as two operations:\n *\n * - \"stop contacting me\" → `suppressPhoneContact`. The number is RETAINED,\n * here and only here. Whatever copy the profile\n * holds is left alone; they did not ask us to\n * drop it.\n * - \"delete my number\" → `forgetUserPhoneNumber` (user-profiles.ts).\n * The profile copy is cleared, the account is\n * marked so an IdP cannot re-assert it, AND a\n * suppression record is written here with\n * `erasePhoneOnFile: true`.\n *\n * That last clause is the whole point: a number we no longer hold anywhere is\n * a number we cannot recognise, and therefore a number we will happily dial\n * the day it arrives from somewhere else. Deletion WITHOUT suppression is the\n * outcome that gets the person called. So the minimal retained record is not\n * a hedge against the deletion request — it is the only way to honour what the\n * person actually wanted.\n *\n * This carve-out is the standard one and is not optional: CCPA §1798.105(d)\n * lets a business keep what it needs to honour an opt-out, and the TSR's\n * entity-specific do-not-call duty is unmeetable without a retained list.\n * §11's current wording does not say so, and should — see the wording proposal\n * recorded on AGL-1592.\n *\n * WHY KEYED ON THE NUMBER AND NOT ON A `uid`\n *\n * The number is what gets dialled. At dial time an outbound programme holds a\n * phone number and often nothing else, so the check has to be answerable from\n * the number alone — one `get()` by document id, no query, no composite index\n * that could go missing and fail the lookup open.\n *\n * It is also the only key that spans accounts. One human can reach us as a\n * self-serve signup AND as an SSO account in their employer's GCIP tenant,\n * with a different `uid` in each and a third if they are ever reprovisioned.\n * A `uid`-keyed record would suppress one of those and leave the same person\n * reachable on the same handset through the others.\n *\n * WHY THE RECORD IS NOT HASHED\n *\n * A phone number is a low-entropy identifier — NANP is ~10^10 values, which a\n * laptop enumerates against SHA-256 in minutes. Hashing it would buy the\n * appearance of de-identification and none of the substance, while making the\n * record unauditable by the person who has to honour it. The collection is\n * Admin-SDK-only in rules (`read, write: if false`), which is strictly less\n * exposed than `users/{uid}.phoneNumber` already is — that one is readable by\n * its owner.\n *\n * WHY A REVOCATION IS A FIELD AND NOT A DELETE\n *\n * Same reasoning as the clickwrap acceptances: the record exists to be\n * evidence. Deleting it on opt-in destroys the proof that the opt-out was\n * honoured for the period it was in force. `revokedAt` is set instead, and\n * every read treats a revoked record as not suppressed.\n */\n\nimport { normalizePhone } from '@aglyn/aglyn/server'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport firebaseAdmin from './firebase-admin'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\nexport const CONTACT_SUPPRESSIONS_COLLECTION = 'contactSuppressions'\n\n/**\n * The channels §11 names. Kept as an explicit list rather than a boolean\n * because they are governed differently — a STOP text is a texting opt-out by\n * statute, and reading it as a calling opt-out too would be us inventing a\n * request the person did not make. Callers that mean both say both.\n */\nexport const CONTACT_CHANNELS = ['calls', 'texts'] as const\nexport type ContactChannel = (typeof CONTACT_CHANNELS)[number]\n\n/** Where the request came in. Every route §11 offers, plus staff-initiated. */\nexport type ContactSuppressionSource =\n /** Reply STOP (or another CTIA keyword) to a marketing text. */\n | 'sms-keyword'\n /** Emailed privacy@aglyn.com and a human recorded it. */\n | 'email'\n /** Said so on a call and a human recorded it. */\n | 'verbal'\n /** The user asked us to delete the number they had on file. */\n | 'erasure-request'\n /** Recorded by staff for any other reason (a complaint, a bounce, a court). */\n | 'staff'\n\nexport interface SuppressPhoneContactInput {\n /** Anything `normalizePhone` accepts; stored as E.164. */\n phoneNumber: string\n /** Defaults to BOTH channels — the conservative reading of an ambiguous ask. */\n channels?: ContactChannel[]\n source: ContactSuppressionSource\n /** The account the request was matched to, when it was matched to one. */\n uid?: string | null\n /** Staff member who took the email or the call. */\n recordedByUid?: string | null\n /** Free text: what they actually said, the ticket id, the case number. */\n note?: string | null\n /**\n * True only when the person also asked us to stop holding the number.\n * `seedUserProfile` reads this to refuse an IdP re-assertion, so it is the\n * difference between \"do not call me\" and \"do not keep my number\".\n */\n erasePhoneOnFile?: boolean\n /** Injectable for tests; defaults to the admin app's Firestore. */\n firestore?: any\n}\n\nexport interface ContactSuppressionRecord {\n phoneNumber: string\n channels: ContactChannel[]\n source: ContactSuppressionSource\n uid: string | null\n recordedByUid: string | null\n note: string | null\n erasePhoneOnFile: boolean\n /** Non-null once the person opted back in. A revoked record does not suppress. */\n revokedAt: unknown | null\n}\n\n/**\n * Document id for an E.164 number: the digits, with the `+` dropped.\n *\n * The `+` is a legal Firestore id character, but it is also the one character\n * that means \"space\" in a form-encoded query string, so a number carried\n * through a URL somewhere down the line would silently become a different\n * number. Dropping it here costs nothing — the full E.164 is stored in the\n * document — and removes the whole class of bug in advance.\n *\n * @returns the id, or `null` when the input is not a number we can be sure of.\n * Never a best-guess id: suppressing the wrong subscriber and\n * believing you suppressed the right one is worse than refusing.\n */\nexport function contactSuppressionKey(\n phoneNumber: string | null | undefined,\n): string | null {\n const e164 = normalizePhone(phoneNumber)\n return e164 ? e164.slice(1) : null\n}\n\n/**\n * Record a do-not-contact request. Idempotent by document id, so a retried\n * request or a second STOP cannot produce a second record.\n *\n * Merging rather than replacing, and the channel list UNIONS with whatever is\n * already recorded: someone who opted out of texts in March and calls in June\n * is opted out of both in July. Narrowing a suppression is `releasePhoneContact`\n * and nothing else — no write on this path may ever make a person MORE\n * reachable than they already were.\n *\n * A previously revoked record is un-revoked, because a fresh request is a\n * fresh request.\n *\n * @throws when the number cannot be normalized. Deliberately NOT best-effort:\n * a silently dropped opt-out is the exact failure this issue exists to\n * fix, so the caller has to see it.\n */\nexport async function suppressPhoneContact(\n input: SuppressPhoneContactInput,\n): Promise<{ phoneNumber: string; channels: ContactChannel[]; created: boolean }> {\n const e164 = normalizePhone(input.phoneNumber)\n const key = e164 ? e164.slice(1) : null\n if (!e164 || !key) {\n throw new Error(\n `[contact-suppression] cannot normalize phone number for suppression`,\n )\n }\n const db = input.firestore ?? firestore()\n const ref = db.collection(CONTACT_SUPPRESSIONS_COLLECTION).doc(key)\n const snapshot = await ref.get()\n\n const requested = (input.channels?.length ? input.channels : CONTACT_CHANNELS)\n .filter((channel): channel is ContactChannel =>\n (CONTACT_CHANNELS as readonly string[]).includes(channel),\n )\n const existing: ContactChannel[] = Array.isArray(snapshot.get('channels'))\n ? snapshot.get('channels')\n : []\n const channels = CONTACT_CHANNELS.filter(\n (channel) => requested.includes(channel) || existing.includes(channel),\n )\n\n await ref.set(\n {\n phoneNumber: e164,\n channels,\n source: input.source,\n uid: input.uid ?? snapshot.get('uid') ?? null,\n recordedByUid: input.recordedByUid ?? null,\n note: input.note ?? null,\n // Sticky: an erasure request recorded in March is still an erasure\n // request after a plain \"stop texting me\" arrives in June. Only an\n // explicit re-supply of the number clears it, and that happens on the\n // profile, not here.\n erasePhoneOnFile:\n input.erasePhoneOnFile === true || snapshot.get('erasePhoneOnFile') === true,\n revokedAt: null,\n updatedAt: FieldValue.serverTimestamp(),\n ...(snapshot.exists ? {} : { createdAt: FieldValue.serverTimestamp() }),\n },\n { merge: true },\n )\n return { phoneNumber: e164, channels, created: !snapshot.exists }\n}\n\n/**\n * Opt back in (a START/UNSTOP reply, or a staff correction of a mis-recorded\n * opt-out). The record is kept and marked revoked, never deleted.\n *\n * IMPORTANT — this does NOT create consent. Clearing a suppression only\n * removes a prohibition; it does not manufacture the prior express written\n * consent that TCPA requires before a marketing call or text may be placed at\n * all. That mechanism does not exist yet (AGL-1564, and the privacy benchmark\n * items 9–10), so after this call the correct number of marketing messages to\n * send is still zero.\n *\n * @returns false when there was no record to revoke.\n */\nexport async function releasePhoneContact(input: {\n phoneNumber: string\n releasedByUid?: string | null\n note?: string | null\n firestore?: any\n}): Promise<boolean> {\n const key = contactSuppressionKey(input.phoneNumber)\n if (!key) return false\n const db = input.firestore ?? firestore()\n const ref = db.collection(CONTACT_SUPPRESSIONS_COLLECTION).doc(key)\n const snapshot = await ref.get()\n if (!snapshot.exists || snapshot.get('revokedAt')) return false\n await ref.set(\n {\n revokedAt: FieldValue.serverTimestamp(),\n revokedByUid: input.releasedByUid ?? null,\n revokedNote: input.note ?? null,\n },\n { merge: true },\n )\n return true\n}\n\n/**\n * The dial-time gate. Every outbound marketing call or text MUST pass through\n * this before the number reaches a provider.\n *\n * FAILS CLOSED. A read that throws — rules, quota, a cold Firestore — answers\n * `true`, meaning \"treat as suppressed\". The alternative is a list outage that\n * silently turns into a round of calls to people who asked us never to call\n * again, which is a statutory violation per call rather than a delayed\n * campaign. Never \"fix\" a flaky read here by returning false.\n */\nexport async function isPhoneContactSuppressed(\n phoneNumber: string | null | undefined,\n channel?: ContactChannel,\n injectedFirestore?: any,\n): Promise<boolean> {\n const key = contactSuppressionKey(phoneNumber)\n // An unrecognizable number is one we cannot check against the list, so it is\n // one we must not dial. Same fail-closed rule.\n if (!key) return true\n try {\n const db = injectedFirestore ?? firestore()\n const snapshot = await db\n .collection(CONTACT_SUPPRESSIONS_COLLECTION)\n .doc(key)\n .get()\n if (!snapshot.exists) return false\n if (snapshot.get('revokedAt')) return false\n if (!channel) return true\n const channels = snapshot.get('channels')\n return Array.isArray(channels) ? channels.includes(channel) : true\n } catch (error) {\n console.error('[contact-suppression] lookup failed; failing closed', error)\n return true\n }\n}\n\n/**\n * Read one record, for the staff surface and for `seedUserProfile`'s\n * re-assertion guard.\n */\nexport async function getContactSuppression(\n phoneNumber: string | null | undefined,\n injectedFirestore?: any,\n): Promise<(ContactSuppressionRecord & { $id: string }) | null> {\n const key = contactSuppressionKey(phoneNumber)\n if (!key) return null\n const db = injectedFirestore ?? firestore()\n const snapshot = await db\n .collection(CONTACT_SUPPRESSIONS_COLLECTION)\n .doc(key)\n .get()\n if (!snapshot.exists) return null\n return { $id: snapshot.id, ...(snapshot.data() as ContactSuppressionRecord) }\n}\n\n/**\n * The staff queue, newest first. Ordered by `updatedAt` so a re-recorded or\n * revoked entry surfaces again — the operator's question is \"what changed\",\n * not \"what was first written\".\n */\nexport async function listContactSuppressions(options?: {\n limit?: number\n firestore?: any\n}): Promise<Array<ContactSuppressionRecord & { $id: string }>> {\n const db = options?.firestore ?? firestore()\n const snapshot = await db\n .collection(CONTACT_SUPPRESSIONS_COLLECTION)\n .orderBy('updatedAt', 'desc')\n .limit(Math.min(Math.max(options?.limit ?? 100, 1), 500))\n .get()\n return snapshot.docs.map((doc: any) => ({\n $id: doc.id,\n ...(doc.data() as ContactSuppressionRecord),\n }))\n}\n"],"names":["normalizePhone","FieldValue","firebaseAdmin","firestore","app","CONTACT_SUPPRESSIONS_COLLECTION","CONTACT_CHANNELS","contactSuppressionKey","phoneNumber","e164","slice","suppressPhoneContact","input","key","Error","db","ref","collection","doc","snapshot","get","requested","channels","length","filter","channel","includes","existing","Array","isArray","set","source","uid","recordedByUid","note","erasePhoneOnFile","revokedAt","updatedAt","serverTimestamp","exists","createdAt","merge","created","releasePhoneContact","revokedByUid","releasedByUid","revokedNote","isPhoneContactSuppressed","injectedFirestore","error","console","getContactSuppression","$id","id","data","listContactSuppressions","options","orderBy","limit","Math","min","max","docs","map"],"mappings":"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"}
|
|
@@ -0,0 +1,86 @@
|
|
|
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 CrmActivityLink } from '@aglyn/aglyn/server';
|
|
18
|
+
export interface CrmBookingRecordInput {
|
|
19
|
+
hostId: string;
|
|
20
|
+
/**
|
|
21
|
+
* The owning org, as `getOrgForHost` returns it — the plugin set, the
|
|
22
|
+
* consent groups and the scope default are all read off it.
|
|
23
|
+
*/
|
|
24
|
+
org: Record<string, unknown> | null | undefined;
|
|
25
|
+
orgId: string;
|
|
26
|
+
/** The booking as its row was written. */
|
|
27
|
+
booking: {
|
|
28
|
+
id: string;
|
|
29
|
+
serviceId?: string;
|
|
30
|
+
serviceName?: string;
|
|
31
|
+
email?: string;
|
|
32
|
+
startsAtMs: number;
|
|
33
|
+
endsAtMs: number;
|
|
34
|
+
/** The `crm` query value the link carried, kept on the row. */
|
|
35
|
+
crmRef?: string | null;
|
|
36
|
+
};
|
|
37
|
+
/** The service as stored: its two CRM switches and its timezone. */
|
|
38
|
+
service: {
|
|
39
|
+
name?: string;
|
|
40
|
+
timezone?: string;
|
|
41
|
+
crmFollowUpTask?: boolean;
|
|
42
|
+
crmMeetingActivity?: boolean;
|
|
43
|
+
} | null | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* The host document's plugin fields, when the caller already holds them;
|
|
46
|
+
* read here otherwise. The deny-list is per site, and a site that has
|
|
47
|
+
* switched the CRM off must file nothing.
|
|
48
|
+
*/
|
|
49
|
+
host?: {
|
|
50
|
+
disabledPlugins?: string[];
|
|
51
|
+
enabledPlugins?: string[];
|
|
52
|
+
} | null;
|
|
53
|
+
}
|
|
54
|
+
export type CrmBookingRecordOutcome = {
|
|
55
|
+
filed: false;
|
|
56
|
+
reason:
|
|
57
|
+
/** The CRM plugin does not run on this site. */
|
|
58
|
+
'crm-off'
|
|
59
|
+
/**
|
|
60
|
+
* The org's plan does not carry the CRM suite, whose activities and
|
|
61
|
+
* tasks these are (AGL-2787).
|
|
62
|
+
*/
|
|
63
|
+
| 'not-entitled'
|
|
64
|
+
/** Neither the reference nor the address named a record. */
|
|
65
|
+
| 'no-record'
|
|
66
|
+
/** A meeting for this booking is already on the timeline. */
|
|
67
|
+
| 'already-filed'
|
|
68
|
+
/** The service asks for neither a meeting nor a follow-up. */
|
|
69
|
+
| 'nothing-to-file'
|
|
70
|
+
/** A read or a write threw; logged, and nothing is promised. */
|
|
71
|
+
| 'failed';
|
|
72
|
+
} | {
|
|
73
|
+
filed: true;
|
|
74
|
+
/** How the record was found. */
|
|
75
|
+
matchedBy: 'crmRef' | 'email';
|
|
76
|
+
link: CrmActivityLink;
|
|
77
|
+
/** The meeting's id, or `null` when the service files no meeting. */
|
|
78
|
+
activityId: string | null;
|
|
79
|
+
/** The follow-up's id, or `null` when the service asks for none. */
|
|
80
|
+
taskId: string | null;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Files what a confirmed booking owes the CRM: the meeting, and the
|
|
84
|
+
* follow-up when the service asks for one. See the module comment.
|
|
85
|
+
*/
|
|
86
|
+
export declare function recordCrmBooking(firestore: FirebaseFirestore.Firestore, input: CrmBookingRecordInput): Promise<CrmBookingRecordOutcome>;
|
|
@@ -0,0 +1,251 @@
|
|
|
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
|
+
*/ import { checkEntitlement, consentGroupForHost, CRM_COLLECTIONS, crmActivityLogHasRoom, crmBookingFollowUpDueMs, crmBookingFollowUpTitle, crmBookingMeetingBody, crmScopeTokens, isHostPluginEnabled, parseCrmBookingRef, readContactFacet, visibleToHost } from "@aglyn/aglyn/server";
|
|
18
|
+
import { FieldValue } from "firebase-admin/firestore";
|
|
19
|
+
import { findContactByEmail } from "./contact-email-index.js";
|
|
20
|
+
import { countCrmActivitiesForRecord } from "./crm-records.js";
|
|
21
|
+
/**
|
|
22
|
+
* WHAT A BOOKING FILES ON THE CRM RECORD (AGL-2660).
|
|
23
|
+
*
|
|
24
|
+
* A confirmed booking is a meeting the record has on its calendar, and the
|
|
25
|
+
* timeline should say so without a rep logging it by hand. So once a
|
|
26
|
+
* booking's row is written — at creation for a free service, on payment
|
|
27
|
+
* for a paid one — the record is found and a `meeting` activity filed under
|
|
28
|
+
* it, with a follow-up task beside it when the service asks for one.
|
|
29
|
+
*
|
|
30
|
+
* ## Which record
|
|
31
|
+
*
|
|
32
|
+
* The reference the booking link carried wins: `crmRef` names the contact,
|
|
33
|
+
* lead or deal the rep dropped the link from, and a booking made through
|
|
34
|
+
* that link belongs to that record even when the booker typed a different
|
|
35
|
+
* address. Without one — a booking taken off the widget cold — the booker's
|
|
36
|
+
* address is looked up through the org's address index, narrowed to this
|
|
37
|
+
* site, exactly as the automation steps resolve an event's contact. A
|
|
38
|
+
* reference to a record the site cannot see, or that no longer exists,
|
|
39
|
+
* falls through to the address rather than filing under nobody.
|
|
40
|
+
*
|
|
41
|
+
* ## Where it lives
|
|
42
|
+
*
|
|
43
|
+
* In the admin library rather than the Bookings plugin, because the row is
|
|
44
|
+
* a CRM record: its scope, its ceiling and its collections are the CRM's,
|
|
45
|
+
* and a plugin that stamped its own would be a second creator to keep in
|
|
46
|
+
* step with `crmScopeTokens`. The Bookings plugin passes what it already
|
|
47
|
+
* holds and reads the outcome; the CRM plugin is never imported.
|
|
48
|
+
*
|
|
49
|
+
* Firestore is a parameter, as in `crm-records.ts`: the callers hold one,
|
|
50
|
+
* and a module that reached for the Admin app itself would drag it into
|
|
51
|
+
* every spec that only wants the arithmetic.
|
|
52
|
+
*
|
|
53
|
+
* ## Failure posture
|
|
54
|
+
*
|
|
55
|
+
* **Never throws.** A booking that could not be filed on the CRM is still
|
|
56
|
+
* a booking — the guest has the slot and the confirmation — so a failure
|
|
57
|
+
* here is logged and reported in the outcome, never raised into the
|
|
58
|
+
* request that took the booking or the webhook that confirmed it.
|
|
59
|
+
*/ /** The CRM plugin's id, as `enabledPlugins` and `disabledPlugins` name it. */ const CRM_PLUGIN_ID = 'crm';
|
|
60
|
+
/**
|
|
61
|
+
* The record a booking belongs to — the reference first, the address second.
|
|
62
|
+
*/ async function resolveBookingRecord(firestore, input, groupId) {
|
|
63
|
+
var _hit_data;
|
|
64
|
+
const { hostId, orgId } = input;
|
|
65
|
+
const orgRef = firestore.collection('orgs').doc(orgId);
|
|
66
|
+
const contactsRef = orgRef.collection('contacts');
|
|
67
|
+
const ref = parseCrmBookingRef(input.booking.crmRef);
|
|
68
|
+
if ((ref == null ? void 0 : ref.kind) === 'contact') {
|
|
69
|
+
const contact = await contactsRef.doc(ref.id).get();
|
|
70
|
+
if (contact.exists && visibleToHost(contact.get('visibleTo'), hostId)) {
|
|
71
|
+
var _contact_data;
|
|
72
|
+
const facet = readContactFacet((_contact_data = contact.data()) != null ? _contact_data : {}, groupId);
|
|
73
|
+
return _extends({
|
|
74
|
+
link: _extends({
|
|
75
|
+
contactId: contact.id
|
|
76
|
+
}, facet.companyId ? {
|
|
77
|
+
companyId: facet.companyId
|
|
78
|
+
} : {}),
|
|
79
|
+
matchedBy: 'crmRef'
|
|
80
|
+
}, facet.ownerUid ? {
|
|
81
|
+
ownerUid: facet.ownerUid
|
|
82
|
+
} : {});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if ((ref == null ? void 0 : ref.kind) === 'deal') {
|
|
86
|
+
const deal = await orgRef.collection(CRM_COLLECTIONS.deals).doc(ref.id).get();
|
|
87
|
+
if (deal.exists && visibleToHost(deal.get('visibleTo'), hostId)) {
|
|
88
|
+
var _deal_get, _deal_get1, _deal_get2;
|
|
89
|
+
const contactId = String((_deal_get = deal.get('contactId')) != null ? _deal_get : '');
|
|
90
|
+
const companyId = String((_deal_get1 = deal.get('companyId')) != null ? _deal_get1 : '');
|
|
91
|
+
const ownerUid = String((_deal_get2 = deal.get('ownerUid')) != null ? _deal_get2 : '');
|
|
92
|
+
return _extends({
|
|
93
|
+
link: _extends({
|
|
94
|
+
dealId: deal.id
|
|
95
|
+
}, contactId ? {
|
|
96
|
+
contactId
|
|
97
|
+
} : {}, companyId ? {
|
|
98
|
+
companyId
|
|
99
|
+
} : {}),
|
|
100
|
+
matchedBy: 'crmRef'
|
|
101
|
+
}, ownerUid ? {
|
|
102
|
+
ownerUid
|
|
103
|
+
} : {});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if ((ref == null ? void 0 : ref.kind) === 'lead') {
|
|
107
|
+
// A lead is host-scoped by path and carries no `visibleTo` of its own.
|
|
108
|
+
const lead = await firestore.collection('hosts').doc(hostId).collection('leads').doc(ref.id).get();
|
|
109
|
+
if (lead.exists) {
|
|
110
|
+
var _lead_get;
|
|
111
|
+
const ownerUid = String((_lead_get = lead.get('ownerUid')) != null ? _lead_get : '');
|
|
112
|
+
return _extends({
|
|
113
|
+
link: {
|
|
114
|
+
leadId: lead.id
|
|
115
|
+
},
|
|
116
|
+
matchedBy: 'crmRef'
|
|
117
|
+
}, ownerUid ? {
|
|
118
|
+
ownerUid
|
|
119
|
+
} : {});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const hit = await findContactByEmail(contactsRef, input.booking.email, {
|
|
123
|
+
hostId
|
|
124
|
+
});
|
|
125
|
+
if (!hit) return null;
|
|
126
|
+
const facet = readContactFacet((_hit_data = hit.data()) != null ? _hit_data : {}, groupId);
|
|
127
|
+
return _extends({
|
|
128
|
+
link: _extends({
|
|
129
|
+
contactId: hit.id
|
|
130
|
+
}, facet.companyId ? {
|
|
131
|
+
companyId: facet.companyId
|
|
132
|
+
} : {}),
|
|
133
|
+
matchedBy: 'email'
|
|
134
|
+
}, facet.ownerUid ? {
|
|
135
|
+
ownerUid: facet.ownerUid
|
|
136
|
+
} : {});
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Files what a confirmed booking owes the CRM: the meeting, and the
|
|
140
|
+
* follow-up when the service asks for one. See the module comment.
|
|
141
|
+
*/ export async function recordCrmBooking(firestore, input) {
|
|
142
|
+
const { hostId, orgId, booking, service } = input;
|
|
143
|
+
try {
|
|
144
|
+
var _data, _input_org, _input_org1, _input_org2, _ref, _ref1;
|
|
145
|
+
if (!hostId || !orgId || !(booking == null ? void 0 : booking.id)) return {
|
|
146
|
+
filed: false,
|
|
147
|
+
reason: 'failed'
|
|
148
|
+
};
|
|
149
|
+
const wantsMeeting = (service == null ? void 0 : service.crmMeetingActivity) !== false;
|
|
150
|
+
const wantsTask = (service == null ? void 0 : service.crmFollowUpTask) === true;
|
|
151
|
+
if (!wantsMeeting && !wantsTask) return {
|
|
152
|
+
filed: false,
|
|
153
|
+
reason: 'nothing-to-file'
|
|
154
|
+
};
|
|
155
|
+
const host = input.host === undefined ? (_data = (await firestore.collection('hosts').doc(hostId).get()).data()) != null ? _data : null : input.host;
|
|
156
|
+
if (!isHostPluginEnabled((_input_org = input.org) != null ? _input_org : null, host, CRM_PLUGIN_ID)) {
|
|
157
|
+
return {
|
|
158
|
+
filed: false,
|
|
159
|
+
reason: 'crm-off'
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
if (!checkEntitlement(input.org, 'crm')) {
|
|
163
|
+
return {
|
|
164
|
+
filed: false,
|
|
165
|
+
reason: 'not-entitled'
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
const orgRef = firestore.collection('orgs').doc(orgId);
|
|
169
|
+
const activities = orgRef.collection(CRM_COLLECTIONS.activities);
|
|
170
|
+
// One meeting per booking, whatever path re-enters: the paid webhook is
|
|
171
|
+
// redelivered for days after any 500, and its own guard is the status,
|
|
172
|
+
// which this row is not part of.
|
|
173
|
+
const already = await activities.where('bookingId', '==', booking.id).limit(1).get();
|
|
174
|
+
if (!already.empty) return {
|
|
175
|
+
filed: false,
|
|
176
|
+
reason: 'already-filed'
|
|
177
|
+
};
|
|
178
|
+
const group = consentGroupForHost((_input_org1 = input.org) != null ? _input_org1 : null, hostId);
|
|
179
|
+
const record = await resolveBookingRecord(firestore, input, group.groupId);
|
|
180
|
+
if (!record) return {
|
|
181
|
+
filed: false,
|
|
182
|
+
reason: 'no-record'
|
|
183
|
+
};
|
|
184
|
+
const visibleTo = crmScopeTokens((_input_org2 = input.org) != null ? _input_org2 : null, group);
|
|
185
|
+
const serviceName = String((_ref = (_ref1 = service == null ? void 0 : service.name) != null ? _ref1 : booking.serviceName) != null ? _ref : '').trim();
|
|
186
|
+
const timezone = (service == null ? void 0 : service.timezone) || undefined;
|
|
187
|
+
let activityId = null;
|
|
188
|
+
if (wantsMeeting) {
|
|
189
|
+
// The per-record ceiling (AGL-2611): a full log refuses the meeting
|
|
190
|
+
// but not the follow-up — a task is owed either way.
|
|
191
|
+
const logged = await countCrmActivitiesForRecord(orgRef, record.link);
|
|
192
|
+
if (crmActivityLogHasRoom(logged)) {
|
|
193
|
+
const activity = _extends({
|
|
194
|
+
kind: 'meeting',
|
|
195
|
+
body: crmBookingMeetingBody({
|
|
196
|
+
serviceName,
|
|
197
|
+
startsAtMs: booking.startsAtMs,
|
|
198
|
+
timezone
|
|
199
|
+
}),
|
|
200
|
+
// When it HAPPENS, which is the slot — not when it was booked.
|
|
201
|
+
atMs: booking.startsAtMs,
|
|
202
|
+
byUid: ''
|
|
203
|
+
}, record.link, {
|
|
204
|
+
bookingId: booking.id,
|
|
205
|
+
hostId,
|
|
206
|
+
visibleTo,
|
|
207
|
+
createdAt: FieldValue.serverTimestamp(),
|
|
208
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
209
|
+
});
|
|
210
|
+
const written = await activities.add(activity);
|
|
211
|
+
activityId = written.id;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
let taskId = null;
|
|
215
|
+
if (wantsTask) {
|
|
216
|
+
const task = _extends({
|
|
217
|
+
title: crmBookingFollowUpTitle(serviceName),
|
|
218
|
+
kind: 'todo',
|
|
219
|
+
priority: 'normal',
|
|
220
|
+
status: 'open',
|
|
221
|
+
dueAtMs: crmBookingFollowUpDueMs(booking.endsAtMs, timezone)
|
|
222
|
+
}, record.ownerUid ? {
|
|
223
|
+
assigneeUid: record.ownerUid
|
|
224
|
+
} : {}, {
|
|
225
|
+
createdByUid: ''
|
|
226
|
+
}, record.link, {
|
|
227
|
+
hostId,
|
|
228
|
+
visibleTo,
|
|
229
|
+
createdAt: FieldValue.serverTimestamp(),
|
|
230
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
231
|
+
});
|
|
232
|
+
const written = await orgRef.collection(CRM_COLLECTIONS.tasks).add(task);
|
|
233
|
+
taskId = written.id;
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
filed: true,
|
|
237
|
+
matchedBy: record.matchedBy,
|
|
238
|
+
link: record.link,
|
|
239
|
+
activityId,
|
|
240
|
+
taskId
|
|
241
|
+
};
|
|
242
|
+
} catch (error) {
|
|
243
|
+
console.error('[crm] booking could not be filed', hostId, booking == null ? void 0 : booking.id, error);
|
|
244
|
+
return {
|
|
245
|
+
filed: false,
|
|
246
|
+
reason: 'failed'
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
//# sourceMappingURL=crm-booking-activity.js.map
|