@aglyn/plugins-crm 1.0.0-beta.217 → 1.0.0-beta.219
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/package.json +14 -13
- package/src/index.d.ts +1 -0
- package/src/index.js.map +1 -1
- package/src/lib/components/companies-bulk-bar.js +12 -4
- package/src/lib/components/companies-bulk-bar.js.map +1 -1
- package/src/lib/components/company-detail-page.js +11 -0
- package/src/lib/components/company-detail-page.js.map +1 -1
- package/src/lib/components/company-edit-drawer.js +12 -2
- package/src/lib/components/company-edit-drawer.js.map +1 -1
- package/src/lib/components/company-import-drawer.d.ts +1 -1
- package/src/lib/components/company-picker.js +10 -3
- package/src/lib/components/company-picker.js.map +1 -1
- package/src/lib/components/contact-detail-page.d.ts +4 -4
- package/src/lib/components/contact-detail-page.js +50 -53
- package/src/lib/components/contact-detail-page.js.map +1 -1
- package/src/lib/components/contact-list-columns.d.ts +3 -0
- package/src/lib/components/contact-list-columns.js +21 -5
- package/src/lib/components/contact-list-columns.js.map +1 -1
- package/src/lib/components/contact-merge-dialog.js +2 -1
- package/src/lib/components/contact-merge-dialog.js.map +1 -1
- package/src/lib/components/contact-timeline-card.js +4 -3
- package/src/lib/components/contact-timeline-card.js.map +1 -1
- package/src/lib/components/contacts-bulk-bar.d.ts +3 -3
- package/src/lib/components/contacts-bulk-bar.js +24 -51
- package/src/lib/components/contacts-bulk-bar.js.map +1 -1
- package/src/lib/components/contacts-section.js +9 -3
- package/src/lib/components/contacts-section.js.map +1 -1
- package/src/lib/components/crm-export-all-button.d.ts +1 -1
- package/src/lib/components/crm-export-all-button.js.map +1 -1
- package/src/lib/components/crm-send-email-dialog.js +9 -6
- package/src/lib/components/crm-send-email-dialog.js.map +1 -1
- package/src/lib/components/csv-import-drawer.js +2 -2
- package/src/lib/components/csv-import-drawer.js.map +1 -1
- package/src/lib/components/deal-detail-page.js +18 -2
- package/src/lib/components/deal-detail-page.js.map +1 -1
- package/src/lib/components/deal-edit-drawer.js +10 -0
- package/src/lib/components/deal-edit-drawer.js.map +1 -1
- package/src/lib/components/deals-bulk-bar.js +13 -5
- package/src/lib/components/deals-bulk-bar.js.map +1 -1
- package/src/lib/components/lead-campaigns-card.js +6 -0
- package/src/lib/components/lead-campaigns-card.js.map +1 -1
- package/src/lib/components/lead-detail-page.js +25 -5
- package/src/lib/components/lead-detail-page.js.map +1 -1
- package/src/lib/components/lead-properties-card.js +6 -0
- package/src/lib/components/lead-properties-card.js.map +1 -1
- package/src/lib/components/lead-surfaces-note.js +4 -5
- package/src/lib/components/lead-surfaces-note.js.map +1 -1
- package/src/lib/components/lead-unqualify-dialog.js +6 -0
- package/src/lib/components/lead-unqualify-dialog.js.map +1 -1
- package/src/lib/components/leads-bulk-bar.js +28 -3
- package/src/lib/components/leads-bulk-bar.js.map +1 -1
- package/src/lib/components/leads-section.js +36 -7
- package/src/lib/components/leads-section.js.map +1 -1
- package/src/lib/components/org-lead-surfaces-note.js +6 -2
- package/src/lib/components/org-lead-surfaces-note.js.map +1 -1
- package/src/lib/components/recipes-card.js +9 -4
- package/src/lib/components/recipes-card.js.map +1 -1
- package/src/lib/components/record-sharing-card.d.ts +57 -0
- package/src/lib/components/record-sharing-card.js +244 -0
- package/src/lib/components/record-sharing-card.js.map +1 -0
- package/src/lib/components/settings-section.js +7 -0
- package/src/lib/components/settings-section.js.map +1 -1
- package/src/lib/components/share-records-dialog.d.ts +28 -0
- package/src/lib/components/share-records-dialog.js +184 -0
- package/src/lib/components/share-records-dialog.js.map +1 -0
- package/src/lib/components/sharing-rule-drawer.d.ts +37 -0
- package/src/lib/components/sharing-rule-drawer.js +374 -0
- package/src/lib/components/sharing-rule-drawer.js.map +1 -0
- package/src/lib/components/sharing-rules-card.d.ts +24 -0
- package/src/lib/components/sharing-rules-card.js +287 -0
- package/src/lib/components/sharing-rules-card.js.map +1 -0
- package/src/lib/components/use-crm-api.d.ts +1 -1
- package/src/lib/components/use-crm-api.js.map +1 -1
- package/src/lib/components/use-site-route-context.d.ts +25 -0
- package/src/lib/components/use-site-route-context.js +58 -0
- package/src/lib/components/use-site-route-context.js.map +1 -0
- package/src/lib/constants/api-routes.d.ts +31 -0
- package/src/lib/constants/api-routes.js +27 -1
- package/src/lib/constants/api-routes.js.map +1 -1
- package/src/lib/declarations.console-server.d.ts +37 -0
- package/src/lib/declarations.console-server.js +103 -0
- package/src/lib/declarations.console-server.js.map +1 -0
- package/src/lib/declarations.server.d.ts +6 -0
- package/src/lib/declarations.server.js +33 -0
- package/src/lib/declarations.server.js.map +1 -1
- package/src/lib/hooks/use-contact-remove.d.ts +35 -0
- package/src/lib/hooks/use-contact-remove.js +77 -0
- package/src/lib/hooks/use-contact-remove.js.map +1 -0
- package/src/lib/hooks/use-crm-sharing.d.ts +30 -0
- package/src/lib/hooks/use-crm-sharing.js +109 -0
- package/src/lib/hooks/use-crm-sharing.js.map +1 -0
- package/src/lib/model/companies-csv.d.ts +1 -1
- package/src/lib/model/companies-csv.js +2 -2
- package/src/lib/model/companies-csv.js.map +1 -1
- package/src/lib/model/contact-holder.d.ts +68 -0
- package/src/lib/model/contact-holder.js +129 -0
- package/src/lib/model/contact-holder.js.map +1 -0
- package/src/lib/model/contact-interaction-links.d.ts +46 -0
- package/src/lib/model/contact-interaction-links.js +65 -0
- package/src/lib/model/contact-interaction-links.js.map +1 -0
- package/src/lib/model/contact-merge.d.ts +136 -0
- package/src/lib/model/contact-merge.js +412 -0
- package/src/lib/model/contact-merge.js.map +1 -0
- package/src/lib/model/contact-record.d.ts +7 -1
- package/src/lib/model/contact-record.js +6 -5
- package/src/lib/model/contact-record.js.map +1 -1
- package/src/lib/model/contact-remove.d.ts +48 -0
- package/src/lib/model/contact-remove.js +22 -0
- package/src/lib/model/contact-remove.js.map +1 -0
- package/src/lib/model/contacts-bulk-writes.d.ts +2 -71
- package/src/lib/model/contacts-bulk-writes.js +2 -82
- package/src/lib/model/contacts-bulk-writes.js.map +1 -1
- package/src/lib/model/contacts-csv.d.ts +2 -2
- package/src/lib/model/contacts-csv.js +2 -2
- package/src/lib/model/contacts-csv.js.map +1 -1
- package/src/lib/model/crm-csv.d.ts +239 -0
- package/src/lib/model/crm-csv.js +467 -0
- package/src/lib/model/crm-csv.js.map +1 -0
- package/src/lib/model/crm-digest.d.ts +185 -0
- package/src/lib/model/crm-digest.js +272 -0
- package/src/lib/model/crm-digest.js.map +1 -0
- package/src/lib/model/crm-import.d.ts +7 -1
- package/src/lib/model/crm-import.js +2 -1
- package/src/lib/model/crm-import.js.map +1 -1
- package/src/lib/model/crm-record-routes.d.ts +5 -0
- package/src/lib/model/crm-record-routes.js +7 -0
- package/src/lib/model/crm-record-routes.js.map +1 -1
- package/src/lib/model/crm-routes.d.ts +6 -5
- package/src/lib/model/crm-routes.js +7 -7
- package/src/lib/model/crm-routes.js.map +1 -1
- package/src/lib/model/crm-search-sources.d.ts +15 -0
- package/src/lib/model/crm-search-sources.js +162 -0
- package/src/lib/model/crm-search-sources.js.map +1 -0
- package/src/lib/model/crm-sharing-api.d.ts +32 -0
- package/src/lib/model/crm-sharing-api.js +59 -0
- package/src/lib/model/crm-sharing-api.js.map +1 -0
- package/src/lib/model/crm-sharing.d.ts +333 -0
- package/src/lib/model/crm-sharing.js +603 -0
- package/src/lib/model/crm-sharing.js.map +1 -0
- package/src/lib/model/deals-csv.d.ts +1 -1
- package/src/lib/model/deals-csv.js +4 -5
- package/src/lib/model/deals-csv.js.map +1 -1
- package/src/lib/model/lead-filters.d.ts +1 -1
- package/src/lib/model/leads-csv.d.ts +1 -1
- package/src/lib/model/leads-csv.js +1 -1
- package/src/lib/model/leads-csv.js.map +1 -1
- package/src/lib/model/tasks-csv.d.ts +1 -1
- package/src/lib/model/tasks-csv.js +1 -1
- package/src/lib/model/tasks-csv.js.map +1 -1
- package/src/lib/plan-entitlements.d.ts +75 -0
- package/src/lib/plan-entitlements.js +56 -0
- package/src/lib/plan-entitlements.js.map +1 -0
- package/src/lib/plugin.js +3 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/server/api-v1/contacts-merge.d.ts +20 -0
- package/src/lib/server/api-v1/contacts-merge.js +120 -0
- package/src/lib/server/api-v1/contacts-merge.js.map +1 -0
- package/src/lib/server/api-v1/contacts.d.ts +18 -0
- package/src/lib/server/api-v1/contacts.js +1076 -0
- package/src/lib/server/api-v1/contacts.js.map +1 -0
- package/src/lib/server/api-v1/crm-activities.d.ts +18 -0
- package/src/lib/server/api-v1/crm-activities.js +269 -0
- package/src/lib/server/api-v1/crm-activities.js.map +1 -0
- package/src/lib/server/api-v1/crm-companies.d.ts +18 -0
- package/src/lib/server/api-v1/crm-companies.js +391 -0
- package/src/lib/server/api-v1/crm-companies.js.map +1 -0
- package/src/lib/server/api-v1/crm-deals.d.ts +18 -0
- package/src/lib/server/api-v1/crm-deals.js +631 -0
- package/src/lib/server/api-v1/crm-deals.js.map +1 -0
- package/src/lib/server/api-v1/crm-email-templates.d.ts +18 -0
- package/src/lib/server/api-v1/crm-email-templates.js +336 -0
- package/src/lib/server/api-v1/crm-email-templates.js.map +1 -0
- package/src/lib/server/api-v1/crm-leads.d.ts +18 -0
- package/src/lib/server/api-v1/crm-leads.js +832 -0
- package/src/lib/server/api-v1/crm-leads.js.map +1 -0
- package/src/lib/server/api-v1/crm-pipelines.d.ts +76 -0
- package/src/lib/server/api-v1/crm-pipelines.js +146 -0
- package/src/lib/server/api-v1/crm-pipelines.js.map +1 -0
- package/src/lib/server/api-v1/crm-shared.d.ts +271 -0
- package/src/lib/server/api-v1/crm-shared.js +436 -0
- package/src/lib/server/api-v1/crm-shared.js.map +1 -0
- package/src/lib/server/api-v1/crm-tasks.d.ts +18 -0
- package/src/lib/server/api-v1/crm-tasks.js +398 -0
- package/src/lib/server/api-v1/crm-tasks.js.map +1 -0
- package/src/lib/server/api-v1/openapi.d.ts +29 -0
- package/src/lib/server/api-v1/openapi.js +985 -0
- package/src/lib/server/api-v1/openapi.js.map +1 -0
- package/src/lib/server/api-v1/usage.d.ts +26 -0
- package/src/lib/server/api-v1/usage.js +73 -0
- package/src/lib/server/api-v1/usage.js.map +1 -0
- package/src/lib/server/consent-group-participant.js +1 -1
- package/src/lib/server/consent-group-participant.js.map +1 -1
- package/src/lib/server/contact-lifecycle-floor.d.ts +89 -0
- package/src/lib/server/contact-lifecycle-floor.js +91 -0
- package/src/lib/server/contact-lifecycle-floor.js.map +1 -0
- package/src/lib/server/contact-remove.d.ts +75 -0
- package/src/lib/server/contact-remove.js +244 -0
- package/src/lib/server/contact-remove.js.map +1 -0
- package/src/lib/server/contact-update.js +2 -1
- package/src/lib/server/contact-update.js.map +1 -1
- package/src/lib/server/contacts-import.d.ts +47 -11
- package/src/lib/server/contacts-import.js +171 -71
- package/src/lib/server/contacts-import.js.map +1 -1
- package/src/lib/server/contacts-merge.d.ts +1 -1
- package/src/lib/server/contacts-merge.js +3 -2
- package/src/lib/server/contacts-merge.js.map +1 -1
- package/src/lib/server/convert-host-lead.d.ts +189 -0
- package/src/lib/server/convert-host-lead.js +518 -0
- package/src/lib/server/convert-host-lead.js.map +1 -0
- package/src/lib/server/crm-sharing.d.ts +108 -0
- package/src/lib/server/crm-sharing.js +790 -0
- package/src/lib/server/crm-sharing.js.map +1 -0
- package/src/lib/server/daily-digest-route.d.ts +107 -0
- package/src/lib/server/daily-digest-route.js +588 -0
- package/src/lib/server/daily-digest-route.js.map +1 -0
- package/src/lib/server/export-route.d.ts +60 -0
- package/src/lib/server/export-route.js +447 -0
- package/src/lib/server/export-route.js.map +1 -0
- package/src/lib/server/inbound-route.d.ts +74 -0
- package/src/lib/server/inbound-route.js +257 -0
- package/src/lib/server/inbound-route.js.map +1 -0
- package/src/lib/server/lead-convert.d.ts +1 -1
- package/src/lib/server/lead-convert.js +2 -2
- package/src/lib/server/lead-convert.js.map +1 -1
- package/src/lib/server/merge-contacts.d.ts +55 -0
- package/src/lib/server/merge-contacts.js +274 -0
- package/src/lib/server/merge-contacts.js.map +1 -0
- package/src/lib/server/pipeline-record-index.d.ts +36 -0
- package/src/lib/server/pipeline-record-index.js +87 -0
- package/src/lib/server/pipeline-record-index.js.map +1 -0
- package/src/lib/server/record-facts.js +2 -1
- package/src/lib/server/record-facts.js.map +1 -1
- package/src/lib/server/task-reminders-route.d.ts +82 -0
- package/src/lib/server/task-reminders-route.js +497 -0
- package/src/lib/server/task-reminders-route.js.map +1 -0
- package/src/lib/server-deal-stage.js +2 -1
- package/src/lib/server-deal-stage.js.map +1 -1
- package/src/lib/server.d.ts +5 -2
- package/src/lib/server.js +47 -2
- package/src/lib/server.js.map +1 -1
- package/src/lib/testing/stand-in-site-record-routes.d.ts +26 -0
- package/src/lib/testing/stand-in-site-record-routes.js +55 -0
- package/src/lib/testing/stand-in-site-record-routes.js.map +1 -0
- package/src/lib/usage-axes.d.ts +35 -0
- package/src/lib/usage-axes.js +68 -0
- package/src/lib/usage-axes.js.map +1 -0
- package/src/lib/components/use-emails-hub-path.d.ts +0 -24
- package/src/lib/components/use-emails-hub-path.js +0 -59
- package/src/lib/components/use-emails-hub-path.js.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/daily-digest-route.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n type AglynOrgBilling,\n type AglynOrgMember,\n buildRoute,\n CRM_COLLECTIONS,\n DIGEST_PREFS_FIELD,\n digestEnabled,\n hostRoleFor,\n isReleaseFlagOnForOrg,\n notificationMuted,\n parseOrgReleaseFlagOverrides,\n pluginRequestFromWeb,\n resolveBrandingProfile,\n resolveEffectivePlan,\n resolveOrgEntitlements,\n Route,\n} from '@aglyn/aglyn/server'\nimport {\n buildMemberDigests,\n composeCrmDigestEmailText,\n composeCrmDigestSubject,\n composeCrmDigestSummary,\n CRM_DIGEST_DEFAULT_TIME_ZONE,\n CRM_DIGEST_LEAD_AGE_MS,\n CRM_DIGEST_LEAD_WINDOW,\n CRM_DIGEST_TASK_CEILING,\n crmDigestCounts,\n crmDigestEntitled,\n type CrmDigestLead,\n type CrmDigestTask,\n crmDigestWindow,\n type CrmMemberDigest,\n isUnworkedLead,\n utcDayKey,\n} from '../model/crm-digest'\nimport { rateLimitedRetryAtMs, sendEmail } from '@aglyn/shared-util-email'\nimport { withoutMailWithheldAccounts } from '@aglyn/tenant-data-admin/server/account-mail'\nimport {\n findUserByUidAcrossPools,\n firebaseAdmin,\n getServerReleaseFlagValues,\n listOrgMembers,\n memberHasOrgPermission,\n meterPlatformEmail,\n notifyUsers,\n} from '@aglyn/tenant-data-admin'\n// From the LEAF, not the barrel (AGL-2407): the cron specs mock\n// `@aglyn/tenant-data-admin` wholesale, and a suppression gate imported\n// through the barrel would be silently replaced by whatever the factory\n// lists. Same reasoning as `usage-alert-email.ts`.\nimport { filterSuppressedEmails } from '@aglyn/tenant-data-admin/server/email-suppression'\nimport {\n loadSystemEmail,\n renderLoadedSystemEmail,\n systemEmailBrand,\n systemEmailContent,\n type LoadedSystemEmail,\n} from '@aglyn/tenant-data-admin/server/render-system-email'\nimport { isCronAuthorized, isCronDryRun } from '@aglyn/tenant-data-admin/server/cron-auth'\nimport { writeCronBeat } from '@aglyn/aglyn/app-utils/health-report'\nimport { brandSupportLine } from '@aglyn/aglyn/app-utils/brand-support-line'\nimport { platformConsoleOrigin as consoleOrigin } from '@aglyn/aglyn/app-utils/platform-brand'\nimport { scopeTokensForHost } from '@aglyn/aglyn/app-utils/scope-tokens'\n\n/**\n * The mark a scheduled run leaves for `/api/health/crons` (AGL-1955), on the\n * INVOCATION and before the work — the console's rule for every cron. Never\n * throws: reaching the admin app is inside the try, because a monitor that\n * can take down the job it describes is worse than the failure it watches.\n */\nasync function recordCronBeat(jobId: string): Promise<void> {\n try {\n await writeCronBeat(firebaseAdmin.app().firestore(), jobId)\n } catch {\n // Deliberately swallowed: a job that stops stamping reds its own row.\n }\n}\n\n// lockdown-423: exempt — server-internal cron (x-cron-secret), no user caller; it reads an org's CRM to remind its members and writes nothing a locked org could lose.\n\n/**\n * The daily CRM digest (AGL-2619): `POST /api/crm/daily-digest`.\n *\n * Overdue and due-today are computed at read time by design (AGL-2599), so\n * until this route nothing ever told anybody a task was late — overdue work\n * was found by opening the Tasks section. Once a morning, per org and per\n * member with open work, this counts their overdue and due-today tasks and\n * the leads nobody has worked, and says so ONCE: a console notification\n * (`content.crmDailyDigest`) and an email through the platform's own\n * sending path. The words and the arithmetic live in `crm-digest.ts`; this\n * file is the reads, the sends and the sweep.\n *\n * ## Who is told, and who is not\n *\n * A task's assignee, a lead's owner, and — for a lead nobody owns — every\n * member who reaches the site it was captured on. Only members who may open\n * the CRM (`data.manage`, resolved through their custom role the way the task\n * routes resolve it), and only in an org whose plan carries the suite and\n * whose `release_crm` flag is on: a digest about a surface the reader\n * cannot open is a reminder to pay, not a reminder to call.\n *\n * Two switches on the recipient's own account are honored. The **Daily CRM\n * digest** preference (`users/{uid}.digestPrefs.crmDaily`, on until turned\n * off) governs the whole digest, mail included. The operational category\n * mute (`notificationPrefs.content`) governs the console notification alone,\n * as it does for every other `content.` type — the mute is a fact about the\n * console feed, and the digest switch is a fact about the digest.\n *\n * ## Once a day, and resumable\n *\n * `orgs/{orgId}/crmDigests/{YYYY-MM-DD}` records, per member, that today's\n * digest went out. It is stamped AFTER the sends, so a run that dies\n * mid-org re-sends only to the members it never reached, and a second run\n * on the same UTC day sends nothing. Orgs are swept in id order behind a\n * cursor the way `usage-alerts` sweeps them, so the workflow's `done:false`\n * loop carries a platform of any size across invocations.\n *\n * The email is a `bulk` send under the platform send-rate governor\n * (AGL-2409): a cron fan-out on the shared sending domain, answering nothing\n * anybody just did. A refusal STOPS the run and reports `done: true` — the\n * window is full, and re-POSTing into it would only red the workflow — and\n * because the refused member was never stamped, tomorrow's run digests them\n * afresh. Nothing about today's list is worth mailing tomorrow anyway.\n *\n * Metered as PLATFORM mail, not the org's: this is the platform reminding a\n * member, and a reminder that pushed an org toward its email overage would\n * be its own small absurdity. It is not a one-to-one CRM send and counts\n * against no per-member sending allowance.\n */\n\n/** The beat `/api/health/crons` would read, were this job in its inventory. */\nexport const CRM_DIGEST_JOB_ID = 'crm-daily-digest'\n\n/**\n * Orgs per invocation.\n *\n * Fewer than `usage-alerts`' hundred: an org costs a task query, a paged\n * host list, a leads query per site, the roster, and a send per member with\n * work, under `maxDuration = 60`. A chunk that times out never advances its\n * cursor, so the safe size is the one that finishes.\n */\nexport const CRM_DIGEST_ORG_CHUNK = 25\n\n/** Hosts read per page inside one org, ordered by id like the orgs sweep. */\nconst CRM_DIGEST_HOST_PAGE = 100\n\n/**\n * The most sites one org's digest reads leads from, after which the sweep\n * stops and SAYS SO in the response rather than reading without bound.\n */\nexport const CRM_DIGEST_HOST_CEILING = 200\n\n/**\n * The key a person's switch for this digest is stored under in\n * `users/{uid}.digestPrefs` — the one the CRM declares under\n * `notificationDigests` in `plugins.config.json`, which is the one the\n * settings page writes. `daily-digest-route.spec.ts` holds the two to one\n * spelling.\n */\nexport const CRM_DAILY_DIGEST_KEY = 'crmDaily'\n\n/** `orgs/{orgId}/crmDigests/{day}` — server-written, matched by no rule. */\nexport const CRM_DIGEST_MARKER_COLLECTION = 'crmDigests'\n\n/**\n * The most members per org whose address is chased through the auth pools\n * when their member document carries none. Bounds WORK, not audience — the\n * denormalized copy is normally present, and `orgAdminEmails` draws the same\n * line for the same reason.\n */\nexport const CRM_DIGEST_EMAIL_MAX_UID_LOOKUPS = 5\n\n/**\n * The zone the digest's \"today\" is drawn in.\n *\n * `CRM_DIGEST_TIME_ZONE` overrides the default for a self-hosted console\n * whose team wakes up elsewhere; a value `Intl` does not know falls back to\n * the default with a warning rather than throwing the whole run away.\n */\nexport function digestTimeZone(env: NodeJS.ProcessEnv = process.env): string {\n const configured = String(env['CRM_DIGEST_TIME_ZONE'] ?? '').trim()\n if (!configured) return CRM_DIGEST_DEFAULT_TIME_ZONE\n try {\n new Intl.DateTimeFormat('en-US', { timeZone: configured })\n return configured\n } catch {\n console.warn(\n `crm-daily-digest: CRM_DIGEST_TIME_ZONE \"${configured}\" is not a zone — ` +\n `using ${CRM_DIGEST_DEFAULT_TIME_ZONE}`,\n )\n return CRM_DIGEST_DEFAULT_TIME_ZONE\n }\n}\n\ntype Firestore = FirebaseFirestore.Firestore\ntype Snapshot = FirebaseFirestore.QueryDocumentSnapshot\n\n/**\n * Every host owned by an org, paged by document id — the shape\n * `usage-alerts` reads hosts with, at a lower ceiling because each host here\n * costs a leads query.\n */\nasync function hostsOfOrg(\n firestore: Firestore,\n orgId: string,\n): Promise<{ docs: Snapshot[]; truncated: boolean }> {\n const docs: Snapshot[] = []\n let cursor: string | null = null\n for (;;) {\n let query = firestore\n .collection('hosts')\n .where('orgId', '==', orgId)\n .orderBy(firebaseAdmin.firestore.FieldPath.documentId())\n .limit(CRM_DIGEST_HOST_PAGE)\n if (cursor) query = query.startAfter(firestore.collection('hosts').doc(cursor))\n const page = await query.get()\n for (const doc of page.docs) docs.push(doc)\n if (page.docs.length < CRM_DIGEST_HOST_PAGE) return { docs, truncated: false }\n if (docs.length >= CRM_DIGEST_HOST_CEILING) {\n console.warn(\n `crm-daily-digest: org ${orgId} has more than ${CRM_DIGEST_HOST_CEILING} ` +\n 'hosts — leads on the rest are not digested',\n )\n return { docs, truncated: true }\n }\n cursor = page.docs[page.docs.length - 1].id\n }\n}\n\n/** What one member's row in the response says. */\ninterface MemberReport {\n skipped?: string\n overdue?: number\n today?: number\n leads?: number\n notified?: boolean\n emailed?: boolean\n emailReason?: string\n deferred?: boolean\n}\n\ninterface OrgReport {\n skipped?: string\n digests: number\n notified: number\n emailed: number\n members?: Record<string, MemberReport>\n hostsTruncated?: boolean\n tasksTruncated?: boolean\n /** The send-rate governor refused; the sweep stops here. */\n deferred?: boolean\n}\n\ninterface SweepContext {\n firestore: Firestore\n nowMs: number\n day: string\n timeZone: string\n dryRun: boolean\n flagValues: Awaited<ReturnType<typeof getServerReleaseFlagValues>>\n origin: string\n}\n\n/**\n * The console path a notification opens — the legacy `/{hostDocId}/rest`\n * shape every host notification uses, or the `/org/rest` shape the console\n * rewrites onto the organization's own hub when nothing owed names a site:\n * an organization task (AGL-2637) has none.\n */\nfunction notificationLink(hostId: string, section: 'tasks' | 'leads'): string {\n return hostId ? `/${hostId}/crm/${section}` : `/org/crm/${section}`\n}\n\nasync function digestOrg(ctx: SweepContext, orgDoc: Snapshot): Promise<OrgReport> {\n const { firestore, nowMs, day, timeZone, dryRun } = ctx\n const orgId = orgDoc.id\n const org = (orgDoc.data() ?? {}) as Partial<AglynOrgBilling> & Record<string, unknown>\n const quiet: OrgReport = { digests: 0, notified: 0, emailed: 0 }\n\n if (!crmDigestEntitled(resolveOrgEntitlements(org).features)) {\n return { ...quiet, skipped: 'not-entitled' }\n }\n const flagOn = isReleaseFlagOnForOrg(\n 'release_crm',\n ctx.flagValues['release_crm'],\n orgId,\n parseOrgReleaseFlagOverrides(org['releaseFlags']),\n resolveEffectivePlan(org),\n )\n if (!flagOn) return { ...quiet, skipped: 'release-flag' }\n\n const window = crmDigestWindow(nowMs, timeZone)\n\n /*\n * Every open task due before tomorrow, soonest first — overdue and today\n * in one query on `(status, dueAtMs)`, bucketed afterwards. Undated tasks\n * carry `dueAtMs: null`, which a range excludes.\n */\n const taskPage = await orgDoc.ref\n .collection(CRM_COLLECTIONS.tasks)\n .where('status', '==', 'open')\n .where('dueAtMs', '<', window.endMs)\n .orderBy('dueAtMs', 'asc')\n .limit(CRM_DIGEST_TASK_CEILING)\n .get()\n const tasks: CrmDigestTask[] = taskPage.docs.map((doc) => ({\n id: doc.id,\n title: String(doc.get('title') ?? ''),\n kind: doc.get('kind') ?? 'todo',\n dueAtMs: Number(doc.get('dueAtMs')),\n assigneeUid: String(doc.get('assigneeUid') ?? ''),\n hostId: String(doc.get('hostId') ?? ''),\n contactId: doc.get('contactId') || undefined,\n companyId: doc.get('companyId') || undefined,\n dealId: doc.get('dealId') || undefined,\n }))\n const tasksTruncated = taskPage.docs.length >= CRM_DIGEST_TASK_CEILING\n\n /*\n * The leads nobody has worked, per site. A window of the newest old leads\n * by first sighting, filtered in memory, for the reason the Leads section\n * gives: the capture door stamps no `status`, and a query cannot select on\n * a field's absence.\n */\n const hosts = await hostsOfOrg(firestore, orgId)\n const hostById = new Map(hosts.docs.map((doc) => [doc.id, doc]))\n const leadCutoff = nowMs - CRM_DIGEST_LEAD_AGE_MS\n const leads: CrmDigestLead[] = []\n for (const host of hosts.docs) {\n /*\n * Still per site, but over the ORG collection narrowed to what that site\n * may see (AGL-3275). The digest is a per-site message, so the loop stays;\n * what changed is that a site's leads are selected by `visibleTo` rather\n * than by the collection's parent, and a lead two brands share is in both\n * of their digests — which is the honest answer, since both hold it.\n */\n const page = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('leads')\n .where('visibleTo', 'array-contains-any', scopeTokensForHost(host.id))\n .where('firstSeenAtMs', '<=', leadCutoff)\n .orderBy('firstSeenAtMs', 'desc')\n .limit(CRM_DIGEST_LEAD_WINDOW)\n .get()\n for (const doc of page.docs) {\n const data = doc.data() as Record<string, unknown>\n if (!isUnworkedLead(data as Parameters<typeof isUnworkedLead>[0], nowMs)) continue\n leads.push({\n id: doc.id,\n hostId: host.id,\n email: String(data['email'] ?? ''),\n name: String(data['name'] ?? '') || undefined,\n ownerUid: String(data['ownerUid'] ?? '') || undefined,\n firstSeenAtMs: Number(data['firstSeenAtMs']),\n })\n }\n }\n\n if (!tasks.length && !leads.length) return { ...quiet, skipped: 'nothing-owed' }\n\n const listed = (await listOrgMembers(orgId)).filter(\n (member) => member.orgSuspended !== true,\n )\n // A locked or disabled account is not told (AGL-3418): it cannot sign in\n // to act on it.\n const reachable = new Set(\n await withoutMailWithheldAccounts(listed.map((member) => member.$id)),\n )\n const members = listed.filter((member) => reachable.has(member.$id))\n const memberById = new Map(members.map((member) => [member.$id, member]))\n const digests = buildMemberDigests({\n tasks,\n leads,\n window,\n members: members.map((member) => ({\n uid: member.$id,\n reachesHost: (hostId: string) => hostRoleFor(member, hostId) !== null,\n })),\n })\n if (!digests.size) return { ...quiet, skipped: 'nobody-to-tell' }\n\n const markerRef = orgDoc.ref.collection(CRM_DIGEST_MARKER_COLLECTION).doc(day)\n const marker = await markerRef.get()\n const sentToday = (marker.get('members') ?? {}) as Record<string, unknown>\n\n // Sorted so the order members are told in — and the member a refusal\n // lands on — is the same on every run.\n const uids = [...digests.keys()].sort()\n const userDocs = await firestore.getAll(\n ...uids.map((uid) => firestore.collection('users').doc(uid)),\n )\n const userById = new Map(userDocs.map((doc) => [doc.id, doc]))\n\n const branding = resolveBrandingProfile(org)\n const orgSlug = String(org['slug'] ?? '')\n const hostName = (hostId: string): string => {\n const host = hostById.get(hostId)\n return String(host?.get('name') || host?.get('subdomain') || hostId)\n }\n const hubUrl = (hostId: string): string | null => {\n const subdomain = String(hostById.get(hostId)?.get('subdomain') ?? '')\n if (!orgSlug || !subdomain) return null\n return `${ctx.origin}${buildRoute(Route.HOST_DASHBOARD, { orgSlug, host: subdomain })}/crm`\n }\n // The organization's own hub, for a member whose owed tasks name no site.\n const orgHubUrl = orgSlug\n ? `${ctx.origin}${buildRoute(Route.ORG_HOME, { orgSlug })}/crm`\n : null\n const settingsUrl = `${ctx.origin}${buildRoute(Route.MANAGE_NOTIFICATION_SETTINGS)}`\n const supportLine = brandSupportLine(branding)\n const brand = systemEmailBrand(org)\n let digestEmail: Promise<LoadedSystemEmail | null> | undefined\n\n const report: OrgReport = { digests: 0, notified: 0, emailed: 0, members: {} }\n if (hosts.truncated) report.hostsTruncated = true\n if (tasksTruncated) report.tasksTruncated = true\n const rows = report.members as Record<string, MemberReport>\n let poolLookups = 0\n\n for (const uid of uids) {\n const digest = digests.get(uid) as CrmMemberDigest\n if (sentToday[uid]) {\n rows[uid] = { skipped: 'already-sent' }\n continue\n }\n const member = memberById.get(uid) as AglynOrgMember\n if (!(await memberHasOrgPermission(orgId, member, 'data.manage'))) {\n rows[uid] = { skipped: 'no-data-manage' }\n continue\n }\n const userDoc = userById.get(uid)\n if (!digestEnabled(userDoc?.get(DIGEST_PREFS_FIELD), CRM_DAILY_DIGEST_KEY)) {\n rows[uid] = { skipped: 'digest-off' }\n continue\n }\n const muted = notificationMuted(\n userDoc?.get('notificationPrefs'),\n 'content.crmDailyDigest',\n )\n const counts = crmDigestCounts(digest)\n const row: MemberReport = { ...counts }\n rows[uid] = row\n report.digests += 1\n if (dryRun) {\n row.notified = !muted\n continue\n }\n\n // The site the links open on: where the first task THAT HAS A SITE\n // lives, else where the first lead was captured. An organization task\n // (AGL-2637) names no site, and a digest owed nothing but those opens\n // on the organization's hub.\n const anchorHostId =\n [...digest.overdue, ...digest.today].find((task) => task.hostId)?.hostId ||\n digest.leads[0]?.hostId ||\n ''\n const section = counts.overdue + counts.today > 0 ? 'tasks' : 'leads'\n const hub = anchorHostId ? hubUrl(anchorHostId) : orgHubUrl\n\n /*\n * THE EMAIL FIRST. A refusal from the send-rate governor stops the run\n * with nothing else done for this member — no notification to double\n * tomorrow, no marker to skip them on — so the order here is the\n * idempotence, not a preference.\n */\n let emailed = false\n let emailReason: string | undefined\n let address = String(member.email ?? '').trim().toLowerCase()\n if (!address.includes('@') && poolLookups < CRM_DIGEST_EMAIL_MAX_UID_LOOKUPS) {\n poolLookups += 1\n const pooled = await findUserByUidAcrossPools(uid).catch(() => null)\n address = String(pooled?.record?.email ?? '').trim().toLowerCase()\n }\n const recipients = address.includes('@')\n ? await filterSuppressedEmails([address], firestore)\n : []\n if (!recipients.length) {\n emailReason = address.includes('@') ? 'suppressed' : 'no-recipient'\n } else {\n const digestInput = {\n digest,\n nowMs,\n timeZone,\n productName: branding.productName,\n tasksUrl: hub ? `${hub}/tasks` : ctx.origin,\n leadsUrl: (hostId: string) => `${hubUrl(hostId) ?? ctx.origin}/leads`,\n settingsUrl,\n hostName,\n supportLine,\n }\n const subject = composeCrmDigestSubject(counts)\n // The `crm-daily-digest` system email (AGL-3367), in the org's brand,\n // read once per org and rendered per member.\n digestEmail ??= loadSystemEmail('crm-daily-digest').catch(() => null)\n const loaded = await digestEmail\n const rendered = loaded\n ? renderLoadedSystemEmail(\n loaded,\n {\n ...brand.merge,\n 'digest.subject': subject,\n 'digest.body': composeCrmDigestEmailText({ ...digestInput, omitClosing: true }),\n settingsUrl,\n },\n brand.options,\n )\n : null\n const result = await sendEmail({\n to: recipients,\n ...systemEmailContent(rendered, {\n subject,\n text: composeCrmDigestEmailText(digestInput),\n }),\n fromName: branding.fromName,\n context: 'crm-daily-digest',\n priority: 'bulk',\n })\n if (result.sent) {\n emailed = true\n await meterPlatformEmail().catch(() => undefined)\n } else if (rateLimitedRetryAtMs(result) !== null) {\n row.deferred = true\n report.deferred = true\n return report\n } else {\n emailReason = (result as { reason?: string }).reason ?? 'failed'\n }\n }\n\n let notified = false\n if (!muted) {\n await notifyUsers([uid], {\n type: 'content.crmDailyDigest',\n title: 'Your CRM today',\n body: composeCrmDigestSummary(counts),\n link: notificationLink(anchorHostId, section),\n orgId,\n ...(anchorHostId ? { hostId: anchorHostId } : {}),\n })\n notified = true\n }\n\n await markerRef.set(\n {\n day,\n members: {\n [uid]: { atMs: nowMs, ...counts, notified, emailed },\n },\n updatedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n row.notified = notified\n row.emailed = emailed\n if (emailReason) row.emailReason = emailReason\n if (notified) report.notified += 1\n if (emailed) report.emailed += 1\n }\n return report\n}\n\nexport async function crmDailyDigestRoute(request: Request): Promise<Response> {\n const { method, body, headers: rawHeaders, query } = await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'POST' && method !== 'GET') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n if (!process.env.CRON_SECRET) {\n return Response.json(\n { error: 'The CRM digest is not configured (CRON_SECRET).' },\n { status: 501 },\n )\n }\n if (!isCronAuthorized(headers)) {\n return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n }\n // A GET reports the plan and sends nothing; a bodyless POST — what the\n // scheduled workflow sends — is a real run. `isCronDryRun` keys the\n // default on the METHOD for exactly that reason.\n const dryRun = isCronDryRun({ method, body, query })\n // Stamped on the invocation, not on the work (AGL-1955), so the beat is\n // already there the day this job joins `SCHEDULED_JOBS`.\n if (method === 'POST') await recordCronBeat(CRM_DIGEST_JOB_ID)\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n const nowMs = Date.now()\n const ctx: SweepContext = {\n firestore,\n nowMs,\n day: utcDayKey(nowMs),\n timeZone: digestTimeZone(),\n dryRun,\n flagValues: await getServerReleaseFlagValues(),\n origin: consoleOrigin(),\n }\n\n // The resumable sweep, in the shape `usage-alerts` reads orgs (AGL-2220):\n // ordered by id, `limit + 1` to learn whether there is more, the cursor\n // validated as an org id before it becomes a reference.\n const requestedLimit = Number((body as { limit?: unknown } | undefined)?.limit)\n const pageSize =\n Number.isFinite(requestedLimit) && requestedLimit > 0\n ? Math.min(Math.floor(requestedLimit), CRM_DIGEST_ORG_CHUNK)\n : CRM_DIGEST_ORG_CHUNK\n const rawCursor = (body as { cursor?: unknown } | undefined)?.cursor\n const cursor =\n typeof rawCursor === 'string' && rawCursor.length > 0 && !rawCursor.includes('/')\n ? rawCursor\n : null\n let orgQuery = firestore\n .collection('orgs')\n .orderBy(firebaseAdmin.firestore.FieldPath.documentId())\n .limit(pageSize + 1)\n if (cursor) orgQuery = orgQuery.startAfter(firestore.collection('orgs').doc(cursor))\n const page = await orgQuery.get()\n const hasMore = page.docs.length > pageSize\n const orgDocs = hasMore ? page.docs.slice(0, pageSize) : page.docs\n\n const orgs: Record<string, OrgReport> = {}\n const failures: Record<string, string> = {}\n let digests = 0\n let notified = 0\n let emailed = 0\n let deferred = false\n for (const orgDoc of orgDocs) {\n try {\n const report = await digestOrg(ctx, orgDoc)\n orgs[orgDoc.id] = report\n digests += report.digests\n notified += report.notified\n emailed += report.emailed\n if (report.deferred) {\n deferred = true\n break\n }\n } catch (error) {\n console.error('[crm] daily digest failed for org', orgDoc.id, error)\n failures[orgDoc.id] = (error as Error)?.message ?? 'unknown'\n }\n }\n const failed = Object.keys(failures)\n return Response.json(\n {\n day: ctx.day,\n timeZone: ctx.timeZone,\n dryRun,\n swept: orgDocs.length,\n digests,\n notified,\n emailed,\n orgs,\n ...(deferred ? { deferred: true } : {}),\n ...(failed.length ? { failures } : {}),\n // A deferred run reports `done: true` — see the module comment —\n // so the workflow's loop does not re-POST into a full window.\n nextCursor: deferred || !hasMore ? null : (orgDocs[orgDocs.length - 1]?.id ?? null),\n done: deferred || !hasMore,\n },\n // 207: this chunk finished and some org in it needs a person, which is\n // what fails the workflow (its one alerting channel).\n { status: failed.length ? 207 : 200 },\n )\n } catch (error) {\n console.error('[crm] daily digest run failed', error)\n return Response.json({ error: 'CRM digest run failed' }, { status: 500 })\n }\n}\n"],"names":["buildRoute","CRM_COLLECTIONS","DIGEST_PREFS_FIELD","digestEnabled","hostRoleFor","isReleaseFlagOnForOrg","notificationMuted","parseOrgReleaseFlagOverrides","pluginRequestFromWeb","resolveBrandingProfile","resolveEffectivePlan","resolveOrgEntitlements","Route","buildMemberDigests","composeCrmDigestEmailText","composeCrmDigestSubject","composeCrmDigestSummary","CRM_DIGEST_DEFAULT_TIME_ZONE","CRM_DIGEST_LEAD_AGE_MS","CRM_DIGEST_LEAD_WINDOW","CRM_DIGEST_TASK_CEILING","crmDigestCounts","crmDigestEntitled","crmDigestWindow","isUnworkedLead","utcDayKey","rateLimitedRetryAtMs","sendEmail","withoutMailWithheldAccounts","findUserByUidAcrossPools","firebaseAdmin","getServerReleaseFlagValues","listOrgMembers","memberHasOrgPermission","meterPlatformEmail","notifyUsers","filterSuppressedEmails","loadSystemEmail","renderLoadedSystemEmail","systemEmailBrand","systemEmailContent","isCronAuthorized","isCronDryRun","writeCronBeat","brandSupportLine","platformConsoleOrigin","consoleOrigin","scopeTokensForHost","recordCronBeat","jobId","app","firestore","CRM_DIGEST_JOB_ID","CRM_DIGEST_ORG_CHUNK","CRM_DIGEST_HOST_PAGE","CRM_DIGEST_HOST_CEILING","CRM_DAILY_DIGEST_KEY","CRM_DIGEST_MARKER_COLLECTION","CRM_DIGEST_EMAIL_MAX_UID_LOOKUPS","digestTimeZone","env","process","configured","String","trim","Intl","DateTimeFormat","timeZone","console","warn","hostsOfOrg","orgId","docs","cursor","query","collection","where","orderBy","FieldPath","documentId","limit","startAfter","doc","page","get","push","length","truncated","id","notificationLink","hostId","section","digestOrg","ctx","orgDoc","marker","org","nowMs","day","dryRun","data","quiet","digests","notified","emailed","features","skipped","flagOn","flagValues","window","taskPage","ref","tasks","endMs","map","title","kind","dueAtMs","Number","assigneeUid","contactId","undefined","companyId","dealId","tasksTruncated","hosts","hostById","Map","leadCutoff","leads","host","email","name","ownerUid","firstSeenAtMs","listed","filter","member","orgSuspended","reachable","Set","$id","members","has","memberById","uid","reachesHost","size","markerRef","sentToday","uids","keys","sort","userDocs","getAll","userById","branding","orgSlug","hostName","hubUrl","subdomain","origin","HOST_DASHBOARD","orgHubUrl","ORG_HOME","settingsUrl","MANAGE_NOTIFICATION_SETTINGS","supportLine","brand","digestEmail","report","hostsTruncated","rows","poolLookups","digest","userDoc","muted","counts","row","anchorHostId","overdue","today","find","task","hub","emailReason","address","toLowerCase","includes","pooled","catch","record","recipients","digestInput","productName","tasksUrl","leadsUrl","subject","loaded","rendered","merge","omitClosing","options","result","to","text","fromName","context","priority","sent","deferred","reason","type","body","link","set","atMs","updatedAt","FieldValue","serverTimestamp","crmDailyDigestRoute","request","method","headers","rawHeaders","Response","json","error","status","CRON_SECRET","orgDocs","Date","now","requestedLimit","pageSize","isFinite","Math","min","floor","rawCursor","orgQuery","hasMore","slice","orgs","failures","message","failed","Object","swept","nextCursor","done"],"mappings":"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"}
|
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
/** Header naming the row count the server undertook to send. */
|
|
18
|
+
export declare const EXPORT_ROWS_HEADER = "X-Aglyn-Export-Rows";
|
|
19
|
+
/**
|
|
20
|
+
* THE WHOLE COLLECTION, STREAMED (AGL-2662).
|
|
21
|
+
*
|
|
22
|
+
* ## What this fixes
|
|
23
|
+
*
|
|
24
|
+
* Every CRM export until now wrote the rows the LIST had loaded — a
|
|
25
|
+
* `limit(1000)` window for contacts, a `limit(200)` view for tasks — and
|
|
26
|
+
* called the file `contacts.csv`. A merchant with 40,000 contacts got 1,000
|
|
27
|
+
* of them and nothing on screen or in the file said so. `docs/bulk-actions.md`
|
|
28
|
+
* said it in prose, which is not where somebody opening a spreadsheet looks.
|
|
29
|
+
* This is the same file over every row there is, and it carries
|
|
30
|
+
* `X-Aglyn-Export-Rows` so the client can prove what it received is whole —
|
|
31
|
+
* a stream that dies halfway yields a perfectly well-formed shorter file, and
|
|
32
|
+
* nothing about the bytes says they are short.
|
|
33
|
+
*
|
|
34
|
+
* ## Why it is a route and not a bigger client read
|
|
35
|
+
*
|
|
36
|
+
* A browser cannot hold an unbounded collection, and the tier that most
|
|
37
|
+
* needs a complete export is the tier with the most rows. Cursor-paged and
|
|
38
|
+
* streamed, completeness costs neither a hang nor a buffer.
|
|
39
|
+
*
|
|
40
|
+
* ## The columns are the client's columns
|
|
41
|
+
*
|
|
42
|
+
* `crmExportHeader` and `crmExportCells` (`model/crm-csv.ts`) are the
|
|
43
|
+
* writers each section's Export button uses. One feature, one file format.
|
|
44
|
+
*
|
|
45
|
+
* ## The order is by document id, deliberately
|
|
46
|
+
*
|
|
47
|
+
* Firestore DROPS every document missing the ordered field, so ordering by
|
|
48
|
+
* `updatedAt` or `dueAtMs` would silently omit rows written before that
|
|
49
|
+
* field existed — the exact silent loss an export must not have. A document
|
|
50
|
+
* id is on every document and is a total order.
|
|
51
|
+
*
|
|
52
|
+
* ## Scope is enforced here, because nothing else can
|
|
53
|
+
*
|
|
54
|
+
* The Admin SDK bypasses the rules, so `memberScopeTokens` IS the
|
|
55
|
+
* enforcement: an org-wide member reads every row, and a scoped
|
|
56
|
+
* collaborator's query carries their own tokens as the `array-contains-any`
|
|
57
|
+
* the rules would have evaluated. Leads are host data by path, so the
|
|
58
|
+
* equivalent check is whether the caller reaches the site.
|
|
59
|
+
*/
|
|
60
|
+
export declare function crmExportRoute(request: Request): Promise<Response>;
|
|
@@ -0,0 +1,447 @@
|
|
|
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, csvCell, hostScopeToken, isOrgWideMember, MAX_SCOPE_HOSTS, memberCanSee, memberScopeTokens, scopeTokensForHost, planLabelGrantingFeature, pluginRequestFromWeb } from "@aglyn/aglyn/server";
|
|
18
|
+
import { emailUnverifiedResponse, firebaseAdmin, isImpersonationSession, isServerReleaseFlagOnForOrg, lockdownRefusal, memberHasOrgPermission, resolveOrgMembership } from "@aglyn/tenant-data-admin";
|
|
19
|
+
import { FieldPath, FieldValue } from "firebase-admin/firestore";
|
|
20
|
+
import { addAdminAudit } from "@aglyn/tenant-data-admin/server/admin-audit-write";
|
|
21
|
+
import { invalidIdTokenResponse } from "@aglyn/tenant-data-admin/server/id-token-refusal";
|
|
22
|
+
import { contactCsvRowFromDoc, crmExportCells, crmExportHeader, isCrmExportResource } from "../model/crm-csv.js";
|
|
23
|
+
import { contactPrimaryGroup } from "../model/contact-holder.js";
|
|
24
|
+
/**
|
|
25
|
+
* Documents read per round trip. Not a cap — the stream keeps paging until
|
|
26
|
+
* the collection is exhausted. It bounds what is held at once, which is the
|
|
27
|
+
* only thing a page size should ever bound.
|
|
28
|
+
*/ const PAGE_SIZE = 500;
|
|
29
|
+
/** Sites read per page when the leads file spans an organization. */ const HOST_PAGE = 100;
|
|
30
|
+
/**
|
|
31
|
+
* The most sites one organization-level leads file reads from.
|
|
32
|
+
*
|
|
33
|
+
* A ceiling rather than an unbounded sweep, and it is REPORTED: the file's
|
|
34
|
+
* row header counts only what was read, so a short file is provably short
|
|
35
|
+
* rather than quietly partial.
|
|
36
|
+
*/ const HOST_CEILING = 200;
|
|
37
|
+
/** The most linked records a tasks file resolves names for before writing ids. */ const NAME_CACHE_CEILING = 2000;
|
|
38
|
+
/** Header naming the row count the server undertook to send. */ export const EXPORT_ROWS_HEADER = 'X-Aglyn-Export-Rows';
|
|
39
|
+
const json = (body, status)=>Response.json(body, {
|
|
40
|
+
status
|
|
41
|
+
});
|
|
42
|
+
/** Which collection under the org root each resource is, bar leads. */ const ORG_COLLECTION = {
|
|
43
|
+
contacts: 'contacts',
|
|
44
|
+
companies: CRM_COLLECTIONS.companies,
|
|
45
|
+
deals: CRM_COLLECTIONS.deals,
|
|
46
|
+
tasks: CRM_COLLECTIONS.tasks
|
|
47
|
+
};
|
|
48
|
+
/** The file's name, before the date stamp. */ const FILE_BASE = {
|
|
49
|
+
contacts: 'contacts',
|
|
50
|
+
companies: 'companies',
|
|
51
|
+
deals: 'deals',
|
|
52
|
+
tasks: 'tasks',
|
|
53
|
+
leads: 'leads'
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* THE WHOLE COLLECTION, STREAMED (AGL-2662).
|
|
57
|
+
*
|
|
58
|
+
* ## What this fixes
|
|
59
|
+
*
|
|
60
|
+
* Every CRM export until now wrote the rows the LIST had loaded — a
|
|
61
|
+
* `limit(1000)` window for contacts, a `limit(200)` view for tasks — and
|
|
62
|
+
* called the file `contacts.csv`. A merchant with 40,000 contacts got 1,000
|
|
63
|
+
* of them and nothing on screen or in the file said so. `docs/bulk-actions.md`
|
|
64
|
+
* said it in prose, which is not where somebody opening a spreadsheet looks.
|
|
65
|
+
* This is the same file over every row there is, and it carries
|
|
66
|
+
* `X-Aglyn-Export-Rows` so the client can prove what it received is whole —
|
|
67
|
+
* a stream that dies halfway yields a perfectly well-formed shorter file, and
|
|
68
|
+
* nothing about the bytes says they are short.
|
|
69
|
+
*
|
|
70
|
+
* ## Why it is a route and not a bigger client read
|
|
71
|
+
*
|
|
72
|
+
* A browser cannot hold an unbounded collection, and the tier that most
|
|
73
|
+
* needs a complete export is the tier with the most rows. Cursor-paged and
|
|
74
|
+
* streamed, completeness costs neither a hang nor a buffer.
|
|
75
|
+
*
|
|
76
|
+
* ## The columns are the client's columns
|
|
77
|
+
*
|
|
78
|
+
* `crmExportHeader` and `crmExportCells` (`model/crm-csv.ts`) are the
|
|
79
|
+
* writers each section's Export button uses. One feature, one file format.
|
|
80
|
+
*
|
|
81
|
+
* ## The order is by document id, deliberately
|
|
82
|
+
*
|
|
83
|
+
* Firestore DROPS every document missing the ordered field, so ordering by
|
|
84
|
+
* `updatedAt` or `dueAtMs` would silently omit rows written before that
|
|
85
|
+
* field existed — the exact silent loss an export must not have. A document
|
|
86
|
+
* id is on every document and is a total order.
|
|
87
|
+
*
|
|
88
|
+
* ## Scope is enforced here, because nothing else can
|
|
89
|
+
*
|
|
90
|
+
* The Admin SDK bypasses the rules, so `memberScopeTokens` IS the
|
|
91
|
+
* enforcement: an org-wide member reads every row, and a scoped
|
|
92
|
+
* collaborator's query carries their own tokens as the `array-contains-any`
|
|
93
|
+
* the rules would have evaluated. Leads are host data by path, so the
|
|
94
|
+
* equivalent check is whether the caller reaches the site.
|
|
95
|
+
*/ export async function crmExportRoute(request) {
|
|
96
|
+
var _headers_authorization, _ref, _ref1, _ref2;
|
|
97
|
+
const { method, query, headers: rawHeaders } = await pluginRequestFromWeb(request);
|
|
98
|
+
const headers = rawHeaders;
|
|
99
|
+
if (method !== 'GET') return json({
|
|
100
|
+
error: 'Method not allowed'
|
|
101
|
+
}, 405);
|
|
102
|
+
const authorization = (_headers_authorization = headers.authorization) != null ? _headers_authorization : '';
|
|
103
|
+
const idToken = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined;
|
|
104
|
+
if (!idToken) return json({
|
|
105
|
+
error: 'Unauthenticated'
|
|
106
|
+
}, 401);
|
|
107
|
+
const orgId = String((_ref = query == null ? void 0 : query['orgId']) != null ? _ref : '');
|
|
108
|
+
const resource = String((_ref1 = query == null ? void 0 : query['resource']) != null ? _ref1 : '');
|
|
109
|
+
const hostId = String((_ref2 = query == null ? void 0 : query['hostId']) != null ? _ref2 : '');
|
|
110
|
+
if (!orgId || !isCrmExportResource(resource)) {
|
|
111
|
+
return json({
|
|
112
|
+
error: 'Missing orgId or resource'
|
|
113
|
+
}, 400);
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
|
|
117
|
+
if (!decoded.email_verified && !isImpersonationSession(decoded)) {
|
|
118
|
+
return emailUnverifiedResponse();
|
|
119
|
+
}
|
|
120
|
+
const staff = decoded['staff'] === true;
|
|
121
|
+
const membership = await resolveOrgMembership(decoded.uid, orgId);
|
|
122
|
+
const member = membership == null ? void 0 : membership.member;
|
|
123
|
+
if (!member && !staff) return json({
|
|
124
|
+
error: 'Not found'
|
|
125
|
+
}, 404);
|
|
126
|
+
/*
|
|
127
|
+
* THE PEOPLE FILES ARE EVERY WORKSPACE'S (AGL-2839, AGL-2851).
|
|
128
|
+
*
|
|
129
|
+
* Exporting the contacts and leads a workspace holds is an obligation,
|
|
130
|
+
* not a CRM feature — a Free workspace takes them from its Privacy
|
|
131
|
+
* settings — so neither the release flag nor the plan is asked of those
|
|
132
|
+
* two. The CRM's own records, companies, deals and tasks, are asked
|
|
133
|
+
* both: the flag here, and the plan once the org has been read.
|
|
134
|
+
*/ const peopleFile = resource === 'contacts' || resource === 'leads';
|
|
135
|
+
if (!peopleFile && !staff && !await isServerReleaseFlagOnForOrg('release_crm', orgId)) {
|
|
136
|
+
return json({
|
|
137
|
+
error: 'Not available'
|
|
138
|
+
}, 404);
|
|
139
|
+
}
|
|
140
|
+
// The permission the CRM's own rules read for. Reading is not writing,
|
|
141
|
+
// but these records ARE the audience: `data.manage` is what admits a
|
|
142
|
+
// person to the list this file copies.
|
|
143
|
+
if (!staff && !await memberHasOrgPermission(orgId, member, 'data.manage')) {
|
|
144
|
+
return json({
|
|
145
|
+
error: 'Not found'
|
|
146
|
+
}, 404);
|
|
147
|
+
}
|
|
148
|
+
const firestore = firebaseAdmin.app().firestore();
|
|
149
|
+
const orgRef = firestore.collection('orgs').doc(orgId);
|
|
150
|
+
const orgSnapshot = await orgRef.get();
|
|
151
|
+
if (!orgSnapshot.exists) return json({
|
|
152
|
+
error: 'Unknown organization'
|
|
153
|
+
}, 404);
|
|
154
|
+
const org = orgSnapshot.data();
|
|
155
|
+
const locked = await lockdownRefusal({
|
|
156
|
+
request,
|
|
157
|
+
staff,
|
|
158
|
+
uid: decoded.uid,
|
|
159
|
+
org
|
|
160
|
+
});
|
|
161
|
+
if (locked) return locked;
|
|
162
|
+
// The CRM's own records on a plan without the CRM, staff included: the
|
|
163
|
+
// refusal every `crm/*` route gives, after the caller is known.
|
|
164
|
+
if (!peopleFile && !checkEntitlement(org, 'crm')) {
|
|
165
|
+
const plan = planLabelGrantingFeature('crm');
|
|
166
|
+
return json({
|
|
167
|
+
error: `Exporting ${resource} is part of the CRM, which is not included in ` + 'your current plan. Manage your plan and add-ons from Billing.' + (plan ? ` Included from ${plan}.` : ''),
|
|
168
|
+
reason: 'plan_required',
|
|
169
|
+
code: 'crm'
|
|
170
|
+
}, 403);
|
|
171
|
+
}
|
|
172
|
+
const orgWide = staff || isOrgWideMember(member);
|
|
173
|
+
// `array-contains-any` takes at most thirty values, which is also the
|
|
174
|
+
// widest a consent group can be — a reader past that is the org-wide
|
|
175
|
+
// case and never reaches here.
|
|
176
|
+
const tokens = memberScopeTokens(member).slice(0, MAX_SCOPE_HOSTS);
|
|
177
|
+
if (!orgWide && !tokens.length) return json({
|
|
178
|
+
error: 'Not found'
|
|
179
|
+
}, 404);
|
|
180
|
+
/* The roster, once, so every owner and assignee cell is an address. */ const ownerEmails = new Map();
|
|
181
|
+
for (const entry of (await orgRef.collection('members').get()).docs){
|
|
182
|
+
var _ref3;
|
|
183
|
+
var _entry_data;
|
|
184
|
+
const email = String((_ref3 = (_entry_data = entry.data()) == null ? void 0 : _entry_data['email']) != null ? _ref3 : '');
|
|
185
|
+
if (email) ownerEmails.set(entry.id, email);
|
|
186
|
+
}
|
|
187
|
+
const ownerEmail = (uid)=>{
|
|
188
|
+
var _ownerEmails_get;
|
|
189
|
+
return (_ownerEmails_get = ownerEmails.get(uid)) != null ? _ownerEmails_get : uid;
|
|
190
|
+
};
|
|
191
|
+
const options = {
|
|
192
|
+
ownerEmail,
|
|
193
|
+
assigneeEmail: ownerEmail
|
|
194
|
+
};
|
|
195
|
+
if (resource === 'contacts') {
|
|
196
|
+
/*
|
|
197
|
+
* A contact's fields live in one holder's FACET, so the file has to
|
|
198
|
+
* say which. Under a site that is the site's own group, for every
|
|
199
|
+
* row; at the organization level it is each row's PRIMARY holder —
|
|
200
|
+
* the same answer the org-level Contacts list flattens by, so the
|
|
201
|
+
* file and the table read one profile per person. What a file must
|
|
202
|
+
* never do is read the top of the document, which is where the
|
|
203
|
+
* pre-facet migration left one holder's fields.
|
|
204
|
+
*/ if (!hostId && !orgWide) return json({
|
|
205
|
+
error: 'Missing hostId'
|
|
206
|
+
}, 400);
|
|
207
|
+
const fields = await orgRef.collection(CRM_COLLECTIONS.contactFields).get();
|
|
208
|
+
options.customFields = fields.docs.map((entry)=>{
|
|
209
|
+
var _ref, _ref1;
|
|
210
|
+
var _entry_data, _entry_data1, _entry_data2;
|
|
211
|
+
return {
|
|
212
|
+
key: String((_ref = (_entry_data = entry.data()) == null ? void 0 : _entry_data['key']) != null ? _ref : entry.id),
|
|
213
|
+
label: String((_ref1 = (_entry_data1 = entry.data()) == null ? void 0 : _entry_data1['label']) != null ? _ref1 : entry.id),
|
|
214
|
+
archivedAt: (_entry_data2 = entry.data()) == null ? void 0 : _entry_data2['archivedAt']
|
|
215
|
+
};
|
|
216
|
+
}).filter((field)=>!field.archivedAt).map(({ key, label })=>({
|
|
217
|
+
key,
|
|
218
|
+
label
|
|
219
|
+
}));
|
|
220
|
+
}
|
|
221
|
+
if (resource === 'deals') {
|
|
222
|
+
const pipelines = await orgRef.collection(CRM_COLLECTIONS.pipelines).get();
|
|
223
|
+
const names = new Map();
|
|
224
|
+
const stages = new Map();
|
|
225
|
+
for (const entry of pipelines.docs){
|
|
226
|
+
var _entry_data1, _data_name, _data_stages;
|
|
227
|
+
const data = (_entry_data1 = entry.data()) != null ? _entry_data1 : {};
|
|
228
|
+
names.set(entry.id, String((_data_name = data['name']) != null ? _data_name : entry.id));
|
|
229
|
+
for (const stage of (_data_stages = data['stages']) != null ? _data_stages : []){
|
|
230
|
+
var _ref4;
|
|
231
|
+
const stageId = String((_ref4 = stage == null ? void 0 : stage['id']) != null ? _ref4 : '');
|
|
232
|
+
if (stageId) {
|
|
233
|
+
var _ref5;
|
|
234
|
+
stages.set(`${entry.id}/${stageId}`, String((_ref5 = stage == null ? void 0 : stage['name']) != null ? _ref5 : stageId));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
options.pipelineName = (id)=>names.get(id);
|
|
239
|
+
options.stageName = (pipelineId, stageId)=>stages.get(`${pipelineId}/${stageId}`);
|
|
240
|
+
}
|
|
241
|
+
/*
|
|
242
|
+
* A task names its contact, company and deal by ID, and a spreadsheet
|
|
243
|
+
* cannot read one. The list resolves them from rows it already holds;
|
|
244
|
+
* the server looks them up as it meets them — batched per page,
|
|
245
|
+
* remembered, and capped, so a tasks file costs reads in proportion to
|
|
246
|
+
* the records it actually references rather than to the org. Past the
|
|
247
|
+
* cap the id is written, which is the documented degradation everywhere
|
|
248
|
+
* else in these files.
|
|
249
|
+
*/ const recordNames = new Map();
|
|
250
|
+
const resolveNames = async (wanted)=>{
|
|
251
|
+
const missing = wanted.filter((entry)=>entry.id && !recordNames.has(`${entry.kind}:${entry.id}`) && recordNames.size < NAME_CACHE_CEILING);
|
|
252
|
+
if (!missing.length) return;
|
|
253
|
+
const refs = missing.map((entry)=>orgRef.collection(entry.kind === 'contact' ? 'contacts' : entry.kind === 'company' ? CRM_COLLECTIONS.companies : CRM_COLLECTIONS.deals).doc(entry.id));
|
|
254
|
+
const snapshots = await firestore.getAll(...refs);
|
|
255
|
+
snapshots.forEach((snapshot, index)=>{
|
|
256
|
+
var _snapshot_data, _data_title, _ref, _data_name;
|
|
257
|
+
const entry = missing[index];
|
|
258
|
+
const data = (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {};
|
|
259
|
+
const name = entry.kind === 'deal' ? String((_data_title = data['title']) != null ? _data_title : '') : String((_ref = (_data_name = data['name']) != null ? _data_name : data['email']) != null ? _ref : '');
|
|
260
|
+
recordNames.set(`${entry.kind}:${entry.id}`, name || entry.id);
|
|
261
|
+
});
|
|
262
|
+
};
|
|
263
|
+
if (resource === 'tasks') {
|
|
264
|
+
options.recordName = (kind, id)=>recordNames.get(`${kind}:${id}`);
|
|
265
|
+
}
|
|
266
|
+
/*
|
|
267
|
+
* LEADS ARE HOST DATA BY PATH. Under one site the file is that site's;
|
|
268
|
+
* with no site named, an org-wide member takes every site's leads in one
|
|
269
|
+
* file with a `Site` column — the same file the organization-level Leads
|
|
270
|
+
* list writes. A scoped collaborator must name a site they reach.
|
|
271
|
+
*/ const siteNames = new Map();
|
|
272
|
+
let leadHostIds = [];
|
|
273
|
+
if (resource === 'leads') {
|
|
274
|
+
if (hostId) {
|
|
275
|
+
if (!orgWide && !memberCanSee(member, [
|
|
276
|
+
hostScopeToken(hostId)
|
|
277
|
+
])) {
|
|
278
|
+
return json({
|
|
279
|
+
error: 'Not found'
|
|
280
|
+
}, 404);
|
|
281
|
+
}
|
|
282
|
+
leadHostIds = [
|
|
283
|
+
hostId
|
|
284
|
+
];
|
|
285
|
+
} else {
|
|
286
|
+
if (!orgWide) return json({
|
|
287
|
+
error: 'Missing hostId'
|
|
288
|
+
}, 400);
|
|
289
|
+
let cursor = null;
|
|
290
|
+
for(;;){
|
|
291
|
+
let page = firestore.collection('hosts').where('orgId', '==', orgId).orderBy(FieldPath.documentId()).limit(HOST_PAGE);
|
|
292
|
+
if (cursor) page = page.startAfter(firestore.collection('hosts').doc(cursor));
|
|
293
|
+
const snapshot = await page.get();
|
|
294
|
+
for (const entry of snapshot.docs){
|
|
295
|
+
var _ref6, _ref7;
|
|
296
|
+
var _entry_data2, _entry_data3;
|
|
297
|
+
leadHostIds.push(entry.id);
|
|
298
|
+
siteNames.set(entry.id, String((_ref6 = (_ref7 = (_entry_data2 = entry.data()) == null ? void 0 : _entry_data2['displayName']) != null ? _ref7 : (_entry_data3 = entry.data()) == null ? void 0 : _entry_data3['subdomain']) != null ? _ref6 : entry.id));
|
|
299
|
+
}
|
|
300
|
+
if (snapshot.docs.length < HOST_PAGE) break;
|
|
301
|
+
if (leadHostIds.length >= HOST_CEILING) break;
|
|
302
|
+
cursor = snapshot.docs[snapshot.docs.length - 1].id;
|
|
303
|
+
}
|
|
304
|
+
options.siteName = (id)=>siteNames.get(id);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
/** One resource's rows, as a query per collection the file spans. */ const sources = resource === 'leads' ? leadHostIds.map((id)=>({
|
|
308
|
+
hostId: id,
|
|
309
|
+
/*
|
|
310
|
+
* The org collection, narrowed per SITE (AGL-3275).
|
|
311
|
+
*
|
|
312
|
+
* One source per site is still right — the file names the site a
|
|
313
|
+
* row came from — but a site's leads are selected by `visibleTo`
|
|
314
|
+
* now rather than by the collection's parent, so the narrowing
|
|
315
|
+
* moves onto the reference itself. It cannot use the `scoped`
|
|
316
|
+
* flag below: that applies the MEMBER's tokens, and here each
|
|
317
|
+
* source needs the tokens of its own site.
|
|
318
|
+
*/ reference: orgRef.collection('leads').where('visibleTo', 'array-contains-any', scopeTokensForHost(id)),
|
|
319
|
+
scoped: false
|
|
320
|
+
})) : [
|
|
321
|
+
{
|
|
322
|
+
hostId: '',
|
|
323
|
+
reference: orgRef.collection(ORG_COLLECTION[resource]),
|
|
324
|
+
scoped: !orgWide
|
|
325
|
+
}
|
|
326
|
+
];
|
|
327
|
+
// Taken BEFORE the first page, so it describes the collections the
|
|
328
|
+
// export started from. A `count()` aggregate bills one read per 1,000
|
|
329
|
+
// documents — cheap next to the export it is describing, and the only
|
|
330
|
+
// way the client can tell a complete file from a truncated one.
|
|
331
|
+
let total = 0;
|
|
332
|
+
for (const source of sources){
|
|
333
|
+
var _data_count;
|
|
334
|
+
const counted = source.scoped ? source.reference.where('visibleTo', 'array-contains-any', tokens) : source.reference;
|
|
335
|
+
total += Number((_data_count = (await counted.count().get()).data().count) != null ? _data_count : 0);
|
|
336
|
+
}
|
|
337
|
+
/** The holder one contact is written through, resolved per level. */ const holderFor = (document)=>{
|
|
338
|
+
const group = hostId ? consentGroupForHost(org, hostId) : contactPrimaryGroup(document, org);
|
|
339
|
+
return {
|
|
340
|
+
groupId: group.groupId,
|
|
341
|
+
hostIds: group.hostIds
|
|
342
|
+
};
|
|
343
|
+
};
|
|
344
|
+
const encoder = new TextEncoder();
|
|
345
|
+
const encode = (text)=>encoder.encode(text);
|
|
346
|
+
const line = (cells)=>cells.map(csvCell).join(',');
|
|
347
|
+
let sourceIndex = 0;
|
|
348
|
+
let cursor = null;
|
|
349
|
+
let phase = 'head';
|
|
350
|
+
const stream = new ReadableStream({
|
|
351
|
+
async pull (controller) {
|
|
352
|
+
try {
|
|
353
|
+
if (phase === 'head') {
|
|
354
|
+
phase = sources.length ? 'body' : 'tail';
|
|
355
|
+
controller.enqueue(encode(line(crmExportHeader(resource, options))));
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (phase === 'body') {
|
|
359
|
+
var _snapshot_docs_;
|
|
360
|
+
const source = sources[sourceIndex];
|
|
361
|
+
let page = source.reference;
|
|
362
|
+
if (source.scoped) {
|
|
363
|
+
page = page.where('visibleTo', 'array-contains-any', tokens);
|
|
364
|
+
}
|
|
365
|
+
page = page.orderBy(FieldPath.documentId()).limit(PAGE_SIZE);
|
|
366
|
+
if (cursor) page = page.startAfter(cursor);
|
|
367
|
+
const snapshot = await page.get();
|
|
368
|
+
const exhausted = snapshot.docs.length < PAGE_SIZE;
|
|
369
|
+
cursor = exhausted ? null : (_snapshot_docs_ = snapshot.docs[snapshot.docs.length - 1]) != null ? _snapshot_docs_ : null;
|
|
370
|
+
if (exhausted) {
|
|
371
|
+
sourceIndex += 1;
|
|
372
|
+
if (sourceIndex >= sources.length) phase = 'tail';
|
|
373
|
+
}
|
|
374
|
+
if (!snapshot.empty) {
|
|
375
|
+
const documents = snapshot.docs.map((entry)=>{
|
|
376
|
+
var _entry_data;
|
|
377
|
+
return _extends({}, (_entry_data = entry.data()) != null ? _entry_data : {}, {
|
|
378
|
+
$id: entry.id
|
|
379
|
+
}, source.hostId ? {
|
|
380
|
+
hostId: source.hostId
|
|
381
|
+
} : {});
|
|
382
|
+
});
|
|
383
|
+
if (resource === 'tasks') {
|
|
384
|
+
await resolveNames(documents.flatMap((document)=>[
|
|
385
|
+
'contact',
|
|
386
|
+
'company',
|
|
387
|
+
'deal'
|
|
388
|
+
].map((kind)=>{
|
|
389
|
+
var _document_;
|
|
390
|
+
return {
|
|
391
|
+
kind,
|
|
392
|
+
id: String((_document_ = document[kind === 'contact' ? 'contactId' : kind === 'company' ? 'companyId' : 'dealId']) != null ? _document_ : '')
|
|
393
|
+
};
|
|
394
|
+
})));
|
|
395
|
+
}
|
|
396
|
+
const lines = documents.map((document)=>line(crmExportCells(resource, resource === 'contacts' ? contactCsvRowFromDoc(document, holderFor(document)) : document, options)));
|
|
397
|
+
controller.enqueue(encode(`\n${lines.join('\n')}`));
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
if (phase === 'body') return;
|
|
401
|
+
}
|
|
402
|
+
controller.enqueue(encode('\n'));
|
|
403
|
+
controller.close();
|
|
404
|
+
} catch (error) {
|
|
405
|
+
// Erroring the stream truncates the download rather than
|
|
406
|
+
// completing it short — a half-written file the browser reports
|
|
407
|
+
// as a failed transfer, not a quiet partial handover.
|
|
408
|
+
controller.error(error);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
// Ids and counts only, never content: a full copy of an audience
|
|
413
|
+
// leaving the platform is worth a row; who was in it is not ours to log.
|
|
414
|
+
void addAdminAudit(firestore, {
|
|
415
|
+
actorUid: decoded.uid,
|
|
416
|
+
action: 'crm.exported',
|
|
417
|
+
target: `orgs/${orgId}/${resource}`,
|
|
418
|
+
before: null,
|
|
419
|
+
after: {
|
|
420
|
+
resource,
|
|
421
|
+
rows: total,
|
|
422
|
+
hostId: hostId || null
|
|
423
|
+
},
|
|
424
|
+
at: FieldValue.serverTimestamp()
|
|
425
|
+
}).catch(()=>undefined);
|
|
426
|
+
const stamp = new Date().toISOString().slice(0, 10);
|
|
427
|
+
return new Response(stream, {
|
|
428
|
+
status: 200,
|
|
429
|
+
headers: {
|
|
430
|
+
'Content-Type': 'text/csv; charset=utf-8',
|
|
431
|
+
'Content-Disposition': `attachment; filename="${FILE_BASE[resource]}-${stamp}.csv"`,
|
|
432
|
+
[EXPORT_ROWS_HEADER]: String(total),
|
|
433
|
+
'Cache-Control': 'no-store, private'
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
} catch (error) {
|
|
437
|
+
// A refused credential is a 401, not a fault of ours (AGL-1993). Null
|
|
438
|
+
// for anything else, so a real failure keeps the answer below.
|
|
439
|
+
const unauthenticated = invalidIdTokenResponse(error);
|
|
440
|
+
if (unauthenticated) return unauthenticated;
|
|
441
|
+
return json({
|
|
442
|
+
error: 'Export failed'
|
|
443
|
+
}, 500);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
//# sourceMappingURL=export-route.js.map
|