@aglyn/plugins-crm 1.0.0-beta.218 → 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,588 @@
|
|
|
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 { buildRoute, CRM_COLLECTIONS, DIGEST_PREFS_FIELD, digestEnabled, hostRoleFor, isReleaseFlagOnForOrg, notificationMuted, parseOrgReleaseFlagOverrides, pluginRequestFromWeb, resolveBrandingProfile, resolveEffectivePlan, resolveOrgEntitlements, Route } from "@aglyn/aglyn/server";
|
|
18
|
+
import { 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 } from "../model/crm-digest.js";
|
|
19
|
+
import { rateLimitedRetryAtMs, sendEmail } from "@aglyn/shared-util-email";
|
|
20
|
+
import { withoutMailWithheldAccounts } from "@aglyn/tenant-data-admin/server/account-mail";
|
|
21
|
+
import { findUserByUidAcrossPools, firebaseAdmin, getServerReleaseFlagValues, listOrgMembers, memberHasOrgPermission, meterPlatformEmail, notifyUsers } from "@aglyn/tenant-data-admin";
|
|
22
|
+
// From the LEAF, not the barrel (AGL-2407): the cron specs mock
|
|
23
|
+
// `@aglyn/tenant-data-admin` wholesale, and a suppression gate imported
|
|
24
|
+
// through the barrel would be silently replaced by whatever the factory
|
|
25
|
+
// lists. Same reasoning as `usage-alert-email.ts`.
|
|
26
|
+
import { filterSuppressedEmails } from "@aglyn/tenant-data-admin/server/email-suppression";
|
|
27
|
+
import { loadSystemEmail, renderLoadedSystemEmail, systemEmailBrand, systemEmailContent } from "@aglyn/tenant-data-admin/server/render-system-email";
|
|
28
|
+
import { isCronAuthorized, isCronDryRun } from "@aglyn/tenant-data-admin/server/cron-auth";
|
|
29
|
+
import { writeCronBeat } from "@aglyn/aglyn/app-utils/health-report";
|
|
30
|
+
import { brandSupportLine } from "@aglyn/aglyn/app-utils/brand-support-line";
|
|
31
|
+
import { platformConsoleOrigin as consoleOrigin } from "@aglyn/aglyn/app-utils/platform-brand";
|
|
32
|
+
import { scopeTokensForHost } from "@aglyn/aglyn/app-utils/scope-tokens";
|
|
33
|
+
/**
|
|
34
|
+
* The mark a scheduled run leaves for `/api/health/crons` (AGL-1955), on the
|
|
35
|
+
* INVOCATION and before the work — the console's rule for every cron. Never
|
|
36
|
+
* throws: reaching the admin app is inside the try, because a monitor that
|
|
37
|
+
* can take down the job it describes is worse than the failure it watches.
|
|
38
|
+
*/ async function recordCronBeat(jobId) {
|
|
39
|
+
try {
|
|
40
|
+
await writeCronBeat(firebaseAdmin.app().firestore(), jobId);
|
|
41
|
+
} catch (unused) {
|
|
42
|
+
// Deliberately swallowed: a job that stops stamping reds its own row.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// 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.
|
|
46
|
+
/**
|
|
47
|
+
* The daily CRM digest (AGL-2619): `POST /api/crm/daily-digest`.
|
|
48
|
+
*
|
|
49
|
+
* Overdue and due-today are computed at read time by design (AGL-2599), so
|
|
50
|
+
* until this route nothing ever told anybody a task was late — overdue work
|
|
51
|
+
* was found by opening the Tasks section. Once a morning, per org and per
|
|
52
|
+
* member with open work, this counts their overdue and due-today tasks and
|
|
53
|
+
* the leads nobody has worked, and says so ONCE: a console notification
|
|
54
|
+
* (`content.crmDailyDigest`) and an email through the platform's own
|
|
55
|
+
* sending path. The words and the arithmetic live in `crm-digest.ts`; this
|
|
56
|
+
* file is the reads, the sends and the sweep.
|
|
57
|
+
*
|
|
58
|
+
* ## Who is told, and who is not
|
|
59
|
+
*
|
|
60
|
+
* A task's assignee, a lead's owner, and — for a lead nobody owns — every
|
|
61
|
+
* member who reaches the site it was captured on. Only members who may open
|
|
62
|
+
* the CRM (`data.manage`, resolved through their custom role the way the task
|
|
63
|
+
* routes resolve it), and only in an org whose plan carries the suite and
|
|
64
|
+
* whose `release_crm` flag is on: a digest about a surface the reader
|
|
65
|
+
* cannot open is a reminder to pay, not a reminder to call.
|
|
66
|
+
*
|
|
67
|
+
* Two switches on the recipient's own account are honored. The **Daily CRM
|
|
68
|
+
* digest** preference (`users/{uid}.digestPrefs.crmDaily`, on until turned
|
|
69
|
+
* off) governs the whole digest, mail included. The operational category
|
|
70
|
+
* mute (`notificationPrefs.content`) governs the console notification alone,
|
|
71
|
+
* as it does for every other `content.` type — the mute is a fact about the
|
|
72
|
+
* console feed, and the digest switch is a fact about the digest.
|
|
73
|
+
*
|
|
74
|
+
* ## Once a day, and resumable
|
|
75
|
+
*
|
|
76
|
+
* `orgs/{orgId}/crmDigests/{YYYY-MM-DD}` records, per member, that today's
|
|
77
|
+
* digest went out. It is stamped AFTER the sends, so a run that dies
|
|
78
|
+
* mid-org re-sends only to the members it never reached, and a second run
|
|
79
|
+
* on the same UTC day sends nothing. Orgs are swept in id order behind a
|
|
80
|
+
* cursor the way `usage-alerts` sweeps them, so the workflow's `done:false`
|
|
81
|
+
* loop carries a platform of any size across invocations.
|
|
82
|
+
*
|
|
83
|
+
* The email is a `bulk` send under the platform send-rate governor
|
|
84
|
+
* (AGL-2409): a cron fan-out on the shared sending domain, answering nothing
|
|
85
|
+
* anybody just did. A refusal STOPS the run and reports `done: true` — the
|
|
86
|
+
* window is full, and re-POSTing into it would only red the workflow — and
|
|
87
|
+
* because the refused member was never stamped, tomorrow's run digests them
|
|
88
|
+
* afresh. Nothing about today's list is worth mailing tomorrow anyway.
|
|
89
|
+
*
|
|
90
|
+
* Metered as PLATFORM mail, not the org's: this is the platform reminding a
|
|
91
|
+
* member, and a reminder that pushed an org toward its email overage would
|
|
92
|
+
* be its own small absurdity. It is not a one-to-one CRM send and counts
|
|
93
|
+
* against no per-member sending allowance.
|
|
94
|
+
*/ /** The beat `/api/health/crons` would read, were this job in its inventory. */ export const CRM_DIGEST_JOB_ID = 'crm-daily-digest';
|
|
95
|
+
/**
|
|
96
|
+
* Orgs per invocation.
|
|
97
|
+
*
|
|
98
|
+
* Fewer than `usage-alerts`' hundred: an org costs a task query, a paged
|
|
99
|
+
* host list, a leads query per site, the roster, and a send per member with
|
|
100
|
+
* work, under `maxDuration = 60`. A chunk that times out never advances its
|
|
101
|
+
* cursor, so the safe size is the one that finishes.
|
|
102
|
+
*/ export const CRM_DIGEST_ORG_CHUNK = 25;
|
|
103
|
+
/** Hosts read per page inside one org, ordered by id like the orgs sweep. */ const CRM_DIGEST_HOST_PAGE = 100;
|
|
104
|
+
/**
|
|
105
|
+
* The most sites one org's digest reads leads from, after which the sweep
|
|
106
|
+
* stops and SAYS SO in the response rather than reading without bound.
|
|
107
|
+
*/ export const CRM_DIGEST_HOST_CEILING = 200;
|
|
108
|
+
/**
|
|
109
|
+
* The key a person's switch for this digest is stored under in
|
|
110
|
+
* `users/{uid}.digestPrefs` — the one the CRM declares under
|
|
111
|
+
* `notificationDigests` in `plugins.config.json`, which is the one the
|
|
112
|
+
* settings page writes. `daily-digest-route.spec.ts` holds the two to one
|
|
113
|
+
* spelling.
|
|
114
|
+
*/ export const CRM_DAILY_DIGEST_KEY = 'crmDaily';
|
|
115
|
+
/** `orgs/{orgId}/crmDigests/{day}` — server-written, matched by no rule. */ export const CRM_DIGEST_MARKER_COLLECTION = 'crmDigests';
|
|
116
|
+
/**
|
|
117
|
+
* The most members per org whose address is chased through the auth pools
|
|
118
|
+
* when their member document carries none. Bounds WORK, not audience — the
|
|
119
|
+
* denormalized copy is normally present, and `orgAdminEmails` draws the same
|
|
120
|
+
* line for the same reason.
|
|
121
|
+
*/ export const CRM_DIGEST_EMAIL_MAX_UID_LOOKUPS = 5;
|
|
122
|
+
/**
|
|
123
|
+
* The zone the digest's "today" is drawn in.
|
|
124
|
+
*
|
|
125
|
+
* `CRM_DIGEST_TIME_ZONE` overrides the default for a self-hosted console
|
|
126
|
+
* whose team wakes up elsewhere; a value `Intl` does not know falls back to
|
|
127
|
+
* the default with a warning rather than throwing the whole run away.
|
|
128
|
+
*/ export function digestTimeZone(env = process.env) {
|
|
129
|
+
var _env_CRM_DIGEST_TIME_ZONE;
|
|
130
|
+
const configured = String((_env_CRM_DIGEST_TIME_ZONE = env['CRM_DIGEST_TIME_ZONE']) != null ? _env_CRM_DIGEST_TIME_ZONE : '').trim();
|
|
131
|
+
if (!configured) return CRM_DIGEST_DEFAULT_TIME_ZONE;
|
|
132
|
+
try {
|
|
133
|
+
new Intl.DateTimeFormat('en-US', {
|
|
134
|
+
timeZone: configured
|
|
135
|
+
});
|
|
136
|
+
return configured;
|
|
137
|
+
} catch (unused) {
|
|
138
|
+
console.warn(`crm-daily-digest: CRM_DIGEST_TIME_ZONE "${configured}" is not a zone — ` + `using ${CRM_DIGEST_DEFAULT_TIME_ZONE}`);
|
|
139
|
+
return CRM_DIGEST_DEFAULT_TIME_ZONE;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Every host owned by an org, paged by document id — the shape
|
|
144
|
+
* `usage-alerts` reads hosts with, at a lower ceiling because each host here
|
|
145
|
+
* costs a leads query.
|
|
146
|
+
*/ async function hostsOfOrg(firestore, orgId) {
|
|
147
|
+
const docs = [];
|
|
148
|
+
let cursor = null;
|
|
149
|
+
for(;;){
|
|
150
|
+
let query = firestore.collection('hosts').where('orgId', '==', orgId).orderBy(firebaseAdmin.firestore.FieldPath.documentId()).limit(CRM_DIGEST_HOST_PAGE);
|
|
151
|
+
if (cursor) query = query.startAfter(firestore.collection('hosts').doc(cursor));
|
|
152
|
+
const page = await query.get();
|
|
153
|
+
for (const doc of page.docs)docs.push(doc);
|
|
154
|
+
if (page.docs.length < CRM_DIGEST_HOST_PAGE) return {
|
|
155
|
+
docs,
|
|
156
|
+
truncated: false
|
|
157
|
+
};
|
|
158
|
+
if (docs.length >= CRM_DIGEST_HOST_CEILING) {
|
|
159
|
+
console.warn(`crm-daily-digest: org ${orgId} has more than ${CRM_DIGEST_HOST_CEILING} ` + 'hosts — leads on the rest are not digested');
|
|
160
|
+
return {
|
|
161
|
+
docs,
|
|
162
|
+
truncated: true
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
cursor = page.docs[page.docs.length - 1].id;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* The console path a notification opens — the legacy `/{hostDocId}/rest`
|
|
170
|
+
* shape every host notification uses, or the `/org/rest` shape the console
|
|
171
|
+
* rewrites onto the organization's own hub when nothing owed names a site:
|
|
172
|
+
* an organization task (AGL-2637) has none.
|
|
173
|
+
*/ function notificationLink(hostId, section) {
|
|
174
|
+
return hostId ? `/${hostId}/crm/${section}` : `/org/crm/${section}`;
|
|
175
|
+
}
|
|
176
|
+
async function digestOrg(ctx, orgDoc) {
|
|
177
|
+
var _orgDoc_data, _marker_get, _org_slug;
|
|
178
|
+
const { firestore, nowMs, day, timeZone, dryRun } = ctx;
|
|
179
|
+
const orgId = orgDoc.id;
|
|
180
|
+
const org = (_orgDoc_data = orgDoc.data()) != null ? _orgDoc_data : {};
|
|
181
|
+
const quiet = {
|
|
182
|
+
digests: 0,
|
|
183
|
+
notified: 0,
|
|
184
|
+
emailed: 0
|
|
185
|
+
};
|
|
186
|
+
if (!crmDigestEntitled(resolveOrgEntitlements(org).features)) {
|
|
187
|
+
return _extends({}, quiet, {
|
|
188
|
+
skipped: 'not-entitled'
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
const flagOn = isReleaseFlagOnForOrg('release_crm', ctx.flagValues['release_crm'], orgId, parseOrgReleaseFlagOverrides(org['releaseFlags']), resolveEffectivePlan(org));
|
|
192
|
+
if (!flagOn) return _extends({}, quiet, {
|
|
193
|
+
skipped: 'release-flag'
|
|
194
|
+
});
|
|
195
|
+
const window = crmDigestWindow(nowMs, timeZone);
|
|
196
|
+
/*
|
|
197
|
+
* Every open task due before tomorrow, soonest first — overdue and today
|
|
198
|
+
* in one query on `(status, dueAtMs)`, bucketed afterwards. Undated tasks
|
|
199
|
+
* carry `dueAtMs: null`, which a range excludes.
|
|
200
|
+
*/ const taskPage = await orgDoc.ref.collection(CRM_COLLECTIONS.tasks).where('status', '==', 'open').where('dueAtMs', '<', window.endMs).orderBy('dueAtMs', 'asc').limit(CRM_DIGEST_TASK_CEILING).get();
|
|
201
|
+
const tasks = taskPage.docs.map((doc)=>{
|
|
202
|
+
var _doc_get, _doc_get1, _doc_get2, _doc_get3;
|
|
203
|
+
return {
|
|
204
|
+
id: doc.id,
|
|
205
|
+
title: String((_doc_get = doc.get('title')) != null ? _doc_get : ''),
|
|
206
|
+
kind: (_doc_get1 = doc.get('kind')) != null ? _doc_get1 : 'todo',
|
|
207
|
+
dueAtMs: Number(doc.get('dueAtMs')),
|
|
208
|
+
assigneeUid: String((_doc_get2 = doc.get('assigneeUid')) != null ? _doc_get2 : ''),
|
|
209
|
+
hostId: String((_doc_get3 = doc.get('hostId')) != null ? _doc_get3 : ''),
|
|
210
|
+
contactId: doc.get('contactId') || undefined,
|
|
211
|
+
companyId: doc.get('companyId') || undefined,
|
|
212
|
+
dealId: doc.get('dealId') || undefined
|
|
213
|
+
};
|
|
214
|
+
});
|
|
215
|
+
const tasksTruncated = taskPage.docs.length >= CRM_DIGEST_TASK_CEILING;
|
|
216
|
+
/*
|
|
217
|
+
* The leads nobody has worked, per site. A window of the newest old leads
|
|
218
|
+
* by first sighting, filtered in memory, for the reason the Leads section
|
|
219
|
+
* gives: the capture door stamps no `status`, and a query cannot select on
|
|
220
|
+
* a field's absence.
|
|
221
|
+
*/ const hosts = await hostsOfOrg(firestore, orgId);
|
|
222
|
+
const hostById = new Map(hosts.docs.map((doc)=>[
|
|
223
|
+
doc.id,
|
|
224
|
+
doc
|
|
225
|
+
]));
|
|
226
|
+
const leadCutoff = nowMs - CRM_DIGEST_LEAD_AGE_MS;
|
|
227
|
+
const leads = [];
|
|
228
|
+
for (const host of hosts.docs){
|
|
229
|
+
/*
|
|
230
|
+
* Still per site, but over the ORG collection narrowed to what that site
|
|
231
|
+
* may see (AGL-3275). The digest is a per-site message, so the loop stays;
|
|
232
|
+
* what changed is that a site's leads are selected by `visibleTo` rather
|
|
233
|
+
* than by the collection's parent, and a lead two brands share is in both
|
|
234
|
+
* of their digests — which is the honest answer, since both hold it.
|
|
235
|
+
*/ const page = await firestore.collection('orgs').doc(orgId).collection('leads').where('visibleTo', 'array-contains-any', scopeTokensForHost(host.id)).where('firstSeenAtMs', '<=', leadCutoff).orderBy('firstSeenAtMs', 'desc').limit(CRM_DIGEST_LEAD_WINDOW).get();
|
|
236
|
+
for (const doc of page.docs){
|
|
237
|
+
var _data_email, _data_name, _data_ownerUid;
|
|
238
|
+
const data = doc.data();
|
|
239
|
+
if (!isUnworkedLead(data, nowMs)) continue;
|
|
240
|
+
leads.push({
|
|
241
|
+
id: doc.id,
|
|
242
|
+
hostId: host.id,
|
|
243
|
+
email: String((_data_email = data['email']) != null ? _data_email : ''),
|
|
244
|
+
name: String((_data_name = data['name']) != null ? _data_name : '') || undefined,
|
|
245
|
+
ownerUid: String((_data_ownerUid = data['ownerUid']) != null ? _data_ownerUid : '') || undefined,
|
|
246
|
+
firstSeenAtMs: Number(data['firstSeenAtMs'])
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (!tasks.length && !leads.length) return _extends({}, quiet, {
|
|
251
|
+
skipped: 'nothing-owed'
|
|
252
|
+
});
|
|
253
|
+
const listed = (await listOrgMembers(orgId)).filter((member)=>member.orgSuspended !== true);
|
|
254
|
+
// A locked or disabled account is not told (AGL-3418): it cannot sign in
|
|
255
|
+
// to act on it.
|
|
256
|
+
const reachable = new Set(await withoutMailWithheldAccounts(listed.map((member)=>member.$id)));
|
|
257
|
+
const members = listed.filter((member)=>reachable.has(member.$id));
|
|
258
|
+
const memberById = new Map(members.map((member)=>[
|
|
259
|
+
member.$id,
|
|
260
|
+
member
|
|
261
|
+
]));
|
|
262
|
+
const digests = buildMemberDigests({
|
|
263
|
+
tasks,
|
|
264
|
+
leads,
|
|
265
|
+
window,
|
|
266
|
+
members: members.map((member)=>({
|
|
267
|
+
uid: member.$id,
|
|
268
|
+
reachesHost: (hostId)=>hostRoleFor(member, hostId) !== null
|
|
269
|
+
}))
|
|
270
|
+
});
|
|
271
|
+
if (!digests.size) return _extends({}, quiet, {
|
|
272
|
+
skipped: 'nobody-to-tell'
|
|
273
|
+
});
|
|
274
|
+
const markerRef = orgDoc.ref.collection(CRM_DIGEST_MARKER_COLLECTION).doc(day);
|
|
275
|
+
const marker = await markerRef.get();
|
|
276
|
+
const sentToday = (_marker_get = marker.get('members')) != null ? _marker_get : {};
|
|
277
|
+
// Sorted so the order members are told in — and the member a refusal
|
|
278
|
+
// lands on — is the same on every run.
|
|
279
|
+
const uids = [
|
|
280
|
+
...digests.keys()
|
|
281
|
+
].sort();
|
|
282
|
+
const userDocs = await firestore.getAll(...uids.map((uid)=>firestore.collection('users').doc(uid)));
|
|
283
|
+
const userById = new Map(userDocs.map((doc)=>[
|
|
284
|
+
doc.id,
|
|
285
|
+
doc
|
|
286
|
+
]));
|
|
287
|
+
const branding = resolveBrandingProfile(org);
|
|
288
|
+
const orgSlug = String((_org_slug = org['slug']) != null ? _org_slug : '');
|
|
289
|
+
const hostName = (hostId)=>{
|
|
290
|
+
const host = hostById.get(hostId);
|
|
291
|
+
return String((host == null ? void 0 : host.get('name')) || (host == null ? void 0 : host.get('subdomain')) || hostId);
|
|
292
|
+
};
|
|
293
|
+
const hubUrl = (hostId)=>{
|
|
294
|
+
var _ref;
|
|
295
|
+
var _hostById_get;
|
|
296
|
+
const subdomain = String((_ref = (_hostById_get = hostById.get(hostId)) == null ? void 0 : _hostById_get.get('subdomain')) != null ? _ref : '');
|
|
297
|
+
if (!orgSlug || !subdomain) return null;
|
|
298
|
+
return `${ctx.origin}${buildRoute(Route.HOST_DASHBOARD, {
|
|
299
|
+
orgSlug,
|
|
300
|
+
host: subdomain
|
|
301
|
+
})}/crm`;
|
|
302
|
+
};
|
|
303
|
+
// The organization's own hub, for a member whose owed tasks name no site.
|
|
304
|
+
const orgHubUrl = orgSlug ? `${ctx.origin}${buildRoute(Route.ORG_HOME, {
|
|
305
|
+
orgSlug
|
|
306
|
+
})}/crm` : null;
|
|
307
|
+
const settingsUrl = `${ctx.origin}${buildRoute(Route.MANAGE_NOTIFICATION_SETTINGS)}`;
|
|
308
|
+
const supportLine = brandSupportLine(branding);
|
|
309
|
+
const brand = systemEmailBrand(org);
|
|
310
|
+
let digestEmail;
|
|
311
|
+
const report = {
|
|
312
|
+
digests: 0,
|
|
313
|
+
notified: 0,
|
|
314
|
+
emailed: 0,
|
|
315
|
+
members: {}
|
|
316
|
+
};
|
|
317
|
+
if (hosts.truncated) report.hostsTruncated = true;
|
|
318
|
+
if (tasksTruncated) report.tasksTruncated = true;
|
|
319
|
+
const rows = report.members;
|
|
320
|
+
let poolLookups = 0;
|
|
321
|
+
for (const uid of uids){
|
|
322
|
+
var _member_email;
|
|
323
|
+
var _find, _digest_leads_;
|
|
324
|
+
const digest = digests.get(uid);
|
|
325
|
+
if (sentToday[uid]) {
|
|
326
|
+
rows[uid] = {
|
|
327
|
+
skipped: 'already-sent'
|
|
328
|
+
};
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
const member = memberById.get(uid);
|
|
332
|
+
if (!await memberHasOrgPermission(orgId, member, 'data.manage')) {
|
|
333
|
+
rows[uid] = {
|
|
334
|
+
skipped: 'no-data-manage'
|
|
335
|
+
};
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
const userDoc = userById.get(uid);
|
|
339
|
+
if (!digestEnabled(userDoc == null ? void 0 : userDoc.get(DIGEST_PREFS_FIELD), CRM_DAILY_DIGEST_KEY)) {
|
|
340
|
+
rows[uid] = {
|
|
341
|
+
skipped: 'digest-off'
|
|
342
|
+
};
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
const muted = notificationMuted(userDoc == null ? void 0 : userDoc.get('notificationPrefs'), 'content.crmDailyDigest');
|
|
346
|
+
const counts = crmDigestCounts(digest);
|
|
347
|
+
const row = _extends({}, counts);
|
|
348
|
+
rows[uid] = row;
|
|
349
|
+
report.digests += 1;
|
|
350
|
+
if (dryRun) {
|
|
351
|
+
row.notified = !muted;
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
// The site the links open on: where the first task THAT HAS A SITE
|
|
355
|
+
// lives, else where the first lead was captured. An organization task
|
|
356
|
+
// (AGL-2637) names no site, and a digest owed nothing but those opens
|
|
357
|
+
// on the organization's hub.
|
|
358
|
+
const anchorHostId = ((_find = [
|
|
359
|
+
...digest.overdue,
|
|
360
|
+
...digest.today
|
|
361
|
+
].find((task)=>task.hostId)) == null ? void 0 : _find.hostId) || ((_digest_leads_ = digest.leads[0]) == null ? void 0 : _digest_leads_.hostId) || '';
|
|
362
|
+
const section = counts.overdue + counts.today > 0 ? 'tasks' : 'leads';
|
|
363
|
+
const hub = anchorHostId ? hubUrl(anchorHostId) : orgHubUrl;
|
|
364
|
+
/*
|
|
365
|
+
* THE EMAIL FIRST. A refusal from the send-rate governor stops the run
|
|
366
|
+
* with nothing else done for this member — no notification to double
|
|
367
|
+
* tomorrow, no marker to skip them on — so the order here is the
|
|
368
|
+
* idempotence, not a preference.
|
|
369
|
+
*/ let emailed = false;
|
|
370
|
+
let emailReason;
|
|
371
|
+
let address = String((_member_email = member.email) != null ? _member_email : '').trim().toLowerCase();
|
|
372
|
+
if (!address.includes('@') && poolLookups < CRM_DIGEST_EMAIL_MAX_UID_LOOKUPS) {
|
|
373
|
+
var _ref;
|
|
374
|
+
var _pooled_record;
|
|
375
|
+
poolLookups += 1;
|
|
376
|
+
const pooled = await findUserByUidAcrossPools(uid).catch(()=>null);
|
|
377
|
+
address = String((_ref = pooled == null ? void 0 : (_pooled_record = pooled.record) == null ? void 0 : _pooled_record.email) != null ? _ref : '').trim().toLowerCase();
|
|
378
|
+
}
|
|
379
|
+
const recipients = address.includes('@') ? await filterSuppressedEmails([
|
|
380
|
+
address
|
|
381
|
+
], firestore) : [];
|
|
382
|
+
if (!recipients.length) {
|
|
383
|
+
emailReason = address.includes('@') ? 'suppressed' : 'no-recipient';
|
|
384
|
+
} else {
|
|
385
|
+
const digestInput = {
|
|
386
|
+
digest,
|
|
387
|
+
nowMs,
|
|
388
|
+
timeZone,
|
|
389
|
+
productName: branding.productName,
|
|
390
|
+
tasksUrl: hub ? `${hub}/tasks` : ctx.origin,
|
|
391
|
+
leadsUrl: (hostId)=>{
|
|
392
|
+
var _hubUrl;
|
|
393
|
+
return `${(_hubUrl = hubUrl(hostId)) != null ? _hubUrl : ctx.origin}/leads`;
|
|
394
|
+
},
|
|
395
|
+
settingsUrl,
|
|
396
|
+
hostName,
|
|
397
|
+
supportLine
|
|
398
|
+
};
|
|
399
|
+
const subject = composeCrmDigestSubject(counts);
|
|
400
|
+
// The `crm-daily-digest` system email (AGL-3367), in the org's brand,
|
|
401
|
+
// read once per org and rendered per member.
|
|
402
|
+
digestEmail != null ? digestEmail : digestEmail = loadSystemEmail('crm-daily-digest').catch(()=>null);
|
|
403
|
+
const loaded = await digestEmail;
|
|
404
|
+
const rendered = loaded ? renderLoadedSystemEmail(loaded, _extends({}, brand.merge, {
|
|
405
|
+
'digest.subject': subject,
|
|
406
|
+
'digest.body': composeCrmDigestEmailText(_extends({}, digestInput, {
|
|
407
|
+
omitClosing: true
|
|
408
|
+
})),
|
|
409
|
+
settingsUrl
|
|
410
|
+
}), brand.options) : null;
|
|
411
|
+
const result = await sendEmail(_extends({
|
|
412
|
+
to: recipients
|
|
413
|
+
}, systemEmailContent(rendered, {
|
|
414
|
+
subject,
|
|
415
|
+
text: composeCrmDigestEmailText(digestInput)
|
|
416
|
+
}), {
|
|
417
|
+
fromName: branding.fromName,
|
|
418
|
+
context: 'crm-daily-digest',
|
|
419
|
+
priority: 'bulk'
|
|
420
|
+
}));
|
|
421
|
+
if (result.sent) {
|
|
422
|
+
emailed = true;
|
|
423
|
+
await meterPlatformEmail().catch(()=>undefined);
|
|
424
|
+
} else if (rateLimitedRetryAtMs(result) !== null) {
|
|
425
|
+
row.deferred = true;
|
|
426
|
+
report.deferred = true;
|
|
427
|
+
return report;
|
|
428
|
+
} else {
|
|
429
|
+
var _result_reason;
|
|
430
|
+
emailReason = (_result_reason = result.reason) != null ? _result_reason : 'failed';
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
let notified = false;
|
|
434
|
+
if (!muted) {
|
|
435
|
+
await notifyUsers([
|
|
436
|
+
uid
|
|
437
|
+
], _extends({
|
|
438
|
+
type: 'content.crmDailyDigest',
|
|
439
|
+
title: 'Your CRM today',
|
|
440
|
+
body: composeCrmDigestSummary(counts),
|
|
441
|
+
link: notificationLink(anchorHostId, section),
|
|
442
|
+
orgId
|
|
443
|
+
}, anchorHostId ? {
|
|
444
|
+
hostId: anchorHostId
|
|
445
|
+
} : {}));
|
|
446
|
+
notified = true;
|
|
447
|
+
}
|
|
448
|
+
await markerRef.set({
|
|
449
|
+
day,
|
|
450
|
+
members: {
|
|
451
|
+
[uid]: _extends({
|
|
452
|
+
atMs: nowMs
|
|
453
|
+
}, counts, {
|
|
454
|
+
notified,
|
|
455
|
+
emailed
|
|
456
|
+
})
|
|
457
|
+
},
|
|
458
|
+
updatedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp()
|
|
459
|
+
}, {
|
|
460
|
+
merge: true
|
|
461
|
+
});
|
|
462
|
+
row.notified = notified;
|
|
463
|
+
row.emailed = emailed;
|
|
464
|
+
if (emailReason) row.emailReason = emailReason;
|
|
465
|
+
if (notified) report.notified += 1;
|
|
466
|
+
if (emailed) report.emailed += 1;
|
|
467
|
+
}
|
|
468
|
+
return report;
|
|
469
|
+
}
|
|
470
|
+
export async function crmDailyDigestRoute(request) {
|
|
471
|
+
const { method, body, headers: rawHeaders, query } = await pluginRequestFromWeb(request);
|
|
472
|
+
const headers = rawHeaders;
|
|
473
|
+
if (method !== 'POST' && method !== 'GET') {
|
|
474
|
+
return Response.json({
|
|
475
|
+
error: 'Method not allowed'
|
|
476
|
+
}, {
|
|
477
|
+
status: 405
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
if (!process.env.CRON_SECRET) {
|
|
481
|
+
return Response.json({
|
|
482
|
+
error: 'The CRM digest is not configured (CRON_SECRET).'
|
|
483
|
+
}, {
|
|
484
|
+
status: 501
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
if (!isCronAuthorized(headers)) {
|
|
488
|
+
return Response.json({
|
|
489
|
+
error: 'Unauthenticated'
|
|
490
|
+
}, {
|
|
491
|
+
status: 401
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
// A GET reports the plan and sends nothing; a bodyless POST — what the
|
|
495
|
+
// scheduled workflow sends — is a real run. `isCronDryRun` keys the
|
|
496
|
+
// default on the METHOD for exactly that reason.
|
|
497
|
+
const dryRun = isCronDryRun({
|
|
498
|
+
method,
|
|
499
|
+
body,
|
|
500
|
+
query
|
|
501
|
+
});
|
|
502
|
+
// Stamped on the invocation, not on the work (AGL-1955), so the beat is
|
|
503
|
+
// already there the day this job joins `SCHEDULED_JOBS`.
|
|
504
|
+
if (method === 'POST') await recordCronBeat(CRM_DIGEST_JOB_ID);
|
|
505
|
+
try {
|
|
506
|
+
var _ref;
|
|
507
|
+
var _orgDocs_;
|
|
508
|
+
const firestore = firebaseAdmin.app().firestore();
|
|
509
|
+
const nowMs = Date.now();
|
|
510
|
+
const ctx = {
|
|
511
|
+
firestore,
|
|
512
|
+
nowMs,
|
|
513
|
+
day: utcDayKey(nowMs),
|
|
514
|
+
timeZone: digestTimeZone(),
|
|
515
|
+
dryRun,
|
|
516
|
+
flagValues: await getServerReleaseFlagValues(),
|
|
517
|
+
origin: consoleOrigin()
|
|
518
|
+
};
|
|
519
|
+
// The resumable sweep, in the shape `usage-alerts` reads orgs (AGL-2220):
|
|
520
|
+
// ordered by id, `limit + 1` to learn whether there is more, the cursor
|
|
521
|
+
// validated as an org id before it becomes a reference.
|
|
522
|
+
const requestedLimit = Number(body == null ? void 0 : body.limit);
|
|
523
|
+
const pageSize = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(Math.floor(requestedLimit), CRM_DIGEST_ORG_CHUNK) : CRM_DIGEST_ORG_CHUNK;
|
|
524
|
+
const rawCursor = body == null ? void 0 : body.cursor;
|
|
525
|
+
const cursor = typeof rawCursor === 'string' && rawCursor.length > 0 && !rawCursor.includes('/') ? rawCursor : null;
|
|
526
|
+
let orgQuery = firestore.collection('orgs').orderBy(firebaseAdmin.firestore.FieldPath.documentId()).limit(pageSize + 1);
|
|
527
|
+
if (cursor) orgQuery = orgQuery.startAfter(firestore.collection('orgs').doc(cursor));
|
|
528
|
+
const page = await orgQuery.get();
|
|
529
|
+
const hasMore = page.docs.length > pageSize;
|
|
530
|
+
const orgDocs = hasMore ? page.docs.slice(0, pageSize) : page.docs;
|
|
531
|
+
const orgs = {};
|
|
532
|
+
const failures = {};
|
|
533
|
+
let digests = 0;
|
|
534
|
+
let notified = 0;
|
|
535
|
+
let emailed = 0;
|
|
536
|
+
let deferred = false;
|
|
537
|
+
for (const orgDoc of orgDocs){
|
|
538
|
+
try {
|
|
539
|
+
const report = await digestOrg(ctx, orgDoc);
|
|
540
|
+
orgs[orgDoc.id] = report;
|
|
541
|
+
digests += report.digests;
|
|
542
|
+
notified += report.notified;
|
|
543
|
+
emailed += report.emailed;
|
|
544
|
+
if (report.deferred) {
|
|
545
|
+
deferred = true;
|
|
546
|
+
break;
|
|
547
|
+
}
|
|
548
|
+
} catch (error) {
|
|
549
|
+
var _ref1;
|
|
550
|
+
console.error('[crm] daily digest failed for org', orgDoc.id, error);
|
|
551
|
+
failures[orgDoc.id] = (_ref1 = error == null ? void 0 : error.message) != null ? _ref1 : 'unknown';
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
const failed = Object.keys(failures);
|
|
555
|
+
return Response.json(_extends({
|
|
556
|
+
day: ctx.day,
|
|
557
|
+
timeZone: ctx.timeZone,
|
|
558
|
+
dryRun,
|
|
559
|
+
swept: orgDocs.length,
|
|
560
|
+
digests,
|
|
561
|
+
notified,
|
|
562
|
+
emailed,
|
|
563
|
+
orgs
|
|
564
|
+
}, deferred ? {
|
|
565
|
+
deferred: true
|
|
566
|
+
} : {}, failed.length ? {
|
|
567
|
+
failures
|
|
568
|
+
} : {}, {
|
|
569
|
+
// A deferred run reports `done: true` — see the module comment —
|
|
570
|
+
// so the workflow's loop does not re-POST into a full window.
|
|
571
|
+
nextCursor: deferred || !hasMore ? null : (_ref = (_orgDocs_ = orgDocs[orgDocs.length - 1]) == null ? void 0 : _orgDocs_.id) != null ? _ref : null,
|
|
572
|
+
done: deferred || !hasMore
|
|
573
|
+
}), // 207: this chunk finished and some org in it needs a person, which is
|
|
574
|
+
// what fails the workflow (its one alerting channel).
|
|
575
|
+
{
|
|
576
|
+
status: failed.length ? 207 : 200
|
|
577
|
+
});
|
|
578
|
+
} catch (error) {
|
|
579
|
+
console.error('[crm] daily digest run failed', error);
|
|
580
|
+
return Response.json({
|
|
581
|
+
error: 'CRM digest run failed'
|
|
582
|
+
}, {
|
|
583
|
+
status: 500
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
//# sourceMappingURL=daily-digest-route.js.map
|