@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,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ /**
|
|
17
|
+
* THE CONTACT'S OWN ENGAGEMENT STAMP (AGL-2616):
|
|
18
|
+
* `facets.{groupId}.lastEmailEngagementAtMs` on `orgs/{orgId}/contacts`.
|
|
19
|
+
*
|
|
20
|
+
* ## What it answers
|
|
21
|
+
*
|
|
22
|
+
* "When did this person last read one of OUR campaigns?" — on the record
|
|
23
|
+
* page as "Last engaged", as a column on the list, and as the audience rule
|
|
24
|
+
* `engagedWithinDays`. The per-person rollup on `emailDeliveries/{key}`
|
|
25
|
+
* already answers "when did this address last read anything from anybody",
|
|
26
|
+
* which is the right question for a sunset that refuses to mail cold
|
|
27
|
+
* addresses and the wrong one for a re-engagement audience: it moves when
|
|
28
|
+
* the person opens a receipt, an invite, or a sibling business's newsletter
|
|
29
|
+
* on the same shared row. This stamp moves only when they open or click a
|
|
30
|
+
* campaign this holder's own sites sent.
|
|
31
|
+
*
|
|
32
|
+
* ## What one webhook event costs
|
|
33
|
+
*
|
|
34
|
+
* The same bound the rollup keeps: only an event that is the FIRST of its
|
|
35
|
+
* type for its message reaches here, which `recordEmailDeliveryEvent`'s
|
|
36
|
+
* transaction already decided. A reader opening one newsletter six times is
|
|
37
|
+
* one transaction, and a replayed event finds its type already recorded and
|
|
38
|
+
* contributes nothing. Inside that bound the cost per person is one org
|
|
39
|
+
* resolution and one group resolution per batch, then one keyed query and
|
|
40
|
+
* at most one update per person.
|
|
41
|
+
*
|
|
42
|
+
* ## Forward-only, in a transaction
|
|
43
|
+
*
|
|
44
|
+
* Provider events are unordered and at-least-once, so an old instant
|
|
45
|
+
* arriving late must not overwrite a fresh stamp — the same reasoning
|
|
46
|
+
* `recordPersonEngagement` gives. The query for the contact rides inside
|
|
47
|
+
* the transaction so the compare-and-write is atomic against a second event
|
|
48
|
+
* for the same person landing in another instance.
|
|
49
|
+
*
|
|
50
|
+
* ## Only a contact this site may see, and only this holder's facet
|
|
51
|
+
*
|
|
52
|
+
* The contact row is shared by every site in the org. The stamp is written
|
|
53
|
+
* under the sending site's consent group, never at the top of the document,
|
|
54
|
+
* and only onto a row whose `visibleTo` admits the site — a person another
|
|
55
|
+
* holder captured and this site never met is not this site's contact, and a
|
|
56
|
+
* write onto their row would mint a facet for a holder that does not hold
|
|
57
|
+
* them. `updatedAt` is left alone: an open is something the person did, not
|
|
58
|
+
* an edit the team made, and a list sorted on recency must not reshuffle on
|
|
59
|
+
* every mailbox prefetch.
|
|
60
|
+
*
|
|
61
|
+
* ## Never throws
|
|
62
|
+
*
|
|
63
|
+
* Best-effort for the reason everything on the webhook path is: a stamp that
|
|
64
|
+
* failed loses a fact a page can live without, and a stamp that threw would
|
|
65
|
+
* lose the provider's acknowledgement and teach it to retry the whole event.
|
|
66
|
+
*/ import { contactFacetPath, normalizeContactEmail, readContactFacet, visibleToHost } from "@aglyn/aglyn/server";
|
|
67
|
+
import { findContactByEmail } from "./contact-email-index.js";
|
|
68
|
+
import { firebaseAdmin } from "./firebase-admin.js";
|
|
69
|
+
import { consentGroupForSite, orgDataCollectionForHost } from "./organizations.js";
|
|
70
|
+
/** The facet field the stamp lives under. */ export const CONTACT_EMAIL_ENGAGEMENT_FIELD = 'lastEmailEngagementAtMs';
|
|
71
|
+
/** The event types that count as a person engaging with a campaign. */ const ENGAGEMENT_TYPES = new Set([
|
|
72
|
+
'opened',
|
|
73
|
+
'clicked'
|
|
74
|
+
]);
|
|
75
|
+
/**
|
|
76
|
+
* Stamps the sending site's contact facet for every person these outcomes
|
|
77
|
+
* say engaged for the first time with a message.
|
|
78
|
+
*
|
|
79
|
+
* @param hostId the site the campaign went out from — the `hostId` tag the
|
|
80
|
+
* send stamped, which is the only tenant identity a delivery event carries.
|
|
81
|
+
* @returns how many contact documents were written.
|
|
82
|
+
*/ export async function recordContactEmailEngagement(args) {
|
|
83
|
+
var _args_hostId, _args_firestore;
|
|
84
|
+
const hostId = String((_args_hostId = args.hostId) != null ? _args_hostId : '');
|
|
85
|
+
if (!hostId) return 0;
|
|
86
|
+
/** Address → the newest engagement instant in this batch. */ const byEmail = new Map();
|
|
87
|
+
for (const outcome of args.outcomes){
|
|
88
|
+
var _byEmail_get;
|
|
89
|
+
if (!outcome.firstOfType) continue;
|
|
90
|
+
if (!ENGAGEMENT_TYPES.has(outcome.type)) continue;
|
|
91
|
+
const email = normalizeContactEmail(outcome.to);
|
|
92
|
+
const at = Number(outcome.at);
|
|
93
|
+
if (!email || !Number.isFinite(at) || at <= 0) continue;
|
|
94
|
+
byEmail.set(email, Math.max((_byEmail_get = byEmail.get(email)) != null ? _byEmail_get : 0, at));
|
|
95
|
+
}
|
|
96
|
+
if (!byEmail.size) return 0;
|
|
97
|
+
let contactsRef;
|
|
98
|
+
let groupId;
|
|
99
|
+
try {
|
|
100
|
+
contactsRef = await orgDataCollectionForHost(hostId, 'contacts');
|
|
101
|
+
groupId = (await consentGroupForSite(hostId)).groupId;
|
|
102
|
+
} catch (error) {
|
|
103
|
+
console.error('[contact-email-engagement] site could not be resolved', hostId, error);
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
const db = (_args_firestore = args.firestore) != null ? _args_firestore : firebaseAdmin.app().firestore();
|
|
107
|
+
const field = contactFacetPath(groupId, CONTACT_EMAIL_ENGAGEMENT_FIELD);
|
|
108
|
+
let written = 0;
|
|
109
|
+
for (const [email, at] of byEmail){
|
|
110
|
+
try {
|
|
111
|
+
await db.runTransaction(async (transaction)=>{
|
|
112
|
+
var _snapshot_data, _readContactFacet_CONTACT_EMAIL_ENGAGEMENT_FIELD;
|
|
113
|
+
/*
|
|
114
|
+
* The same unscoped lookup the capture door makes — one human is one
|
|
115
|
+
* row whichever site met them, and through the address index
|
|
116
|
+
* (AGL-2633) the row is found under an address a merge folded into
|
|
117
|
+
* it too — followed by the scope check the capture door's
|
|
118
|
+
* `visibleTo` write is the source of. Read THROUGH the transaction:
|
|
119
|
+
* the stamp below is a compare-and-set against the instant this
|
|
120
|
+
* read saw.
|
|
121
|
+
*/ const snapshot = await findContactByEmail(contactsRef, email, {
|
|
122
|
+
transaction
|
|
123
|
+
});
|
|
124
|
+
if (!snapshot) return;
|
|
125
|
+
const data = (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {};
|
|
126
|
+
if (!visibleToHost(data['visibleTo'], hostId)) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const stored = Number((_readContactFacet_CONTACT_EMAIL_ENGAGEMENT_FIELD = readContactFacet(data, groupId)[CONTACT_EMAIL_ENGAGEMENT_FIELD]) != null ? _readContactFacet_CONTACT_EMAIL_ENGAGEMENT_FIELD : 0);
|
|
130
|
+
// Nothing moved forward, so nothing is written — the out-of-order
|
|
131
|
+
// and the replayed event are the ordinary cases this skips.
|
|
132
|
+
if (Number.isFinite(stored) && stored >= at) return;
|
|
133
|
+
transaction.update(snapshot.ref, {
|
|
134
|
+
[field]: at
|
|
135
|
+
});
|
|
136
|
+
written += 1;
|
|
137
|
+
});
|
|
138
|
+
} catch (error) {
|
|
139
|
+
console.error('[contact-email-engagement] stamp failed', hostId, error);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return written;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
//# sourceMappingURL=contact-email-engagement.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/contact-email-engagement.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * THE CONTACT'S OWN ENGAGEMENT STAMP (AGL-2616):\n * `facets.{groupId}.lastEmailEngagementAtMs` on `orgs/{orgId}/contacts`.\n *\n * ## What it answers\n *\n * \"When did this person last read one of OUR campaigns?\" — on the record\n * page as \"Last engaged\", as a column on the list, and as the audience rule\n * `engagedWithinDays`. The per-person rollup on `emailDeliveries/{key}`\n * already answers \"when did this address last read anything from anybody\",\n * which is the right question for a sunset that refuses to mail cold\n * addresses and the wrong one for a re-engagement audience: it moves when\n * the person opens a receipt, an invite, or a sibling business's newsletter\n * on the same shared row. This stamp moves only when they open or click a\n * campaign this holder's own sites sent.\n *\n * ## What one webhook event costs\n *\n * The same bound the rollup keeps: only an event that is the FIRST of its\n * type for its message reaches here, which `recordEmailDeliveryEvent`'s\n * transaction already decided. A reader opening one newsletter six times is\n * one transaction, and a replayed event finds its type already recorded and\n * contributes nothing. Inside that bound the cost per person is one org\n * resolution and one group resolution per batch, then one keyed query and\n * at most one update per person.\n *\n * ## Forward-only, in a transaction\n *\n * Provider events are unordered and at-least-once, so an old instant\n * arriving late must not overwrite a fresh stamp — the same reasoning\n * `recordPersonEngagement` gives. The query for the contact rides inside\n * the transaction so the compare-and-write is atomic against a second event\n * for the same person landing in another instance.\n *\n * ## Only a contact this site may see, and only this holder's facet\n *\n * The contact row is shared by every site in the org. The stamp is written\n * under the sending site's consent group, never at the top of the document,\n * and only onto a row whose `visibleTo` admits the site — a person another\n * holder captured and this site never met is not this site's contact, and a\n * write onto their row would mint a facet for a holder that does not hold\n * them. `updatedAt` is left alone: an open is something the person did, not\n * an edit the team made, and a list sorted on recency must not reshuffle on\n * every mailbox prefetch.\n *\n * ## Never throws\n *\n * Best-effort for the reason everything on the webhook path is: a stamp that\n * failed loses a fact a page can live without, and a stamp that threw would\n * lose the provider's acknowledgement and teach it to retry the whole event.\n */\n\nimport {\n contactFacetPath,\n normalizeContactEmail,\n readContactFacet,\n visibleToHost,\n} from '@aglyn/aglyn/server'\nimport { findContactByEmail } from './contact-email-index'\nimport type { EmailDeliveryEventOutcome } from './email-delivery-log'\nimport { firebaseAdmin } from './firebase-admin'\nimport { consentGroupForSite, orgDataCollectionForHost } from './organizations'\n\n/** The facet field the stamp lives under. */\nexport const CONTACT_EMAIL_ENGAGEMENT_FIELD = 'lastEmailEngagementAtMs'\n\n/** The event types that count as a person engaging with a campaign. */\nconst ENGAGEMENT_TYPES: ReadonlySet<EmailDeliveryEventOutcome['type']> = new Set([\n 'opened',\n 'clicked',\n])\n\n/**\n * Stamps the sending site's contact facet for every person these outcomes\n * say engaged for the first time with a message.\n *\n * @param hostId the site the campaign went out from — the `hostId` tag the\n * send stamped, which is the only tenant identity a delivery event carries.\n * @returns how many contact documents were written.\n */\nexport async function recordContactEmailEngagement(args: {\n hostId: string\n outcomes: readonly EmailDeliveryEventOutcome[]\n firestore?: any\n}): Promise<number> {\n const hostId = String(args.hostId ?? '')\n if (!hostId) return 0\n\n /** Address → the newest engagement instant in this batch. */\n const byEmail = new Map<string, number>()\n for (const outcome of args.outcomes) {\n if (!outcome.firstOfType) continue\n if (!ENGAGEMENT_TYPES.has(outcome.type)) continue\n const email = normalizeContactEmail(outcome.to)\n const at = Number(outcome.at)\n if (!email || !Number.isFinite(at) || at <= 0) continue\n byEmail.set(email, Math.max(byEmail.get(email) ?? 0, at))\n }\n if (!byEmail.size) return 0\n\n let contactsRef: FirebaseFirestore.CollectionReference\n let groupId: string\n try {\n contactsRef = await orgDataCollectionForHost(hostId, 'contacts')\n groupId = (await consentGroupForSite(hostId)).groupId\n } catch (error) {\n console.error('[contact-email-engagement] site could not be resolved', hostId, error)\n return 0\n }\n\n const db = args.firestore ?? firebaseAdmin.app().firestore()\n const field = contactFacetPath(groupId, CONTACT_EMAIL_ENGAGEMENT_FIELD)\n let written = 0\n for (const [email, at] of byEmail) {\n try {\n await db.runTransaction(async (transaction: any) => {\n /*\n * The same unscoped lookup the capture door makes — one human is one\n * row whichever site met them, and through the address index\n * (AGL-2633) the row is found under an address a merge folded into\n * it too — followed by the scope check the capture door's\n * `visibleTo` write is the source of. Read THROUGH the transaction:\n * the stamp below is a compare-and-set against the instant this\n * read saw.\n */\n const snapshot = await findContactByEmail(contactsRef, email, { transaction })\n if (!snapshot) return\n const data = (snapshot.data() ?? {}) as Record<string, unknown>\n if (!visibleToHost(data['visibleTo'] as string[] | undefined, hostId)) {\n return\n }\n const stored = Number(\n readContactFacet(data, groupId)[CONTACT_EMAIL_ENGAGEMENT_FIELD] ?? 0,\n )\n // Nothing moved forward, so nothing is written — the out-of-order\n // and the replayed event are the ordinary cases this skips.\n if (Number.isFinite(stored) && stored >= at) return\n transaction.update(snapshot.ref, { [field]: at })\n written += 1\n })\n } catch (error) {\n console.error('[contact-email-engagement] stamp failed', hostId, error)\n }\n }\n return written\n}\n"],"names":["contactFacetPath","normalizeContactEmail","readContactFacet","visibleToHost","findContactByEmail","firebaseAdmin","consentGroupForSite","orgDataCollectionForHost","CONTACT_EMAIL_ENGAGEMENT_FIELD","ENGAGEMENT_TYPES","Set","recordContactEmailEngagement","args","hostId","String","byEmail","Map","outcome","outcomes","firstOfType","has","type","email","to","at","Number","isFinite","set","Math","max","get","size","contactsRef","groupId","error","console","db","firestore","app","field","written","runTransaction","transaction","snapshot","data","stored","update","ref"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkDC,GAED,SACEA,gBAAgB,EAChBC,qBAAqB,EACrBC,gBAAgB,EAChBC,aAAa,QACR,sBAAqB;AAC5B,SAASC,kBAAkB,QAAQ,2BAAuB;AAE1D,SAASC,aAAa,QAAQ,sBAAkB;AAChD,SAASC,mBAAmB,EAAEC,wBAAwB,QAAQ,qBAAiB;AAE/E,2CAA2C,GAC3C,OAAO,MAAMC,iCAAiC,0BAAyB;AAEvE,qEAAqE,GACrE,MAAMC,mBAAmE,IAAIC,IAAI;IAC/E;IACA;CACD;AAED;;;;;;;CAOC,GACD,OAAO,eAAeC,6BAA6BC,IAIlD;QACuBA,cAyBXA;IAzBX,MAAMC,SAASC,QAAOF,eAAAA,KAAKC,MAAM,YAAXD,eAAe;IACrC,IAAI,CAACC,QAAQ,OAAO;IAEpB,2DAA2D,GAC3D,MAAME,UAAU,IAAIC;IACpB,KAAK,MAAMC,WAAWL,KAAKM,QAAQ,CAAE;YAMPH;QAL5B,IAAI,CAACE,QAAQE,WAAW,EAAE;QAC1B,IAAI,CAACV,iBAAiBW,GAAG,CAACH,QAAQI,IAAI,GAAG;QACzC,MAAMC,QAAQrB,sBAAsBgB,QAAQM,EAAE;QAC9C,MAAMC,KAAKC,OAAOR,QAAQO,EAAE;QAC5B,IAAI,CAACF,SAAS,CAACG,OAAOC,QAAQ,CAACF,OAAOA,MAAM,GAAG;QAC/CT,QAAQY,GAAG,CAACL,OAAOM,KAAKC,GAAG,EAACd,eAAAA,QAAQe,GAAG,CAACR,kBAAZP,eAAsB,GAAGS;IACvD;IACA,IAAI,CAACT,QAAQgB,IAAI,EAAE,OAAO;IAE1B,IAAIC;IACJ,IAAIC;IACJ,IAAI;QACFD,cAAc,MAAMzB,yBAAyBM,QAAQ;QACrDoB,UAAU,AAAC,CAAA,MAAM3B,oBAAoBO,OAAM,EAAGoB,OAAO;IACvD,EAAE,OAAOC,OAAO;QACdC,QAAQD,KAAK,CAAC,yDAAyDrB,QAAQqB;QAC/E,OAAO;IACT;IAEA,MAAME,MAAKxB,kBAAAA,KAAKyB,SAAS,YAAdzB,kBAAkBP,cAAciC,GAAG,GAAGD,SAAS;IAC1D,MAAME,QAAQvC,iBAAiBiC,SAASzB;IACxC,IAAIgC,UAAU;IACd,KAAK,MAAM,CAAClB,OAAOE,GAAG,IAAIT,QAAS;QACjC,IAAI;YACF,MAAMqB,GAAGK,cAAc,CAAC,OAAOC;oBAYfC,gBAKZzC;gBAhBF;;;;;;;;SAQC,GACD,MAAMyC,WAAW,MAAMvC,mBAAmB4B,aAAaV,OAAO;oBAAEoB;gBAAY;gBAC5E,IAAI,CAACC,UAAU;gBACf,MAAMC,QAAQD,iBAAAA,SAASC,IAAI,cAAbD,iBAAmB,CAAC;gBAClC,IAAI,CAACxC,cAAcyC,IAAI,CAAC,YAAY,EAA0B/B,SAAS;oBACrE;gBACF;gBACA,MAAMgC,SAASpB,QACbvB,mDAAAA,iBAAiB0C,MAAMX,QAAQ,CAACzB,+BAA+B,YAA/DN,mDAAmE;gBAErE,kEAAkE;gBAClE,4DAA4D;gBAC5D,IAAIuB,OAAOC,QAAQ,CAACmB,WAAWA,UAAUrB,IAAI;gBAC7CkB,YAAYI,MAAM,CAACH,SAASI,GAAG,EAAE;oBAAE,CAACR,MAAM,EAAEf;gBAAG;gBAC/CgB,WAAW;YACb;QACF,EAAE,OAAON,OAAO;YACdC,QAAQD,KAAK,CAAC,2CAA2CrB,QAAQqB;QACnE;IACF;IACA,OAAOM;AACT"}
|
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
/** One entry: the address as stored, and the contact it resolves to. */
|
|
18
|
+
export interface ContactEmailIndexEntry {
|
|
19
|
+
email: string;
|
|
20
|
+
contactId: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The org's index collection, beside its contacts one — or `null` when the
|
|
24
|
+
* handle has no parent, which is a fixture's contacts reference and never
|
|
25
|
+
* a real org subcollection. `null` means "no index": the caller falls back
|
|
26
|
+
* to the query and writes nothing.
|
|
27
|
+
*/
|
|
28
|
+
export declare function emailIndexBeside(contactsRef: FirebaseFirestore.CollectionReference): FirebaseFirestore.CollectionReference | null;
|
|
29
|
+
/**
|
|
30
|
+
* Point every address in `emails` at `contactId`. Never rejects: an entry
|
|
31
|
+
* that could not be written is one the next lookup writes again.
|
|
32
|
+
*/
|
|
33
|
+
export declare function writeContactEmailIndex(index: FirebaseFirestore.CollectionReference | null, contactId: string, emails: readonly string[]): Promise<void>;
|
|
34
|
+
/** How one door reads differently from the capture; see the module notes. */
|
|
35
|
+
export interface FindContactByEmailOptions {
|
|
36
|
+
/**
|
|
37
|
+
* Answer only a contact THIS site may see. A document the address names
|
|
38
|
+
* but the site cannot read is `null` — the same answer the scoped
|
|
39
|
+
* `email ==` query gave, since one address names one document.
|
|
40
|
+
*/
|
|
41
|
+
hostId?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Read through this transaction. Every read — the index entry, the
|
|
44
|
+
* contact it names, the fallback query — goes through it; the lazy index
|
|
45
|
+
* write does not, so the caller may still read after this returns.
|
|
46
|
+
*/
|
|
47
|
+
transaction?: FirebaseFirestore.Transaction;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The contact an address belongs to, as a document snapshot — or `null`.
|
|
51
|
+
*
|
|
52
|
+
* The index first, then the per-document query it summarizes. A query hit
|
|
53
|
+
* writes the entry the index lacked, so the read that paid for the miss is
|
|
54
|
+
* the last one that has to.
|
|
55
|
+
*/
|
|
56
|
+
export declare function findContactByEmail(contactsRef: FirebaseFirestore.CollectionReference, email: unknown, options?: FindContactByEmailOptions): Promise<FirebaseFirestore.DocumentSnapshot | null>;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ /**
|
|
17
|
+
* THE ADDRESS INDEX (AGL-2625): `orgs/{orgId}/emailIndex/{personKey}`.
|
|
18
|
+
*
|
|
19
|
+
* `upsertHostContact` finds a person by `where('email', '==', …)` on the
|
|
20
|
+
* contacts collection, and that query answers one document per address by
|
|
21
|
+
* construction — which is exactly what stops a merged record's address from
|
|
22
|
+
* ever finding the survivor. The survivor's `email` is the identity that
|
|
23
|
+
* stayed; the merged address is in its `alternateEmails`, and Firestore
|
|
24
|
+
* cannot answer "the document whose primary OR alternate address is X" as
|
|
25
|
+
* one indexed query. So the address is indexed on its own: one document per
|
|
26
|
+
* address, naming the contact it belongs to.
|
|
27
|
+
*
|
|
28
|
+
* ## Consulted first, never trusted alone
|
|
29
|
+
*
|
|
30
|
+
* The lookup reads the index entry and then the contact it names, and
|
|
31
|
+
* takes the contact only if it still exists. A stale entry — a contact
|
|
32
|
+
* detached and deleted after its entry was written — falls through to the
|
|
33
|
+
* query, which is the truth the index only summarizes.
|
|
34
|
+
*
|
|
35
|
+
* ## Written lazily, never backfilled
|
|
36
|
+
*
|
|
37
|
+
* An entry is written when a capture CREATES a contact, and when a lookup
|
|
38
|
+
* finds one through the query rather than the index — so every existing
|
|
39
|
+
* contact gains an entry on its next write, and no job has to walk the
|
|
40
|
+
* collection. A merge writes one entry per address the survivor answers to.
|
|
41
|
+
*
|
|
42
|
+
* ## Never in the way of a capture
|
|
43
|
+
*
|
|
44
|
+
* Every read and write here is wrapped: a form submission or an order must
|
|
45
|
+
* succeed whatever the index did, exactly as the capture itself swallows
|
|
46
|
+
* its own failures. An index that cannot be reached is a miss, and a miss
|
|
47
|
+
* is the query.
|
|
48
|
+
*
|
|
49
|
+
* ## One lookup for every door (AGL-2633)
|
|
50
|
+
*
|
|
51
|
+
* The capture door was the first reader; every other place that finds a
|
|
52
|
+
* person by address — an automation step, the flow-email consent gate, the
|
|
53
|
+
* delivery webhook's engagement stamp, the Inbox add-to-list check, a
|
|
54
|
+
* refund, a campaign's proof and consent reads, the REST create and
|
|
55
|
+
* `?email=` list — resolves through this same function, so a merged
|
|
56
|
+
* record's alternate address answers the same person everywhere. Two of
|
|
57
|
+
* those doors read differently from the capture, and the options carry it:
|
|
58
|
+
*
|
|
59
|
+
* - **`hostId`** narrows the answer to what ONE site may see. The index is
|
|
60
|
+
* org-wide, so the lookup is too; the scope check is applied to the
|
|
61
|
+
* document it names, in memory, which answers exactly what the scoped
|
|
62
|
+
* `email ==` query used to — one document per address, so a contact the
|
|
63
|
+
* site cannot see is `null` rather than a reason to look further.
|
|
64
|
+
* - **`transaction`** routes every read through the caller's transaction,
|
|
65
|
+
* for a door that updates the contact it finds inside one. The lazy index
|
|
66
|
+
* write stays OUTSIDE the transaction, deliberately: a queued write would
|
|
67
|
+
* forbid the caller any later read, and the entry is a cache fill that
|
|
68
|
+
* never has to be atomic with anything. It is idempotent, so a retried
|
|
69
|
+
* transaction body writing it twice is harmless.
|
|
70
|
+
*
|
|
71
|
+
* Imported by module path rather than through the `@aglyn/aglyn/server`
|
|
72
|
+
* barrel, for the reason `upsert-contact.ts` gives: the door's specs
|
|
73
|
+
* substitute a fixture barrel, and a helper that reached the normalizer only
|
|
74
|
+
* through it would find `undefined` there. Callers outside this library
|
|
75
|
+
* import THIS module by its leaf path for the mirror-image reason: most of
|
|
76
|
+
* their specs substitute the `@aglyn/tenant-data-admin` barrel wholesale.
|
|
77
|
+
*/ import { CONTACT_EMAIL_INDEX_COLLECTION, normalizeContactEmail } from "@aglyn/aglyn/app-utils/contacts";
|
|
78
|
+
import { personKey } from "@aglyn/aglyn/app-utils/person-key";
|
|
79
|
+
import { visibleToHost } from "@aglyn/aglyn/app-utils/scope-tokens";
|
|
80
|
+
import { FieldValue } from "firebase-admin/firestore";
|
|
81
|
+
/**
|
|
82
|
+
* The org's index collection, beside its contacts one — or `null` when the
|
|
83
|
+
* handle has no parent, which is a fixture's contacts reference and never
|
|
84
|
+
* a real org subcollection. `null` means "no index": the caller falls back
|
|
85
|
+
* to the query and writes nothing.
|
|
86
|
+
*/ export function emailIndexBeside(contactsRef) {
|
|
87
|
+
try {
|
|
88
|
+
const parent = contactsRef.parent;
|
|
89
|
+
return parent ? parent.collection(CONTACT_EMAIL_INDEX_COLLECTION) : null;
|
|
90
|
+
} catch (unused) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Point every address in `emails` at `contactId`. Never rejects: an entry
|
|
96
|
+
* that could not be written is one the next lookup writes again.
|
|
97
|
+
*/ export async function writeContactEmailIndex(index, contactId, emails) {
|
|
98
|
+
if (!index || !contactId) return;
|
|
99
|
+
await Promise.all(emails.map(async (raw)=>{
|
|
100
|
+
const email = normalizeContactEmail(raw);
|
|
101
|
+
const key = email ? personKey(email) : null;
|
|
102
|
+
if (!email || !key) return;
|
|
103
|
+
const entry = {
|
|
104
|
+
email,
|
|
105
|
+
contactId,
|
|
106
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
107
|
+
};
|
|
108
|
+
await index.doc(key).set(entry, {
|
|
109
|
+
merge: true
|
|
110
|
+
});
|
|
111
|
+
})).catch((error)=>{
|
|
112
|
+
console.error('[contact-email-index] write failed', contactId, error);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The contact an address belongs to, as a document snapshot — or `null`.
|
|
117
|
+
*
|
|
118
|
+
* The index first, then the per-document query it summarizes. A query hit
|
|
119
|
+
* writes the entry the index lacked, so the read that paid for the miss is
|
|
120
|
+
* the last one that has to.
|
|
121
|
+
*/ export async function findContactByEmail(contactsRef, email, options = {}) {
|
|
122
|
+
const normalized = normalizeContactEmail(email);
|
|
123
|
+
if (!normalized) return null;
|
|
124
|
+
const { hostId, transaction } = options;
|
|
125
|
+
const readDoc = (ref)=>transaction ? transaction.get(ref) : ref.get();
|
|
126
|
+
const readQuery = (query)=>transaction ? transaction.get(query) : query.get();
|
|
127
|
+
const index = emailIndexBeside(contactsRef);
|
|
128
|
+
const key = index ? personKey(normalized) : null;
|
|
129
|
+
let hit = null;
|
|
130
|
+
if (index && key) {
|
|
131
|
+
try {
|
|
132
|
+
var _entry_get;
|
|
133
|
+
const entry = await readDoc(index.doc(key));
|
|
134
|
+
const contactId = entry.exists ? String((_entry_get = entry.get('contactId')) != null ? _entry_get : '') : '';
|
|
135
|
+
if (contactId) {
|
|
136
|
+
const contact = await readDoc(contactsRef.doc(contactId));
|
|
137
|
+
if (contact.exists) hit = contact;
|
|
138
|
+
}
|
|
139
|
+
} catch (error) {
|
|
140
|
+
console.error('[contact-email-index] lookup failed', error);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (!hit) {
|
|
144
|
+
var _found_docs_;
|
|
145
|
+
const found = await readQuery(contactsRef.where('email', '==', normalized).limit(1));
|
|
146
|
+
hit = (_found_docs_ = found.docs[0]) != null ? _found_docs_ : null;
|
|
147
|
+
if (!hit) return null;
|
|
148
|
+
await writeContactEmailIndex(index, hit.id, [
|
|
149
|
+
normalized
|
|
150
|
+
]);
|
|
151
|
+
}
|
|
152
|
+
if (hostId && !visibleToHost(hit.get('visibleTo'), hostId)) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
return hit;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
//# sourceMappingURL=contact-email-index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/contact-email-index.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 ADDRESS INDEX (AGL-2625): `orgs/{orgId}/emailIndex/{personKey}`.\n *\n * `upsertHostContact` finds a person by `where('email', '==', …)` on the\n * contacts collection, and that query answers one document per address by\n * construction — which is exactly what stops a merged record's address from\n * ever finding the survivor. The survivor's `email` is the identity that\n * stayed; the merged address is in its `alternateEmails`, and Firestore\n * cannot answer \"the document whose primary OR alternate address is X\" as\n * one indexed query. So the address is indexed on its own: one document per\n * address, naming the contact it belongs to.\n *\n * ## Consulted first, never trusted alone\n *\n * The lookup reads the index entry and then the contact it names, and\n * takes the contact only if it still exists. A stale entry — a contact\n * detached and deleted after its entry was written — falls through to the\n * query, which is the truth the index only summarizes.\n *\n * ## Written lazily, never backfilled\n *\n * An entry is written when a capture CREATES a contact, and when a lookup\n * finds one through the query rather than the index — so every existing\n * contact gains an entry on its next write, and no job has to walk the\n * collection. A merge writes one entry per address the survivor answers to.\n *\n * ## Never in the way of a capture\n *\n * Every read and write here is wrapped: a form submission or an order must\n * succeed whatever the index did, exactly as the capture itself swallows\n * its own failures. An index that cannot be reached is a miss, and a miss\n * is the query.\n *\n * ## One lookup for every door (AGL-2633)\n *\n * The capture door was the first reader; every other place that finds a\n * person by address — an automation step, the flow-email consent gate, the\n * delivery webhook's engagement stamp, the Inbox add-to-list check, a\n * refund, a campaign's proof and consent reads, the REST create and\n * `?email=` list — resolves through this same function, so a merged\n * record's alternate address answers the same person everywhere. Two of\n * those doors read differently from the capture, and the options carry it:\n *\n * - **`hostId`** narrows the answer to what ONE site may see. The index is\n * org-wide, so the lookup is too; the scope check is applied to the\n * document it names, in memory, which answers exactly what the scoped\n * `email ==` query used to — one document per address, so a contact the\n * site cannot see is `null` rather than a reason to look further.\n * - **`transaction`** routes every read through the caller's transaction,\n * for a door that updates the contact it finds inside one. The lazy index\n * write stays OUTSIDE the transaction, deliberately: a queued write would\n * forbid the caller any later read, and the entry is a cache fill that\n * never has to be atomic with anything. It is idempotent, so a retried\n * transaction body writing it twice is harmless.\n *\n * Imported by module path rather than through the `@aglyn/aglyn/server`\n * barrel, for the reason `upsert-contact.ts` gives: the door's specs\n * substitute a fixture barrel, and a helper that reached the normalizer only\n * through it would find `undefined` there. Callers outside this library\n * import THIS module by its leaf path for the mirror-image reason: most of\n * their specs substitute the `@aglyn/tenant-data-admin` barrel wholesale.\n */\n\nimport {\n CONTACT_EMAIL_INDEX_COLLECTION,\n normalizeContactEmail,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport { visibleToHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/** One entry: the address as stored, and the contact it resolves to. */\nexport interface ContactEmailIndexEntry {\n email: string\n contactId: string\n}\n\n/**\n * The org's index collection, beside its contacts one — or `null` when the\n * handle has no parent, which is a fixture's contacts reference and never\n * a real org subcollection. `null` means \"no index\": the caller falls back\n * to the query and writes nothing.\n */\nexport function emailIndexBeside(\n contactsRef: FirebaseFirestore.CollectionReference,\n): FirebaseFirestore.CollectionReference | null {\n try {\n const parent = contactsRef.parent\n return parent ? parent.collection(CONTACT_EMAIL_INDEX_COLLECTION) : null\n } catch {\n return null\n }\n}\n\n/**\n * Point every address in `emails` at `contactId`. Never rejects: an entry\n * that could not be written is one the next lookup writes again.\n */\nexport async function writeContactEmailIndex(\n index: FirebaseFirestore.CollectionReference | null,\n contactId: string,\n emails: readonly string[],\n): Promise<void> {\n if (!index || !contactId) return\n await Promise.all(\n emails.map(async (raw) => {\n const email = normalizeContactEmail(raw)\n const key = email ? personKey(email) : null\n if (!email || !key) return\n const entry: ContactEmailIndexEntry & { updatedAt: unknown } = {\n email,\n contactId,\n updatedAt: FieldValue.serverTimestamp(),\n }\n await index.doc(key).set(entry, { merge: true })\n }),\n ).catch((error: unknown) => {\n console.error('[contact-email-index] write failed', contactId, error)\n })\n}\n\n/** How one door reads differently from the capture; see the module notes. */\nexport interface FindContactByEmailOptions {\n /**\n * Answer only a contact THIS site may see. A document the address names\n * but the site cannot read is `null` — the same answer the scoped\n * `email ==` query gave, since one address names one document.\n */\n hostId?: string\n /**\n * Read through this transaction. Every read — the index entry, the\n * contact it names, the fallback query — goes through it; the lazy index\n * write does not, so the caller may still read after this returns.\n */\n transaction?: FirebaseFirestore.Transaction\n}\n\n/**\n * The contact an address belongs to, as a document snapshot — or `null`.\n *\n * The index first, then the per-document query it summarizes. A query hit\n * writes the entry the index lacked, so the read that paid for the miss is\n * the last one that has to.\n */\nexport async function findContactByEmail(\n contactsRef: FirebaseFirestore.CollectionReference,\n email: unknown,\n options: FindContactByEmailOptions = {},\n): Promise<FirebaseFirestore.DocumentSnapshot | null> {\n const normalized = normalizeContactEmail(email)\n if (!normalized) return null\n const { hostId, transaction } = options\n const readDoc = (ref: FirebaseFirestore.DocumentReference) =>\n transaction ? transaction.get(ref) : ref.get()\n const readQuery = (query: FirebaseFirestore.Query) =>\n transaction ? transaction.get(query) : query.get()\n\n const index = emailIndexBeside(contactsRef)\n const key = index ? personKey(normalized) : null\n let hit: FirebaseFirestore.DocumentSnapshot | null = null\n if (index && key) {\n try {\n const entry = await readDoc(index.doc(key))\n const contactId = entry.exists ? String(entry.get('contactId') ?? '') : ''\n if (contactId) {\n const contact = await readDoc(contactsRef.doc(contactId))\n if (contact.exists) hit = contact\n }\n } catch (error) {\n console.error('[contact-email-index] lookup failed', error)\n }\n }\n if (!hit) {\n const found = await readQuery(\n contactsRef.where('email', '==', normalized).limit(1),\n )\n hit = found.docs[0] ?? null\n if (!hit) return null\n await writeContactEmailIndex(index, hit.id, [normalized])\n }\n if (hostId && !visibleToHost(hit.get('visibleTo') as string[] | undefined, hostId)) {\n return null\n }\n return hit\n}\n"],"names":["CONTACT_EMAIL_INDEX_COLLECTION","normalizeContactEmail","personKey","visibleToHost","FieldValue","emailIndexBeside","contactsRef","parent","collection","writeContactEmailIndex","index","contactId","emails","Promise","all","map","raw","email","key","entry","updatedAt","serverTimestamp","doc","set","merge","catch","error","console","findContactByEmail","options","normalized","hostId","transaction","readDoc","ref","get","readQuery","query","hit","exists","String","contact","found","where","limit","docs","id"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA6DC,GAED,SACEA,8BAA8B,EAC9BC,qBAAqB,QAChB,kCAAiC;AACxC,SAASC,SAAS,QAAQ,oCAAmC;AAC7D,SAASC,aAAa,QAAQ,sCAAqC;AACnE,SAASC,UAAU,QAAQ,2BAA0B;AAQrD;;;;;CAKC,GACD,OAAO,SAASC,iBACdC,WAAkD;IAElD,IAAI;QACF,MAAMC,SAASD,YAAYC,MAAM;QACjC,OAAOA,SAASA,OAAOC,UAAU,CAACR,kCAAkC;IACtE,EAAE,eAAM;QACN,OAAO;IACT;AACF;AAEA;;;CAGC,GACD,OAAO,eAAeS,uBACpBC,KAAmD,EACnDC,SAAiB,EACjBC,MAAyB;IAEzB,IAAI,CAACF,SAAS,CAACC,WAAW;IAC1B,MAAME,QAAQC,GAAG,CACfF,OAAOG,GAAG,CAAC,OAAOC;QAChB,MAAMC,QAAQhB,sBAAsBe;QACpC,MAAME,MAAMD,QAAQf,UAAUe,SAAS;QACvC,IAAI,CAACA,SAAS,CAACC,KAAK;QACpB,MAAMC,QAAyD;YAC7DF;YACAN;YACAS,WAAWhB,WAAWiB,eAAe;QACvC;QACA,MAAMX,MAAMY,GAAG,CAACJ,KAAKK,GAAG,CAACJ,OAAO;YAAEK,OAAO;QAAK;IAChD,IACAC,KAAK,CAAC,CAACC;QACPC,QAAQD,KAAK,CAAC,sCAAsCf,WAAWe;IACjE;AACF;AAkBA;;;;;;CAMC,GACD,OAAO,eAAeE,mBACpBtB,WAAkD,EAClDW,KAAc,EACdY,UAAqC,CAAC,CAAC;IAEvC,MAAMC,aAAa7B,sBAAsBgB;IACzC,IAAI,CAACa,YAAY,OAAO;IACxB,MAAM,EAAEC,MAAM,EAAEC,WAAW,EAAE,GAAGH;IAChC,MAAMI,UAAU,CAACC,MACfF,cAAcA,YAAYG,GAAG,CAACD,OAAOA,IAAIC,GAAG;IAC9C,MAAMC,YAAY,CAACC,QACjBL,cAAcA,YAAYG,GAAG,CAACE,SAASA,MAAMF,GAAG;IAElD,MAAMzB,QAAQL,iBAAiBC;IAC/B,MAAMY,MAAMR,QAAQR,UAAU4B,cAAc;IAC5C,IAAIQ,MAAiD;IACrD,IAAI5B,SAASQ,KAAK;QAChB,IAAI;gBAEsCC;YADxC,MAAMA,QAAQ,MAAMc,QAAQvB,MAAMY,GAAG,CAACJ;YACtC,MAAMP,YAAYQ,MAAMoB,MAAM,GAAGC,QAAOrB,aAAAA,MAAMgB,GAAG,CAAC,wBAAVhB,aAA0B,MAAM;YACxE,IAAIR,WAAW;gBACb,MAAM8B,UAAU,MAAMR,QAAQ3B,YAAYgB,GAAG,CAACX;gBAC9C,IAAI8B,QAAQF,MAAM,EAAED,MAAMG;YAC5B;QACF,EAAE,OAAOf,OAAO;YACdC,QAAQD,KAAK,CAAC,uCAAuCA;QACvD;IACF;IACA,IAAI,CAACY,KAAK;YAIFI;QAHN,MAAMA,QAAQ,MAAMN,UAClB9B,YAAYqC,KAAK,CAAC,SAAS,MAAMb,YAAYc,KAAK,CAAC;QAErDN,OAAMI,eAAAA,MAAMG,IAAI,CAAC,EAAE,YAAbH,eAAiB;QACvB,IAAI,CAACJ,KAAK,OAAO;QACjB,MAAM7B,uBAAuBC,OAAO4B,IAAIQ,EAAE,EAAE;YAAChB;SAAW;IAC1D;IACA,IAAIC,UAAU,CAAC5B,cAAcmC,IAAIH,GAAG,CAAC,cAAsCJ,SAAS;QAClF,OAAO;IACT;IACA,OAAOO;AACT"}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* A person's lifecycle stage, FLOORED at what just happened to them
|
|
19
|
+
* (AGL-2641).
|
|
20
|
+
*
|
|
21
|
+
* The capture doors floor a stage inside `upsertHostContact`: an order
|
|
22
|
+
* names `customer` as its `initialLifecycleStage`, and the door fills an
|
|
23
|
+
* empty stage or advances an earlier one and never moves anybody back. A
|
|
24
|
+
* won deal is the same fact as a purchase — the business has decided this
|
|
25
|
+
* person bought — but it arrives at a record that already exists, through a
|
|
26
|
+
* writer that is not a capture door: the deal-stage route and the REST
|
|
27
|
+
* deals resource. This is the door's rule for them, over a contact document
|
|
28
|
+
* they already hold a reference to.
|
|
29
|
+
*
|
|
30
|
+
* The write goes into ONE holder's facet, because a stage is one business's
|
|
31
|
+
* reading of a person and the row is shared by every site in the org. The
|
|
32
|
+
* holder is the site the deal was made on, resolved to its consent group
|
|
33
|
+
* the way every other facet writer resolves it; a deal that names no site
|
|
34
|
+
* falls back to the site that captured the person, which is the one
|
|
35
|
+
* holder a contact always has. A contact that names none either is left
|
|
36
|
+
* alone and the caller told, rather than written into a facet nobody reads.
|
|
37
|
+
*
|
|
38
|
+
* The write is skipped when it would change nothing — a customer stays a
|
|
39
|
+
* customer, an evangelist stays an evangelist, `other` is never overwritten
|
|
40
|
+
* — so a caller can announce `contactStageChanged` exactly when `advanced`
|
|
41
|
+
* says the stage moved, and an automation listening for the change never
|
|
42
|
+
* hears one that did not happen.
|
|
43
|
+
*/
|
|
44
|
+
import { type ContactLifecycleStage } from '@aglyn/aglyn/server';
|
|
45
|
+
export type ContactLifecycleFloor = {
|
|
46
|
+
/** The stage was raised to the floor. */
|
|
47
|
+
outcome: 'advanced';
|
|
48
|
+
contactId: string;
|
|
49
|
+
/** The address on the row, for the event a caller announces. */
|
|
50
|
+
email: string;
|
|
51
|
+
/** The site whose facet was written. */
|
|
52
|
+
hostId: string;
|
|
53
|
+
groupId: string;
|
|
54
|
+
/** The stage before the write, or `''` for a person who had none. */
|
|
55
|
+
previousStage: ContactLifecycleStage | '';
|
|
56
|
+
lifecycleStage: ContactLifecycleStage;
|
|
57
|
+
} | {
|
|
58
|
+
/** The person already held the floor or a later stage; nothing was written. */
|
|
59
|
+
outcome: 'held';
|
|
60
|
+
contactId: string;
|
|
61
|
+
email: string;
|
|
62
|
+
hostId: string;
|
|
63
|
+
groupId: string;
|
|
64
|
+
lifecycleStage: ContactLifecycleStage;
|
|
65
|
+
} | {
|
|
66
|
+
/** No such contact. */
|
|
67
|
+
outcome: 'missing';
|
|
68
|
+
contactId: string;
|
|
69
|
+
} | {
|
|
70
|
+
/** Neither the caller nor the contact names a site, so there is no facet to write. */
|
|
71
|
+
outcome: 'unheld';
|
|
72
|
+
contactId: string;
|
|
73
|
+
};
|
|
74
|
+
export interface ContactLifecycleFloorOptions {
|
|
75
|
+
contactRef: FirebaseFirestore.DocumentReference;
|
|
76
|
+
/**
|
|
77
|
+
* The org the contact belongs to, for the consent groups — the same
|
|
78
|
+
* document every facet writer resolves the holder through.
|
|
79
|
+
*/
|
|
80
|
+
org: Record<string, unknown> | null | undefined;
|
|
81
|
+
/**
|
|
82
|
+
* The site the act happened on, whose facet takes the stage. `null` for a
|
|
83
|
+
* record no site captured; the contact's own capturing site is used then.
|
|
84
|
+
*/
|
|
85
|
+
hostId: string | null | undefined;
|
|
86
|
+
/** The EARLIEST stage that describes what happened — see `advanceContactLifecycleStage`. */
|
|
87
|
+
floor: ContactLifecycleStage;
|
|
88
|
+
}
|
|
89
|
+
export declare function floorContactLifecycleStage(options: ContactLifecycleFloorOptions): Promise<ContactLifecycleFloor>;
|
|
@@ -0,0 +1,88 @@
|
|
|
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
|
+
* A person's lifecycle stage, FLOORED at what just happened to them
|
|
18
|
+
* (AGL-2641).
|
|
19
|
+
*
|
|
20
|
+
* The capture doors floor a stage inside `upsertHostContact`: an order
|
|
21
|
+
* names `customer` as its `initialLifecycleStage`, and the door fills an
|
|
22
|
+
* empty stage or advances an earlier one and never moves anybody back. A
|
|
23
|
+
* won deal is the same fact as a purchase — the business has decided this
|
|
24
|
+
* person bought — but it arrives at a record that already exists, through a
|
|
25
|
+
* writer that is not a capture door: the deal-stage route and the REST
|
|
26
|
+
* deals resource. This is the door's rule for them, over a contact document
|
|
27
|
+
* they already hold a reference to.
|
|
28
|
+
*
|
|
29
|
+
* The write goes into ONE holder's facet, because a stage is one business's
|
|
30
|
+
* reading of a person and the row is shared by every site in the org. The
|
|
31
|
+
* holder is the site the deal was made on, resolved to its consent group
|
|
32
|
+
* the way every other facet writer resolves it; a deal that names no site
|
|
33
|
+
* falls back to the site that captured the person, which is the one
|
|
34
|
+
* holder a contact always has. A contact that names none either is left
|
|
35
|
+
* alone and the caller told, rather than written into a facet nobody reads.
|
|
36
|
+
*
|
|
37
|
+
* The write is skipped when it would change nothing — a customer stays a
|
|
38
|
+
* customer, an evangelist stays an evangelist, `other` is never overwritten
|
|
39
|
+
* — so a caller can announce `contactStageChanged` exactly when `advanced`
|
|
40
|
+
* says the stage moved, and an automation listening for the change never
|
|
41
|
+
* hears one that did not happen.
|
|
42
|
+
*/ import { consentGroupForHost, advanceContactLifecycleStage, contactFacetPath, isContactLifecycleStage, readContactFacet } from "@aglyn/aglyn/server";
|
|
43
|
+
import { FieldValue } from "firebase-admin/firestore";
|
|
44
|
+
export async function floorContactLifecycleStage(options) {
|
|
45
|
+
var _snapshot_data, _options_hostId, _data_hostId, _advanceContactLifecycleStage, _data_email;
|
|
46
|
+
const { contactRef, org, floor } = options;
|
|
47
|
+
const snapshot = await contactRef.get();
|
|
48
|
+
if (!snapshot.exists) return {
|
|
49
|
+
outcome: 'missing',
|
|
50
|
+
contactId: contactRef.id
|
|
51
|
+
};
|
|
52
|
+
const data = (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {};
|
|
53
|
+
const hostId = String((_options_hostId = options.hostId) != null ? _options_hostId : '').trim() || String((_data_hostId = data['hostId']) != null ? _data_hostId : '').trim();
|
|
54
|
+
if (!hostId) return {
|
|
55
|
+
outcome: 'unheld',
|
|
56
|
+
contactId: snapshot.id
|
|
57
|
+
};
|
|
58
|
+
const { groupId } = consentGroupForHost(org != null ? org : null, hostId);
|
|
59
|
+
const facet = readContactFacet(data, groupId);
|
|
60
|
+
const held = isContactLifecycleStage(facet.lifecycleStage) ? facet.lifecycleStage : undefined;
|
|
61
|
+
const lifecycleStage = (_advanceContactLifecycleStage = advanceContactLifecycleStage(held, floor)) != null ? _advanceContactLifecycleStage : floor;
|
|
62
|
+
const email = String((_data_email = data['email']) != null ? _data_email : '');
|
|
63
|
+
if (lifecycleStage === held) {
|
|
64
|
+
return {
|
|
65
|
+
outcome: 'held',
|
|
66
|
+
contactId: snapshot.id,
|
|
67
|
+
email,
|
|
68
|
+
hostId,
|
|
69
|
+
groupId,
|
|
70
|
+
lifecycleStage
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
await snapshot.ref.update({
|
|
74
|
+
[contactFacetPath(groupId, 'lifecycleStage')]: lifecycleStage,
|
|
75
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
76
|
+
});
|
|
77
|
+
return {
|
|
78
|
+
outcome: 'advanced',
|
|
79
|
+
contactId: snapshot.id,
|
|
80
|
+
email,
|
|
81
|
+
hostId,
|
|
82
|
+
groupId,
|
|
83
|
+
previousStage: held != null ? held : '',
|
|
84
|
+
lifecycleStage
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
//# sourceMappingURL=contact-lifecycle-floor.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/contact-lifecycle-floor.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 * A person's lifecycle stage, FLOORED at what just happened to them\n * (AGL-2641).\n *\n * The capture doors floor a stage inside `upsertHostContact`: an order\n * names `customer` as its `initialLifecycleStage`, and the door fills an\n * empty stage or advances an earlier one and never moves anybody back. A\n * won deal is the same fact as a purchase — the business has decided this\n * person bought — but it arrives at a record that already exists, through a\n * writer that is not a capture door: the deal-stage route and the REST\n * deals resource. This is the door's rule for them, over a contact document\n * they already hold a reference to.\n *\n * The write goes into ONE holder's facet, because a stage is one business's\n * reading of a person and the row is shared by every site in the org. The\n * holder is the site the deal was made on, resolved to its consent group\n * the way every other facet writer resolves it; a deal that names no site\n * falls back to the site that captured the person, which is the one\n * holder a contact always has. A contact that names none either is left\n * alone and the caller told, rather than written into a facet nobody reads.\n *\n * The write is skipped when it would change nothing — a customer stays a\n * customer, an evangelist stays an evangelist, `other` is never overwritten\n * — so a caller can announce `contactStageChanged` exactly when `advanced`\n * says the stage moved, and an automation listening for the change never\n * hears one that did not happen.\n */\n\nimport {\n consentGroupForHost,\n advanceContactLifecycleStage,\n contactFacetPath,\n type ContactLifecycleStage,\n isContactLifecycleStage,\n readContactFacet,\n} from '@aglyn/aglyn/server'\nimport { FieldValue } from 'firebase-admin/firestore'\n\nexport type ContactLifecycleFloor =\n | {\n /** The stage was raised to the floor. */\n outcome: 'advanced'\n contactId: string\n /** The address on the row, for the event a caller announces. */\n email: string\n /** The site whose facet was written. */\n hostId: string\n groupId: string\n /** The stage before the write, or `''` for a person who had none. */\n previousStage: ContactLifecycleStage | ''\n lifecycleStage: ContactLifecycleStage\n }\n | {\n /** The person already held the floor or a later stage; nothing was written. */\n outcome: 'held'\n contactId: string\n email: string\n hostId: string\n groupId: string\n lifecycleStage: ContactLifecycleStage\n }\n | {\n /** No such contact. */\n outcome: 'missing'\n contactId: string\n }\n | {\n /** Neither the caller nor the contact names a site, so there is no facet to write. */\n outcome: 'unheld'\n contactId: string\n }\n\nexport interface ContactLifecycleFloorOptions {\n contactRef: FirebaseFirestore.DocumentReference\n /**\n * The org the contact belongs to, for the consent groups — the same\n * document every facet writer resolves the holder through.\n */\n org: Record<string, unknown> | null | undefined\n /**\n * The site the act happened on, whose facet takes the stage. `null` for a\n * record no site captured; the contact's own capturing site is used then.\n */\n hostId: string | null | undefined\n /** The EARLIEST stage that describes what happened — see `advanceContactLifecycleStage`. */\n floor: ContactLifecycleStage\n}\n\nexport async function floorContactLifecycleStage(\n options: ContactLifecycleFloorOptions,\n): Promise<ContactLifecycleFloor> {\n const { contactRef, org, floor } = options\n const snapshot = await contactRef.get()\n if (!snapshot.exists) return { outcome: 'missing', contactId: contactRef.id }\n const data = (snapshot.data() ?? {}) as Record<string, unknown>\n const hostId = String(options.hostId ?? '').trim() || String(data['hostId'] ?? '').trim()\n if (!hostId) return { outcome: 'unheld', contactId: snapshot.id }\n\n const { groupId } = consentGroupForHost(org ?? null, hostId)\n const facet = readContactFacet(data, groupId)\n const held = isContactLifecycleStage(facet.lifecycleStage)\n ? facet.lifecycleStage\n : undefined\n const lifecycleStage = advanceContactLifecycleStage(held, floor) ?? floor\n const email = String(data['email'] ?? '')\n if (lifecycleStage === held) {\n return { outcome: 'held', contactId: snapshot.id, email, hostId, groupId, lifecycleStage }\n }\n await snapshot.ref.update({\n [contactFacetPath(groupId, 'lifecycleStage')]: lifecycleStage,\n updatedAt: FieldValue.serverTimestamp(),\n })\n return {\n outcome: 'advanced',\n contactId: snapshot.id,\n email,\n hostId,\n groupId,\n previousStage: held ?? '',\n lifecycleStage,\n }\n}\n"],"names":["consentGroupForHost","advanceContactLifecycleStage","contactFacetPath","isContactLifecycleStage","readContactFacet","FieldValue","floorContactLifecycleStage","options","snapshot","data","contactRef","org","floor","get","exists","outcome","contactId","id","hostId","String","trim","groupId","facet","held","lifecycleStage","undefined","email","ref","update","updatedAt","serverTimestamp","previousStage"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GAED,SACEA,mBAAmB,EACnBC,4BAA4B,EAC5BC,gBAAgB,EAEhBC,uBAAuB,EACvBC,gBAAgB,QACX,sBAAqB;AAC5B,SAASC,UAAU,QAAQ,2BAA0B;AAoDrD,OAAO,eAAeC,2BACpBC,OAAqC;QAKvBC,gBACQD,iBAAuCE,cAQtCR,+BACFQ;IAbrB,MAAM,EAAEC,UAAU,EAAEC,GAAG,EAAEC,KAAK,EAAE,GAAGL;IACnC,MAAMC,WAAW,MAAME,WAAWG,GAAG;IACrC,IAAI,CAACL,SAASM,MAAM,EAAE,OAAO;QAAEC,SAAS;QAAWC,WAAWN,WAAWO,EAAE;IAAC;IAC5E,MAAMR,QAAQD,iBAAAA,SAASC,IAAI,cAAbD,iBAAmB,CAAC;IAClC,MAAMU,SAASC,QAAOZ,kBAAAA,QAAQW,MAAM,YAAdX,kBAAkB,IAAIa,IAAI,MAAMD,QAAOV,eAAAA,IAAI,CAAC,SAAS,YAAdA,eAAkB,IAAIW,IAAI;IACvF,IAAI,CAACF,QAAQ,OAAO;QAAEH,SAAS;QAAUC,WAAWR,SAASS,EAAE;IAAC;IAEhE,MAAM,EAAEI,OAAO,EAAE,GAAGrB,oBAAoBW,cAAAA,MAAO,MAAMO;IACrD,MAAMI,QAAQlB,iBAAiBK,MAAMY;IACrC,MAAME,OAAOpB,wBAAwBmB,MAAME,cAAc,IACrDF,MAAME,cAAc,GACpBC;IACJ,MAAMD,kBAAiBvB,gCAAAA,6BAA6BsB,MAAMX,kBAAnCX,gCAA6CW;IACpE,MAAMc,QAAQP,QAAOV,cAAAA,IAAI,CAAC,QAAQ,YAAbA,cAAiB;IACtC,IAAIe,mBAAmBD,MAAM;QAC3B,OAAO;YAAER,SAAS;YAAQC,WAAWR,SAASS,EAAE;YAAES;YAAOR;YAAQG;YAASG;QAAe;IAC3F;IACA,MAAMhB,SAASmB,GAAG,CAACC,MAAM,CAAC;QACxB,CAAC1B,iBAAiBmB,SAAS,kBAAkB,EAAEG;QAC/CK,WAAWxB,WAAWyB,eAAe;IACvC;IACA,OAAO;QACLf,SAAS;QACTC,WAAWR,SAASS,EAAE;QACtBS;QACAR;QACAG;QACAU,aAAa,EAAER,eAAAA,OAAQ;QACvBC;IACF;AACF"}
|