@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.
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,82 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * A task's reminder at its own time (AGL-2659): `POST /api/crm/task-reminders`.
19
+ *
20
+ * The digest beside this route says once a morning what is late and what
21
+ * is due today; nothing said "the 3:00 call is at 3:00". Every hour this
22
+ * reads, per org, the open tasks whose `remindAtMs` has come and tells
23
+ * each one's assignee ONCE — a console notification per task
24
+ * (`content.taskReminder`, opening the record the task is for) and one
25
+ * email per member per run listing everything of theirs that fell due.
26
+ * The words and the rule live in `crm-task-reminders.ts`; this file is the
27
+ * reads, the sends and the sweep, in the digest's shape wherever the two
28
+ * do the same thing.
29
+ *
30
+ * ## Who is told, and who is not
31
+ *
32
+ * The task's ASSIGNEE, and nobody else — an unassigned task is a deliberate
33
+ * choice in the drawer, as the digest reads it. Only a member who may open
34
+ * the CRM (`data.manage`, resolved through their custom role), only in an
35
+ * org whose plan carries the suite and whose `release_crm` flag is
36
+ * on: the plan gate is the digest's own (`crmDigestEntitled`), so a plan
37
+ * that gets no digest gets no reminders either. The recipient's
38
+ * operational-category mute (`notificationPrefs.content`) governs the WHOLE
39
+ * reminder, mail included — unlike the digest, which has a switch of its
40
+ * own for the schedule it keeps; a reminder is one message about one task.
41
+ *
42
+ * ## Once, and resumable
43
+ *
44
+ * `reminderSentAtMs` on the task is the idempotence: stamped in one batch
45
+ * with the member's sends, so a rerun over the same hour reads the mark and
46
+ * sends nothing, and stamped on every reminder the run HANDLED — sent, or
47
+ * found nobody to send it to — so a reminder nobody can be told about does
48
+ * not come back every hour. A reminder is left unstamped only when the run
49
+ * never reached a verdict: the send-rate governor refused, or the org
50
+ * threw. Orgs are swept in id order behind a cursor, as the digest sweeps
51
+ * them, so the workflow's `done:false` loop carries a platform of any
52
+ * size across invocations.
53
+ *
54
+ * The task query reads the NEWEST reminders that have come due, not the
55
+ * oldest — see `CRM_TASK_REMINDER_CEILING` for why that is what makes a
56
+ * bounded page safe against a window that only ever grows.
57
+ *
58
+ * The email is a `bulk` send under the platform send-rate governor
59
+ * (AGL-2409), metered as PLATFORM mail, for the digest's reasons: a cron
60
+ * fan-out on the shared sending domain, and the platform reminding a
61
+ * member is not the org's sending. A refusal STOPS the run and reports
62
+ * `done: true`; the refused member was never stamped, and the next hour's
63
+ * run finds their reminders at the top of the window again.
64
+ */
65
+ /** The beat `/api/health/crons` reads — the `SCHEDULED_JOBS` row's id. */
66
+ export declare const CRM_TASK_REMINDERS_JOB_ID = "crm-task-reminders";
67
+ /**
68
+ * Orgs per invocation.
69
+ *
70
+ * More than the digest's twenty-five: an org here costs one task query and,
71
+ * only when something fell due, the roster and the sends. The common hour
72
+ * finds nothing due in most orgs, and a chunk that reads a hundred empty
73
+ * queries finishes well inside `maxDuration = 60`.
74
+ */
75
+ export declare const CRM_TASK_REMINDERS_ORG_CHUNK = 100;
76
+ /**
77
+ * The most members per org whose address is chased through the auth pools
78
+ * when their member document carries none — the digest's bound, for the
79
+ * digest's reason: work, not audience.
80
+ */
81
+ export declare const CRM_TASK_REMINDERS_MAX_UID_LOOKUPS = 5;
82
+ export declare function crmTaskRemindersRoute(request: Request): Promise<Response>;
@@ -0,0 +1,497 @@
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, composeCrmTaskReminderBody, composeCrmTaskReminderEmailText, composeCrmTaskReminderSubject, CRM_COLLECTIONS, CRM_TASK_REMINDER_CEILING, crmTaskReminderDue, crmTaskReminderLink, isReleaseFlagOnForOrg, notificationMuted, parseOrgReleaseFlagOverrides, pluginRequestFromWeb, resolveBrandingProfile, resolveEffectivePlan, resolveOrgEntitlements, Route } from "@aglyn/aglyn/server";
18
+ import { crmDigestEntitled } 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 the digest beside this route.
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 { digestTimeZone } from "./daily-digest-route.js";
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
+ * A task's reminder at its own time (AGL-2659): `POST /api/crm/task-reminders`.
48
+ *
49
+ * The digest beside this route says once a morning what is late and what
50
+ * is due today; nothing said "the 3:00 call is at 3:00". Every hour this
51
+ * reads, per org, the open tasks whose `remindAtMs` has come and tells
52
+ * each one's assignee ONCE — a console notification per task
53
+ * (`content.taskReminder`, opening the record the task is for) and one
54
+ * email per member per run listing everything of theirs that fell due.
55
+ * The words and the rule live in `crm-task-reminders.ts`; this file is the
56
+ * reads, the sends and the sweep, in the digest's shape wherever the two
57
+ * do the same thing.
58
+ *
59
+ * ## Who is told, and who is not
60
+ *
61
+ * The task's ASSIGNEE, and nobody else — an unassigned task is a deliberate
62
+ * choice in the drawer, as the digest reads it. Only a member who may open
63
+ * the CRM (`data.manage`, resolved through their custom role), only in an
64
+ * org whose plan carries the suite and whose `release_crm` flag is
65
+ * on: the plan gate is the digest's own (`crmDigestEntitled`), so a plan
66
+ * that gets no digest gets no reminders either. The recipient's
67
+ * operational-category mute (`notificationPrefs.content`) governs the WHOLE
68
+ * reminder, mail included — unlike the digest, which has a switch of its
69
+ * own for the schedule it keeps; a reminder is one message about one task.
70
+ *
71
+ * ## Once, and resumable
72
+ *
73
+ * `reminderSentAtMs` on the task is the idempotence: stamped in one batch
74
+ * with the member's sends, so a rerun over the same hour reads the mark and
75
+ * sends nothing, and stamped on every reminder the run HANDLED — sent, or
76
+ * found nobody to send it to — so a reminder nobody can be told about does
77
+ * not come back every hour. A reminder is left unstamped only when the run
78
+ * never reached a verdict: the send-rate governor refused, or the org
79
+ * threw. Orgs are swept in id order behind a cursor, as the digest sweeps
80
+ * them, so the workflow's `done:false` loop carries a platform of any
81
+ * size across invocations.
82
+ *
83
+ * The task query reads the NEWEST reminders that have come due, not the
84
+ * oldest — see `CRM_TASK_REMINDER_CEILING` for why that is what makes a
85
+ * bounded page safe against a window that only ever grows.
86
+ *
87
+ * The email is a `bulk` send under the platform send-rate governor
88
+ * (AGL-2409), metered as PLATFORM mail, for the digest's reasons: a cron
89
+ * fan-out on the shared sending domain, and the platform reminding a
90
+ * member is not the org's sending. A refusal STOPS the run and reports
91
+ * `done: true`; the refused member was never stamped, and the next hour's
92
+ * run finds their reminders at the top of the window again.
93
+ */ /** The beat `/api/health/crons` reads — the `SCHEDULED_JOBS` row's id. */ export const CRM_TASK_REMINDERS_JOB_ID = 'crm-task-reminders';
94
+ /**
95
+ * Orgs per invocation.
96
+ *
97
+ * More than the digest's twenty-five: an org here costs one task query and,
98
+ * only when something fell due, the roster and the sends. The common hour
99
+ * finds nothing due in most orgs, and a chunk that reads a hundred empty
100
+ * queries finishes well inside `maxDuration = 60`.
101
+ */ export const CRM_TASK_REMINDERS_ORG_CHUNK = 100;
102
+ /**
103
+ * The most members per org whose address is chased through the auth pools
104
+ * when their member document carries none — the digest's bound, for the
105
+ * digest's reason: work, not audience.
106
+ */ export const CRM_TASK_REMINDERS_MAX_UID_LOOKUPS = 5;
107
+ async function remindOrg(ctx, orgDoc) {
108
+ var _orgDoc_data, _org_slug;
109
+ const { firestore, nowMs, timeZone, dryRun } = ctx;
110
+ const orgId = orgDoc.id;
111
+ const org = (_orgDoc_data = orgDoc.data()) != null ? _orgDoc_data : {};
112
+ const quiet = {
113
+ due: 0,
114
+ handled: 0,
115
+ notified: 0,
116
+ emailed: 0
117
+ };
118
+ if (!crmDigestEntitled(resolveOrgEntitlements(org).features)) {
119
+ return _extends({}, quiet, {
120
+ skipped: 'not-entitled'
121
+ });
122
+ }
123
+ const flagOn = isReleaseFlagOnForOrg('release_crm', ctx.flagValues['release_crm'], orgId, parseOrgReleaseFlagOverrides(org['releaseFlags']), resolveEffectivePlan(org));
124
+ if (!flagOn) return _extends({}, quiet, {
125
+ skipped: 'release-flag'
126
+ });
127
+ /*
128
+ * Every open task whose reminder has come, newest first, on
129
+ * `(status, remindAtMs desc)`. The "not yet handled" half is a fact about
130
+ * a field's absence, which a query cannot select on, so it is asked of
131
+ * the page in memory — and the page being the NEWEST five hundred is what
132
+ * keeps the handled ones from crowding out the next one due.
133
+ */ const page = await orgDoc.ref.collection(CRM_COLLECTIONS.tasks).where('status', '==', 'open').where('remindAtMs', '<=', nowMs).orderBy('remindAtMs', 'desc').limit(CRM_TASK_REMINDER_CEILING).get();
134
+ const due = [];
135
+ for (const doc of page.docs){
136
+ var _data_title, _data_kind, _data_assigneeUid, _data_hostId;
137
+ const data = doc.data();
138
+ if (!crmTaskReminderDue(data, nowMs)) continue;
139
+ due.push({
140
+ ref: doc.ref,
141
+ id: doc.id,
142
+ title: String((_data_title = data['title']) != null ? _data_title : ''),
143
+ kind: (_data_kind = data['kind']) != null ? _data_kind : 'todo',
144
+ dueAtMs: typeof data['dueAtMs'] === 'number' ? data['dueAtMs'] : null,
145
+ remindAtMs: Number(data['remindAtMs']),
146
+ assigneeUid: String((_data_assigneeUid = data['assigneeUid']) != null ? _data_assigneeUid : ''),
147
+ hostId: String((_data_hostId = data['hostId']) != null ? _data_hostId : ''),
148
+ contactId: data['contactId'] || undefined,
149
+ companyId: data['companyId'] || undefined,
150
+ dealId: data['dealId'] || undefined
151
+ });
152
+ }
153
+ const tasksTruncated = page.docs.length >= CRM_TASK_REMINDER_CEILING;
154
+ if (!due.length) {
155
+ return _extends({}, quiet, {
156
+ skipped: 'nothing-due'
157
+ }, tasksTruncated ? {
158
+ tasksTruncated
159
+ } : {});
160
+ }
161
+ const report = {
162
+ due: due.length,
163
+ handled: 0,
164
+ notified: 0,
165
+ emailed: 0,
166
+ members: {}
167
+ };
168
+ if (tasksTruncated) report.tasksTruncated = true;
169
+ const rows = report.members;
170
+ /**
171
+ * Stamp a set of reminders as handled, in one batch, unless this is a
172
+ * plan. Everything a member's verdict covers lands together, which is
173
+ * what makes a rerun read the same answer for all of them.
174
+ */ const settle = async (tasks)=>{
175
+ report.handled += tasks.length;
176
+ if (dryRun) return;
177
+ const batch = firestore.batch();
178
+ for (const task of tasks)batch.update(task.ref, {
179
+ reminderSentAtMs: nowMs
180
+ });
181
+ await batch.commit();
182
+ };
183
+ const byAssignee = new Map();
184
+ const unassigned = [];
185
+ for (const task of due){
186
+ var _byAssignee_get;
187
+ if (!task.assigneeUid) {
188
+ unassigned.push(task);
189
+ continue;
190
+ }
191
+ const mine = (_byAssignee_get = byAssignee.get(task.assigneeUid)) != null ? _byAssignee_get : [];
192
+ mine.push(task);
193
+ byAssignee.set(task.assigneeUid, mine);
194
+ }
195
+ // A reminder for nobody is handled by saying so: it would otherwise sit
196
+ // at the top of the window every hour for as long as the task is open.
197
+ if (unassigned.length) {
198
+ rows[''] = {
199
+ tasks: unassigned.length,
200
+ skipped: 'unassigned'
201
+ };
202
+ await settle(unassigned);
203
+ }
204
+ if (!byAssignee.size) return report;
205
+ const listed = (await listOrgMembers(orgId)).filter((member)=>member.orgSuspended !== true);
206
+ // A locked or disabled account is not told (AGL-3418): it cannot sign in
207
+ // to act on it.
208
+ const reachable = new Set(await withoutMailWithheldAccounts(listed.map((member)=>member.$id)));
209
+ const members = listed.filter((member)=>reachable.has(member.$id));
210
+ const memberById = new Map(members.map((member)=>[
211
+ member.$id,
212
+ member
213
+ ]));
214
+ // Sorted so the order members are told in — and the member a refusal
215
+ // lands on — is the same on every run.
216
+ const uids = [
217
+ ...byAssignee.keys()
218
+ ].sort();
219
+ const userDocs = await firestore.getAll(...uids.map((uid)=>firestore.collection('users').doc(uid)));
220
+ const userById = new Map(userDocs.map((doc)=>[
221
+ doc.id,
222
+ doc
223
+ ]));
224
+ // The sites the due tasks name, for the absolute links the mail carries.
225
+ const hostIds = [
226
+ ...new Set(due.map((task)=>task.hostId).filter(Boolean))
227
+ ];
228
+ const hostDocs = hostIds.length ? await firestore.getAll(...hostIds.map((hostId)=>firestore.collection('hosts').doc(hostId))) : [];
229
+ const hostById = new Map(hostDocs.map((doc)=>[
230
+ doc.id,
231
+ doc
232
+ ]));
233
+ const branding = resolveBrandingProfile(org);
234
+ const orgSlug = String((_org_slug = org['slug']) != null ? _org_slug : '');
235
+ const orgHub = orgSlug ? `${ctx.origin}${buildRoute(Route.ORG_HOME, {
236
+ orgSlug
237
+ })}/crm` : null;
238
+ const hubUrl = (hostId)=>{
239
+ var _ref;
240
+ var _hostById_get;
241
+ if (!hostId) return orgHub;
242
+ const subdomain = String((_ref = (_hostById_get = hostById.get(hostId)) == null ? void 0 : _hostById_get.get('subdomain')) != null ? _ref : '');
243
+ if (!orgSlug || !subdomain) return null;
244
+ return `${ctx.origin}${buildRoute(Route.HOST_DASHBOARD, {
245
+ orgSlug,
246
+ host: subdomain
247
+ })}/crm`;
248
+ };
249
+ // The record the task is for, on its own hub — the notification's link
250
+ // past the `/{hostId}/crm` or `/org/crm` prefix the console rewrites.
251
+ const taskUrl = (task)=>{
252
+ const hub = hubUrl(task.hostId);
253
+ if (!hub) return ctx.origin;
254
+ const link = crmTaskReminderLink(task.hostId || null, task);
255
+ return `${hub}${link.slice(link.indexOf('/crm') + '/crm'.length)}`;
256
+ };
257
+ const settingsUrl = `${ctx.origin}${buildRoute(Route.MANAGE_NOTIFICATION_SETTINGS)}`;
258
+ const supportLine = brandSupportLine(branding);
259
+ const brand = systemEmailBrand(org);
260
+ let reminderEmail;
261
+ let poolLookups = 0;
262
+ for (const uid of uids){
263
+ var _member_email;
264
+ const tasks = byAssignee.get(uid);
265
+ const row = {
266
+ tasks: tasks.length
267
+ };
268
+ rows[uid] = row;
269
+ const member = memberById.get(uid);
270
+ if (!member) {
271
+ row.skipped = 'not-a-member';
272
+ await settle(tasks);
273
+ continue;
274
+ }
275
+ if (!await memberHasOrgPermission(orgId, member, 'data.manage')) {
276
+ row.skipped = 'no-data-manage';
277
+ await settle(tasks);
278
+ continue;
279
+ }
280
+ const userDoc = userById.get(uid);
281
+ if (notificationMuted(userDoc == null ? void 0 : userDoc.get('notificationPrefs'), 'content.taskReminder')) {
282
+ row.skipped = 'muted';
283
+ await settle(tasks);
284
+ continue;
285
+ }
286
+ if (dryRun) {
287
+ row.notified = tasks.length;
288
+ row.emailed = true;
289
+ await settle(tasks);
290
+ continue;
291
+ }
292
+ /*
293
+ * THE EMAIL FIRST, for the digest's reason: a refusal from the
294
+ * send-rate governor stops the run with nothing else done for this
295
+ * member — no notification to double next hour, no mark to skip them
296
+ * on — so the order here is the idempotence, not a preference.
297
+ */ let emailed = false;
298
+ let emailReason;
299
+ let address = String((_member_email = member.email) != null ? _member_email : '').trim().toLowerCase();
300
+ if (!address.includes('@') && poolLookups < CRM_TASK_REMINDERS_MAX_UID_LOOKUPS) {
301
+ var _ref;
302
+ var _pooled_record;
303
+ poolLookups += 1;
304
+ const pooled = await findUserByUidAcrossPools(uid).catch(()=>null);
305
+ address = String((_ref = pooled == null ? void 0 : (_pooled_record = pooled.record) == null ? void 0 : _pooled_record.email) != null ? _ref : '').trim().toLowerCase();
306
+ }
307
+ const recipients = address.includes('@') ? await filterSuppressedEmails([
308
+ address
309
+ ], firestore) : [];
310
+ if (!recipients.length) {
311
+ emailReason = address.includes('@') ? 'suppressed' : 'no-recipient';
312
+ } else {
313
+ const reminderInput = {
314
+ tasks,
315
+ timeZone,
316
+ productName: branding.productName,
317
+ taskUrl,
318
+ settingsUrl,
319
+ supportLine
320
+ };
321
+ const subject = composeCrmTaskReminderSubject(tasks);
322
+ // The `crm-task-reminder` system email (AGL-3367), in the org's brand,
323
+ // read once per org and rendered per member.
324
+ reminderEmail != null ? reminderEmail : reminderEmail = loadSystemEmail('crm-task-reminder').catch(()=>null);
325
+ const loaded = await reminderEmail;
326
+ const rendered = loaded ? renderLoadedSystemEmail(loaded, _extends({}, brand.merge, {
327
+ 'reminder.subject': subject,
328
+ 'reminder.body': composeCrmTaskReminderEmailText(_extends({}, reminderInput, {
329
+ omitClosing: true
330
+ })),
331
+ settingsUrl
332
+ }), brand.options) : null;
333
+ const result = await sendEmail(_extends({
334
+ to: recipients
335
+ }, systemEmailContent(rendered, {
336
+ subject,
337
+ text: composeCrmTaskReminderEmailText(reminderInput)
338
+ }), {
339
+ fromName: branding.fromName,
340
+ context: 'crm-task-reminder',
341
+ priority: 'bulk'
342
+ }));
343
+ if (result.sent) {
344
+ emailed = true;
345
+ await meterPlatformEmail().catch(()=>undefined);
346
+ } else if (rateLimitedRetryAtMs(result) !== null) {
347
+ row.deferred = true;
348
+ report.deferred = true;
349
+ return report;
350
+ } else {
351
+ var _result_reason;
352
+ emailReason = (_result_reason = result.reason) != null ? _result_reason : 'failed';
353
+ }
354
+ }
355
+ // One notification per task: each opens the record it is for.
356
+ for (const task of tasks){
357
+ await notifyUsers([
358
+ uid
359
+ ], _extends({
360
+ type: 'content.taskReminder',
361
+ title: 'Task reminder',
362
+ body: composeCrmTaskReminderBody(task, timeZone),
363
+ link: crmTaskReminderLink(task.hostId || null, task),
364
+ orgId
365
+ }, task.hostId ? {
366
+ hostId: task.hostId
367
+ } : {}));
368
+ }
369
+ await settle(tasks);
370
+ row.notified = tasks.length;
371
+ row.emailed = emailed;
372
+ if (emailReason) row.emailReason = emailReason;
373
+ report.notified += tasks.length;
374
+ if (emailed) report.emailed += 1;
375
+ }
376
+ return report;
377
+ }
378
+ export async function crmTaskRemindersRoute(request) {
379
+ const { method, body, headers: rawHeaders, query } = await pluginRequestFromWeb(request);
380
+ const headers = rawHeaders;
381
+ if (method !== 'POST' && method !== 'GET') {
382
+ return Response.json({
383
+ error: 'Method not allowed'
384
+ }, {
385
+ status: 405
386
+ });
387
+ }
388
+ if (!process.env.CRON_SECRET) {
389
+ return Response.json({
390
+ error: 'Task reminders are not configured (CRON_SECRET).'
391
+ }, {
392
+ status: 501
393
+ });
394
+ }
395
+ if (!isCronAuthorized(headers)) {
396
+ return Response.json({
397
+ error: 'Unauthenticated'
398
+ }, {
399
+ status: 401
400
+ });
401
+ }
402
+ // A GET reports the plan and sends nothing; a bodyless POST — what the
403
+ // scheduled workflow sends — is a real run. `isCronDryRun` keys the
404
+ // default on the METHOD for exactly that reason.
405
+ const dryRun = isCronDryRun({
406
+ method,
407
+ body,
408
+ query
409
+ });
410
+ // Stamped on the invocation, not on the work (AGL-1955): an hour with
411
+ // nothing due still ran.
412
+ if (method === 'POST') await recordCronBeat(CRM_TASK_REMINDERS_JOB_ID);
413
+ try {
414
+ var _ref;
415
+ var _orgDocs_;
416
+ const firestore = firebaseAdmin.app().firestore();
417
+ const nowMs = Date.now();
418
+ const ctx = {
419
+ firestore,
420
+ nowMs,
421
+ timeZone: digestTimeZone(),
422
+ dryRun,
423
+ flagValues: await getServerReleaseFlagValues(),
424
+ origin: consoleOrigin()
425
+ };
426
+ // The resumable sweep, in the shape the digest reads orgs (AGL-2220):
427
+ // ordered by id, `limit + 1` to learn whether there is more, the cursor
428
+ // validated as an org id before it becomes a reference.
429
+ const requestedLimit = Number(body == null ? void 0 : body.limit);
430
+ const pageSize = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(Math.floor(requestedLimit), CRM_TASK_REMINDERS_ORG_CHUNK) : CRM_TASK_REMINDERS_ORG_CHUNK;
431
+ const rawCursor = body == null ? void 0 : body.cursor;
432
+ const cursor = typeof rawCursor === 'string' && rawCursor.length > 0 && !rawCursor.includes('/') ? rawCursor : null;
433
+ let orgQuery = firestore.collection('orgs').orderBy(firebaseAdmin.firestore.FieldPath.documentId()).limit(pageSize + 1);
434
+ if (cursor) orgQuery = orgQuery.startAfter(firestore.collection('orgs').doc(cursor));
435
+ const page = await orgQuery.get();
436
+ const hasMore = page.docs.length > pageSize;
437
+ const orgDocs = hasMore ? page.docs.slice(0, pageSize) : page.docs;
438
+ const orgs = {};
439
+ const failures = {};
440
+ let due = 0;
441
+ let handled = 0;
442
+ let notified = 0;
443
+ let emailed = 0;
444
+ let deferred = false;
445
+ for (const orgDoc of orgDocs){
446
+ try {
447
+ const report = await remindOrg(ctx, orgDoc);
448
+ orgs[orgDoc.id] = report;
449
+ due += report.due;
450
+ handled += report.handled;
451
+ notified += report.notified;
452
+ emailed += report.emailed;
453
+ if (report.deferred) {
454
+ deferred = true;
455
+ break;
456
+ }
457
+ } catch (error) {
458
+ var _ref1;
459
+ console.error('[crm] task reminders failed for org', orgDoc.id, error);
460
+ failures[orgDoc.id] = (_ref1 = error == null ? void 0 : error.message) != null ? _ref1 : 'unknown';
461
+ }
462
+ }
463
+ const failed = Object.keys(failures);
464
+ return Response.json(_extends({
465
+ timeZone: ctx.timeZone,
466
+ dryRun,
467
+ swept: orgDocs.length,
468
+ due,
469
+ handled,
470
+ notified,
471
+ emailed,
472
+ orgs
473
+ }, deferred ? {
474
+ deferred: true
475
+ } : {}, failed.length ? {
476
+ failures
477
+ } : {}, {
478
+ // A deferred run reports `done: true` — see the module comment —
479
+ // so the workflow's loop does not re-POST into a full window.
480
+ nextCursor: deferred || !hasMore ? null : (_ref = (_orgDocs_ = orgDocs[orgDocs.length - 1]) == null ? void 0 : _orgDocs_.id) != null ? _ref : null,
481
+ done: deferred || !hasMore
482
+ }), // 207: this chunk finished and some org in it needs a person, which is
483
+ // what fails the workflow (its one alerting channel).
484
+ {
485
+ status: failed.length ? 207 : 200
486
+ });
487
+ } catch (error) {
488
+ console.error('[crm] task reminders run failed', error);
489
+ return Response.json({
490
+ error: 'Task reminders run failed'
491
+ }, {
492
+ status: 500
493
+ });
494
+ }
495
+ }
496
+
497
+ //# sourceMappingURL=task-reminders-route.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/task-reminders-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 composeCrmTaskReminderBody,\n composeCrmTaskReminderEmailText,\n composeCrmTaskReminderSubject,\n CRM_COLLECTIONS,\n CRM_TASK_REMINDER_CEILING,\n type CrmReminderTask,\n crmTaskReminderDue,\n crmTaskReminderLink,\n isReleaseFlagOnForOrg,\n notificationMuted,\n parseOrgReleaseFlagOverrides,\n pluginRequestFromWeb,\n resolveBrandingProfile,\n resolveEffectivePlan,\n resolveOrgEntitlements,\n Route,\n} from '@aglyn/aglyn/server'\nimport {\n crmDigestEntitled,\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 the digest beside this route.\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 { digestTimeZone } from './daily-digest-route'\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 * A task's reminder at its own time (AGL-2659): `POST /api/crm/task-reminders`.\n *\n * The digest beside this route says once a morning what is late and what\n * is due today; nothing said \"the 3:00 call is at 3:00\". Every hour this\n * reads, per org, the open tasks whose `remindAtMs` has come and tells\n * each one's assignee ONCE — a console notification per task\n * (`content.taskReminder`, opening the record the task is for) and one\n * email per member per run listing everything of theirs that fell due.\n * The words and the rule live in `crm-task-reminders.ts`; this file is the\n * reads, the sends and the sweep, in the digest's shape wherever the two\n * do the same thing.\n *\n * ## Who is told, and who is not\n *\n * The task's ASSIGNEE, and nobody else — an unassigned task is a deliberate\n * choice in the drawer, as the digest reads it. Only a member who may open\n * the CRM (`data.manage`, resolved through their custom role), only in an\n * org whose plan carries the suite and whose `release_crm` flag is\n * on: the plan gate is the digest's own (`crmDigestEntitled`), so a plan\n * that gets no digest gets no reminders either. The recipient's\n * operational-category mute (`notificationPrefs.content`) governs the WHOLE\n * reminder, mail included — unlike the digest, which has a switch of its\n * own for the schedule it keeps; a reminder is one message about one task.\n *\n * ## Once, and resumable\n *\n * `reminderSentAtMs` on the task is the idempotence: stamped in one batch\n * with the member's sends, so a rerun over the same hour reads the mark and\n * sends nothing, and stamped on every reminder the run HANDLED — sent, or\n * found nobody to send it to — so a reminder nobody can be told about does\n * not come back every hour. A reminder is left unstamped only when the run\n * never reached a verdict: the send-rate governor refused, or the org\n * threw. Orgs are swept in id order behind a cursor, as the digest sweeps\n * them, so the workflow's `done:false` loop carries a platform of any\n * size across invocations.\n *\n * The task query reads the NEWEST reminders that have come due, not the\n * oldest — see `CRM_TASK_REMINDER_CEILING` for why that is what makes a\n * bounded page safe against a window that only ever grows.\n *\n * The email is a `bulk` send under the platform send-rate governor\n * (AGL-2409), metered as PLATFORM mail, for the digest's reasons: a cron\n * fan-out on the shared sending domain, and the platform reminding a\n * member is not the org's sending. A refusal STOPS the run and reports\n * `done: true`; the refused member was never stamped, and the next hour's\n * run finds their reminders at the top of the window again.\n */\n\n/** The beat `/api/health/crons` reads — the `SCHEDULED_JOBS` row's id. */\nexport const CRM_TASK_REMINDERS_JOB_ID = 'crm-task-reminders'\n\n/**\n * Orgs per invocation.\n *\n * More than the digest's twenty-five: an org here costs one task query and,\n * only when something fell due, the roster and the sends. The common hour\n * finds nothing due in most orgs, and a chunk that reads a hundred empty\n * queries finishes well inside `maxDuration = 60`.\n */\nexport const CRM_TASK_REMINDERS_ORG_CHUNK = 100\n\n/**\n * The most members per org whose address is chased through the auth pools\n * when their member document carries none — the digest's bound, for the\n * digest's reason: work, not audience.\n */\nexport const CRM_TASK_REMINDERS_MAX_UID_LOOKUPS = 5\n\ntype Firestore = FirebaseFirestore.Firestore\ntype Snapshot = FirebaseFirestore.QueryDocumentSnapshot\n\n/** What one member's row in the response says. */\ninterface MemberReport {\n tasks: number\n skipped?: string\n notified?: number\n emailed?: boolean\n emailReason?: string\n deferred?: boolean\n}\n\ninterface OrgReport {\n skipped?: string\n /** Reminders that had come due and were not yet handled. */\n due: number\n /** Reminders this run stamped as handled. */\n handled: number\n /** Console notifications sent. */\n notified: number\n /** Emails sent — one per member per run. */\n emailed: number\n members?: Record<string, MemberReport>\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 timeZone: string\n dryRun: boolean\n flagValues: Awaited<ReturnType<typeof getServerReleaseFlagValues>>\n origin: string\n}\n\n/** A due reminder with the document it came from, so the stamp has a target. */\ninterface DueTask extends CrmReminderTask {\n ref: FirebaseFirestore.DocumentReference\n}\n\nasync function remindOrg(ctx: SweepContext, orgDoc: Snapshot): Promise<OrgReport> {\n const { firestore, nowMs, timeZone, dryRun } = ctx\n const orgId = orgDoc.id\n const org = (orgDoc.data() ?? {}) as Partial<AglynOrgBilling> & Record<string, unknown>\n const quiet: OrgReport = { due: 0, handled: 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 /*\n * Every open task whose reminder has come, newest first, on\n * `(status, remindAtMs desc)`. The \"not yet handled\" half is a fact about\n * a field's absence, which a query cannot select on, so it is asked of\n * the page in memory — and the page being the NEWEST five hundred is what\n * keeps the handled ones from crowding out the next one due.\n */\n const page = await orgDoc.ref\n .collection(CRM_COLLECTIONS.tasks)\n .where('status', '==', 'open')\n .where('remindAtMs', '<=', nowMs)\n .orderBy('remindAtMs', 'desc')\n .limit(CRM_TASK_REMINDER_CEILING)\n .get()\n const due: DueTask[] = []\n for (const doc of page.docs) {\n const data = doc.data() as Record<string, unknown>\n if (!crmTaskReminderDue(data as Parameters<typeof crmTaskReminderDue>[0], nowMs)) continue\n due.push({\n ref: doc.ref,\n id: doc.id,\n title: String(data['title'] ?? ''),\n kind: (data['kind'] as CrmReminderTask['kind']) ?? 'todo',\n dueAtMs: typeof data['dueAtMs'] === 'number' ? (data['dueAtMs'] as number) : null,\n remindAtMs: Number(data['remindAtMs']),\n assigneeUid: String(data['assigneeUid'] ?? ''),\n hostId: String(data['hostId'] ?? ''),\n contactId: (data['contactId'] as string) || undefined,\n companyId: (data['companyId'] as string) || undefined,\n dealId: (data['dealId'] as string) || undefined,\n })\n }\n const tasksTruncated = page.docs.length >= CRM_TASK_REMINDER_CEILING\n if (!due.length) {\n return { ...quiet, skipped: 'nothing-due', ...(tasksTruncated ? { tasksTruncated } : {}) }\n }\n\n const report: OrgReport = { due: due.length, handled: 0, notified: 0, emailed: 0, members: {} }\n if (tasksTruncated) report.tasksTruncated = true\n const rows = report.members as Record<string, MemberReport>\n\n /**\n * Stamp a set of reminders as handled, in one batch, unless this is a\n * plan. Everything a member's verdict covers lands together, which is\n * what makes a rerun read the same answer for all of them.\n */\n const settle = async (tasks: DueTask[]): Promise<void> => {\n report.handled += tasks.length\n if (dryRun) return\n const batch = firestore.batch()\n for (const task of tasks) batch.update(task.ref, { reminderSentAtMs: nowMs })\n await batch.commit()\n }\n\n const byAssignee = new Map<string, DueTask[]>()\n const unassigned: DueTask[] = []\n for (const task of due) {\n if (!task.assigneeUid) {\n unassigned.push(task)\n continue\n }\n const mine = byAssignee.get(task.assigneeUid) ?? []\n mine.push(task)\n byAssignee.set(task.assigneeUid, mine)\n }\n // A reminder for nobody is handled by saying so: it would otherwise sit\n // at the top of the window every hour for as long as the task is open.\n if (unassigned.length) {\n rows[''] = { tasks: unassigned.length, skipped: 'unassigned' }\n await settle(unassigned)\n }\n if (!byAssignee.size) return report\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 // 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 = [...byAssignee.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 // The sites the due tasks name, for the absolute links the mail carries.\n const hostIds = [...new Set(due.map((task) => task.hostId).filter(Boolean))]\n const hostDocs = hostIds.length\n ? await firestore.getAll(...hostIds.map((hostId) => firestore.collection('hosts').doc(hostId)))\n : []\n const hostById = new Map(hostDocs.map((doc) => [doc.id, doc]))\n\n const branding = resolveBrandingProfile(org)\n const orgSlug = String(org['slug'] ?? '')\n const orgHub = orgSlug ? `${ctx.origin}${buildRoute(Route.ORG_HOME, { orgSlug })}/crm` : null\n const hubUrl = (hostId: string): string | null => {\n if (!hostId) return orgHub\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 record the task is for, on its own hub — the notification's link\n // past the `/{hostId}/crm` or `/org/crm` prefix the console rewrites.\n const taskUrl = (task: CrmReminderTask): string => {\n const hub = hubUrl(task.hostId)\n if (!hub) return ctx.origin\n const link = crmTaskReminderLink(task.hostId || null, task)\n return `${hub}${link.slice(link.indexOf('/crm') + '/crm'.length)}`\n }\n const settingsUrl = `${ctx.origin}${buildRoute(Route.MANAGE_NOTIFICATION_SETTINGS)}`\n const supportLine = brandSupportLine(branding)\n const brand = systemEmailBrand(org)\n let reminderEmail: Promise<LoadedSystemEmail | null> | undefined\n let poolLookups = 0\n\n for (const uid of uids) {\n const tasks = byAssignee.get(uid) as DueTask[]\n const row: MemberReport = { tasks: tasks.length }\n rows[uid] = row\n const member = memberById.get(uid)\n if (!member) {\n row.skipped = 'not-a-member'\n await settle(tasks)\n continue\n }\n if (!(await memberHasOrgPermission(orgId, member as AglynOrgMember, 'data.manage'))) {\n row.skipped = 'no-data-manage'\n await settle(tasks)\n continue\n }\n const userDoc = userById.get(uid)\n if (notificationMuted(userDoc?.get('notificationPrefs'), 'content.taskReminder')) {\n row.skipped = 'muted'\n await settle(tasks)\n continue\n }\n if (dryRun) {\n row.notified = tasks.length\n row.emailed = true\n await settle(tasks)\n continue\n }\n\n /*\n * THE EMAIL FIRST, for the digest's reason: a refusal from the\n * send-rate governor stops the run with nothing else done for this\n * member — no notification to double next hour, no mark to skip them\n * on — so the order here is the 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_TASK_REMINDERS_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 reminderInput = {\n tasks,\n timeZone,\n productName: branding.productName,\n taskUrl,\n settingsUrl,\n supportLine,\n }\n const subject = composeCrmTaskReminderSubject(tasks)\n // The `crm-task-reminder` system email (AGL-3367), in the org's brand,\n // read once per org and rendered per member.\n reminderEmail ??= loadSystemEmail('crm-task-reminder').catch(() => null)\n const loaded = await reminderEmail\n const rendered = loaded\n ? renderLoadedSystemEmail(\n loaded,\n {\n ...brand.merge,\n 'reminder.subject': subject,\n 'reminder.body': composeCrmTaskReminderEmailText({ ...reminderInput, 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: composeCrmTaskReminderEmailText(reminderInput),\n }),\n fromName: branding.fromName,\n context: 'crm-task-reminder',\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 // One notification per task: each opens the record it is for.\n for (const task of tasks) {\n await notifyUsers([uid], {\n type: 'content.taskReminder',\n title: 'Task reminder',\n body: composeCrmTaskReminderBody(task, timeZone),\n link: crmTaskReminderLink(task.hostId || null, task),\n orgId,\n ...(task.hostId ? { hostId: task.hostId } : {}),\n })\n }\n await settle(tasks)\n row.notified = tasks.length\n row.emailed = emailed\n if (emailReason) row.emailReason = emailReason\n report.notified += tasks.length\n if (emailed) report.emailed += 1\n }\n return report\n}\n\nexport async function crmTaskRemindersRoute(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: 'Task reminders are 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): an hour with\n // nothing due still ran.\n if (method === 'POST') await recordCronBeat(CRM_TASK_REMINDERS_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 timeZone: digestTimeZone(),\n dryRun,\n flagValues: await getServerReleaseFlagValues(),\n origin: consoleOrigin(),\n }\n\n // The resumable sweep, in the shape the digest 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_TASK_REMINDERS_ORG_CHUNK)\n : CRM_TASK_REMINDERS_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 due = 0\n let handled = 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 remindOrg(ctx, orgDoc)\n orgs[orgDoc.id] = report\n due += report.due\n handled += report.handled\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] task reminders 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 timeZone: ctx.timeZone,\n dryRun,\n swept: orgDocs.length,\n due,\n handled,\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] task reminders run failed', error)\n return Response.json({ error: 'Task reminders run failed' }, { status: 500 })\n }\n}\n"],"names":["buildRoute","composeCrmTaskReminderBody","composeCrmTaskReminderEmailText","composeCrmTaskReminderSubject","CRM_COLLECTIONS","CRM_TASK_REMINDER_CEILING","crmTaskReminderDue","crmTaskReminderLink","isReleaseFlagOnForOrg","notificationMuted","parseOrgReleaseFlagOverrides","pluginRequestFromWeb","resolveBrandingProfile","resolveEffectivePlan","resolveOrgEntitlements","Route","crmDigestEntitled","rateLimitedRetryAtMs","sendEmail","withoutMailWithheldAccounts","findUserByUidAcrossPools","firebaseAdmin","getServerReleaseFlagValues","listOrgMembers","memberHasOrgPermission","meterPlatformEmail","notifyUsers","filterSuppressedEmails","loadSystemEmail","renderLoadedSystemEmail","systemEmailBrand","systemEmailContent","isCronAuthorized","isCronDryRun","writeCronBeat","brandSupportLine","platformConsoleOrigin","consoleOrigin","digestTimeZone","recordCronBeat","jobId","app","firestore","CRM_TASK_REMINDERS_JOB_ID","CRM_TASK_REMINDERS_ORG_CHUNK","CRM_TASK_REMINDERS_MAX_UID_LOOKUPS","remindOrg","ctx","orgDoc","org","nowMs","timeZone","dryRun","orgId","id","data","quiet","due","handled","notified","emailed","features","skipped","flagOn","flagValues","page","ref","collection","tasks","where","orderBy","limit","get","doc","docs","push","title","String","kind","dueAtMs","remindAtMs","Number","assigneeUid","hostId","contactId","undefined","companyId","dealId","tasksTruncated","length","report","members","rows","settle","batch","task","update","reminderSentAtMs","commit","byAssignee","Map","unassigned","mine","set","size","listed","filter","member","orgSuspended","reachable","Set","map","$id","has","memberById","uids","keys","sort","userDocs","getAll","uid","userById","hostIds","Boolean","hostDocs","hostById","branding","orgSlug","orgHub","origin","ORG_HOME","hubUrl","subdomain","HOST_DASHBOARD","host","taskUrl","hub","link","slice","indexOf","settingsUrl","MANAGE_NOTIFICATION_SETTINGS","supportLine","brand","reminderEmail","poolLookups","row","userDoc","emailReason","address","email","trim","toLowerCase","includes","pooled","catch","record","recipients","reminderInput","productName","subject","loaded","rendered","merge","omitClosing","options","result","to","text","fromName","context","priority","sent","deferred","reason","type","body","crmTaskRemindersRoute","request","method","headers","rawHeaders","query","Response","json","error","status","process","env","CRON_SECRET","orgDocs","Date","now","requestedLimit","pageSize","isFinite","Math","min","floor","rawCursor","cursor","orgQuery","FieldPath","documentId","startAfter","hasMore","orgs","failures","console","message","failed","Object","swept","nextCursor","done"],"mappings":"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"}