@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.
Files changed (249) hide show
  1. package/package.json +14 -13
  2. package/src/index.d.ts +1 -0
  3. package/src/index.js.map +1 -1
  4. package/src/lib/components/companies-bulk-bar.js +12 -4
  5. package/src/lib/components/companies-bulk-bar.js.map +1 -1
  6. package/src/lib/components/company-detail-page.js +11 -0
  7. package/src/lib/components/company-detail-page.js.map +1 -1
  8. package/src/lib/components/company-edit-drawer.js +12 -2
  9. package/src/lib/components/company-edit-drawer.js.map +1 -1
  10. package/src/lib/components/company-import-drawer.d.ts +1 -1
  11. package/src/lib/components/company-picker.js +10 -3
  12. package/src/lib/components/company-picker.js.map +1 -1
  13. package/src/lib/components/contact-detail-page.d.ts +4 -4
  14. package/src/lib/components/contact-detail-page.js +50 -53
  15. package/src/lib/components/contact-detail-page.js.map +1 -1
  16. package/src/lib/components/contact-list-columns.d.ts +3 -0
  17. package/src/lib/components/contact-list-columns.js +21 -5
  18. package/src/lib/components/contact-list-columns.js.map +1 -1
  19. package/src/lib/components/contact-merge-dialog.js +2 -1
  20. package/src/lib/components/contact-merge-dialog.js.map +1 -1
  21. package/src/lib/components/contact-timeline-card.js +4 -3
  22. package/src/lib/components/contact-timeline-card.js.map +1 -1
  23. package/src/lib/components/contacts-bulk-bar.d.ts +3 -3
  24. package/src/lib/components/contacts-bulk-bar.js +24 -51
  25. package/src/lib/components/contacts-bulk-bar.js.map +1 -1
  26. package/src/lib/components/contacts-section.js +9 -3
  27. package/src/lib/components/contacts-section.js.map +1 -1
  28. package/src/lib/components/crm-export-all-button.d.ts +1 -1
  29. package/src/lib/components/crm-export-all-button.js.map +1 -1
  30. package/src/lib/components/crm-send-email-dialog.js +9 -6
  31. package/src/lib/components/crm-send-email-dialog.js.map +1 -1
  32. package/src/lib/components/csv-import-drawer.js +2 -2
  33. package/src/lib/components/csv-import-drawer.js.map +1 -1
  34. package/src/lib/components/deal-detail-page.js +18 -2
  35. package/src/lib/components/deal-detail-page.js.map +1 -1
  36. package/src/lib/components/deal-edit-drawer.js +10 -0
  37. package/src/lib/components/deal-edit-drawer.js.map +1 -1
  38. package/src/lib/components/deals-bulk-bar.js +13 -5
  39. package/src/lib/components/deals-bulk-bar.js.map +1 -1
  40. package/src/lib/components/lead-campaigns-card.js +6 -0
  41. package/src/lib/components/lead-campaigns-card.js.map +1 -1
  42. package/src/lib/components/lead-detail-page.js +25 -5
  43. package/src/lib/components/lead-detail-page.js.map +1 -1
  44. package/src/lib/components/lead-properties-card.js +6 -0
  45. package/src/lib/components/lead-properties-card.js.map +1 -1
  46. package/src/lib/components/lead-surfaces-note.js +4 -5
  47. package/src/lib/components/lead-surfaces-note.js.map +1 -1
  48. package/src/lib/components/lead-unqualify-dialog.js +6 -0
  49. package/src/lib/components/lead-unqualify-dialog.js.map +1 -1
  50. package/src/lib/components/leads-bulk-bar.js +28 -3
  51. package/src/lib/components/leads-bulk-bar.js.map +1 -1
  52. package/src/lib/components/leads-section.js +36 -7
  53. package/src/lib/components/leads-section.js.map +1 -1
  54. package/src/lib/components/org-lead-surfaces-note.js +6 -2
  55. package/src/lib/components/org-lead-surfaces-note.js.map +1 -1
  56. package/src/lib/components/recipes-card.js +9 -4
  57. package/src/lib/components/recipes-card.js.map +1 -1
  58. package/src/lib/components/record-sharing-card.d.ts +57 -0
  59. package/src/lib/components/record-sharing-card.js +244 -0
  60. package/src/lib/components/record-sharing-card.js.map +1 -0
  61. package/src/lib/components/settings-section.js +7 -0
  62. package/src/lib/components/settings-section.js.map +1 -1
  63. package/src/lib/components/share-records-dialog.d.ts +28 -0
  64. package/src/lib/components/share-records-dialog.js +184 -0
  65. package/src/lib/components/share-records-dialog.js.map +1 -0
  66. package/src/lib/components/sharing-rule-drawer.d.ts +37 -0
  67. package/src/lib/components/sharing-rule-drawer.js +374 -0
  68. package/src/lib/components/sharing-rule-drawer.js.map +1 -0
  69. package/src/lib/components/sharing-rules-card.d.ts +24 -0
  70. package/src/lib/components/sharing-rules-card.js +287 -0
  71. package/src/lib/components/sharing-rules-card.js.map +1 -0
  72. package/src/lib/components/use-crm-api.d.ts +1 -1
  73. package/src/lib/components/use-crm-api.js.map +1 -1
  74. package/src/lib/components/use-site-route-context.d.ts +25 -0
  75. package/src/lib/components/use-site-route-context.js +58 -0
  76. package/src/lib/components/use-site-route-context.js.map +1 -0
  77. package/src/lib/constants/api-routes.d.ts +31 -0
  78. package/src/lib/constants/api-routes.js +27 -1
  79. package/src/lib/constants/api-routes.js.map +1 -1
  80. package/src/lib/declarations.console-server.d.ts +37 -0
  81. package/src/lib/declarations.console-server.js +103 -0
  82. package/src/lib/declarations.console-server.js.map +1 -0
  83. package/src/lib/declarations.server.d.ts +6 -0
  84. package/src/lib/declarations.server.js +33 -0
  85. package/src/lib/declarations.server.js.map +1 -1
  86. package/src/lib/hooks/use-contact-remove.d.ts +35 -0
  87. package/src/lib/hooks/use-contact-remove.js +77 -0
  88. package/src/lib/hooks/use-contact-remove.js.map +1 -0
  89. package/src/lib/hooks/use-crm-sharing.d.ts +30 -0
  90. package/src/lib/hooks/use-crm-sharing.js +109 -0
  91. package/src/lib/hooks/use-crm-sharing.js.map +1 -0
  92. package/src/lib/model/companies-csv.d.ts +1 -1
  93. package/src/lib/model/companies-csv.js +2 -2
  94. package/src/lib/model/companies-csv.js.map +1 -1
  95. package/src/lib/model/contact-holder.d.ts +68 -0
  96. package/src/lib/model/contact-holder.js +129 -0
  97. package/src/lib/model/contact-holder.js.map +1 -0
  98. package/src/lib/model/contact-interaction-links.d.ts +46 -0
  99. package/src/lib/model/contact-interaction-links.js +65 -0
  100. package/src/lib/model/contact-interaction-links.js.map +1 -0
  101. package/src/lib/model/contact-merge.d.ts +136 -0
  102. package/src/lib/model/contact-merge.js +412 -0
  103. package/src/lib/model/contact-merge.js.map +1 -0
  104. package/src/lib/model/contact-record.d.ts +7 -1
  105. package/src/lib/model/contact-record.js +6 -5
  106. package/src/lib/model/contact-record.js.map +1 -1
  107. package/src/lib/model/contact-remove.d.ts +48 -0
  108. package/src/lib/model/contact-remove.js +22 -0
  109. package/src/lib/model/contact-remove.js.map +1 -0
  110. package/src/lib/model/contacts-bulk-writes.d.ts +2 -71
  111. package/src/lib/model/contacts-bulk-writes.js +2 -82
  112. package/src/lib/model/contacts-bulk-writes.js.map +1 -1
  113. package/src/lib/model/contacts-csv.d.ts +2 -2
  114. package/src/lib/model/contacts-csv.js +2 -2
  115. package/src/lib/model/contacts-csv.js.map +1 -1
  116. package/src/lib/model/crm-csv.d.ts +239 -0
  117. package/src/lib/model/crm-csv.js +467 -0
  118. package/src/lib/model/crm-csv.js.map +1 -0
  119. package/src/lib/model/crm-digest.d.ts +185 -0
  120. package/src/lib/model/crm-digest.js +272 -0
  121. package/src/lib/model/crm-digest.js.map +1 -0
  122. package/src/lib/model/crm-import.d.ts +7 -1
  123. package/src/lib/model/crm-import.js +2 -1
  124. package/src/lib/model/crm-import.js.map +1 -1
  125. package/src/lib/model/crm-record-routes.d.ts +5 -0
  126. package/src/lib/model/crm-record-routes.js +7 -0
  127. package/src/lib/model/crm-record-routes.js.map +1 -1
  128. package/src/lib/model/crm-routes.d.ts +6 -5
  129. package/src/lib/model/crm-routes.js +7 -7
  130. package/src/lib/model/crm-routes.js.map +1 -1
  131. package/src/lib/model/crm-search-sources.d.ts +15 -0
  132. package/src/lib/model/crm-search-sources.js +162 -0
  133. package/src/lib/model/crm-search-sources.js.map +1 -0
  134. package/src/lib/model/crm-sharing-api.d.ts +32 -0
  135. package/src/lib/model/crm-sharing-api.js +59 -0
  136. package/src/lib/model/crm-sharing-api.js.map +1 -0
  137. package/src/lib/model/crm-sharing.d.ts +333 -0
  138. package/src/lib/model/crm-sharing.js +603 -0
  139. package/src/lib/model/crm-sharing.js.map +1 -0
  140. package/src/lib/model/deals-csv.d.ts +1 -1
  141. package/src/lib/model/deals-csv.js +4 -5
  142. package/src/lib/model/deals-csv.js.map +1 -1
  143. package/src/lib/model/lead-filters.d.ts +1 -1
  144. package/src/lib/model/leads-csv.d.ts +1 -1
  145. package/src/lib/model/leads-csv.js +1 -1
  146. package/src/lib/model/leads-csv.js.map +1 -1
  147. package/src/lib/model/tasks-csv.d.ts +1 -1
  148. package/src/lib/model/tasks-csv.js +1 -1
  149. package/src/lib/model/tasks-csv.js.map +1 -1
  150. package/src/lib/plan-entitlements.d.ts +75 -0
  151. package/src/lib/plan-entitlements.js +56 -0
  152. package/src/lib/plan-entitlements.js.map +1 -0
  153. package/src/lib/plugin.js +3 -0
  154. package/src/lib/plugin.js.map +1 -1
  155. package/src/lib/server/api-v1/contacts-merge.d.ts +20 -0
  156. package/src/lib/server/api-v1/contacts-merge.js +120 -0
  157. package/src/lib/server/api-v1/contacts-merge.js.map +1 -0
  158. package/src/lib/server/api-v1/contacts.d.ts +18 -0
  159. package/src/lib/server/api-v1/contacts.js +1076 -0
  160. package/src/lib/server/api-v1/contacts.js.map +1 -0
  161. package/src/lib/server/api-v1/crm-activities.d.ts +18 -0
  162. package/src/lib/server/api-v1/crm-activities.js +269 -0
  163. package/src/lib/server/api-v1/crm-activities.js.map +1 -0
  164. package/src/lib/server/api-v1/crm-companies.d.ts +18 -0
  165. package/src/lib/server/api-v1/crm-companies.js +391 -0
  166. package/src/lib/server/api-v1/crm-companies.js.map +1 -0
  167. package/src/lib/server/api-v1/crm-deals.d.ts +18 -0
  168. package/src/lib/server/api-v1/crm-deals.js +631 -0
  169. package/src/lib/server/api-v1/crm-deals.js.map +1 -0
  170. package/src/lib/server/api-v1/crm-email-templates.d.ts +18 -0
  171. package/src/lib/server/api-v1/crm-email-templates.js +336 -0
  172. package/src/lib/server/api-v1/crm-email-templates.js.map +1 -0
  173. package/src/lib/server/api-v1/crm-leads.d.ts +18 -0
  174. package/src/lib/server/api-v1/crm-leads.js +832 -0
  175. package/src/lib/server/api-v1/crm-leads.js.map +1 -0
  176. package/src/lib/server/api-v1/crm-pipelines.d.ts +76 -0
  177. package/src/lib/server/api-v1/crm-pipelines.js +146 -0
  178. package/src/lib/server/api-v1/crm-pipelines.js.map +1 -0
  179. package/src/lib/server/api-v1/crm-shared.d.ts +271 -0
  180. package/src/lib/server/api-v1/crm-shared.js +436 -0
  181. package/src/lib/server/api-v1/crm-shared.js.map +1 -0
  182. package/src/lib/server/api-v1/crm-tasks.d.ts +18 -0
  183. package/src/lib/server/api-v1/crm-tasks.js +398 -0
  184. package/src/lib/server/api-v1/crm-tasks.js.map +1 -0
  185. package/src/lib/server/api-v1/openapi.d.ts +29 -0
  186. package/src/lib/server/api-v1/openapi.js +985 -0
  187. package/src/lib/server/api-v1/openapi.js.map +1 -0
  188. package/src/lib/server/api-v1/usage.d.ts +26 -0
  189. package/src/lib/server/api-v1/usage.js +73 -0
  190. package/src/lib/server/api-v1/usage.js.map +1 -0
  191. package/src/lib/server/consent-group-participant.js +1 -1
  192. package/src/lib/server/consent-group-participant.js.map +1 -1
  193. package/src/lib/server/contact-lifecycle-floor.d.ts +89 -0
  194. package/src/lib/server/contact-lifecycle-floor.js +91 -0
  195. package/src/lib/server/contact-lifecycle-floor.js.map +1 -0
  196. package/src/lib/server/contact-remove.d.ts +75 -0
  197. package/src/lib/server/contact-remove.js +244 -0
  198. package/src/lib/server/contact-remove.js.map +1 -0
  199. package/src/lib/server/contact-update.js +2 -1
  200. package/src/lib/server/contact-update.js.map +1 -1
  201. package/src/lib/server/contacts-import.d.ts +47 -11
  202. package/src/lib/server/contacts-import.js +171 -71
  203. package/src/lib/server/contacts-import.js.map +1 -1
  204. package/src/lib/server/contacts-merge.d.ts +1 -1
  205. package/src/lib/server/contacts-merge.js +3 -2
  206. package/src/lib/server/contacts-merge.js.map +1 -1
  207. package/src/lib/server/convert-host-lead.d.ts +189 -0
  208. package/src/lib/server/convert-host-lead.js +518 -0
  209. package/src/lib/server/convert-host-lead.js.map +1 -0
  210. package/src/lib/server/crm-sharing.d.ts +108 -0
  211. package/src/lib/server/crm-sharing.js +790 -0
  212. package/src/lib/server/crm-sharing.js.map +1 -0
  213. package/src/lib/server/daily-digest-route.d.ts +107 -0
  214. package/src/lib/server/daily-digest-route.js +588 -0
  215. package/src/lib/server/daily-digest-route.js.map +1 -0
  216. package/src/lib/server/export-route.d.ts +60 -0
  217. package/src/lib/server/export-route.js +447 -0
  218. package/src/lib/server/export-route.js.map +1 -0
  219. package/src/lib/server/inbound-route.d.ts +74 -0
  220. package/src/lib/server/inbound-route.js +257 -0
  221. package/src/lib/server/inbound-route.js.map +1 -0
  222. package/src/lib/server/lead-convert.d.ts +1 -1
  223. package/src/lib/server/lead-convert.js +2 -2
  224. package/src/lib/server/lead-convert.js.map +1 -1
  225. package/src/lib/server/merge-contacts.d.ts +55 -0
  226. package/src/lib/server/merge-contacts.js +274 -0
  227. package/src/lib/server/merge-contacts.js.map +1 -0
  228. package/src/lib/server/pipeline-record-index.d.ts +36 -0
  229. package/src/lib/server/pipeline-record-index.js +87 -0
  230. package/src/lib/server/pipeline-record-index.js.map +1 -0
  231. package/src/lib/server/record-facts.js +2 -1
  232. package/src/lib/server/record-facts.js.map +1 -1
  233. package/src/lib/server/task-reminders-route.d.ts +82 -0
  234. package/src/lib/server/task-reminders-route.js +497 -0
  235. package/src/lib/server/task-reminders-route.js.map +1 -0
  236. package/src/lib/server-deal-stage.js +2 -1
  237. package/src/lib/server-deal-stage.js.map +1 -1
  238. package/src/lib/server.d.ts +5 -2
  239. package/src/lib/server.js +47 -2
  240. package/src/lib/server.js.map +1 -1
  241. package/src/lib/testing/stand-in-site-record-routes.d.ts +26 -0
  242. package/src/lib/testing/stand-in-site-record-routes.js +55 -0
  243. package/src/lib/testing/stand-in-site-record-routes.js.map +1 -0
  244. package/src/lib/usage-axes.d.ts +35 -0
  245. package/src/lib/usage-axes.js +68 -0
  246. package/src/lib/usage-axes.js.map +1 -0
  247. package/src/lib/components/use-emails-hub-path.d.ts +0 -24
  248. package/src/lib/components/use-emails-hub-path.js +0 -59
  249. 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