@aglyn/plugins-crm 1.0.0-beta.263 → 1.0.0-beta.266
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +14 -14
- package/src/lib/model/crm-digest.d.ts +29 -0
- package/src/lib/model/crm-digest.js +70 -0
- package/src/lib/model/crm-digest.js.map +1 -1
- package/src/lib/model/crm-task-reminders.d.ts +22 -0
- package/src/lib/model/crm-task-reminders.js +35 -0
- package/src/lib/model/crm-task-reminders.js.map +1 -1
- package/src/lib/server/daily-digest-route.js +3 -2
- package/src/lib/server/daily-digest-route.js.map +1 -1
- package/src/lib/server/task-reminders-route.js +3 -2
- package/src/lib/server/task-reminders-route.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/plugins-crm",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.266",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,19 +25,19 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-data-enums": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-data-mdi": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-ui-jsx": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-ui-next": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-ui-snackstack": "1.0.0-beta.
|
|
34
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
35
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
36
|
-
"@aglyn/shared-util-timestamp": "1.0.0-beta.
|
|
37
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
38
|
-
"@aglyn/tenant-data-admin": "1.0.0-beta.
|
|
39
|
-
"@aglyn/tenant-feature-instance": "1.0.0-beta.
|
|
40
|
-
"@aglyn/tenant-runtime": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.266",
|
|
29
|
+
"@aglyn/shared-data-enums": "1.0.0-beta.266",
|
|
30
|
+
"@aglyn/shared-data-mdi": "1.0.0-beta.266",
|
|
31
|
+
"@aglyn/shared-ui-jsx": "1.0.0-beta.266",
|
|
32
|
+
"@aglyn/shared-ui-next": "1.0.0-beta.266",
|
|
33
|
+
"@aglyn/shared-ui-snackstack": "1.0.0-beta.266",
|
|
34
|
+
"@aglyn/shared-util-email": "1.0.0-beta.266",
|
|
35
|
+
"@aglyn/shared-util-http": "1.0.0-beta.266",
|
|
36
|
+
"@aglyn/shared-util-timestamp": "1.0.0-beta.266",
|
|
37
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.266",
|
|
38
|
+
"@aglyn/tenant-data-admin": "1.0.0-beta.266",
|
|
39
|
+
"@aglyn/tenant-feature-instance": "1.0.0-beta.266",
|
|
40
|
+
"@aglyn/tenant-runtime": "1.0.0-beta.266",
|
|
41
41
|
"@dnd-kit/core": "^6.3.1",
|
|
42
42
|
"@dnd-kit/utilities": "^3.2.2",
|
|
43
43
|
"@swc/helpers": "0.5.23",
|
|
@@ -225,3 +225,32 @@ export interface CrmDigestEmailInput {
|
|
|
225
225
|
* today, then the leads, each capped and counted, and the links last.
|
|
226
226
|
*/
|
|
227
227
|
export declare function composeCrmDigestEmailText(input: CrmDigestEmailInput): string;
|
|
228
|
+
/** The digest in the parts the designed email lays out. */
|
|
229
|
+
export interface CrmDigestEmailParts {
|
|
230
|
+
/** The workspace and the day: "Acme Co · Friday, October 16". */
|
|
231
|
+
eyebrow: string;
|
|
232
|
+
/** "2 tasks due today, 1 overdue, 3 unworked leads." */
|
|
233
|
+
summary: string;
|
|
234
|
+
/** Each list's heading with its count, or empty when the list is. */
|
|
235
|
+
overdueHeading: string;
|
|
236
|
+
todayHeading: string;
|
|
237
|
+
leadsHeading: string;
|
|
238
|
+
/** Each list as "Title — when" detail rows. */
|
|
239
|
+
overdue: string;
|
|
240
|
+
today: string;
|
|
241
|
+
leads: string;
|
|
242
|
+
nurturing: string;
|
|
243
|
+
/** Where the reader starts: the Tasks section, else the first leads list. */
|
|
244
|
+
url: string;
|
|
245
|
+
urlLabel: string;
|
|
246
|
+
/** The other places to go, one "Label — URL" line each. */
|
|
247
|
+
links: string;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* The digest for the `crm-daily-digest` system email: the same facts as
|
|
251
|
+
* {@link composeCrmDigestEmailText}, cut into an eyebrow, a summary, a
|
|
252
|
+
* details panel per list and the links, so the email can draw each.
|
|
253
|
+
*/
|
|
254
|
+
export declare function composeCrmDigestEmailParts(input: CrmDigestEmailInput): CrmDigestEmailParts;
|
|
255
|
+
/** {@link CrmDigestEmailParts} as the `crm-daily-digest` email's merge tokens. */
|
|
256
|
+
export declare function crmDigestMergeValues(parts: CrmDigestEmailParts): Record<string, string>;
|
|
@@ -332,5 +332,75 @@ function section(heading, lines) {
|
|
|
332
332
|
lines.push('', 'You get this each morning because the Daily CRM digest is on in your ' + `notification settings: ${input.settingsUrl}`);
|
|
333
333
|
return lines.join('\n') + ((_input_supportLine = input.supportLine) != null ? _input_supportLine : '');
|
|
334
334
|
}
|
|
335
|
+
/**
|
|
336
|
+
* The digest for the `crm-daily-digest` system email: the same facts as
|
|
337
|
+
* {@link composeCrmDigestEmailText}, cut into an eyebrow, a summary, a
|
|
338
|
+
* details panel per list and the links, so the email can draw each.
|
|
339
|
+
*/ export function composeCrmDigestEmailParts(input) {
|
|
340
|
+
var _input_workspaceName;
|
|
341
|
+
const { digest, nowMs, timeZone } = input;
|
|
342
|
+
const counts = crmDigestCounts(digest);
|
|
343
|
+
const day = new Date(nowMs).toLocaleDateString('en-US', {
|
|
344
|
+
timeZone,
|
|
345
|
+
weekday: 'long',
|
|
346
|
+
month: 'long',
|
|
347
|
+
day: 'numeric'
|
|
348
|
+
});
|
|
349
|
+
const rows = (lines)=>{
|
|
350
|
+
const shown = lines.slice(0, CRM_DIGEST_LIST_MAX);
|
|
351
|
+
const rest = lines.length - shown.length;
|
|
352
|
+
return [
|
|
353
|
+
...shown,
|
|
354
|
+
...rest > 0 ? [
|
|
355
|
+
`More — ${rest} more in the CRM`
|
|
356
|
+
] : []
|
|
357
|
+
].join('\n');
|
|
358
|
+
};
|
|
359
|
+
const taskRow = (task)=>`${task.title} — ` + [
|
|
360
|
+
task.hostId ? input.hostName(task.hostId) : '',
|
|
361
|
+
whenInZone(task.dueAtMs, timeZone)
|
|
362
|
+
].filter(Boolean).join(' · ');
|
|
363
|
+
const leadRow = (lead)=>`${lead.name ? `${lead.name} <${lead.email}>` : lead.email} — ` + `${input.hostName(lead.hostId)} · first seen ${dateInZone(lead.firstSeenAtMs, timeZone)}`;
|
|
364
|
+
const heading = (label, count)=>count > 0 ? `${label} (${count})` : '';
|
|
365
|
+
const leadHosts = [
|
|
366
|
+
...new Set(digest.leads.map((lead)=>lead.hostId))
|
|
367
|
+
].slice(0, 3);
|
|
368
|
+
const hasTasks = counts.overdue + counts.today > 0;
|
|
369
|
+
const leadLinks = leadHosts.map((hostId)=>`Leads on ${input.hostName(hostId)} — ${input.leadsUrl(hostId)}`);
|
|
370
|
+
const firstLeads = leadHosts[0];
|
|
371
|
+
return {
|
|
372
|
+
eyebrow: [
|
|
373
|
+
(_input_workspaceName = input.workspaceName) == null ? void 0 : _input_workspaceName.trim(),
|
|
374
|
+
day
|
|
375
|
+
].filter(Boolean).join(' · '),
|
|
376
|
+
summary: `${composeCrmDigestSummary(counts)}.`.replace(/^\./, ''),
|
|
377
|
+
overdueHeading: heading('Overdue', counts.overdue),
|
|
378
|
+
todayHeading: heading('Due today', counts.today),
|
|
379
|
+
leadsHeading: heading('Unworked leads', counts.leads),
|
|
380
|
+
overdue: rows(digest.overdue.map(taskRow)),
|
|
381
|
+
today: rows(digest.today.map(taskRow)),
|
|
382
|
+
leads: rows(digest.leads.map(leadRow)),
|
|
383
|
+
nurturing: composeCrmDigestNurturingLine(counts),
|
|
384
|
+
url: hasTasks || !firstLeads ? input.tasksUrl : input.leadsUrl(firstLeads),
|
|
385
|
+
urlLabel: hasTasks || !firstLeads ? 'Open your tasks' : 'Open your leads',
|
|
386
|
+
links: (hasTasks ? leadLinks : leadLinks.slice(1)).join('\n')
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
/** {@link CrmDigestEmailParts} as the `crm-daily-digest` email's merge tokens. */ export function crmDigestMergeValues(parts) {
|
|
390
|
+
return {
|
|
391
|
+
'digest.eyebrow': parts.eyebrow,
|
|
392
|
+
'digest.summary': parts.summary,
|
|
393
|
+
'digest.overdueHeading': parts.overdueHeading,
|
|
394
|
+
'digest.overdue': parts.overdue,
|
|
395
|
+
'digest.todayHeading': parts.todayHeading,
|
|
396
|
+
'digest.today': parts.today,
|
|
397
|
+
'digest.leadsHeading': parts.leadsHeading,
|
|
398
|
+
'digest.leads': parts.leads,
|
|
399
|
+
'digest.nurturing': parts.nurturing,
|
|
400
|
+
'digest.url': parts.url,
|
|
401
|
+
'digest.action': parts.urlLabel,
|
|
402
|
+
'digest.links': parts.links
|
|
403
|
+
};
|
|
404
|
+
}
|
|
335
405
|
|
|
336
406
|
//# sourceMappingURL=crm-digest.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/crm-digest.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The daily CRM digest (AGL-2619): what one member is owed this morning,\n * computed from the tasks and leads a scheduled route has read.\n *\n * \"Overdue\" and \"due today\" are read off the clock at read time by design\n * (AGL-2599) — nothing stamps them — which is why nothing ever told anybody.\n * The digest is the one reader that runs on a schedule: once a day it\n * counts, per org and per member, the open tasks whose due date has passed\n * or falls today and the leads nobody has worked, and says so once, in the\n * console and by mail. Every judgment here is pure so the route that sends\n * and the spec that proves it read the same arithmetic.\n *\n * ## Whose day\n *\n * A task's \"today\" on screen is the READER's calendar day, from their own\n * clock. A server has no reader, so the digest reads the day in ONE zone —\n * {@link CRM_DIGEST_DEFAULT_TIME_ZONE}, the zone the schedule was chosen for\n * (08:00 there) — taken as a parameter so an org-level setting can supply\n * its own the day one exists. The idempotence key is the UTC day\n * ({@link utcDayKey}), which is a fact about the run and not about anybody's\n * morning.\n */\n\nimport {\n startOfNextZonedDay,\n startOfZonedDay,\n} from '@aglyn/shared-util-timestamp/zoned-time'\nimport { type CrmLeadFields, type CrmTask, crmLeadStatus } from '@aglyn/aglyn/app-utils/crm'\n\n/**\n * The zone the digest's \"today\" is read in when nothing else names one.\n *\n * The schedule fires at 13:00 UTC, which is 08:00 here, so the window this\n * zone draws is the morning the mail arrives in for the people the product\n * is built with. A member elsewhere reads a window that is at most a few\n * hours off their own, which is what a morning summary can tolerate and a\n * \"due at 9:00\" reminder could not — that reminder is the assignment\n * notification's job, not this one's.\n */\nexport const CRM_DIGEST_DEFAULT_TIME_ZONE = 'America/Chicago'\n\n/**\n * The zone the digest's \"today\" is drawn in, and the zone every CRM mail\n * and notification prints a task's time in.\n *\n * `CRM_DIGEST_TIME_ZONE` overrides the default for a self-hosted console\n * whose team wakes up elsewhere; a value `Intl` does not know falls back to\n * the default with a warning rather than throwing the whole run away.\n */\nexport function digestTimeZone(env: NodeJS.ProcessEnv = process.env): string {\n const configured = String(env['CRM_DIGEST_TIME_ZONE'] ?? '').trim()\n if (!configured) return CRM_DIGEST_DEFAULT_TIME_ZONE\n try {\n new Intl.DateTimeFormat('en-US', { timeZone: configured })\n return configured\n } catch {\n console.warn(\n `crm-daily-digest: CRM_DIGEST_TIME_ZONE \"${configured}\" is not a zone — ` +\n `using ${CRM_DIGEST_DEFAULT_TIME_ZONE}`,\n )\n return CRM_DIGEST_DEFAULT_TIME_ZONE\n }\n}\n\n/** How long a `new` lead may sit untouched before the digest names it. */\nexport const CRM_DIGEST_LEAD_AGE_MS = 2 * 24 * 60 * 60 * 1000\n\n/**\n * The most due tasks one org's sweep reads.\n *\n * One query per org, ordered soonest-due first, so an org with more than this\n * many overdue-or-today tasks digests the oldest five hundred and reports the\n * window as full rather than reading without bound. Five hundred is an org\n * whose problem is not that nobody reminded them.\n */\nexport const CRM_DIGEST_TASK_CEILING = 500\n\n/**\n * The most old leads one site's sweep reads.\n *\n * The same shape the Leads section uses, for the same reason it gives: a\n * lead the capture door wrote carries no `status` field, and Firestore\n * cannot select on a field's absence, so \"unworked\" is a filter over a\n * loaded window rather than a query. The window is the newest old leads by\n * first sighting, which is where the ones still worth a call are.\n */\nexport const CRM_DIGEST_LEAD_WINDOW = 200\n\n/** How many items one section of the email lists before \"and N more\". */\nexport const CRM_DIGEST_LIST_MAX = 10\n\n/*==========================================\n * CALENDAR DAYS IN A NAMED ZONE\n *\n * The zone math lives in `@aglyn/shared-util-timestamp/zoned-time`, where\n * booking slots and sending windows read it as well; the digest route and\n * its spec call it by the names below.\n *=========================================*/\n\n/** Midnight at the start of the calendar day `ms` falls in, in `timeZone`. */\nexport function startOfDayInZone(ms: number, timeZone: string): number {\n return startOfZonedDay(ms, timeZone)\n}\n\n/**\n * Midnight at the start of the NEXT calendar day in `timeZone` — built from\n * the calendar rather than by adding 24 hours, for the reason the tasks\n * section gives: a day a clock change falls in is 23 or 25 hours long.\n */\nexport function startOfNextDayInZone(ms: number, timeZone: string): number {\n return startOfNextZonedDay(ms, timeZone)\n}\n\n/** `YYYY-MM-DD` of the UTC day `ms` falls in — the digest's idempotence key. */\nexport function utcDayKey(ms: number): string {\n return new Date(ms).toISOString().slice(0, 10)\n}\n\n/** The half-open window `[start of today, start of tomorrow)` in a zone. */\nexport interface CrmDigestWindow {\n startMs: number\n endMs: number\n}\n\nexport function crmDigestWindow(nowMs: number, timeZone: string): CrmDigestWindow {\n return {\n startMs: startOfDayInZone(nowMs, timeZone),\n endMs: startOfNextDayInZone(nowMs, timeZone),\n }\n}\n\n/*==========================================\n * WHAT A DIGEST IS MADE OF\n *=========================================*/\n\n/** A due task as the digest carries it: the fields a line and a link need. */\nexport interface CrmDigestTask\n extends Pick<CrmTask, 'title' | 'kind' | 'contactId' | 'companyId' | 'dealId'> {\n id: string\n dueAtMs: number\n assigneeUid: string\n /** The site the task was created on — where its link opens. */\n hostId: string\n}\n\n/** An unworked lead as the digest carries it. */\nexport interface CrmDigestLead {\n id: string\n hostId: string\n email: string\n name?: string\n ownerUid?: string\n firstSeenAtMs: number\n}\n\nexport interface CrmDigestCounts {\n overdue: number\n today: number\n leads: number\n /**\n * The member's leads automated email is reaching (AGL-3446) — open, but\n * not owed, so a figure the email states and never a reason to send one.\n */\n nurturing: number\n}\n\n/** One member's morning. */\nexport interface CrmMemberDigest {\n overdue: CrmDigestTask[]\n today: CrmDigestTask[]\n leads: CrmDigestLead[]\n /** Leads in `nurturing`, by the same owner-or-site rule as `leads`. */\n nurturing: CrmDigestLead[]\n}\n\n/**\n * Where a due task stands against the digest's window. Only tasks due\n * BEFORE the window's end are read at all, so `later` names a task the\n * query should not have returned and the bucketing refuses to count.\n */\nexport function crmDigestTaskState(\n dueAtMs: number,\n window: CrmDigestWindow,\n): 'overdue' | 'today' | 'later' {\n if (dueAtMs < window.startMs) return 'overdue'\n if (dueAtMs < window.endMs) return 'today'\n return 'later'\n}\n\n/**\n * Whether a lead is one the digest should name: still `new` — which an\n * absent status reads as, because the capture door stamps none — and first\n * seen at least {@link CRM_DIGEST_LEAD_AGE_MS} ago.\n */\nexport function isUnworkedLead(\n lead: Pick<CrmLeadFields, 'status'> & { firstSeenAtMs?: unknown },\n nowMs: number,\n ageMs: number = CRM_DIGEST_LEAD_AGE_MS,\n): boolean {\n if (crmLeadStatus(lead) !== 'new') return false\n return seenBefore(lead, nowMs - ageMs)\n}\n\n/**\n * Whether a lead is one the digest counts as being nurtured (AGL-3446):\n * a sequence step or a campaign email has reached it and no person has\n * engaged yet. The same age as an unworked lead, so the two figures are\n * read over the same leads — the old ones that would otherwise be owed.\n */\nexport function isNurturingLead(\n lead: Pick<CrmLeadFields, 'status'> & { firstSeenAtMs?: unknown },\n nowMs: number,\n ageMs: number = CRM_DIGEST_LEAD_AGE_MS,\n): boolean {\n return crmLeadStatus(lead) === 'nurturing' && seenBefore(lead, nowMs - ageMs)\n}\n\nfunction seenBefore(lead: { firstSeenAtMs?: unknown }, cutoffMs: number): boolean {\n const firstSeen = Number(lead.firstSeenAtMs)\n return Number.isFinite(firstSeen) && firstSeen > 0 && firstSeen <= cutoffMs\n}\n\n/**\n * Whether a lead is THIS member's to work: theirs by owner, or nobody's on\n * a site they reach. An owned lead is one person's; an unowned one is on\n * everybody's list until somebody takes it, which is what the digest is for.\n */\nexport function leadIsForMember(\n lead: Pick<CrmDigestLead, 'ownerUid' | 'hostId'>,\n member: { uid: string; reachesHost: (hostId: string) => boolean },\n): boolean {\n if (lead.ownerUid) return lead.ownerUid === member.uid\n return member.reachesHost(lead.hostId)\n}\n\n/**\n * Whether the org's plan carries the CRM (AGL-2611's `features.crm`).\n *\n * Read by key rather than through the typed flag map so this module does not\n * depend on the key existing: the flag lands with the CRM's gate, and a\n * plan that has not declared it is a plan that does not include it — a\n * digest about a surface the plan cannot open would be a reminder to pay.\n */\nexport function crmDigestEntitled(\n features: Record<string, boolean | undefined> | null | undefined,\n): boolean {\n return features?.['crm'] === true\n}\n\n/**\n * Every member's digest, from what the org's sweep read.\n *\n * A task goes to its assignee and to nobody else — an unassigned task is a\n * deliberate choice in the drawer, and an assignee no longer on the roster\n * is a task nobody owes. A lead goes to its owner, or to every member who\n * reaches the site when it has none. Members with nothing owed are simply\n * absent from the result, which is what makes \"per member with open work\"\n * cost nothing for everyone else — and a nurturing lead is not owed, so it\n * joins a digest that exists and never starts one.\n */\nexport function buildMemberDigests(input: {\n tasks: readonly CrmDigestTask[]\n leads: readonly CrmDigestLead[]\n nurturing?: readonly CrmDigestLead[]\n members: ReadonlyArray<{ uid: string; reachesHost: (hostId: string) => boolean }>\n window: CrmDigestWindow\n}): Map<string, CrmMemberDigest> {\n const { tasks, leads, nurturing = [], members, window } = input\n const digests = new Map<string, CrmMemberDigest>()\n const digestFor = (uid: string): CrmMemberDigest => {\n let digest = digests.get(uid)\n if (!digest) {\n digest = { overdue: [], today: [], leads: [], nurturing: [] }\n digests.set(uid, digest)\n }\n return digest\n }\n const roster = new Map(members.map((member) => [member.uid, member]))\n for (const task of tasks) {\n if (!task.assigneeUid || !roster.has(task.assigneeUid)) continue\n const state = crmDigestTaskState(task.dueAtMs, window)\n if (state === 'later') continue\n digestFor(task.assigneeUid)[state].push(task)\n }\n for (const lead of leads) {\n for (const member of members) {\n if (leadIsForMember(lead, member)) digestFor(member.uid).leads.push(lead)\n }\n }\n for (const lead of nurturing) {\n for (const member of members) {\n const digest = digests.get(member.uid)\n if (digest && leadIsForMember(lead, member)) digest.nurturing.push(lead)\n }\n }\n for (const digest of digests.values()) {\n digest.overdue.sort((a, b) => a.dueAtMs - b.dueAtMs)\n digest.today.sort((a, b) => a.dueAtMs - b.dueAtMs)\n digest.leads.sort((a, b) => a.firstSeenAtMs - b.firstSeenAtMs)\n }\n return digests\n}\n\nexport function crmDigestCounts(digest: CrmMemberDigest): CrmDigestCounts {\n return {\n overdue: digest.overdue.length,\n today: digest.today.length,\n leads: digest.leads.length,\n nurturing: digest.nurturing.length,\n }\n}\n\n/**\n * The line that says the nurturing figure (AGL-3446), or empty when there\n * is none: the leads the unworked list leaves out, and why.\n */\nexport function composeCrmDigestNurturingLine(counts: Pick<CrmDigestCounts, 'nurturing'>): string {\n if (counts.nurturing <= 0) return ''\n return counts.nurturing === 1\n ? '1 lead is in sequences or campaigns.'\n : `${counts.nurturing} leads are in sequences or campaigns.`\n}\n\nconst plural = (count: number, noun: string): string =>\n `${count} ${noun}${count === 1 ? '' : 's'}`\n\n/**\n * The one line the notification is: \"3 tasks due today, 2 overdue, 4\n * unworked leads\". Segments that are zero are left out rather than read as\n * \"0 overdue\" — the line is what is owed, and nothing owed is no segment.\n */\nexport function composeCrmDigestSummary(\n counts: Pick<CrmDigestCounts, 'overdue' | 'today' | 'leads'>,\n): string {\n const segments: string[] = []\n if (counts.today > 0) segments.push(`${plural(counts.today, 'task')} due today`)\n if (counts.overdue > 0) {\n segments.push(\n counts.today > 0 ? `${counts.overdue} overdue` : `${plural(counts.overdue, 'task')} overdue`,\n )\n }\n if (counts.leads > 0) segments.push(`${plural(counts.leads, 'unworked lead')}`)\n return segments.join(', ')\n}\n\n/**\n * The digest's subject. It names the workspace when it has one: the digest\n * is sent per workspace, and a member of two would otherwise get two mails\n * that read the same.\n */\nexport function composeCrmDigestSubject(\n counts: Pick<CrmDigestCounts, 'overdue' | 'today' | 'leads'>,\n workspaceName?: string,\n): string {\n const workspace = workspaceName?.trim()\n return `Your ${workspace ? `${workspace} ` : ''}CRM today: ${composeCrmDigestSummary(counts)}`\n}\n\n/** What the digest email needs from the route besides the digest itself. */\nexport interface CrmDigestEmailInput {\n digest: CrmMemberDigest\n nowMs: number\n timeZone: string\n /** The product name the mail reads as — the org's brand when white-labeled. */\n productName: string\n /**\n * The workspace the digest is for. The system email shows only the body,\n * so the body names it: a member of two workspaces gets one digest each.\n */\n workspaceName?: string\n /** The Tasks section, on the site the member's first task belongs to. */\n tasksUrl: string\n /** The Leads section of a site, for the sites that have unworked leads. */\n leadsUrl: (hostId: string) => string\n /** Account settings → Notifications, where the digest can be switched off. */\n settingsUrl: string\n /** A site's name for a lead line; the id when the site is unknown. */\n hostName: (hostId: string) => string\n /** The brand's support line, already prefixed, or empty. */\n supportLine?: string\n /**\n * Leave out the closing paragraph and the support line (AGL-3367), for the\n * copy that goes inside a system email: its footer says why the mail came\n * and where to get help, and its body links the settings page.\n */\n omitClosing?: boolean\n}\n\n/** A task's due time, with the zone named: it is the digest's, not the reader's. */\nfunction whenInZone(ms: number, timeZone: string): string {\n return new Date(ms).toLocaleString('en-US', {\n timeZone,\n weekday: 'short',\n month: 'short',\n day: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n timeZoneName: 'short',\n })\n}\n\nfunction dateInZone(ms: number, timeZone: string): string {\n return new Date(ms).toLocaleDateString('en-US', {\n timeZone,\n month: 'short',\n day: 'numeric',\n })\n}\n\nfunction section(heading: string, lines: string[]): string[] {\n const shown = lines.slice(0, CRM_DIGEST_LIST_MAX)\n const rest = lines.length - shown.length\n return [\n `${heading} (${lines.length})`,\n ...shown.map((line) => `- ${line}`),\n ...(rest > 0 ? [` and ${rest} more`] : []),\n '',\n ]\n}\n\n/**\n * The plain-text email. Text rather than a designed template because the\n * body is a list, and `sendEmail` synthesizes the HTML part that makes its\n * links live. Overdue first — it is the section a reader acts on — then\n * today, then the leads, each capped and counted, and the links last.\n */\nexport function composeCrmDigestEmailText(input: CrmDigestEmailInput): string {\n const { digest, nowMs, timeZone, productName } = input\n const counts = crmDigestCounts(digest)\n const day = new Date(nowMs).toLocaleDateString('en-US', {\n timeZone,\n weekday: 'long',\n month: 'long',\n day: 'numeric',\n })\n const workspace = input.workspaceName?.trim()\n const lines: string[] = [\n `Here is your ${productName} CRM${workspace ? ` in ${workspace}` : ''} for ${day}: ` +\n `${composeCrmDigestSummary(counts)}.`,\n '',\n ]\n // A task's site, as the lead lines name theirs; an organization task\n // (AGL-2637) has none.\n const taskLine = (task: CrmDigestTask) =>\n [task.title, task.hostId ? input.hostName(task.hostId) : '', whenInZone(task.dueAtMs, timeZone)]\n .filter(Boolean)\n .join(' · ')\n if (digest.overdue.length) {\n lines.push(...section('Overdue', digest.overdue.map(taskLine)))\n }\n if (digest.today.length) {\n lines.push(...section('Due today', digest.today.map(taskLine)))\n }\n if (digest.leads.length) {\n lines.push(\n ...section(\n 'Unworked leads',\n digest.leads.map((lead) => {\n const who = lead.name ? `${lead.name} <${lead.email}>` : lead.email\n return `${who} · ${input.hostName(lead.hostId)} · first seen ${dateInZone(\n lead.firstSeenAtMs,\n timeZone,\n )}`\n }),\n ),\n )\n }\n const nurturingLine = composeCrmDigestNurturingLine(counts)\n if (nurturingLine) lines.push(nurturingLine, '')\n if (counts.overdue + counts.today > 0) lines.push(`Open your tasks: ${input.tasksUrl}`)\n const leadHosts = [...new Set(digest.leads.map((lead) => lead.hostId))].slice(0, 3)\n for (const hostId of leadHosts) {\n lines.push(`Leads on ${input.hostName(hostId)}: ${input.leadsUrl(hostId)}`)\n }\n if (input.omitClosing) return lines.join('\\n').trimEnd()\n lines.push(\n '',\n 'You get this each morning because the Daily CRM digest is on in your ' +\n `notification settings: ${input.settingsUrl}`,\n )\n return lines.join('\\n') + (input.supportLine ?? '')\n}\n"],"names":["startOfNextZonedDay","startOfZonedDay","crmLeadStatus","CRM_DIGEST_DEFAULT_TIME_ZONE","digestTimeZone","env","process","configured","String","trim","Intl","DateTimeFormat","timeZone","console","warn","CRM_DIGEST_LEAD_AGE_MS","CRM_DIGEST_TASK_CEILING","CRM_DIGEST_LEAD_WINDOW","CRM_DIGEST_LIST_MAX","startOfDayInZone","ms","startOfNextDayInZone","utcDayKey","Date","toISOString","slice","crmDigestWindow","nowMs","startMs","endMs","crmDigestTaskState","dueAtMs","window","isUnworkedLead","lead","ageMs","seenBefore","isNurturingLead","cutoffMs","firstSeen","Number","firstSeenAtMs","isFinite","leadIsForMember","member","ownerUid","uid","reachesHost","hostId","crmDigestEntitled","features","buildMemberDigests","input","tasks","leads","nurturing","members","digests","Map","digestFor","digest","get","overdue","today","set","roster","map","task","assigneeUid","has","state","push","values","sort","a","b","crmDigestCounts","length","composeCrmDigestNurturingLine","counts","plural","count","noun","composeCrmDigestSummary","segments","join","composeCrmDigestSubject","workspaceName","workspace","whenInZone","toLocaleString","weekday","month","day","hour","minute","timeZoneName","dateInZone","toLocaleDateString","section","heading","lines","shown","rest","line","composeCrmDigestEmailText","productName","taskLine","title","hostName","filter","Boolean","who","name","email","nurturingLine","tasksUrl","leadHosts","Set","leadsUrl","omitClosing","trimEnd","settingsUrl","supportLine"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;CAqBC,GAED,SACEA,mBAAmB,EACnBC,eAAe,QACV,0CAAyC;AAChD,SAA2CC,aAAa,QAAQ,6BAA4B;AAE5F;;;;;;;;;CASC,GACD,OAAO,MAAMC,+BAA+B,kBAAiB;AAE7D;;;;;;;CAOC,GACD,OAAO,SAASC,eAAeC,MAAyBC,QAAQD,GAAG;QACvCA;IAA1B,MAAME,aAAaC,QAAOH,4BAAAA,GAAG,CAAC,uBAAuB,YAA3BA,4BAA+B,IAAII,IAAI;IACjE,IAAI,CAACF,YAAY,OAAOJ;IACxB,IAAI;QACF,IAAIO,KAAKC,cAAc,CAAC,SAAS;YAAEC,UAAUL;QAAW;QACxD,OAAOA;IACT,EAAE,eAAM;QACNM,QAAQC,IAAI,CACV,CAAC,wCAAwC,EAAEP,WAAW,kBAAkB,CAAC,GACvE,CAAC,MAAM,EAAEJ,8BAA8B;QAE3C,OAAOA;IACT;AACF;AAEA,wEAAwE,GACxE,OAAO,MAAMY,yBAAyB,IAAI,KAAK,KAAK,KAAK,KAAI;AAE7D;;;;;;;CAOC,GACD,OAAO,MAAMC,0BAA0B,IAAG;AAE1C;;;;;;;;CAQC,GACD,OAAO,MAAMC,yBAAyB,IAAG;AAEzC,uEAAuE,GACvE,OAAO,MAAMC,sBAAsB,GAAE;AAErC;;;;;;2CAM2C,GAE3C,4EAA4E,GAC5E,OAAO,SAASC,iBAAiBC,EAAU,EAAER,QAAgB;IAC3D,OAAOX,gBAAgBmB,IAAIR;AAC7B;AAEA;;;;CAIC,GACD,OAAO,SAASS,qBAAqBD,EAAU,EAAER,QAAgB;IAC/D,OAAOZ,oBAAoBoB,IAAIR;AACjC;AAEA,8EAA8E,GAC9E,OAAO,SAASU,UAAUF,EAAU;IAClC,OAAO,IAAIG,KAAKH,IAAII,WAAW,GAAGC,KAAK,CAAC,GAAG;AAC7C;AAQA,OAAO,SAASC,gBAAgBC,KAAa,EAAEf,QAAgB;IAC7D,OAAO;QACLgB,SAAST,iBAAiBQ,OAAOf;QACjCiB,OAAOR,qBAAqBM,OAAOf;IACrC;AACF;AA8CA;;;;CAIC,GACD,OAAO,SAASkB,mBACdC,OAAe,EACfC,MAAuB;IAEvB,IAAID,UAAUC,OAAOJ,OAAO,EAAE,OAAO;IACrC,IAAIG,UAAUC,OAAOH,KAAK,EAAE,OAAO;IACnC,OAAO;AACT;AAEA;;;;CAIC,GACD,OAAO,SAASI,eACdC,IAAiE,EACjEP,KAAa,EACbQ,QAAgBpB,sBAAsB;IAEtC,IAAIb,cAAcgC,UAAU,OAAO,OAAO;IAC1C,OAAOE,WAAWF,MAAMP,QAAQQ;AAClC;AAEA;;;;;CAKC,GACD,OAAO,SAASE,gBACdH,IAAiE,EACjEP,KAAa,EACbQ,QAAgBpB,sBAAsB;IAEtC,OAAOb,cAAcgC,UAAU,eAAeE,WAAWF,MAAMP,QAAQQ;AACzE;AAEA,SAASC,WAAWF,IAAiC,EAAEI,QAAgB;IACrE,MAAMC,YAAYC,OAAON,KAAKO,aAAa;IAC3C,OAAOD,OAAOE,QAAQ,CAACH,cAAcA,YAAY,KAAKA,aAAaD;AACrE;AAEA;;;;CAIC,GACD,OAAO,SAASK,gBACdT,IAAgD,EAChDU,MAAiE;IAEjE,IAAIV,KAAKW,QAAQ,EAAE,OAAOX,KAAKW,QAAQ,KAAKD,OAAOE,GAAG;IACtD,OAAOF,OAAOG,WAAW,CAACb,KAAKc,MAAM;AACvC;AAEA;;;;;;;CAOC,GACD,OAAO,SAASC,kBACdC,QAAgE;IAEhE,OAAOA,CAAAA,4BAAAA,QAAU,CAAC,MAAM,MAAK;AAC/B;AAEA;;;;;;;;;;CAUC,GACD,OAAO,SAASC,mBAAmBC,KAMlC;IACC,MAAM,EAAEC,KAAK,EAAEC,KAAK,EAAEC,YAAY,EAAE,EAAEC,OAAO,EAAExB,MAAM,EAAE,GAAGoB;IAC1D,MAAMK,UAAU,IAAIC;IACpB,MAAMC,YAAY,CAACb;QACjB,IAAIc,SAASH,QAAQI,GAAG,CAACf;QACzB,IAAI,CAACc,QAAQ;YACXA,SAAS;gBAAEE,SAAS,EAAE;gBAAEC,OAAO,EAAE;gBAAET,OAAO,EAAE;gBAAEC,WAAW,EAAE;YAAC;YAC5DE,QAAQO,GAAG,CAAClB,KAAKc;QACnB;QACA,OAAOA;IACT;IACA,MAAMK,SAAS,IAAIP,IAAIF,QAAQU,GAAG,CAAC,CAACtB,SAAW;YAACA,OAAOE,GAAG;YAAEF;SAAO;IACnE,KAAK,MAAMuB,QAAQd,MAAO;QACxB,IAAI,CAACc,KAAKC,WAAW,IAAI,CAACH,OAAOI,GAAG,CAACF,KAAKC,WAAW,GAAG;QACxD,MAAME,QAAQxC,mBAAmBqC,KAAKpC,OAAO,EAAEC;QAC/C,IAAIsC,UAAU,SAAS;QACvBX,UAAUQ,KAAKC,WAAW,CAAC,CAACE,MAAM,CAACC,IAAI,CAACJ;IAC1C;IACA,KAAK,MAAMjC,QAAQoB,MAAO;QACxB,KAAK,MAAMV,UAAUY,QAAS;YAC5B,IAAIb,gBAAgBT,MAAMU,SAASe,UAAUf,OAAOE,GAAG,EAAEQ,KAAK,CAACiB,IAAI,CAACrC;QACtE;IACF;IACA,KAAK,MAAMA,QAAQqB,UAAW;QAC5B,KAAK,MAAMX,UAAUY,QAAS;YAC5B,MAAMI,SAASH,QAAQI,GAAG,CAACjB,OAAOE,GAAG;YACrC,IAAIc,UAAUjB,gBAAgBT,MAAMU,SAASgB,OAAOL,SAAS,CAACgB,IAAI,CAACrC;QACrE;IACF;IACA,KAAK,MAAM0B,UAAUH,QAAQe,MAAM,GAAI;QACrCZ,OAAOE,OAAO,CAACW,IAAI,CAAC,CAACC,GAAGC,IAAMD,EAAE3C,OAAO,GAAG4C,EAAE5C,OAAO;QACnD6B,OAAOG,KAAK,CAACU,IAAI,CAAC,CAACC,GAAGC,IAAMD,EAAE3C,OAAO,GAAG4C,EAAE5C,OAAO;QACjD6B,OAAON,KAAK,CAACmB,IAAI,CAAC,CAACC,GAAGC,IAAMD,EAAEjC,aAAa,GAAGkC,EAAElC,aAAa;IAC/D;IACA,OAAOgB;AACT;AAEA,OAAO,SAASmB,gBAAgBhB,MAAuB;IACrD,OAAO;QACLE,SAASF,OAAOE,OAAO,CAACe,MAAM;QAC9Bd,OAAOH,OAAOG,KAAK,CAACc,MAAM;QAC1BvB,OAAOM,OAAON,KAAK,CAACuB,MAAM;QAC1BtB,WAAWK,OAAOL,SAAS,CAACsB,MAAM;IACpC;AACF;AAEA;;;CAGC,GACD,OAAO,SAASC,8BAA8BC,MAA0C;IACtF,IAAIA,OAAOxB,SAAS,IAAI,GAAG,OAAO;IAClC,OAAOwB,OAAOxB,SAAS,KAAK,IACxB,yCACA,GAAGwB,OAAOxB,SAAS,CAAC,qCAAqC,CAAC;AAChE;AAEA,MAAMyB,SAAS,CAACC,OAAeC,OAC7B,GAAGD,MAAM,CAAC,EAAEC,OAAOD,UAAU,IAAI,KAAK,KAAK;AAE7C;;;;CAIC,GACD,OAAO,SAASE,wBACdJ,MAA4D;IAE5D,MAAMK,WAAqB,EAAE;IAC7B,IAAIL,OAAOhB,KAAK,GAAG,GAAGqB,SAASb,IAAI,CAAC,GAAGS,OAAOD,OAAOhB,KAAK,EAAE,QAAQ,UAAU,CAAC;IAC/E,IAAIgB,OAAOjB,OAAO,GAAG,GAAG;QACtBsB,SAASb,IAAI,CACXQ,OAAOhB,KAAK,GAAG,IAAI,GAAGgB,OAAOjB,OAAO,CAAC,QAAQ,CAAC,GAAG,GAAGkB,OAAOD,OAAOjB,OAAO,EAAE,QAAQ,QAAQ,CAAC;IAEhG;IACA,IAAIiB,OAAOzB,KAAK,GAAG,GAAG8B,SAASb,IAAI,CAAC,GAAGS,OAAOD,OAAOzB,KAAK,EAAE,kBAAkB;IAC9E,OAAO8B,SAASC,IAAI,CAAC;AACvB;AAEA;;;;CAIC,GACD,OAAO,SAASC,wBACdP,MAA4D,EAC5DQ,aAAsB;IAEtB,MAAMC,YAAYD,iCAAAA,cAAe9E,IAAI;IACrC,OAAO,CAAC,KAAK,EAAE+E,YAAY,GAAGA,UAAU,CAAC,CAAC,GAAG,GAAG,WAAW,EAAEL,wBAAwBJ,SAAS;AAChG;AAgCA,kFAAkF,GAClF,SAASU,WAAWrE,EAAU,EAAER,QAAgB;IAC9C,OAAO,IAAIW,KAAKH,IAAIsE,cAAc,CAAC,SAAS;QAC1C9E;QACA+E,SAAS;QACTC,OAAO;QACPC,KAAK;QACLC,MAAM;QACNC,QAAQ;QACRC,cAAc;IAChB;AACF;AAEA,SAASC,WAAW7E,EAAU,EAAER,QAAgB;IAC9C,OAAO,IAAIW,KAAKH,IAAI8E,kBAAkB,CAAC,SAAS;QAC9CtF;QACAgF,OAAO;QACPC,KAAK;IACP;AACF;AAEA,SAASM,QAAQC,OAAe,EAAEC,KAAe;IAC/C,MAAMC,QAAQD,MAAM5E,KAAK,CAAC,GAAGP;IAC7B,MAAMqF,OAAOF,MAAMxB,MAAM,GAAGyB,MAAMzB,MAAM;IACxC,OAAO;QACL,GAAGuB,QAAQ,EAAE,EAAEC,MAAMxB,MAAM,CAAC,CAAC,CAAC;WAC3ByB,MAAMpC,GAAG,CAAC,CAACsC,OAAS,CAAC,EAAE,EAAEA,MAAM;WAC9BD,OAAO,IAAI;YAAC,CAAC,MAAM,EAAEA,KAAK,KAAK,CAAC;SAAC,GAAG,EAAE;QAC1C;KACD;AACH;AAEA;;;;;CAKC,GACD,OAAO,SAASE,0BAA0BrD,KAA0B;QAsDvCA;QA7CTA;IARlB,MAAM,EAAEQ,MAAM,EAAEjC,KAAK,EAAEf,QAAQ,EAAE8F,WAAW,EAAE,GAAGtD;IACjD,MAAM2B,SAASH,gBAAgBhB;IAC/B,MAAMiC,MAAM,IAAItE,KAAKI,OAAOuE,kBAAkB,CAAC,SAAS;QACtDtF;QACA+E,SAAS;QACTC,OAAO;QACPC,KAAK;IACP;IACA,MAAML,aAAYpC,uBAAAA,MAAMmC,aAAa,qBAAnBnC,qBAAqB3C,IAAI;IAC3C,MAAM4F,QAAkB;QACtB,CAAC,aAAa,EAAEK,YAAY,IAAI,EAAElB,YAAY,CAAC,IAAI,EAAEA,WAAW,GAAG,GAAG,KAAK,EAAEK,IAAI,EAAE,CAAC,GAClF,GAAGV,wBAAwBJ,QAAQ,CAAC,CAAC;QACvC;KACD;IACD,qEAAqE;IACrE,uBAAuB;IACvB,MAAM4B,WAAW,CAACxC,OAChB;YAACA,KAAKyC,KAAK;YAAEzC,KAAKnB,MAAM,GAAGI,MAAMyD,QAAQ,CAAC1C,KAAKnB,MAAM,IAAI;YAAIyC,WAAWtB,KAAKpC,OAAO,EAAEnB;SAAU,CAC7FkG,MAAM,CAACC,SACP1B,IAAI,CAAC;IACV,IAAIzB,OAAOE,OAAO,CAACe,MAAM,EAAE;QACzBwB,MAAM9B,IAAI,IAAI4B,QAAQ,WAAWvC,OAAOE,OAAO,CAACI,GAAG,CAACyC;IACtD;IACA,IAAI/C,OAAOG,KAAK,CAACc,MAAM,EAAE;QACvBwB,MAAM9B,IAAI,IAAI4B,QAAQ,aAAavC,OAAOG,KAAK,CAACG,GAAG,CAACyC;IACtD;IACA,IAAI/C,OAAON,KAAK,CAACuB,MAAM,EAAE;QACvBwB,MAAM9B,IAAI,IACL4B,QACD,kBACAvC,OAAON,KAAK,CAACY,GAAG,CAAC,CAAChC;YAChB,MAAM8E,MAAM9E,KAAK+E,IAAI,GAAG,GAAG/E,KAAK+E,IAAI,CAAC,EAAE,EAAE/E,KAAKgF,KAAK,CAAC,CAAC,CAAC,GAAGhF,KAAKgF,KAAK;YACnE,OAAO,GAAGF,IAAI,GAAG,EAAE5D,MAAMyD,QAAQ,CAAC3E,KAAKc,MAAM,EAAE,cAAc,EAAEiD,WAC7D/D,KAAKO,aAAa,EAClB7B,WACC;QACL;IAGN;IACA,MAAMuG,gBAAgBrC,8BAA8BC;IACpD,IAAIoC,eAAed,MAAM9B,IAAI,CAAC4C,eAAe;IAC7C,IAAIpC,OAAOjB,OAAO,GAAGiB,OAAOhB,KAAK,GAAG,GAAGsC,MAAM9B,IAAI,CAAC,CAAC,iBAAiB,EAAEnB,MAAMgE,QAAQ,EAAE;IACtF,MAAMC,YAAY;WAAI,IAAIC,IAAI1D,OAAON,KAAK,CAACY,GAAG,CAAC,CAAChC,OAASA,KAAKc,MAAM;KAAG,CAACvB,KAAK,CAAC,GAAG;IACjF,KAAK,MAAMuB,UAAUqE,UAAW;QAC9BhB,MAAM9B,IAAI,CAAC,CAAC,SAAS,EAAEnB,MAAMyD,QAAQ,CAAC7D,QAAQ,EAAE,EAAEI,MAAMmE,QAAQ,CAACvE,SAAS;IAC5E;IACA,IAAII,MAAMoE,WAAW,EAAE,OAAOnB,MAAMhB,IAAI,CAAC,MAAMoC,OAAO;IACtDpB,MAAM9B,IAAI,CACR,IACA,0EACE,CAAC,uBAAuB,EAAEnB,MAAMsE,WAAW,EAAE;IAEjD,OAAOrB,MAAMhB,IAAI,CAAC,UAASjC,qBAAAA,MAAMuE,WAAW,YAAjBvE,qBAAqB;AAClD"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/crm-digest.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The daily CRM digest (AGL-2619): what one member is owed this morning,\n * computed from the tasks and leads a scheduled route has read.\n *\n * \"Overdue\" and \"due today\" are read off the clock at read time by design\n * (AGL-2599) — nothing stamps them — which is why nothing ever told anybody.\n * The digest is the one reader that runs on a schedule: once a day it\n * counts, per org and per member, the open tasks whose due date has passed\n * or falls today and the leads nobody has worked, and says so once, in the\n * console and by mail. Every judgment here is pure so the route that sends\n * and the spec that proves it read the same arithmetic.\n *\n * ## Whose day\n *\n * A task's \"today\" on screen is the READER's calendar day, from their own\n * clock. A server has no reader, so the digest reads the day in ONE zone —\n * {@link CRM_DIGEST_DEFAULT_TIME_ZONE}, the zone the schedule was chosen for\n * (08:00 there) — taken as a parameter so an org-level setting can supply\n * its own the day one exists. The idempotence key is the UTC day\n * ({@link utcDayKey}), which is a fact about the run and not about anybody's\n * morning.\n */\n\nimport {\n startOfNextZonedDay,\n startOfZonedDay,\n} from '@aglyn/shared-util-timestamp/zoned-time'\nimport { type CrmLeadFields, type CrmTask, crmLeadStatus } from '@aglyn/aglyn/app-utils/crm'\n\n/**\n * The zone the digest's \"today\" is read in when nothing else names one.\n *\n * The schedule fires at 13:00 UTC, which is 08:00 here, so the window this\n * zone draws is the morning the mail arrives in for the people the product\n * is built with. A member elsewhere reads a window that is at most a few\n * hours off their own, which is what a morning summary can tolerate and a\n * \"due at 9:00\" reminder could not — that reminder is the assignment\n * notification's job, not this one's.\n */\nexport const CRM_DIGEST_DEFAULT_TIME_ZONE = 'America/Chicago'\n\n/**\n * The zone the digest's \"today\" is drawn in, and the zone every CRM mail\n * and notification prints a task's time in.\n *\n * `CRM_DIGEST_TIME_ZONE` overrides the default for a self-hosted console\n * whose team wakes up elsewhere; a value `Intl` does not know falls back to\n * the default with a warning rather than throwing the whole run away.\n */\nexport function digestTimeZone(env: NodeJS.ProcessEnv = process.env): string {\n const configured = String(env['CRM_DIGEST_TIME_ZONE'] ?? '').trim()\n if (!configured) return CRM_DIGEST_DEFAULT_TIME_ZONE\n try {\n new Intl.DateTimeFormat('en-US', { timeZone: configured })\n return configured\n } catch {\n console.warn(\n `crm-daily-digest: CRM_DIGEST_TIME_ZONE \"${configured}\" is not a zone — ` +\n `using ${CRM_DIGEST_DEFAULT_TIME_ZONE}`,\n )\n return CRM_DIGEST_DEFAULT_TIME_ZONE\n }\n}\n\n/** How long a `new` lead may sit untouched before the digest names it. */\nexport const CRM_DIGEST_LEAD_AGE_MS = 2 * 24 * 60 * 60 * 1000\n\n/**\n * The most due tasks one org's sweep reads.\n *\n * One query per org, ordered soonest-due first, so an org with more than this\n * many overdue-or-today tasks digests the oldest five hundred and reports the\n * window as full rather than reading without bound. Five hundred is an org\n * whose problem is not that nobody reminded them.\n */\nexport const CRM_DIGEST_TASK_CEILING = 500\n\n/**\n * The most old leads one site's sweep reads.\n *\n * The same shape the Leads section uses, for the same reason it gives: a\n * lead the capture door wrote carries no `status` field, and Firestore\n * cannot select on a field's absence, so \"unworked\" is a filter over a\n * loaded window rather than a query. The window is the newest old leads by\n * first sighting, which is where the ones still worth a call are.\n */\nexport const CRM_DIGEST_LEAD_WINDOW = 200\n\n/** How many items one section of the email lists before \"and N more\". */\nexport const CRM_DIGEST_LIST_MAX = 10\n\n/*==========================================\n * CALENDAR DAYS IN A NAMED ZONE\n *\n * The zone math lives in `@aglyn/shared-util-timestamp/zoned-time`, where\n * booking slots and sending windows read it as well; the digest route and\n * its spec call it by the names below.\n *=========================================*/\n\n/** Midnight at the start of the calendar day `ms` falls in, in `timeZone`. */\nexport function startOfDayInZone(ms: number, timeZone: string): number {\n return startOfZonedDay(ms, timeZone)\n}\n\n/**\n * Midnight at the start of the NEXT calendar day in `timeZone` — built from\n * the calendar rather than by adding 24 hours, for the reason the tasks\n * section gives: a day a clock change falls in is 23 or 25 hours long.\n */\nexport function startOfNextDayInZone(ms: number, timeZone: string): number {\n return startOfNextZonedDay(ms, timeZone)\n}\n\n/** `YYYY-MM-DD` of the UTC day `ms` falls in — the digest's idempotence key. */\nexport function utcDayKey(ms: number): string {\n return new Date(ms).toISOString().slice(0, 10)\n}\n\n/** The half-open window `[start of today, start of tomorrow)` in a zone. */\nexport interface CrmDigestWindow {\n startMs: number\n endMs: number\n}\n\nexport function crmDigestWindow(nowMs: number, timeZone: string): CrmDigestWindow {\n return {\n startMs: startOfDayInZone(nowMs, timeZone),\n endMs: startOfNextDayInZone(nowMs, timeZone),\n }\n}\n\n/*==========================================\n * WHAT A DIGEST IS MADE OF\n *=========================================*/\n\n/** A due task as the digest carries it: the fields a line and a link need. */\nexport interface CrmDigestTask\n extends Pick<CrmTask, 'title' | 'kind' | 'contactId' | 'companyId' | 'dealId'> {\n id: string\n dueAtMs: number\n assigneeUid: string\n /** The site the task was created on — where its link opens. */\n hostId: string\n}\n\n/** An unworked lead as the digest carries it. */\nexport interface CrmDigestLead {\n id: string\n hostId: string\n email: string\n name?: string\n ownerUid?: string\n firstSeenAtMs: number\n}\n\nexport interface CrmDigestCounts {\n overdue: number\n today: number\n leads: number\n /**\n * The member's leads automated email is reaching (AGL-3446) — open, but\n * not owed, so a figure the email states and never a reason to send one.\n */\n nurturing: number\n}\n\n/** One member's morning. */\nexport interface CrmMemberDigest {\n overdue: CrmDigestTask[]\n today: CrmDigestTask[]\n leads: CrmDigestLead[]\n /** Leads in `nurturing`, by the same owner-or-site rule as `leads`. */\n nurturing: CrmDigestLead[]\n}\n\n/**\n * Where a due task stands against the digest's window. Only tasks due\n * BEFORE the window's end are read at all, so `later` names a task the\n * query should not have returned and the bucketing refuses to count.\n */\nexport function crmDigestTaskState(\n dueAtMs: number,\n window: CrmDigestWindow,\n): 'overdue' | 'today' | 'later' {\n if (dueAtMs < window.startMs) return 'overdue'\n if (dueAtMs < window.endMs) return 'today'\n return 'later'\n}\n\n/**\n * Whether a lead is one the digest should name: still `new` — which an\n * absent status reads as, because the capture door stamps none — and first\n * seen at least {@link CRM_DIGEST_LEAD_AGE_MS} ago.\n */\nexport function isUnworkedLead(\n lead: Pick<CrmLeadFields, 'status'> & { firstSeenAtMs?: unknown },\n nowMs: number,\n ageMs: number = CRM_DIGEST_LEAD_AGE_MS,\n): boolean {\n if (crmLeadStatus(lead) !== 'new') return false\n return seenBefore(lead, nowMs - ageMs)\n}\n\n/**\n * Whether a lead is one the digest counts as being nurtured (AGL-3446):\n * a sequence step or a campaign email has reached it and no person has\n * engaged yet. The same age as an unworked lead, so the two figures are\n * read over the same leads — the old ones that would otherwise be owed.\n */\nexport function isNurturingLead(\n lead: Pick<CrmLeadFields, 'status'> & { firstSeenAtMs?: unknown },\n nowMs: number,\n ageMs: number = CRM_DIGEST_LEAD_AGE_MS,\n): boolean {\n return crmLeadStatus(lead) === 'nurturing' && seenBefore(lead, nowMs - ageMs)\n}\n\nfunction seenBefore(lead: { firstSeenAtMs?: unknown }, cutoffMs: number): boolean {\n const firstSeen = Number(lead.firstSeenAtMs)\n return Number.isFinite(firstSeen) && firstSeen > 0 && firstSeen <= cutoffMs\n}\n\n/**\n * Whether a lead is THIS member's to work: theirs by owner, or nobody's on\n * a site they reach. An owned lead is one person's; an unowned one is on\n * everybody's list until somebody takes it, which is what the digest is for.\n */\nexport function leadIsForMember(\n lead: Pick<CrmDigestLead, 'ownerUid' | 'hostId'>,\n member: { uid: string; reachesHost: (hostId: string) => boolean },\n): boolean {\n if (lead.ownerUid) return lead.ownerUid === member.uid\n return member.reachesHost(lead.hostId)\n}\n\n/**\n * Whether the org's plan carries the CRM (AGL-2611's `features.crm`).\n *\n * Read by key rather than through the typed flag map so this module does not\n * depend on the key existing: the flag lands with the CRM's gate, and a\n * plan that has not declared it is a plan that does not include it — a\n * digest about a surface the plan cannot open would be a reminder to pay.\n */\nexport function crmDigestEntitled(\n features: Record<string, boolean | undefined> | null | undefined,\n): boolean {\n return features?.['crm'] === true\n}\n\n/**\n * Every member's digest, from what the org's sweep read.\n *\n * A task goes to its assignee and to nobody else — an unassigned task is a\n * deliberate choice in the drawer, and an assignee no longer on the roster\n * is a task nobody owes. A lead goes to its owner, or to every member who\n * reaches the site when it has none. Members with nothing owed are simply\n * absent from the result, which is what makes \"per member with open work\"\n * cost nothing for everyone else — and a nurturing lead is not owed, so it\n * joins a digest that exists and never starts one.\n */\nexport function buildMemberDigests(input: {\n tasks: readonly CrmDigestTask[]\n leads: readonly CrmDigestLead[]\n nurturing?: readonly CrmDigestLead[]\n members: ReadonlyArray<{ uid: string; reachesHost: (hostId: string) => boolean }>\n window: CrmDigestWindow\n}): Map<string, CrmMemberDigest> {\n const { tasks, leads, nurturing = [], members, window } = input\n const digests = new Map<string, CrmMemberDigest>()\n const digestFor = (uid: string): CrmMemberDigest => {\n let digest = digests.get(uid)\n if (!digest) {\n digest = { overdue: [], today: [], leads: [], nurturing: [] }\n digests.set(uid, digest)\n }\n return digest\n }\n const roster = new Map(members.map((member) => [member.uid, member]))\n for (const task of tasks) {\n if (!task.assigneeUid || !roster.has(task.assigneeUid)) continue\n const state = crmDigestTaskState(task.dueAtMs, window)\n if (state === 'later') continue\n digestFor(task.assigneeUid)[state].push(task)\n }\n for (const lead of leads) {\n for (const member of members) {\n if (leadIsForMember(lead, member)) digestFor(member.uid).leads.push(lead)\n }\n }\n for (const lead of nurturing) {\n for (const member of members) {\n const digest = digests.get(member.uid)\n if (digest && leadIsForMember(lead, member)) digest.nurturing.push(lead)\n }\n }\n for (const digest of digests.values()) {\n digest.overdue.sort((a, b) => a.dueAtMs - b.dueAtMs)\n digest.today.sort((a, b) => a.dueAtMs - b.dueAtMs)\n digest.leads.sort((a, b) => a.firstSeenAtMs - b.firstSeenAtMs)\n }\n return digests\n}\n\nexport function crmDigestCounts(digest: CrmMemberDigest): CrmDigestCounts {\n return {\n overdue: digest.overdue.length,\n today: digest.today.length,\n leads: digest.leads.length,\n nurturing: digest.nurturing.length,\n }\n}\n\n/**\n * The line that says the nurturing figure (AGL-3446), or empty when there\n * is none: the leads the unworked list leaves out, and why.\n */\nexport function composeCrmDigestNurturingLine(counts: Pick<CrmDigestCounts, 'nurturing'>): string {\n if (counts.nurturing <= 0) return ''\n return counts.nurturing === 1\n ? '1 lead is in sequences or campaigns.'\n : `${counts.nurturing} leads are in sequences or campaigns.`\n}\n\nconst plural = (count: number, noun: string): string =>\n `${count} ${noun}${count === 1 ? '' : 's'}`\n\n/**\n * The one line the notification is: \"3 tasks due today, 2 overdue, 4\n * unworked leads\". Segments that are zero are left out rather than read as\n * \"0 overdue\" — the line is what is owed, and nothing owed is no segment.\n */\nexport function composeCrmDigestSummary(\n counts: Pick<CrmDigestCounts, 'overdue' | 'today' | 'leads'>,\n): string {\n const segments: string[] = []\n if (counts.today > 0) segments.push(`${plural(counts.today, 'task')} due today`)\n if (counts.overdue > 0) {\n segments.push(\n counts.today > 0 ? `${counts.overdue} overdue` : `${plural(counts.overdue, 'task')} overdue`,\n )\n }\n if (counts.leads > 0) segments.push(`${plural(counts.leads, 'unworked lead')}`)\n return segments.join(', ')\n}\n\n/**\n * The digest's subject. It names the workspace when it has one: the digest\n * is sent per workspace, and a member of two would otherwise get two mails\n * that read the same.\n */\nexport function composeCrmDigestSubject(\n counts: Pick<CrmDigestCounts, 'overdue' | 'today' | 'leads'>,\n workspaceName?: string,\n): string {\n const workspace = workspaceName?.trim()\n return `Your ${workspace ? `${workspace} ` : ''}CRM today: ${composeCrmDigestSummary(counts)}`\n}\n\n/** What the digest email needs from the route besides the digest itself. */\nexport interface CrmDigestEmailInput {\n digest: CrmMemberDigest\n nowMs: number\n timeZone: string\n /** The product name the mail reads as — the org's brand when white-labeled. */\n productName: string\n /**\n * The workspace the digest is for. The system email shows only the body,\n * so the body names it: a member of two workspaces gets one digest each.\n */\n workspaceName?: string\n /** The Tasks section, on the site the member's first task belongs to. */\n tasksUrl: string\n /** The Leads section of a site, for the sites that have unworked leads. */\n leadsUrl: (hostId: string) => string\n /** Account settings → Notifications, where the digest can be switched off. */\n settingsUrl: string\n /** A site's name for a lead line; the id when the site is unknown. */\n hostName: (hostId: string) => string\n /** The brand's support line, already prefixed, or empty. */\n supportLine?: string\n /**\n * Leave out the closing paragraph and the support line (AGL-3367), for the\n * copy that goes inside a system email: its footer says why the mail came\n * and where to get help, and its body links the settings page.\n */\n omitClosing?: boolean\n}\n\n/** A task's due time, with the zone named: it is the digest's, not the reader's. */\nfunction whenInZone(ms: number, timeZone: string): string {\n return new Date(ms).toLocaleString('en-US', {\n timeZone,\n weekday: 'short',\n month: 'short',\n day: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n timeZoneName: 'short',\n })\n}\n\nfunction dateInZone(ms: number, timeZone: string): string {\n return new Date(ms).toLocaleDateString('en-US', {\n timeZone,\n month: 'short',\n day: 'numeric',\n })\n}\n\nfunction section(heading: string, lines: string[]): string[] {\n const shown = lines.slice(0, CRM_DIGEST_LIST_MAX)\n const rest = lines.length - shown.length\n return [\n `${heading} (${lines.length})`,\n ...shown.map((line) => `- ${line}`),\n ...(rest > 0 ? [` and ${rest} more`] : []),\n '',\n ]\n}\n\n/**\n * The plain-text email. Text rather than a designed template because the\n * body is a list, and `sendEmail` synthesizes the HTML part that makes its\n * links live. Overdue first — it is the section a reader acts on — then\n * today, then the leads, each capped and counted, and the links last.\n */\nexport function composeCrmDigestEmailText(input: CrmDigestEmailInput): string {\n const { digest, nowMs, timeZone, productName } = input\n const counts = crmDigestCounts(digest)\n const day = new Date(nowMs).toLocaleDateString('en-US', {\n timeZone,\n weekday: 'long',\n month: 'long',\n day: 'numeric',\n })\n const workspace = input.workspaceName?.trim()\n const lines: string[] = [\n `Here is your ${productName} CRM${workspace ? ` in ${workspace}` : ''} for ${day}: ` +\n `${composeCrmDigestSummary(counts)}.`,\n '',\n ]\n // A task's site, as the lead lines name theirs; an organization task\n // (AGL-2637) has none.\n const taskLine = (task: CrmDigestTask) =>\n [task.title, task.hostId ? input.hostName(task.hostId) : '', whenInZone(task.dueAtMs, timeZone)]\n .filter(Boolean)\n .join(' · ')\n if (digest.overdue.length) {\n lines.push(...section('Overdue', digest.overdue.map(taskLine)))\n }\n if (digest.today.length) {\n lines.push(...section('Due today', digest.today.map(taskLine)))\n }\n if (digest.leads.length) {\n lines.push(\n ...section(\n 'Unworked leads',\n digest.leads.map((lead) => {\n const who = lead.name ? `${lead.name} <${lead.email}>` : lead.email\n return `${who} · ${input.hostName(lead.hostId)} · first seen ${dateInZone(\n lead.firstSeenAtMs,\n timeZone,\n )}`\n }),\n ),\n )\n }\n const nurturingLine = composeCrmDigestNurturingLine(counts)\n if (nurturingLine) lines.push(nurturingLine, '')\n if (counts.overdue + counts.today > 0) lines.push(`Open your tasks: ${input.tasksUrl}`)\n const leadHosts = [...new Set(digest.leads.map((lead) => lead.hostId))].slice(0, 3)\n for (const hostId of leadHosts) {\n lines.push(`Leads on ${input.hostName(hostId)}: ${input.leadsUrl(hostId)}`)\n }\n if (input.omitClosing) return lines.join('\\n').trimEnd()\n lines.push(\n '',\n 'You get this each morning because the Daily CRM digest is on in your ' +\n `notification settings: ${input.settingsUrl}`,\n )\n return lines.join('\\n') + (input.supportLine ?? '')\n}\n\n/** The digest in the parts the designed email lays out. */\nexport interface CrmDigestEmailParts {\n /** The workspace and the day: \"Acme Co · Friday, October 16\". */\n eyebrow: string\n /** \"2 tasks due today, 1 overdue, 3 unworked leads.\" */\n summary: string\n /** Each list's heading with its count, or empty when the list is. */\n overdueHeading: string\n todayHeading: string\n leadsHeading: string\n /** Each list as \"Title — when\" detail rows. */\n overdue: string\n today: string\n leads: string\n nurturing: string\n /** Where the reader starts: the Tasks section, else the first leads list. */\n url: string\n urlLabel: string\n /** The other places to go, one \"Label — URL\" line each. */\n links: string\n}\n\n/**\n * The digest for the `crm-daily-digest` system email: the same facts as\n * {@link composeCrmDigestEmailText}, cut into an eyebrow, a summary, a\n * details panel per list and the links, so the email can draw each.\n */\nexport function composeCrmDigestEmailParts(input: CrmDigestEmailInput): CrmDigestEmailParts {\n const { digest, nowMs, timeZone } = input\n const counts = crmDigestCounts(digest)\n const day = new Date(nowMs).toLocaleDateString('en-US', {\n timeZone,\n weekday: 'long',\n month: 'long',\n day: 'numeric',\n })\n const rows = (lines: string[]): string => {\n const shown = lines.slice(0, CRM_DIGEST_LIST_MAX)\n const rest = lines.length - shown.length\n return [...shown, ...(rest > 0 ? [`More — ${rest} more in the CRM`] : [])].join('\\n')\n }\n const taskRow = (task: CrmDigestTask) =>\n `${task.title} — ` +\n [task.hostId ? input.hostName(task.hostId) : '', whenInZone(task.dueAtMs, timeZone)]\n .filter(Boolean)\n .join(' · ')\n const leadRow = (lead: CrmMemberDigest['leads'][number]) =>\n `${lead.name ? `${lead.name} <${lead.email}>` : lead.email} — ` +\n `${input.hostName(lead.hostId)} · first seen ${dateInZone(lead.firstSeenAtMs, timeZone)}`\n const heading = (label: string, count: number) => (count > 0 ? `${label} (${count})` : '')\n const leadHosts = [...new Set(digest.leads.map((lead) => lead.hostId))].slice(0, 3)\n const hasTasks = counts.overdue + counts.today > 0\n const leadLinks = leadHosts.map(\n (hostId) => `Leads on ${input.hostName(hostId)} — ${input.leadsUrl(hostId)}`,\n )\n const firstLeads = leadHosts[0]\n return {\n eyebrow: [input.workspaceName?.trim(), day].filter(Boolean).join(' · '),\n summary: `${composeCrmDigestSummary(counts)}.`.replace(/^\\./, ''),\n overdueHeading: heading('Overdue', counts.overdue),\n todayHeading: heading('Due today', counts.today),\n leadsHeading: heading('Unworked leads', counts.leads),\n overdue: rows(digest.overdue.map(taskRow)),\n today: rows(digest.today.map(taskRow)),\n leads: rows(digest.leads.map(leadRow)),\n nurturing: composeCrmDigestNurturingLine(counts),\n url: hasTasks || !firstLeads ? input.tasksUrl : input.leadsUrl(firstLeads),\n urlLabel: hasTasks || !firstLeads ? 'Open your tasks' : 'Open your leads',\n links: (hasTasks ? leadLinks : leadLinks.slice(1)).join('\\n'),\n }\n}\n\n/** {@link CrmDigestEmailParts} as the `crm-daily-digest` email's merge tokens. */\nexport function crmDigestMergeValues(parts: CrmDigestEmailParts): Record<string, string> {\n return {\n 'digest.eyebrow': parts.eyebrow,\n 'digest.summary': parts.summary,\n 'digest.overdueHeading': parts.overdueHeading,\n 'digest.overdue': parts.overdue,\n 'digest.todayHeading': parts.todayHeading,\n 'digest.today': parts.today,\n 'digest.leadsHeading': parts.leadsHeading,\n 'digest.leads': parts.leads,\n 'digest.nurturing': parts.nurturing,\n 'digest.url': parts.url,\n 'digest.action': parts.urlLabel,\n 'digest.links': parts.links,\n }\n}\n"],"names":["startOfNextZonedDay","startOfZonedDay","crmLeadStatus","CRM_DIGEST_DEFAULT_TIME_ZONE","digestTimeZone","env","process","configured","String","trim","Intl","DateTimeFormat","timeZone","console","warn","CRM_DIGEST_LEAD_AGE_MS","CRM_DIGEST_TASK_CEILING","CRM_DIGEST_LEAD_WINDOW","CRM_DIGEST_LIST_MAX","startOfDayInZone","ms","startOfNextDayInZone","utcDayKey","Date","toISOString","slice","crmDigestWindow","nowMs","startMs","endMs","crmDigestTaskState","dueAtMs","window","isUnworkedLead","lead","ageMs","seenBefore","isNurturingLead","cutoffMs","firstSeen","Number","firstSeenAtMs","isFinite","leadIsForMember","member","ownerUid","uid","reachesHost","hostId","crmDigestEntitled","features","buildMemberDigests","input","tasks","leads","nurturing","members","digests","Map","digestFor","digest","get","overdue","today","set","roster","map","task","assigneeUid","has","state","push","values","sort","a","b","crmDigestCounts","length","composeCrmDigestNurturingLine","counts","plural","count","noun","composeCrmDigestSummary","segments","join","composeCrmDigestSubject","workspaceName","workspace","whenInZone","toLocaleString","weekday","month","day","hour","minute","timeZoneName","dateInZone","toLocaleDateString","section","heading","lines","shown","rest","line","composeCrmDigestEmailText","productName","taskLine","title","hostName","filter","Boolean","who","name","email","nurturingLine","tasksUrl","leadHosts","Set","leadsUrl","omitClosing","trimEnd","settingsUrl","supportLine","composeCrmDigestEmailParts","rows","taskRow","leadRow","label","hasTasks","leadLinks","firstLeads","eyebrow","summary","replace","overdueHeading","todayHeading","leadsHeading","url","urlLabel","links","crmDigestMergeValues","parts"],"mappings":"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"}
|
|
@@ -150,3 +150,25 @@ export interface CrmTaskReminderEmailInput {
|
|
|
150
150
|
* its own line with its link under it, and the way out last.
|
|
151
151
|
*/
|
|
152
152
|
export declare function composeCrmTaskReminderEmailText(input: CrmTaskReminderEmailInput): string;
|
|
153
|
+
/** A reminder in the parts the designed `crm-task-reminder` email lays out. */
|
|
154
|
+
export interface CrmTaskReminderEmailParts {
|
|
155
|
+
/** The workspace the tasks are in, or empty. */
|
|
156
|
+
eyebrow: string;
|
|
157
|
+
/** "A reminder about your task." */
|
|
158
|
+
intro: string;
|
|
159
|
+
/** One "Title — due …" detail row per task. */
|
|
160
|
+
details: string;
|
|
161
|
+
/** The task, or the first task's Tasks page when there are several. */
|
|
162
|
+
url: string;
|
|
163
|
+
urlLabel: string;
|
|
164
|
+
/** For several tasks, each one's own link as "Title — URL". */
|
|
165
|
+
links: string;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The same reminder as {@link composeCrmTaskReminderEmailText}, cut into the
|
|
169
|
+
* parts the system email draws: the tasks as a details panel, a button to
|
|
170
|
+
* the task, and each task's own link when there are several.
|
|
171
|
+
*/
|
|
172
|
+
export declare function composeCrmTaskReminderEmailParts(input: CrmTaskReminderEmailInput): CrmTaskReminderEmailParts;
|
|
173
|
+
/** {@link CrmTaskReminderEmailParts} as the `crm-task-reminder` email's merge tokens. */
|
|
174
|
+
export declare function crmTaskReminderMergeValues(parts: CrmTaskReminderEmailParts): Record<string, string>;
|
|
@@ -157,5 +157,40 @@
|
|
|
157
157
|
lines.push('', 'You get task reminders because the Forms & bookings category is on in ' + `your notification settings: ${input.settingsUrl}`);
|
|
158
158
|
return lines.join('\n') + ((_input_supportLine = input.supportLine) != null ? _input_supportLine : '');
|
|
159
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* The same reminder as {@link composeCrmTaskReminderEmailText}, cut into the
|
|
162
|
+
* parts the system email draws: the tasks as a details panel, a button to
|
|
163
|
+
* the task, and each task's own link when there are several.
|
|
164
|
+
*/ export function composeCrmTaskReminderEmailParts(input) {
|
|
165
|
+
var _ref;
|
|
166
|
+
var _input_workspaceName;
|
|
167
|
+
const ordered = [
|
|
168
|
+
...input.tasks
|
|
169
|
+
].sort((a, b)=>{
|
|
170
|
+
var _a_dueAtMs, _b_dueAtMs;
|
|
171
|
+
return ((_a_dueAtMs = a.dueAtMs) != null ? _a_dueAtMs : a.remindAtMs) - ((_b_dueAtMs = b.dueAtMs) != null ? _b_dueAtMs : b.remindAtMs);
|
|
172
|
+
});
|
|
173
|
+
const due = (task)=>typeof task.dueAtMs === 'number' ? `due ${crmTaskReminderWhen(task.dueAtMs, input.timeZone)}` : 'no due date';
|
|
174
|
+
const several = ordered.length > 1;
|
|
175
|
+
const first = ordered[0];
|
|
176
|
+
return {
|
|
177
|
+
eyebrow: (_ref = (_input_workspaceName = input.workspaceName) == null ? void 0 : _input_workspaceName.trim()) != null ? _ref : '',
|
|
178
|
+
intro: several ? `A reminder about ${ordered.length} of your tasks, in the order they are due.` : 'A reminder about your task. Open it to see the contact or deal it is for.',
|
|
179
|
+
details: ordered.map((task)=>`${task.title} — ${due(task)}`).join('\n'),
|
|
180
|
+
url: first ? input.taskUrl(first) : '',
|
|
181
|
+
urlLabel: several ? 'Open the first task' : 'Open the task',
|
|
182
|
+
links: several ? ordered.map((task)=>`${task.title} — ${input.taskUrl(task)}`).join('\n') : ''
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/** {@link CrmTaskReminderEmailParts} as the `crm-task-reminder` email's merge tokens. */ export function crmTaskReminderMergeValues(parts) {
|
|
186
|
+
return {
|
|
187
|
+
'reminder.eyebrow': parts.eyebrow,
|
|
188
|
+
'reminder.intro': parts.intro,
|
|
189
|
+
'reminder.details': parts.details,
|
|
190
|
+
'reminder.url': parts.url,
|
|
191
|
+
'reminder.action': parts.urlLabel,
|
|
192
|
+
'reminder.links': parts.links
|
|
193
|
+
};
|
|
194
|
+
}
|
|
160
195
|
|
|
161
196
|
//# sourceMappingURL=crm-task-reminders.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/crm-task-reminders.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * A task's reminder at its own time (AGL-2659).\n *\n * The daily digest says once a morning what is late and what is due today;\n * nothing said \"the 3:00 call is at 3:00\". A task now carries `remindAtMs`\n * — the due time unless a person moved it, `null` for none — and an hourly\n * route reads every open task whose reminder has come due and tells the\n * assignee once, in the console and by mail. Every judgment here is pure so\n * the three writers of the field (the task routes, the REST resource, the\n * drawer) and the route that reads it agree on what a reminder is.\n *\n * ## Which reminder a save leaves\n *\n * A caller that SAYS what the reminder is gets what it said. A caller that\n * says nothing gets the rule: a new task's reminder is its due time, and an\n * edit that moves the due date moves a reminder that still sat on the old\n * due time — the reminder was never a choice of its own, so it follows —\n * while a reminder somebody set to a time of their own stays where they put\n * it. The one thing an edit never does is invent a reminder for a task that\n * has none.\n */\n\nimport type { CrmTask } from '@aglyn/aglyn/app-utils/crm'\n\n/**\n * The most due reminders one org's sweep reads per run.\n *\n * NEWEST FIRST, which is the whole reason a ceiling is safe here. A sent\n * reminder keeps its `remindAtMs` — the drawer shows when it fired — so the\n * range \"reminders up to now\" holds every reminder an open task ever had,\n * and a window of the OLDEST five hundred would fill with ones already sent\n * and never reach a new one. The newest five hundred always begin with what\n * fell due since the last run; the sent ones sit behind them.\n */\nexport const CRM_TASK_REMINDER_CEILING = 500\n\n/** The two fields the rule reads off a stored task, or off nothing on create. */\nexport interface CrmTaskReminderState {\n dueAtMs: number | null\n remindAtMs: number | null\n}\n\n/**\n * The reminder a save leaves on the task — see the module comment.\n *\n * `remindAtMs` is what the caller SAID: a time, `null` for \"no reminder\",\n * `undefined` for nothing said. `previous` is the stored task, or `null`\n * when the task is being created. A stored task from before reminders\n * existed carries no `remindAtMs` at all; it reads as `null` here — no\n * reminder — rather than as one that follows the due date, because a task\n * nobody ever set a reminder on should not start reminding the day its\n * title is corrected.\n */\nexport function crmTaskReminderAfterEdit(input: {\n dueAtMs: number | null\n remindAtMs?: number | null\n previous: CrmTaskReminderState | null\n}): number | null {\n const { dueAtMs, remindAtMs, previous } = input\n if (remindAtMs !== undefined) return remindAtMs\n if (!previous) return dueAtMs\n const followed =\n typeof previous.remindAtMs === 'number' && previous.remindAtMs === previous.dueAtMs\n if (followed && dueAtMs !== previous.dueAtMs) return dueAtMs\n return previous.remindAtMs ?? null\n}\n\n/** Whether a task's reminder is still owed: set, and not yet handled. */\nexport function crmTaskReminderPending(\n task: Pick<CrmTask, 'remindAtMs' | 'reminderSentAtMs'>,\n): boolean {\n return typeof task.remindAtMs === 'number' && task.reminderSentAtMs === undefined\n}\n\n/**\n * Whether the runner owes this task a reminder now: open, pending, and the\n * reminder's time has come. The query reads the first and third off the\n * index; the second is a fact about a field's absence, which a query cannot\n * select on, so the runner asks it here over the page it read.\n */\nexport function crmTaskReminderDue(\n task: Pick<CrmTask, 'status' | 'remindAtMs' | 'reminderSentAtMs'>,\n nowMs: number,\n): boolean {\n return (\n task.status === 'open' &&\n crmTaskReminderPending(task) &&\n (task.remindAtMs as number) <= nowMs\n )\n}\n\n/** A due reminder as the runner carries it: the fields a line and a link need. */\nexport interface CrmReminderTask\n extends Pick<CrmTask, 'title' | 'kind' | 'contactId' | 'companyId' | 'dealId'> {\n id: string\n dueAtMs: number | null\n remindAtMs: number\n assigneeUid: string\n /** The site the task was created on, or `''` for the organization's own. */\n hostId: string\n}\n\n/**\n * The console path a reminder opens: the record the task is for, in the\n * hub, or the tasks list when it is for nobody in particular — the same\n * page the assignment notification opens, in the `/{hostDocId}/rest` shape\n * every host notification uses and the `/org/rest` shape the console\n * rewrites onto the organization's own hub for a task with no site.\n */\nexport function crmTaskReminderLink(\n hostId: string | null,\n task: Pick<CrmTask, 'contactId' | 'companyId' | 'dealId'>,\n): string {\n const base = hostId ? `/${hostId}/crm` : '/org/crm'\n if (task.contactId) return `${base}/contacts/${encodeURIComponent(task.contactId)}`\n if (task.dealId) return `${base}/deals/${encodeURIComponent(task.dealId)}`\n if (task.companyId) return `${base}/companies/${encodeURIComponent(task.companyId)}`\n return `${base}/tasks`\n}\n\n/**\n * `Wed, Sep 9, 3:00 PM CDT` — the words a reminder says a time with. The zone\n * is the server's choice, not the reader's, so it is printed: a reader an\n * hour away would otherwise take it for their own clock.\n */\nexport function crmTaskReminderWhen(ms: number, timeZone: string): string {\n return new Date(ms).toLocaleString('en-US', {\n timeZone,\n weekday: 'short',\n month: 'short',\n day: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n timeZoneName: 'short',\n })\n}\n\n/**\n * One task as the reminder email lists it: its title and when it is due, or\n * that it has no due date — a reminder can sit on a task with none, at a time\n * the person chose.\n */\nexport function crmTaskReminderLine(\n task: Pick<CrmReminderTask, 'title' | 'dueAtMs'>,\n timeZone: string,\n): string {\n return typeof task.dueAtMs === 'number'\n ? `${task.title} · due ${crmTaskReminderWhen(task.dueAtMs, timeZone)}`\n : `${task.title} · no due date`\n}\n\n/**\n * The console notification's body, one per task, which is also what a\n * preview of it shows. It says it is a reminder and names the workspace, so\n * it reads whole without the \"Task reminder\" title above it.\n */\nexport function composeCrmTaskReminderBody(\n task: Pick<CrmReminderTask, 'title' | 'dueAtMs'>,\n timeZone: string,\n workspaceName?: string,\n): string {\n const workspace = workspaceName?.trim()\n const when =\n typeof task.dueAtMs === 'number'\n ? `, due ${crmTaskReminderWhen(task.dueAtMs, timeZone)}.`\n : ' (no due date).'\n return `Reminder for your task${workspace ? ` in ${workspace}` : ''}: ${task.title}${when}`\n}\n\n/**\n * One mail per member per run, so five tasks due at nine o'clock are one\n * message and not five. The subject names the task when there is one, and\n * never says a task is due: a reminder can be set ahead of the due time, or\n * on a task with none.\n */\nexport function composeCrmTaskReminderSubject(\n tasks: ReadonlyArray<Pick<CrmReminderTask, 'title'>>,\n): string {\n if (tasks.length === 1) return `Reminder: ${tasks[0].title}`\n return `Reminder: ${tasks.length} tasks`\n}\n\n/** What the reminder email needs from the route besides the tasks. */\nexport interface CrmTaskReminderEmailInput {\n tasks: readonly CrmReminderTask[]\n timeZone: string\n /** The product name the mail reads as — the org's brand when white-labeled. */\n productName: string\n /**\n * The workspace the tasks are in. The system email shows only the body, so\n * the body names it: a member of two workspaces gets reminders from each.\n */\n workspaceName?: string\n /** The console page a task opens on, absolute. */\n taskUrl: (task: CrmReminderTask) => string\n /** Account settings → Notifications, where the category can be muted. */\n settingsUrl: string\n /** The brand's support line, already prefixed, or empty. */\n supportLine?: string\n /**\n * Leave out the closing paragraph and the support line (AGL-3367), for the\n * copy that goes inside a system email: its footer says why the mail came\n * and where to get help, and its body links the settings page.\n */\n omitClosing?: boolean\n}\n\n/**\n * The plain-text email. Text rather than a designed template for the\n * reason the digest gives: the body is a list, and `sendEmail` synthesizes\n * the HTML part that makes its links live. Soonest due first, each task on\n * its own line with its link under it, and the way out last.\n */\nexport function composeCrmTaskReminderEmailText(input: CrmTaskReminderEmailInput): string {\n const { tasks, timeZone, productName } = input\n const ordered = [...tasks].sort(\n (a, b) => (a.dueAtMs ?? a.remindAtMs) - (b.dueAtMs ?? b.remindAtMs),\n )\n // \"About\", never \"is due\": a reminder fires at the time somebody chose,\n // which may be a day ahead of the due date or on a task with none, and\n // each line below says when its task is actually due.\n const where = input.workspaceName?.trim() ? ` in ${input.workspaceName.trim()}` : ''\n const lines: string[] = [\n ordered.length === 1\n ? `A reminder from ${productName} about your task${where}:`\n : `A reminder from ${productName} about ${ordered.length} of your tasks${where}:`,\n '',\n ]\n for (const task of ordered) {\n lines.push(`- ${crmTaskReminderLine(task, timeZone)}`)\n lines.push(` ${input.taskUrl(task)}`)\n }\n if (input.omitClosing) return lines.join('\\n').trimEnd()\n lines.push(\n '',\n 'You get task reminders because the Forms & bookings category is on in ' +\n `your notification settings: ${input.settingsUrl}`,\n )\n return lines.join('\\n') + (input.supportLine ?? '')\n}\n"],"names":["CRM_TASK_REMINDER_CEILING","crmTaskReminderAfterEdit","input","previous","dueAtMs","remindAtMs","undefined","followed","crmTaskReminderPending","task","reminderSentAtMs","crmTaskReminderDue","nowMs","status","crmTaskReminderLink","hostId","base","contactId","encodeURIComponent","dealId","companyId","crmTaskReminderWhen","ms","timeZone","Date","toLocaleString","weekday","month","day","hour","minute","timeZoneName","crmTaskReminderLine","title","composeCrmTaskReminderBody","workspaceName","workspace","trim","when","composeCrmTaskReminderSubject","tasks","length","composeCrmTaskReminderEmailText","productName","ordered","sort","a","b","where","lines","push","taskUrl","omitClosing","join","trimEnd","settingsUrl","supportLine"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;CAoBC,GAID;;;;;;;;;CASC,GACD,OAAO,MAAMA,4BAA4B,IAAG;AAQ5C;;;;;;;;;;CAUC,GACD,OAAO,SAASC,yBAAyBC,KAIxC;QAOQC;IANP,MAAM,EAAEC,OAAO,EAAEC,UAAU,EAAEF,QAAQ,EAAE,GAAGD;IAC1C,IAAIG,eAAeC,WAAW,OAAOD;IACrC,IAAI,CAACF,UAAU,OAAOC;IACtB,MAAMG,WACJ,OAAOJ,SAASE,UAAU,KAAK,YAAYF,SAASE,UAAU,KAAKF,SAASC,OAAO;IACrF,IAAIG,YAAYH,YAAYD,SAASC,OAAO,EAAE,OAAOA;IACrD,QAAOD,uBAAAA,SAASE,UAAU,YAAnBF,uBAAuB;AAChC;AAEA,uEAAuE,GACvE,OAAO,SAASK,uBACdC,IAAsD;IAEtD,OAAO,OAAOA,KAAKJ,UAAU,KAAK,YAAYI,KAAKC,gBAAgB,KAAKJ;AAC1E;AAEA;;;;;CAKC,GACD,OAAO,SAASK,mBACdF,IAAiE,EACjEG,KAAa;IAEb,OACEH,KAAKI,MAAM,KAAK,UAChBL,uBAAuBC,SACvB,AAACA,KAAKJ,UAAU,IAAeO;AAEnC;AAaA;;;;;;CAMC,GACD,OAAO,SAASE,oBACdC,MAAqB,EACrBN,IAAyD;IAEzD,MAAMO,OAAOD,SAAS,CAAC,CAAC,EAAEA,OAAO,IAAI,CAAC,GAAG;IACzC,IAAIN,KAAKQ,SAAS,EAAE,OAAO,GAAGD,KAAK,UAAU,EAAEE,mBAAmBT,KAAKQ,SAAS,GAAG;IACnF,IAAIR,KAAKU,MAAM,EAAE,OAAO,GAAGH,KAAK,OAAO,EAAEE,mBAAmBT,KAAKU,MAAM,GAAG;IAC1E,IAAIV,KAAKW,SAAS,EAAE,OAAO,GAAGJ,KAAK,WAAW,EAAEE,mBAAmBT,KAAKW,SAAS,GAAG;IACpF,OAAO,GAAGJ,KAAK,MAAM,CAAC;AACxB;AAEA;;;;CAIC,GACD,OAAO,SAASK,oBAAoBC,EAAU,EAAEC,QAAgB;IAC9D,OAAO,IAAIC,KAAKF,IAAIG,cAAc,CAAC,SAAS;QAC1CF;QACAG,SAAS;QACTC,OAAO;QACPC,KAAK;QACLC,MAAM;QACNC,QAAQ;QACRC,cAAc;IAChB;AACF;AAEA;;;;CAIC,GACD,OAAO,SAASC,oBACdvB,IAAgD,EAChDc,QAAgB;IAEhB,OAAO,OAAOd,KAAKL,OAAO,KAAK,WAC3B,GAAGK,KAAKwB,KAAK,CAAC,OAAO,EAAEZ,oBAAoBZ,KAAKL,OAAO,EAAEmB,WAAW,GACpE,GAAGd,KAAKwB,KAAK,CAAC,cAAc,CAAC;AACnC;AAEA;;;;CAIC,GACD,OAAO,SAASC,2BACdzB,IAAgD,EAChDc,QAAgB,EAChBY,aAAsB;IAEtB,MAAMC,YAAYD,iCAAAA,cAAeE,IAAI;IACrC,MAAMC,OACJ,OAAO7B,KAAKL,OAAO,KAAK,WACpB,CAAC,MAAM,EAAEiB,oBAAoBZ,KAAKL,OAAO,EAAEmB,UAAU,CAAC,CAAC,GACvD;IACN,OAAO,CAAC,sBAAsB,EAAEa,YAAY,CAAC,IAAI,EAAEA,WAAW,GAAG,GAAG,EAAE,EAAE3B,KAAKwB,KAAK,GAAGK,MAAM;AAC7F;AAEA;;;;;CAKC,GACD,OAAO,SAASC,8BACdC,KAAoD;IAEpD,IAAIA,MAAMC,MAAM,KAAK,GAAG,OAAO,CAAC,UAAU,EAAED,KAAK,CAAC,EAAE,CAACP,KAAK,EAAE;IAC5D,OAAO,CAAC,UAAU,EAAEO,MAAMC,MAAM,CAAC,MAAM,CAAC;AAC1C;AA2BA;;;;;CAKC,GACD,OAAO,SAASC,gCAAgCxC,KAAgC;QAyBnDA;QAjBbA;IAPd,MAAM,EAAEsC,KAAK,EAAEjB,QAAQ,EAAEoB,WAAW,EAAE,GAAGzC;IACzC,MAAM0C,UAAU;WAAIJ;KAAM,CAACK,IAAI,CAC7B,CAACC,GAAGC;YAAOD,YAA8BC;eAA/B,EAACD,aAAAA,EAAE1C,OAAO,YAAT0C,aAAaA,EAAEzC,UAAU,MAAK0C,aAAAA,EAAE3C,OAAO,YAAT2C,aAAaA,EAAE1C,UAAU;;IAEpE,wEAAwE;IACxE,uEAAuE;IACvE,sDAAsD;IACtD,MAAM2C,QAAQ9C,EAAAA,uBAAAA,MAAMiC,aAAa,qBAAnBjC,qBAAqBmC,IAAI,MAAK,CAAC,IAAI,EAAEnC,MAAMiC,aAAa,CAACE,IAAI,IAAI,GAAG;IAClF,MAAMY,QAAkB;QACtBL,QAAQH,MAAM,KAAK,IACf,CAAC,gBAAgB,EAAEE,YAAY,gBAAgB,EAAEK,MAAM,CAAC,CAAC,GACzD,CAAC,gBAAgB,EAAEL,YAAY,OAAO,EAAEC,QAAQH,MAAM,CAAC,cAAc,EAAEO,MAAM,CAAC,CAAC;QACnF;KACD;IACD,KAAK,MAAMvC,QAAQmC,QAAS;QAC1BK,MAAMC,IAAI,CAAC,CAAC,EAAE,EAAElB,oBAAoBvB,MAAMc,WAAW;QACrD0B,MAAMC,IAAI,CAAC,CAAC,EAAE,EAAEhD,MAAMiD,OAAO,CAAC1C,OAAO;IACvC;IACA,IAAIP,MAAMkD,WAAW,EAAE,OAAOH,MAAMI,IAAI,CAAC,MAAMC,OAAO;IACtDL,MAAMC,IAAI,CACR,IACA,2EACE,CAAC,4BAA4B,EAAEhD,MAAMqD,WAAW,EAAE;IAEtD,OAAON,MAAMI,IAAI,CAAC,UAASnD,qBAAAA,MAAMsD,WAAW,YAAjBtD,qBAAqB;AAClD"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/crm-task-reminders.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * A task's reminder at its own time (AGL-2659).\n *\n * The daily digest says once a morning what is late and what is due today;\n * nothing said \"the 3:00 call is at 3:00\". A task now carries `remindAtMs`\n * — the due time unless a person moved it, `null` for none — and an hourly\n * route reads every open task whose reminder has come due and tells the\n * assignee once, in the console and by mail. Every judgment here is pure so\n * the three writers of the field (the task routes, the REST resource, the\n * drawer) and the route that reads it agree on what a reminder is.\n *\n * ## Which reminder a save leaves\n *\n * A caller that SAYS what the reminder is gets what it said. A caller that\n * says nothing gets the rule: a new task's reminder is its due time, and an\n * edit that moves the due date moves a reminder that still sat on the old\n * due time — the reminder was never a choice of its own, so it follows —\n * while a reminder somebody set to a time of their own stays where they put\n * it. The one thing an edit never does is invent a reminder for a task that\n * has none.\n */\n\nimport type { CrmTask } from '@aglyn/aglyn/app-utils/crm'\n\n/**\n * The most due reminders one org's sweep reads per run.\n *\n * NEWEST FIRST, which is the whole reason a ceiling is safe here. A sent\n * reminder keeps its `remindAtMs` — the drawer shows when it fired — so the\n * range \"reminders up to now\" holds every reminder an open task ever had,\n * and a window of the OLDEST five hundred would fill with ones already sent\n * and never reach a new one. The newest five hundred always begin with what\n * fell due since the last run; the sent ones sit behind them.\n */\nexport const CRM_TASK_REMINDER_CEILING = 500\n\n/** The two fields the rule reads off a stored task, or off nothing on create. */\nexport interface CrmTaskReminderState {\n dueAtMs: number | null\n remindAtMs: number | null\n}\n\n/**\n * The reminder a save leaves on the task — see the module comment.\n *\n * `remindAtMs` is what the caller SAID: a time, `null` for \"no reminder\",\n * `undefined` for nothing said. `previous` is the stored task, or `null`\n * when the task is being created. A stored task from before reminders\n * existed carries no `remindAtMs` at all; it reads as `null` here — no\n * reminder — rather than as one that follows the due date, because a task\n * nobody ever set a reminder on should not start reminding the day its\n * title is corrected.\n */\nexport function crmTaskReminderAfterEdit(input: {\n dueAtMs: number | null\n remindAtMs?: number | null\n previous: CrmTaskReminderState | null\n}): number | null {\n const { dueAtMs, remindAtMs, previous } = input\n if (remindAtMs !== undefined) return remindAtMs\n if (!previous) return dueAtMs\n const followed =\n typeof previous.remindAtMs === 'number' && previous.remindAtMs === previous.dueAtMs\n if (followed && dueAtMs !== previous.dueAtMs) return dueAtMs\n return previous.remindAtMs ?? null\n}\n\n/** Whether a task's reminder is still owed: set, and not yet handled. */\nexport function crmTaskReminderPending(\n task: Pick<CrmTask, 'remindAtMs' | 'reminderSentAtMs'>,\n): boolean {\n return typeof task.remindAtMs === 'number' && task.reminderSentAtMs === undefined\n}\n\n/**\n * Whether the runner owes this task a reminder now: open, pending, and the\n * reminder's time has come. The query reads the first and third off the\n * index; the second is a fact about a field's absence, which a query cannot\n * select on, so the runner asks it here over the page it read.\n */\nexport function crmTaskReminderDue(\n task: Pick<CrmTask, 'status' | 'remindAtMs' | 'reminderSentAtMs'>,\n nowMs: number,\n): boolean {\n return (\n task.status === 'open' &&\n crmTaskReminderPending(task) &&\n (task.remindAtMs as number) <= nowMs\n )\n}\n\n/** A due reminder as the runner carries it: the fields a line and a link need. */\nexport interface CrmReminderTask\n extends Pick<CrmTask, 'title' | 'kind' | 'contactId' | 'companyId' | 'dealId'> {\n id: string\n dueAtMs: number | null\n remindAtMs: number\n assigneeUid: string\n /** The site the task was created on, or `''` for the organization's own. */\n hostId: string\n}\n\n/**\n * The console path a reminder opens: the record the task is for, in the\n * hub, or the tasks list when it is for nobody in particular — the same\n * page the assignment notification opens, in the `/{hostDocId}/rest` shape\n * every host notification uses and the `/org/rest` shape the console\n * rewrites onto the organization's own hub for a task with no site.\n */\nexport function crmTaskReminderLink(\n hostId: string | null,\n task: Pick<CrmTask, 'contactId' | 'companyId' | 'dealId'>,\n): string {\n const base = hostId ? `/${hostId}/crm` : '/org/crm'\n if (task.contactId) return `${base}/contacts/${encodeURIComponent(task.contactId)}`\n if (task.dealId) return `${base}/deals/${encodeURIComponent(task.dealId)}`\n if (task.companyId) return `${base}/companies/${encodeURIComponent(task.companyId)}`\n return `${base}/tasks`\n}\n\n/**\n * `Wed, Sep 9, 3:00 PM CDT` — the words a reminder says a time with. The zone\n * is the server's choice, not the reader's, so it is printed: a reader an\n * hour away would otherwise take it for their own clock.\n */\nexport function crmTaskReminderWhen(ms: number, timeZone: string): string {\n return new Date(ms).toLocaleString('en-US', {\n timeZone,\n weekday: 'short',\n month: 'short',\n day: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n timeZoneName: 'short',\n })\n}\n\n/**\n * One task as the reminder email lists it: its title and when it is due, or\n * that it has no due date — a reminder can sit on a task with none, at a time\n * the person chose.\n */\nexport function crmTaskReminderLine(\n task: Pick<CrmReminderTask, 'title' | 'dueAtMs'>,\n timeZone: string,\n): string {\n return typeof task.dueAtMs === 'number'\n ? `${task.title} · due ${crmTaskReminderWhen(task.dueAtMs, timeZone)}`\n : `${task.title} · no due date`\n}\n\n/**\n * The console notification's body, one per task, which is also what a\n * preview of it shows. It says it is a reminder and names the workspace, so\n * it reads whole without the \"Task reminder\" title above it.\n */\nexport function composeCrmTaskReminderBody(\n task: Pick<CrmReminderTask, 'title' | 'dueAtMs'>,\n timeZone: string,\n workspaceName?: string,\n): string {\n const workspace = workspaceName?.trim()\n const when =\n typeof task.dueAtMs === 'number'\n ? `, due ${crmTaskReminderWhen(task.dueAtMs, timeZone)}.`\n : ' (no due date).'\n return `Reminder for your task${workspace ? ` in ${workspace}` : ''}: ${task.title}${when}`\n}\n\n/**\n * One mail per member per run, so five tasks due at nine o'clock are one\n * message and not five. The subject names the task when there is one, and\n * never says a task is due: a reminder can be set ahead of the due time, or\n * on a task with none.\n */\nexport function composeCrmTaskReminderSubject(\n tasks: ReadonlyArray<Pick<CrmReminderTask, 'title'>>,\n): string {\n if (tasks.length === 1) return `Reminder: ${tasks[0].title}`\n return `Reminder: ${tasks.length} tasks`\n}\n\n/** What the reminder email needs from the route besides the tasks. */\nexport interface CrmTaskReminderEmailInput {\n tasks: readonly CrmReminderTask[]\n timeZone: string\n /** The product name the mail reads as — the org's brand when white-labeled. */\n productName: string\n /**\n * The workspace the tasks are in. The system email shows only the body, so\n * the body names it: a member of two workspaces gets reminders from each.\n */\n workspaceName?: string\n /** The console page a task opens on, absolute. */\n taskUrl: (task: CrmReminderTask) => string\n /** Account settings → Notifications, where the category can be muted. */\n settingsUrl: string\n /** The brand's support line, already prefixed, or empty. */\n supportLine?: string\n /**\n * Leave out the closing paragraph and the support line (AGL-3367), for the\n * copy that goes inside a system email: its footer says why the mail came\n * and where to get help, and its body links the settings page.\n */\n omitClosing?: boolean\n}\n\n/**\n * The plain-text email. Text rather than a designed template for the\n * reason the digest gives: the body is a list, and `sendEmail` synthesizes\n * the HTML part that makes its links live. Soonest due first, each task on\n * its own line with its link under it, and the way out last.\n */\nexport function composeCrmTaskReminderEmailText(input: CrmTaskReminderEmailInput): string {\n const { tasks, timeZone, productName } = input\n const ordered = [...tasks].sort(\n (a, b) => (a.dueAtMs ?? a.remindAtMs) - (b.dueAtMs ?? b.remindAtMs),\n )\n // \"About\", never \"is due\": a reminder fires at the time somebody chose,\n // which may be a day ahead of the due date or on a task with none, and\n // each line below says when its task is actually due.\n const where = input.workspaceName?.trim() ? ` in ${input.workspaceName.trim()}` : ''\n const lines: string[] = [\n ordered.length === 1\n ? `A reminder from ${productName} about your task${where}:`\n : `A reminder from ${productName} about ${ordered.length} of your tasks${where}:`,\n '',\n ]\n for (const task of ordered) {\n lines.push(`- ${crmTaskReminderLine(task, timeZone)}`)\n lines.push(` ${input.taskUrl(task)}`)\n }\n if (input.omitClosing) return lines.join('\\n').trimEnd()\n lines.push(\n '',\n 'You get task reminders because the Forms & bookings category is on in ' +\n `your notification settings: ${input.settingsUrl}`,\n )\n return lines.join('\\n') + (input.supportLine ?? '')\n}\n\n/** A reminder in the parts the designed `crm-task-reminder` email lays out. */\nexport interface CrmTaskReminderEmailParts {\n /** The workspace the tasks are in, or empty. */\n eyebrow: string\n /** \"A reminder about your task.\" */\n intro: string\n /** One \"Title — due …\" detail row per task. */\n details: string\n /** The task, or the first task's Tasks page when there are several. */\n url: string\n urlLabel: string\n /** For several tasks, each one's own link as \"Title — URL\". */\n links: string\n}\n\n/**\n * The same reminder as {@link composeCrmTaskReminderEmailText}, cut into the\n * parts the system email draws: the tasks as a details panel, a button to\n * the task, and each task's own link when there are several.\n */\nexport function composeCrmTaskReminderEmailParts(\n input: CrmTaskReminderEmailInput,\n): CrmTaskReminderEmailParts {\n const ordered = [...input.tasks].sort(\n (a, b) => (a.dueAtMs ?? a.remindAtMs) - (b.dueAtMs ?? b.remindAtMs),\n )\n const due = (task: CrmReminderTask) =>\n typeof task.dueAtMs === 'number'\n ? `due ${crmTaskReminderWhen(task.dueAtMs, input.timeZone)}`\n : 'no due date'\n const several = ordered.length > 1\n const first = ordered[0]\n return {\n eyebrow: input.workspaceName?.trim() ?? '',\n intro: several\n ? `A reminder about ${ordered.length} of your tasks, in the order they are due.`\n : 'A reminder about your task. Open it to see the contact or deal it is for.',\n details: ordered.map((task) => `${task.title} — ${due(task)}`).join('\\n'),\n url: first ? input.taskUrl(first) : '',\n urlLabel: several ? 'Open the first task' : 'Open the task',\n links: several ? ordered.map((task) => `${task.title} — ${input.taskUrl(task)}`).join('\\n') : '',\n }\n}\n\n/** {@link CrmTaskReminderEmailParts} as the `crm-task-reminder` email's merge tokens. */\nexport function crmTaskReminderMergeValues(\n parts: CrmTaskReminderEmailParts,\n): Record<string, string> {\n return {\n 'reminder.eyebrow': parts.eyebrow,\n 'reminder.intro': parts.intro,\n 'reminder.details': parts.details,\n 'reminder.url': parts.url,\n 'reminder.action': parts.urlLabel,\n 'reminder.links': parts.links,\n }\n}\n"],"names":["CRM_TASK_REMINDER_CEILING","crmTaskReminderAfterEdit","input","previous","dueAtMs","remindAtMs","undefined","followed","crmTaskReminderPending","task","reminderSentAtMs","crmTaskReminderDue","nowMs","status","crmTaskReminderLink","hostId","base","contactId","encodeURIComponent","dealId","companyId","crmTaskReminderWhen","ms","timeZone","Date","toLocaleString","weekday","month","day","hour","minute","timeZoneName","crmTaskReminderLine","title","composeCrmTaskReminderBody","workspaceName","workspace","trim","when","composeCrmTaskReminderSubject","tasks","length","composeCrmTaskReminderEmailText","productName","ordered","sort","a","b","where","lines","push","taskUrl","omitClosing","join","trimEnd","settingsUrl","supportLine","composeCrmTaskReminderEmailParts","due","several","first","eyebrow","intro","details","map","url","urlLabel","links","crmTaskReminderMergeValues","parts"],"mappings":"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"}
|
|
@@ -15,7 +15,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
15
15
|
* See the License for the specific language governing permissions and
|
|
16
16
|
* limitations under the License.
|
|
17
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_LEAD_AGE_MS, CRM_DIGEST_LEAD_WINDOW, CRM_DIGEST_TASK_CEILING, crmDigestCounts, crmDigestEntitled, crmDigestWindow, digestTimeZone, isNurturingLead, isUnworkedLead, utcDayKey } from "../model/crm-digest.js";
|
|
18
|
+
import { buildMemberDigests, composeCrmDigestEmailParts, composeCrmDigestEmailText, crmDigestMergeValues, composeCrmDigestSubject, composeCrmDigestSummary, CRM_DIGEST_LEAD_AGE_MS, CRM_DIGEST_LEAD_WINDOW, CRM_DIGEST_TASK_CEILING, crmDigestCounts, crmDigestEntitled, crmDigestWindow, digestTimeZone, isNurturingLead, isUnworkedLead, utcDayKey } from "../model/crm-digest.js";
|
|
19
19
|
import { rateLimitedRetryAtMs, sendEmail } from "@aglyn/shared-util-email";
|
|
20
20
|
import { withoutMailWithheldAccounts } from "@aglyn/tenant-data-admin/server/account-mail";
|
|
21
21
|
import { findUserByUidAcrossPools, firebaseAdmin, getServerReleaseFlagValues, listOrgMembers, memberHasOrgPermission, meterPlatformEmail, notifyUsers } from "@aglyn/tenant-data-admin";
|
|
@@ -395,7 +395,8 @@ async function digestOrg(ctx, orgDoc) {
|
|
|
395
395
|
'digest.subject': subject,
|
|
396
396
|
'digest.body': composeCrmDigestEmailText(_extends({}, digestInput, {
|
|
397
397
|
omitClosing: true
|
|
398
|
-
}))
|
|
398
|
+
}))
|
|
399
|
+
}, crmDigestMergeValues(composeCrmDigestEmailParts(digestInput)), {
|
|
399
400
|
settingsUrl
|
|
400
401
|
}), brand.options) : null;
|
|
401
402
|
const result = await sendEmail(_extends({
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/daily-digest-route.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n type AglynOrgBilling,\n type AglynOrgMember,\n buildRoute,\n CRM_COLLECTIONS,\n DIGEST_PREFS_FIELD,\n digestEnabled,\n hostRoleFor,\n isReleaseFlagOnForOrg,\n notificationMuted,\n parseOrgReleaseFlagOverrides,\n pluginRequestFromWeb,\n resolveBrandingProfile,\n resolveEffectivePlan,\n resolveOrgEntitlements,\n Route,\n} from '@aglyn/aglyn/server'\nimport {\n buildMemberDigests,\n composeCrmDigestEmailText,\n composeCrmDigestSubject,\n composeCrmDigestSummary,\n CRM_DIGEST_LEAD_AGE_MS,\n CRM_DIGEST_LEAD_WINDOW,\n CRM_DIGEST_TASK_CEILING,\n crmDigestCounts,\n crmDigestEntitled,\n type CrmDigestLead,\n type CrmDigestTask,\n crmDigestWindow,\n type CrmMemberDigest,\n digestTimeZone,\n isNurturingLead,\n isUnworkedLead,\n utcDayKey,\n} from '../model/crm-digest'\nimport { rateLimitedRetryAtMs, sendEmail } from '@aglyn/shared-util-email'\nimport { withoutMailWithheldAccounts } from '@aglyn/tenant-data-admin/server/account-mail'\nimport {\n findUserByUidAcrossPools,\n firebaseAdmin,\n getServerReleaseFlagValues,\n listOrgMembers,\n memberHasOrgPermission,\n meterPlatformEmail,\n notifyUsers,\n} from '@aglyn/tenant-data-admin'\n// From the LEAF, not the barrel (AGL-2407): the cron specs mock\n// `@aglyn/tenant-data-admin` wholesale, and a suppression gate imported\n// through the barrel would be silently replaced by whatever the factory\n// lists. Same reasoning as `usage-alert-email.ts`.\nimport { filterSuppressedEmails } from '@aglyn/tenant-data-admin/server/email-suppression'\nimport {\n loadSystemEmail,\n renderLoadedSystemEmail,\n systemEmailBrand,\n systemEmailContent,\n type LoadedSystemEmail,\n} from '@aglyn/tenant-data-admin/server/render-system-email'\nimport { isCronAuthorized, isCronDryRun } from '@aglyn/tenant-data-admin/server/cron-auth'\nimport { writeCronBeat } from '@aglyn/aglyn/app-utils/health-report'\nimport { brandSupportLine } from '@aglyn/aglyn/app-utils/brand-support-line'\nimport { platformConsoleOrigin as consoleOrigin } from '@aglyn/aglyn/app-utils/platform-brand'\nimport { scopeTokensForHost } from '@aglyn/aglyn/app-utils/scope-tokens'\n\n/**\n * The mark a scheduled run leaves for `/api/health/crons` (AGL-1955), on the\n * INVOCATION and before the work — the console's rule for every cron. Never\n * throws: reaching the admin app is inside the try, because a monitor that\n * can take down the job it describes is worse than the failure it watches.\n */\nasync function recordCronBeat(jobId: string): Promise<void> {\n try {\n await writeCronBeat(firebaseAdmin.app().firestore(), jobId)\n } catch {\n // Deliberately swallowed: a job that stops stamping reds its own row.\n }\n}\n\n// lockdown-423: exempt — server-internal cron (x-cron-secret), no user caller; it reads an org's CRM to remind its members and writes nothing a locked org could lose.\n\n/**\n * The daily CRM digest (AGL-2619): `POST /api/crm/daily-digest`.\n *\n * Overdue and due-today are computed at read time by design (AGL-2599), so\n * until this route nothing ever told anybody a task was late — overdue work\n * was found by opening the Tasks section. Once a morning, per org and per\n * member with open work, this counts their overdue and due-today tasks and\n * the leads nobody has worked, and says so ONCE: a console notification\n * (`content.crmDailyDigest`) and an email through the platform's own\n * sending path. The words and the arithmetic live in `crm-digest.ts`; this\n * file is the reads, the sends and the sweep.\n *\n * ## Who is told, and who is not\n *\n * A task's assignee, a lead's owner, and — for a lead nobody owns — every\n * member who reaches the site it was captured on. Only members who may open\n * the CRM (`data.manage`, resolved through their custom role the way the task\n * routes resolve it), and only in an org whose plan carries the suite and\n * whose `release_crm` flag is on: a digest about a surface the reader\n * cannot open is a reminder to pay, not a reminder to call.\n *\n * Two switches on the recipient's own account are honored. The **Daily CRM\n * digest** preference (`users/{uid}.digestPrefs.crmDaily`, on until turned\n * off) governs the whole digest, mail included. The operational category\n * mute (`notificationPrefs.content`) governs the console notification alone,\n * as it does for every other `content.` type — the mute is a fact about the\n * console feed, and the digest switch is a fact about the digest.\n *\n * ## Once a day, and resumable\n *\n * `orgs/{orgId}/crmDigests/{YYYY-MM-DD}` records, per member, that today's\n * digest went out. It is stamped AFTER the sends, so a run that dies\n * mid-org re-sends only to the members it never reached, and a second run\n * on the same UTC day sends nothing. Orgs are swept in id order behind a\n * cursor the way `usage-alerts` sweeps them, so the workflow's `done:false`\n * loop carries a platform of any size across invocations.\n *\n * The email is a `bulk` send under the platform send-rate governor\n * (AGL-2409): a cron fan-out on the shared sending domain, answering nothing\n * anybody just did. A refusal STOPS the run and reports `done: true` — the\n * window is full, and re-POSTing into it would only red the workflow — and\n * because the refused member was never stamped, tomorrow's run digests them\n * afresh. Nothing about today's list is worth mailing tomorrow anyway.\n *\n * Metered as PLATFORM mail, not the org's: this is the platform reminding a\n * member, and a reminder that pushed an org toward its email overage would\n * be its own small absurdity. It is not a one-to-one CRM send and counts\n * against no per-member sending allowance.\n */\n\n/** The beat `/api/health/crons` would read, were this job in its inventory. */\nexport const CRM_DIGEST_JOB_ID = 'crm-daily-digest'\n\n/**\n * Orgs per invocation.\n *\n * Fewer than `usage-alerts`' hundred: an org costs a task query, a paged\n * host list, a leads query per site, the roster, and a send per member with\n * work, under `maxDuration = 60`. A chunk that times out never advances its\n * cursor, so the safe size is the one that finishes.\n */\nexport const CRM_DIGEST_ORG_CHUNK = 25\n\n/** Hosts read per page inside one org, ordered by id like the orgs sweep. */\nconst CRM_DIGEST_HOST_PAGE = 100\n\n/**\n * The most sites one org's digest reads leads from, after which the sweep\n * stops and SAYS SO in the response rather than reading without bound.\n */\nexport const CRM_DIGEST_HOST_CEILING = 200\n\n/**\n * The key a person's switch for this digest is stored under in\n * `users/{uid}.digestPrefs` — the one the CRM declares under\n * `notificationDigests` in `plugins.config.json`, which is the one the\n * settings page writes. `daily-digest-route.spec.ts` holds the two to one\n * spelling.\n */\nexport const CRM_DAILY_DIGEST_KEY = 'crmDaily'\n\n/** `orgs/{orgId}/crmDigests/{day}` — server-written, matched by no rule. */\nexport const CRM_DIGEST_MARKER_COLLECTION = 'crmDigests'\n\n/**\n * The most members per org whose address is chased through the auth pools\n * when their member document carries none. Bounds WORK, not audience — the\n * denormalized copy is normally present, and `orgAdminEmails` draws the same\n * line for the same reason.\n */\nexport const CRM_DIGEST_EMAIL_MAX_UID_LOOKUPS = 5\n\n// The zone lives with the digest's other pure judgments, where the task\n// routes read it too; re-exported for the callers that name it here.\nexport { digestTimeZone }\n\ntype Firestore = FirebaseFirestore.Firestore\ntype Snapshot = FirebaseFirestore.QueryDocumentSnapshot\n\n/**\n * Every host owned by an org, paged by document id — the shape\n * `usage-alerts` reads hosts with, at a lower ceiling because each host here\n * costs a leads query.\n */\nasync function hostsOfOrg(\n firestore: Firestore,\n orgId: string,\n): Promise<{ docs: Snapshot[]; truncated: boolean }> {\n const docs: Snapshot[] = []\n let cursor: string | null = null\n for (;;) {\n let query = firestore\n .collection('hosts')\n .where('orgId', '==', orgId)\n .orderBy(firebaseAdmin.firestore.FieldPath.documentId())\n .limit(CRM_DIGEST_HOST_PAGE)\n if (cursor) query = query.startAfter(firestore.collection('hosts').doc(cursor))\n const page = await query.get()\n for (const doc of page.docs) docs.push(doc)\n if (page.docs.length < CRM_DIGEST_HOST_PAGE) return { docs, truncated: false }\n if (docs.length >= CRM_DIGEST_HOST_CEILING) {\n console.warn(\n `crm-daily-digest: org ${orgId} has more than ${CRM_DIGEST_HOST_CEILING} ` +\n 'hosts — leads on the rest are not digested',\n )\n return { docs, truncated: true }\n }\n cursor = page.docs[page.docs.length - 1].id\n }\n}\n\n/** What one member's row in the response says. */\ninterface MemberReport {\n skipped?: string\n overdue?: number\n today?: number\n leads?: number\n nurturing?: number\n notified?: boolean\n emailed?: boolean\n emailReason?: string\n deferred?: boolean\n}\n\ninterface OrgReport {\n skipped?: string\n digests: number\n notified: number\n emailed: number\n members?: Record<string, MemberReport>\n hostsTruncated?: boolean\n tasksTruncated?: boolean\n /** The send-rate governor refused; the sweep stops here. */\n deferred?: boolean\n}\n\ninterface SweepContext {\n firestore: Firestore\n nowMs: number\n day: string\n timeZone: string\n dryRun: boolean\n flagValues: Awaited<ReturnType<typeof getServerReleaseFlagValues>>\n origin: string\n}\n\n/**\n * The console path a notification opens — the legacy `/{hostDocId}/rest`\n * shape every host notification uses, or the `/org/rest` shape the console\n * rewrites onto the organization's own hub when nothing owed names a site:\n * an organization task (AGL-2637) has none.\n */\nfunction notificationLink(hostId: string, section: 'tasks' | 'leads'): string {\n return hostId ? `/${hostId}/crm/${section}` : `/org/crm/${section}`\n}\n\nasync function digestOrg(ctx: SweepContext, orgDoc: Snapshot): Promise<OrgReport> {\n const { firestore, nowMs, day, timeZone, dryRun } = ctx\n const orgId = orgDoc.id\n const org = (orgDoc.data() ?? {}) as Partial<AglynOrgBilling> & Record<string, unknown>\n const quiet: OrgReport = { digests: 0, notified: 0, emailed: 0 }\n\n if (!crmDigestEntitled(resolveOrgEntitlements(org).features)) {\n return { ...quiet, skipped: 'not-entitled' }\n }\n const flagOn = isReleaseFlagOnForOrg(\n 'release_crm',\n ctx.flagValues['release_crm'],\n orgId,\n parseOrgReleaseFlagOverrides(org['releaseFlags']),\n resolveEffectivePlan(org),\n )\n if (!flagOn) return { ...quiet, skipped: 'release-flag' }\n\n const window = crmDigestWindow(nowMs, timeZone)\n\n /*\n * Every open task due before tomorrow, soonest first — overdue and today\n * in one query on `(status, dueAtMs)`, bucketed afterwards. Undated tasks\n * carry `dueAtMs: null`, which a range excludes.\n */\n const taskPage = await orgDoc.ref\n .collection(CRM_COLLECTIONS.tasks)\n .where('status', '==', 'open')\n .where('dueAtMs', '<', window.endMs)\n .orderBy('dueAtMs', 'asc')\n .limit(CRM_DIGEST_TASK_CEILING)\n .get()\n const tasks: CrmDigestTask[] = taskPage.docs.map((doc) => ({\n id: doc.id,\n title: String(doc.get('title') ?? ''),\n kind: doc.get('kind') ?? 'todo',\n dueAtMs: Number(doc.get('dueAtMs')),\n assigneeUid: String(doc.get('assigneeUid') ?? ''),\n hostId: String(doc.get('hostId') ?? ''),\n contactId: doc.get('contactId') || undefined,\n companyId: doc.get('companyId') || undefined,\n dealId: doc.get('dealId') || undefined,\n }))\n const tasksTruncated = taskPage.docs.length >= CRM_DIGEST_TASK_CEILING\n\n /*\n * The leads nobody has worked, per site. A window of the newest old leads\n * by first sighting, filtered in memory, for the reason the Leads section\n * gives: the capture door stamps no `status`, and a query cannot select on\n * a field's absence.\n */\n const hosts = await hostsOfOrg(firestore, orgId)\n const hostById = new Map(hosts.docs.map((doc) => [doc.id, doc]))\n const leadCutoff = nowMs - CRM_DIGEST_LEAD_AGE_MS\n const leads: CrmDigestLead[] = []\n // Read from the same window, and counted, not listed (AGL-3446).\n const nurturing: CrmDigestLead[] = []\n for (const host of hosts.docs) {\n /*\n * Still per site, but over the ORG collection narrowed to what that site\n * may see (AGL-3275). The digest is a per-site message, so the loop stays;\n * what changed is that a site's leads are selected by `visibleTo` rather\n * than by the collection's parent, and a lead two brands share is in both\n * of their digests — which is the honest answer, since both hold it.\n */\n const page = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('leads')\n .where('visibleTo', 'array-contains-any', scopeTokensForHost(host.id))\n .where('firstSeenAtMs', '<=', leadCutoff)\n .orderBy('firstSeenAtMs', 'desc')\n .limit(CRM_DIGEST_LEAD_WINDOW)\n .get()\n for (const doc of page.docs) {\n const data = doc.data() as Record<string, unknown>\n const fields = data as Parameters<typeof isUnworkedLead>[0]\n const into = isUnworkedLead(fields, nowMs)\n ? leads\n : isNurturingLead(fields, nowMs)\n ? nurturing\n : null\n if (!into) continue\n into.push({\n id: doc.id,\n hostId: host.id,\n email: String(data['email'] ?? ''),\n name: String(data['name'] ?? '') || undefined,\n ownerUid: String(data['ownerUid'] ?? '') || undefined,\n firstSeenAtMs: Number(data['firstSeenAtMs']),\n })\n }\n }\n\n if (!tasks.length && !leads.length) return { ...quiet, skipped: 'nothing-owed' }\n\n const listed = (await listOrgMembers(orgId)).filter(\n (member) => member.orgSuspended !== true,\n )\n // A locked or disabled account is not told (AGL-3418): it cannot sign in\n // to act on it.\n const reachable = new Set(\n await withoutMailWithheldAccounts(listed.map((member) => member.$id)),\n )\n const members = listed.filter((member) => reachable.has(member.$id))\n const memberById = new Map(members.map((member) => [member.$id, member]))\n const digests = buildMemberDigests({\n tasks,\n leads,\n nurturing,\n window,\n members: members.map((member) => ({\n uid: member.$id,\n reachesHost: (hostId: string) => hostRoleFor(member, hostId) !== null,\n })),\n })\n if (!digests.size) return { ...quiet, skipped: 'nobody-to-tell' }\n\n const markerRef = orgDoc.ref.collection(CRM_DIGEST_MARKER_COLLECTION).doc(day)\n const marker = await markerRef.get()\n const sentToday = (marker.get('members') ?? {}) as Record<string, unknown>\n\n // Sorted so the order members are told in — and the member a refusal\n // lands on — is the same on every run.\n const uids = [...digests.keys()].sort()\n const userDocs = await firestore.getAll(\n ...uids.map((uid) => firestore.collection('users').doc(uid)),\n )\n const userById = new Map(userDocs.map((doc) => [doc.id, doc]))\n\n const branding = resolveBrandingProfile(org)\n const orgSlug = String(org['slug'] ?? '')\n const workspaceName = String(org['name'] ?? '').trim() || orgSlug\n const hostName = (hostId: string): string => {\n const host = hostById.get(hostId)\n return String(host?.get('name') || host?.get('subdomain') || hostId)\n }\n const hubUrl = (hostId: string): string | null => {\n const subdomain = String(hostById.get(hostId)?.get('subdomain') ?? '')\n if (!orgSlug || !subdomain) return null\n return `${ctx.origin}${buildRoute(Route.HOST_DASHBOARD, { orgSlug, host: subdomain })}/crm`\n }\n // The organization's own hub, for a member whose owed tasks name no site.\n const orgHubUrl = orgSlug\n ? `${ctx.origin}${buildRoute(Route.ORG_HOME, { orgSlug })}/crm`\n : null\n const settingsUrl = `${ctx.origin}${buildRoute(Route.MANAGE_NOTIFICATION_SETTINGS)}`\n const supportLine = brandSupportLine(branding)\n const brand = systemEmailBrand(org)\n let digestEmail: Promise<LoadedSystemEmail | null> | undefined\n\n const report: OrgReport = { digests: 0, notified: 0, emailed: 0, members: {} }\n if (hosts.truncated) report.hostsTruncated = true\n if (tasksTruncated) report.tasksTruncated = true\n const rows = report.members as Record<string, MemberReport>\n let poolLookups = 0\n\n for (const uid of uids) {\n const digest = digests.get(uid) as CrmMemberDigest\n if (sentToday[uid]) {\n rows[uid] = { skipped: 'already-sent' }\n continue\n }\n const member = memberById.get(uid) as AglynOrgMember\n if (!(await memberHasOrgPermission(orgId, member, 'data.manage'))) {\n rows[uid] = { skipped: 'no-data-manage' }\n continue\n }\n const userDoc = userById.get(uid)\n if (!digestEnabled(userDoc?.get(DIGEST_PREFS_FIELD), CRM_DAILY_DIGEST_KEY)) {\n rows[uid] = { skipped: 'digest-off' }\n continue\n }\n const muted = notificationMuted(\n userDoc?.get('notificationPrefs'),\n 'content.crmDailyDigest',\n )\n const counts = crmDigestCounts(digest)\n const row: MemberReport = { ...counts }\n rows[uid] = row\n report.digests += 1\n if (dryRun) {\n row.notified = !muted\n continue\n }\n\n // The site the links open on: where the first task THAT HAS A SITE\n // lives, else where the first lead was captured. An organization task\n // (AGL-2637) names no site, and a digest owed nothing but those opens\n // on the organization's hub.\n const anchorHostId =\n [...digest.overdue, ...digest.today].find((task) => task.hostId)?.hostId ||\n digest.leads[0]?.hostId ||\n ''\n const section = counts.overdue + counts.today > 0 ? 'tasks' : 'leads'\n const hub = anchorHostId ? hubUrl(anchorHostId) : orgHubUrl\n\n /*\n * THE EMAIL FIRST. A refusal from the send-rate governor stops the run\n * with nothing else done for this member — no notification to double\n * tomorrow, no marker to skip them on — so the order here is the\n * idempotence, not a preference.\n */\n let emailed = false\n let emailReason: string | undefined\n let address = String(member.email ?? '').trim().toLowerCase()\n if (!address.includes('@') && poolLookups < CRM_DIGEST_EMAIL_MAX_UID_LOOKUPS) {\n poolLookups += 1\n const pooled = await findUserByUidAcrossPools(uid).catch(() => null)\n address = String(pooled?.record?.email ?? '').trim().toLowerCase()\n }\n const recipients = address.includes('@')\n ? await filterSuppressedEmails([address], firestore)\n : []\n if (!recipients.length) {\n emailReason = address.includes('@') ? 'suppressed' : 'no-recipient'\n } else {\n const digestInput = {\n digest,\n nowMs,\n timeZone,\n productName: branding.productName,\n workspaceName,\n tasksUrl: hub ? `${hub}/tasks` : ctx.origin,\n leadsUrl: (hostId: string) => `${hubUrl(hostId) ?? ctx.origin}/leads`,\n settingsUrl,\n hostName,\n supportLine,\n }\n const subject = composeCrmDigestSubject(counts, workspaceName)\n // The `crm-daily-digest` system email (AGL-3367), in the org's brand,\n // read once per org and rendered per member.\n digestEmail ??= loadSystemEmail('crm-daily-digest').catch(() => null)\n const loaded = await digestEmail\n const rendered = loaded\n ? renderLoadedSystemEmail(\n loaded,\n {\n ...brand.merge,\n 'digest.subject': subject,\n 'digest.body': composeCrmDigestEmailText({ ...digestInput, omitClosing: true }),\n settingsUrl,\n },\n brand.options,\n )\n : null\n const result = await sendEmail({\n to: recipients,\n ...systemEmailContent(rendered, {\n subject,\n text: composeCrmDigestEmailText(digestInput),\n }),\n fromName: branding.fromName,\n context: 'crm-daily-digest',\n priority: 'bulk',\n })\n if (result.sent) {\n emailed = true\n await meterPlatformEmail().catch(() => undefined)\n } else if (rateLimitedRetryAtMs(result) !== null) {\n row.deferred = true\n report.deferred = true\n return report\n } else {\n emailReason = (result as { reason?: string }).reason ?? 'failed'\n }\n }\n\n let notified = false\n if (!muted) {\n await notifyUsers([uid], {\n type: 'content.crmDailyDigest',\n title: 'Your CRM today',\n body: composeCrmDigestSummary(counts),\n link: notificationLink(anchorHostId, section),\n orgId,\n ...(anchorHostId ? { hostId: anchorHostId } : {}),\n })\n notified = true\n }\n\n await markerRef.set(\n {\n day,\n members: {\n [uid]: { atMs: nowMs, ...counts, notified, emailed },\n },\n updatedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n row.notified = notified\n row.emailed = emailed\n if (emailReason) row.emailReason = emailReason\n if (notified) report.notified += 1\n if (emailed) report.emailed += 1\n }\n return report\n}\n\nexport async function crmDailyDigestRoute(request: Request): Promise<Response> {\n const { method, body, headers: rawHeaders, query } = await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'POST' && method !== 'GET') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n if (!process.env.CRON_SECRET) {\n return Response.json(\n { error: 'The CRM digest is not configured (CRON_SECRET).' },\n { status: 501 },\n )\n }\n if (!isCronAuthorized(headers)) {\n return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n }\n // A GET reports the plan and sends nothing; a bodyless POST — what the\n // scheduled workflow sends — is a real run. `isCronDryRun` keys the\n // default on the METHOD for exactly that reason.\n const dryRun = isCronDryRun({ method, body, query })\n // Stamped on the invocation, not on the work (AGL-1955), so the beat is\n // already there the day this job joins `SCHEDULED_JOBS`.\n if (method === 'POST') await recordCronBeat(CRM_DIGEST_JOB_ID)\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n const nowMs = Date.now()\n const ctx: SweepContext = {\n firestore,\n nowMs,\n day: utcDayKey(nowMs),\n timeZone: digestTimeZone(),\n dryRun,\n flagValues: await getServerReleaseFlagValues(),\n origin: consoleOrigin(),\n }\n\n // The resumable sweep, in the shape `usage-alerts` reads orgs (AGL-2220):\n // ordered by id, `limit + 1` to learn whether there is more, the cursor\n // validated as an org id before it becomes a reference.\n const requestedLimit = Number((body as { limit?: unknown } | undefined)?.limit)\n const pageSize =\n Number.isFinite(requestedLimit) && requestedLimit > 0\n ? Math.min(Math.floor(requestedLimit), CRM_DIGEST_ORG_CHUNK)\n : CRM_DIGEST_ORG_CHUNK\n const rawCursor = (body as { cursor?: unknown } | undefined)?.cursor\n const cursor =\n typeof rawCursor === 'string' && rawCursor.length > 0 && !rawCursor.includes('/')\n ? rawCursor\n : null\n let orgQuery = firestore\n .collection('orgs')\n .orderBy(firebaseAdmin.firestore.FieldPath.documentId())\n .limit(pageSize + 1)\n if (cursor) orgQuery = orgQuery.startAfter(firestore.collection('orgs').doc(cursor))\n const page = await orgQuery.get()\n const hasMore = page.docs.length > pageSize\n const orgDocs = hasMore ? page.docs.slice(0, pageSize) : page.docs\n\n const orgs: Record<string, OrgReport> = {}\n const failures: Record<string, string> = {}\n let digests = 0\n let notified = 0\n let emailed = 0\n let deferred = false\n for (const orgDoc of orgDocs) {\n try {\n const report = await digestOrg(ctx, orgDoc)\n orgs[orgDoc.id] = report\n digests += report.digests\n notified += report.notified\n emailed += report.emailed\n if (report.deferred) {\n deferred = true\n break\n }\n } catch (error) {\n console.error('[crm] daily digest failed for org', orgDoc.id, error)\n failures[orgDoc.id] = (error as Error)?.message ?? 'unknown'\n }\n }\n const failed = Object.keys(failures)\n return Response.json(\n {\n day: ctx.day,\n timeZone: ctx.timeZone,\n dryRun,\n swept: orgDocs.length,\n digests,\n notified,\n emailed,\n orgs,\n ...(deferred ? { deferred: true } : {}),\n ...(failed.length ? { failures } : {}),\n // A deferred run reports `done: true` — see the module comment —\n // so the workflow's loop does not re-POST into a full window.\n nextCursor: deferred || !hasMore ? null : (orgDocs[orgDocs.length - 1]?.id ?? null),\n done: deferred || !hasMore,\n },\n // 207: this chunk finished and some org in it needs a person, which is\n // what fails the workflow (its one alerting channel).\n { status: failed.length ? 207 : 200 },\n )\n } catch (error) {\n console.error('[crm] daily digest run failed', error)\n return Response.json({ error: 'CRM digest run failed' }, { status: 500 })\n }\n}\n"],"names":["buildRoute","CRM_COLLECTIONS","DIGEST_PREFS_FIELD","digestEnabled","hostRoleFor","isReleaseFlagOnForOrg","notificationMuted","parseOrgReleaseFlagOverrides","pluginRequestFromWeb","resolveBrandingProfile","resolveEffectivePlan","resolveOrgEntitlements","Route","buildMemberDigests","composeCrmDigestEmailText","composeCrmDigestSubject","composeCrmDigestSummary","CRM_DIGEST_LEAD_AGE_MS","CRM_DIGEST_LEAD_WINDOW","CRM_DIGEST_TASK_CEILING","crmDigestCounts","crmDigestEntitled","crmDigestWindow","digestTimeZone","isNurturingLead","isUnworkedLead","utcDayKey","rateLimitedRetryAtMs","sendEmail","withoutMailWithheldAccounts","findUserByUidAcrossPools","firebaseAdmin","getServerReleaseFlagValues","listOrgMembers","memberHasOrgPermission","meterPlatformEmail","notifyUsers","filterSuppressedEmails","loadSystemEmail","renderLoadedSystemEmail","systemEmailBrand","systemEmailContent","isCronAuthorized","isCronDryRun","writeCronBeat","brandSupportLine","platformConsoleOrigin","consoleOrigin","scopeTokensForHost","recordCronBeat","jobId","app","firestore","CRM_DIGEST_JOB_ID","CRM_DIGEST_ORG_CHUNK","CRM_DIGEST_HOST_PAGE","CRM_DIGEST_HOST_CEILING","CRM_DAILY_DIGEST_KEY","CRM_DIGEST_MARKER_COLLECTION","CRM_DIGEST_EMAIL_MAX_UID_LOOKUPS","hostsOfOrg","orgId","docs","cursor","query","collection","where","orderBy","FieldPath","documentId","limit","startAfter","doc","page","get","push","length","truncated","console","warn","id","notificationLink","hostId","section","digestOrg","ctx","orgDoc","marker","org","nowMs","day","timeZone","dryRun","data","quiet","digests","notified","emailed","features","skipped","flagOn","flagValues","window","taskPage","ref","tasks","endMs","map","title","String","kind","dueAtMs","Number","assigneeUid","contactId","undefined","companyId","dealId","tasksTruncated","hosts","hostById","Map","leadCutoff","leads","nurturing","host","fields","into","email","name","ownerUid","firstSeenAtMs","listed","filter","member","orgSuspended","reachable","Set","$id","members","has","memberById","uid","reachesHost","size","markerRef","sentToday","uids","keys","sort","userDocs","getAll","userById","branding","orgSlug","workspaceName","trim","hostName","hubUrl","subdomain","origin","HOST_DASHBOARD","orgHubUrl","ORG_HOME","settingsUrl","MANAGE_NOTIFICATION_SETTINGS","supportLine","brand","digestEmail","report","hostsTruncated","rows","poolLookups","digest","userDoc","muted","counts","row","anchorHostId","overdue","today","find","task","hub","emailReason","address","toLowerCase","includes","pooled","catch","record","recipients","digestInput","productName","tasksUrl","leadsUrl","subject","loaded","rendered","merge","omitClosing","options","result","to","text","fromName","context","priority","sent","deferred","reason","type","body","link","set","atMs","updatedAt","FieldValue","serverTimestamp","crmDailyDigestRoute","request","method","headers","rawHeaders","Response","json","error","status","process","env","CRON_SECRET","orgDocs","Date","now","requestedLimit","pageSize","isFinite","Math","min","floor","rawCursor","orgQuery","hasMore","slice","orgs","failures","message","failed","Object","swept","nextCursor","done"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/daily-digest-route.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n type AglynOrgBilling,\n type AglynOrgMember,\n buildRoute,\n CRM_COLLECTIONS,\n DIGEST_PREFS_FIELD,\n digestEnabled,\n hostRoleFor,\n isReleaseFlagOnForOrg,\n notificationMuted,\n parseOrgReleaseFlagOverrides,\n pluginRequestFromWeb,\n resolveBrandingProfile,\n resolveEffectivePlan,\n resolveOrgEntitlements,\n Route,\n} from '@aglyn/aglyn/server'\nimport {\n buildMemberDigests,\n composeCrmDigestEmailParts,\n composeCrmDigestEmailText,\n crmDigestMergeValues,\n composeCrmDigestSubject,\n composeCrmDigestSummary,\n CRM_DIGEST_LEAD_AGE_MS,\n CRM_DIGEST_LEAD_WINDOW,\n CRM_DIGEST_TASK_CEILING,\n crmDigestCounts,\n crmDigestEntitled,\n type CrmDigestLead,\n type CrmDigestTask,\n crmDigestWindow,\n type CrmMemberDigest,\n digestTimeZone,\n isNurturingLead,\n isUnworkedLead,\n utcDayKey,\n} from '../model/crm-digest'\nimport { rateLimitedRetryAtMs, sendEmail } from '@aglyn/shared-util-email'\nimport { withoutMailWithheldAccounts } from '@aglyn/tenant-data-admin/server/account-mail'\nimport {\n findUserByUidAcrossPools,\n firebaseAdmin,\n getServerReleaseFlagValues,\n listOrgMembers,\n memberHasOrgPermission,\n meterPlatformEmail,\n notifyUsers,\n} from '@aglyn/tenant-data-admin'\n// From the LEAF, not the barrel (AGL-2407): the cron specs mock\n// `@aglyn/tenant-data-admin` wholesale, and a suppression gate imported\n// through the barrel would be silently replaced by whatever the factory\n// lists. Same reasoning as `usage-alert-email.ts`.\nimport { filterSuppressedEmails } from '@aglyn/tenant-data-admin/server/email-suppression'\nimport {\n loadSystemEmail,\n renderLoadedSystemEmail,\n systemEmailBrand,\n systemEmailContent,\n type LoadedSystemEmail,\n} from '@aglyn/tenant-data-admin/server/render-system-email'\nimport { isCronAuthorized, isCronDryRun } from '@aglyn/tenant-data-admin/server/cron-auth'\nimport { writeCronBeat } from '@aglyn/aglyn/app-utils/health-report'\nimport { brandSupportLine } from '@aglyn/aglyn/app-utils/brand-support-line'\nimport { platformConsoleOrigin as consoleOrigin } from '@aglyn/aglyn/app-utils/platform-brand'\nimport { scopeTokensForHost } from '@aglyn/aglyn/app-utils/scope-tokens'\n\n/**\n * The mark a scheduled run leaves for `/api/health/crons` (AGL-1955), on the\n * INVOCATION and before the work — the console's rule for every cron. Never\n * throws: reaching the admin app is inside the try, because a monitor that\n * can take down the job it describes is worse than the failure it watches.\n */\nasync function recordCronBeat(jobId: string): Promise<void> {\n try {\n await writeCronBeat(firebaseAdmin.app().firestore(), jobId)\n } catch {\n // Deliberately swallowed: a job that stops stamping reds its own row.\n }\n}\n\n// lockdown-423: exempt — server-internal cron (x-cron-secret), no user caller; it reads an org's CRM to remind its members and writes nothing a locked org could lose.\n\n/**\n * The daily CRM digest (AGL-2619): `POST /api/crm/daily-digest`.\n *\n * Overdue and due-today are computed at read time by design (AGL-2599), so\n * until this route nothing ever told anybody a task was late — overdue work\n * was found by opening the Tasks section. Once a morning, per org and per\n * member with open work, this counts their overdue and due-today tasks and\n * the leads nobody has worked, and says so ONCE: a console notification\n * (`content.crmDailyDigest`) and an email through the platform's own\n * sending path. The words and the arithmetic live in `crm-digest.ts`; this\n * file is the reads, the sends and the sweep.\n *\n * ## Who is told, and who is not\n *\n * A task's assignee, a lead's owner, and — for a lead nobody owns — every\n * member who reaches the site it was captured on. Only members who may open\n * the CRM (`data.manage`, resolved through their custom role the way the task\n * routes resolve it), and only in an org whose plan carries the suite and\n * whose `release_crm` flag is on: a digest about a surface the reader\n * cannot open is a reminder to pay, not a reminder to call.\n *\n * Two switches on the recipient's own account are honored. The **Daily CRM\n * digest** preference (`users/{uid}.digestPrefs.crmDaily`, on until turned\n * off) governs the whole digest, mail included. The operational category\n * mute (`notificationPrefs.content`) governs the console notification alone,\n * as it does for every other `content.` type — the mute is a fact about the\n * console feed, and the digest switch is a fact about the digest.\n *\n * ## Once a day, and resumable\n *\n * `orgs/{orgId}/crmDigests/{YYYY-MM-DD}` records, per member, that today's\n * digest went out. It is stamped AFTER the sends, so a run that dies\n * mid-org re-sends only to the members it never reached, and a second run\n * on the same UTC day sends nothing. Orgs are swept in id order behind a\n * cursor the way `usage-alerts` sweeps them, so the workflow's `done:false`\n * loop carries a platform of any size across invocations.\n *\n * The email is a `bulk` send under the platform send-rate governor\n * (AGL-2409): a cron fan-out on the shared sending domain, answering nothing\n * anybody just did. A refusal STOPS the run and reports `done: true` — the\n * window is full, and re-POSTing into it would only red the workflow — and\n * because the refused member was never stamped, tomorrow's run digests them\n * afresh. Nothing about today's list is worth mailing tomorrow anyway.\n *\n * Metered as PLATFORM mail, not the org's: this is the platform reminding a\n * member, and a reminder that pushed an org toward its email overage would\n * be its own small absurdity. It is not a one-to-one CRM send and counts\n * against no per-member sending allowance.\n */\n\n/** The beat `/api/health/crons` would read, were this job in its inventory. */\nexport const CRM_DIGEST_JOB_ID = 'crm-daily-digest'\n\n/**\n * Orgs per invocation.\n *\n * Fewer than `usage-alerts`' hundred: an org costs a task query, a paged\n * host list, a leads query per site, the roster, and a send per member with\n * work, under `maxDuration = 60`. A chunk that times out never advances its\n * cursor, so the safe size is the one that finishes.\n */\nexport const CRM_DIGEST_ORG_CHUNK = 25\n\n/** Hosts read per page inside one org, ordered by id like the orgs sweep. */\nconst CRM_DIGEST_HOST_PAGE = 100\n\n/**\n * The most sites one org's digest reads leads from, after which the sweep\n * stops and SAYS SO in the response rather than reading without bound.\n */\nexport const CRM_DIGEST_HOST_CEILING = 200\n\n/**\n * The key a person's switch for this digest is stored under in\n * `users/{uid}.digestPrefs` — the one the CRM declares under\n * `notificationDigests` in `plugins.config.json`, which is the one the\n * settings page writes. `daily-digest-route.spec.ts` holds the two to one\n * spelling.\n */\nexport const CRM_DAILY_DIGEST_KEY = 'crmDaily'\n\n/** `orgs/{orgId}/crmDigests/{day}` — server-written, matched by no rule. */\nexport const CRM_DIGEST_MARKER_COLLECTION = 'crmDigests'\n\n/**\n * The most members per org whose address is chased through the auth pools\n * when their member document carries none. Bounds WORK, not audience — the\n * denormalized copy is normally present, and `orgAdminEmails` draws the same\n * line for the same reason.\n */\nexport const CRM_DIGEST_EMAIL_MAX_UID_LOOKUPS = 5\n\n// The zone lives with the digest's other pure judgments, where the task\n// routes read it too; re-exported for the callers that name it here.\nexport { digestTimeZone }\n\ntype Firestore = FirebaseFirestore.Firestore\ntype Snapshot = FirebaseFirestore.QueryDocumentSnapshot\n\n/**\n * Every host owned by an org, paged by document id — the shape\n * `usage-alerts` reads hosts with, at a lower ceiling because each host here\n * costs a leads query.\n */\nasync function hostsOfOrg(\n firestore: Firestore,\n orgId: string,\n): Promise<{ docs: Snapshot[]; truncated: boolean }> {\n const docs: Snapshot[] = []\n let cursor: string | null = null\n for (;;) {\n let query = firestore\n .collection('hosts')\n .where('orgId', '==', orgId)\n .orderBy(firebaseAdmin.firestore.FieldPath.documentId())\n .limit(CRM_DIGEST_HOST_PAGE)\n if (cursor) query = query.startAfter(firestore.collection('hosts').doc(cursor))\n const page = await query.get()\n for (const doc of page.docs) docs.push(doc)\n if (page.docs.length < CRM_DIGEST_HOST_PAGE) return { docs, truncated: false }\n if (docs.length >= CRM_DIGEST_HOST_CEILING) {\n console.warn(\n `crm-daily-digest: org ${orgId} has more than ${CRM_DIGEST_HOST_CEILING} ` +\n 'hosts — leads on the rest are not digested',\n )\n return { docs, truncated: true }\n }\n cursor = page.docs[page.docs.length - 1].id\n }\n}\n\n/** What one member's row in the response says. */\ninterface MemberReport {\n skipped?: string\n overdue?: number\n today?: number\n leads?: number\n nurturing?: number\n notified?: boolean\n emailed?: boolean\n emailReason?: string\n deferred?: boolean\n}\n\ninterface OrgReport {\n skipped?: string\n digests: number\n notified: number\n emailed: number\n members?: Record<string, MemberReport>\n hostsTruncated?: boolean\n tasksTruncated?: boolean\n /** The send-rate governor refused; the sweep stops here. */\n deferred?: boolean\n}\n\ninterface SweepContext {\n firestore: Firestore\n nowMs: number\n day: string\n timeZone: string\n dryRun: boolean\n flagValues: Awaited<ReturnType<typeof getServerReleaseFlagValues>>\n origin: string\n}\n\n/**\n * The console path a notification opens — the legacy `/{hostDocId}/rest`\n * shape every host notification uses, or the `/org/rest` shape the console\n * rewrites onto the organization's own hub when nothing owed names a site:\n * an organization task (AGL-2637) has none.\n */\nfunction notificationLink(hostId: string, section: 'tasks' | 'leads'): string {\n return hostId ? `/${hostId}/crm/${section}` : `/org/crm/${section}`\n}\n\nasync function digestOrg(ctx: SweepContext, orgDoc: Snapshot): Promise<OrgReport> {\n const { firestore, nowMs, day, timeZone, dryRun } = ctx\n const orgId = orgDoc.id\n const org = (orgDoc.data() ?? {}) as Partial<AglynOrgBilling> & Record<string, unknown>\n const quiet: OrgReport = { digests: 0, notified: 0, emailed: 0 }\n\n if (!crmDigestEntitled(resolveOrgEntitlements(org).features)) {\n return { ...quiet, skipped: 'not-entitled' }\n }\n const flagOn = isReleaseFlagOnForOrg(\n 'release_crm',\n ctx.flagValues['release_crm'],\n orgId,\n parseOrgReleaseFlagOverrides(org['releaseFlags']),\n resolveEffectivePlan(org),\n )\n if (!flagOn) return { ...quiet, skipped: 'release-flag' }\n\n const window = crmDigestWindow(nowMs, timeZone)\n\n /*\n * Every open task due before tomorrow, soonest first — overdue and today\n * in one query on `(status, dueAtMs)`, bucketed afterwards. Undated tasks\n * carry `dueAtMs: null`, which a range excludes.\n */\n const taskPage = await orgDoc.ref\n .collection(CRM_COLLECTIONS.tasks)\n .where('status', '==', 'open')\n .where('dueAtMs', '<', window.endMs)\n .orderBy('dueAtMs', 'asc')\n .limit(CRM_DIGEST_TASK_CEILING)\n .get()\n const tasks: CrmDigestTask[] = taskPage.docs.map((doc) => ({\n id: doc.id,\n title: String(doc.get('title') ?? ''),\n kind: doc.get('kind') ?? 'todo',\n dueAtMs: Number(doc.get('dueAtMs')),\n assigneeUid: String(doc.get('assigneeUid') ?? ''),\n hostId: String(doc.get('hostId') ?? ''),\n contactId: doc.get('contactId') || undefined,\n companyId: doc.get('companyId') || undefined,\n dealId: doc.get('dealId') || undefined,\n }))\n const tasksTruncated = taskPage.docs.length >= CRM_DIGEST_TASK_CEILING\n\n /*\n * The leads nobody has worked, per site. A window of the newest old leads\n * by first sighting, filtered in memory, for the reason the Leads section\n * gives: the capture door stamps no `status`, and a query cannot select on\n * a field's absence.\n */\n const hosts = await hostsOfOrg(firestore, orgId)\n const hostById = new Map(hosts.docs.map((doc) => [doc.id, doc]))\n const leadCutoff = nowMs - CRM_DIGEST_LEAD_AGE_MS\n const leads: CrmDigestLead[] = []\n // Read from the same window, and counted, not listed (AGL-3446).\n const nurturing: CrmDigestLead[] = []\n for (const host of hosts.docs) {\n /*\n * Still per site, but over the ORG collection narrowed to what that site\n * may see (AGL-3275). The digest is a per-site message, so the loop stays;\n * what changed is that a site's leads are selected by `visibleTo` rather\n * than by the collection's parent, and a lead two brands share is in both\n * of their digests — which is the honest answer, since both hold it.\n */\n const page = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('leads')\n .where('visibleTo', 'array-contains-any', scopeTokensForHost(host.id))\n .where('firstSeenAtMs', '<=', leadCutoff)\n .orderBy('firstSeenAtMs', 'desc')\n .limit(CRM_DIGEST_LEAD_WINDOW)\n .get()\n for (const doc of page.docs) {\n const data = doc.data() as Record<string, unknown>\n const fields = data as Parameters<typeof isUnworkedLead>[0]\n const into = isUnworkedLead(fields, nowMs)\n ? leads\n : isNurturingLead(fields, nowMs)\n ? nurturing\n : null\n if (!into) continue\n into.push({\n id: doc.id,\n hostId: host.id,\n email: String(data['email'] ?? ''),\n name: String(data['name'] ?? '') || undefined,\n ownerUid: String(data['ownerUid'] ?? '') || undefined,\n firstSeenAtMs: Number(data['firstSeenAtMs']),\n })\n }\n }\n\n if (!tasks.length && !leads.length) return { ...quiet, skipped: 'nothing-owed' }\n\n const listed = (await listOrgMembers(orgId)).filter(\n (member) => member.orgSuspended !== true,\n )\n // A locked or disabled account is not told (AGL-3418): it cannot sign in\n // to act on it.\n const reachable = new Set(\n await withoutMailWithheldAccounts(listed.map((member) => member.$id)),\n )\n const members = listed.filter((member) => reachable.has(member.$id))\n const memberById = new Map(members.map((member) => [member.$id, member]))\n const digests = buildMemberDigests({\n tasks,\n leads,\n nurturing,\n window,\n members: members.map((member) => ({\n uid: member.$id,\n reachesHost: (hostId: string) => hostRoleFor(member, hostId) !== null,\n })),\n })\n if (!digests.size) return { ...quiet, skipped: 'nobody-to-tell' }\n\n const markerRef = orgDoc.ref.collection(CRM_DIGEST_MARKER_COLLECTION).doc(day)\n const marker = await markerRef.get()\n const sentToday = (marker.get('members') ?? {}) as Record<string, unknown>\n\n // Sorted so the order members are told in — and the member a refusal\n // lands on — is the same on every run.\n const uids = [...digests.keys()].sort()\n const userDocs = await firestore.getAll(\n ...uids.map((uid) => firestore.collection('users').doc(uid)),\n )\n const userById = new Map(userDocs.map((doc) => [doc.id, doc]))\n\n const branding = resolveBrandingProfile(org)\n const orgSlug = String(org['slug'] ?? '')\n const workspaceName = String(org['name'] ?? '').trim() || orgSlug\n const hostName = (hostId: string): string => {\n const host = hostById.get(hostId)\n return String(host?.get('name') || host?.get('subdomain') || hostId)\n }\n const hubUrl = (hostId: string): string | null => {\n const subdomain = String(hostById.get(hostId)?.get('subdomain') ?? '')\n if (!orgSlug || !subdomain) return null\n return `${ctx.origin}${buildRoute(Route.HOST_DASHBOARD, { orgSlug, host: subdomain })}/crm`\n }\n // The organization's own hub, for a member whose owed tasks name no site.\n const orgHubUrl = orgSlug\n ? `${ctx.origin}${buildRoute(Route.ORG_HOME, { orgSlug })}/crm`\n : null\n const settingsUrl = `${ctx.origin}${buildRoute(Route.MANAGE_NOTIFICATION_SETTINGS)}`\n const supportLine = brandSupportLine(branding)\n const brand = systemEmailBrand(org)\n let digestEmail: Promise<LoadedSystemEmail | null> | undefined\n\n const report: OrgReport = { digests: 0, notified: 0, emailed: 0, members: {} }\n if (hosts.truncated) report.hostsTruncated = true\n if (tasksTruncated) report.tasksTruncated = true\n const rows = report.members as Record<string, MemberReport>\n let poolLookups = 0\n\n for (const uid of uids) {\n const digest = digests.get(uid) as CrmMemberDigest\n if (sentToday[uid]) {\n rows[uid] = { skipped: 'already-sent' }\n continue\n }\n const member = memberById.get(uid) as AglynOrgMember\n if (!(await memberHasOrgPermission(orgId, member, 'data.manage'))) {\n rows[uid] = { skipped: 'no-data-manage' }\n continue\n }\n const userDoc = userById.get(uid)\n if (!digestEnabled(userDoc?.get(DIGEST_PREFS_FIELD), CRM_DAILY_DIGEST_KEY)) {\n rows[uid] = { skipped: 'digest-off' }\n continue\n }\n const muted = notificationMuted(\n userDoc?.get('notificationPrefs'),\n 'content.crmDailyDigest',\n )\n const counts = crmDigestCounts(digest)\n const row: MemberReport = { ...counts }\n rows[uid] = row\n report.digests += 1\n if (dryRun) {\n row.notified = !muted\n continue\n }\n\n // The site the links open on: where the first task THAT HAS A SITE\n // lives, else where the first lead was captured. An organization task\n // (AGL-2637) names no site, and a digest owed nothing but those opens\n // on the organization's hub.\n const anchorHostId =\n [...digest.overdue, ...digest.today].find((task) => task.hostId)?.hostId ||\n digest.leads[0]?.hostId ||\n ''\n const section = counts.overdue + counts.today > 0 ? 'tasks' : 'leads'\n const hub = anchorHostId ? hubUrl(anchorHostId) : orgHubUrl\n\n /*\n * THE EMAIL FIRST. A refusal from the send-rate governor stops the run\n * with nothing else done for this member — no notification to double\n * tomorrow, no marker to skip them on — so the order here is the\n * idempotence, not a preference.\n */\n let emailed = false\n let emailReason: string | undefined\n let address = String(member.email ?? '').trim().toLowerCase()\n if (!address.includes('@') && poolLookups < CRM_DIGEST_EMAIL_MAX_UID_LOOKUPS) {\n poolLookups += 1\n const pooled = await findUserByUidAcrossPools(uid).catch(() => null)\n address = String(pooled?.record?.email ?? '').trim().toLowerCase()\n }\n const recipients = address.includes('@')\n ? await filterSuppressedEmails([address], firestore)\n : []\n if (!recipients.length) {\n emailReason = address.includes('@') ? 'suppressed' : 'no-recipient'\n } else {\n const digestInput = {\n digest,\n nowMs,\n timeZone,\n productName: branding.productName,\n workspaceName,\n tasksUrl: hub ? `${hub}/tasks` : ctx.origin,\n leadsUrl: (hostId: string) => `${hubUrl(hostId) ?? ctx.origin}/leads`,\n settingsUrl,\n hostName,\n supportLine,\n }\n const subject = composeCrmDigestSubject(counts, workspaceName)\n // The `crm-daily-digest` system email (AGL-3367), in the org's brand,\n // read once per org and rendered per member.\n digestEmail ??= loadSystemEmail('crm-daily-digest').catch(() => null)\n const loaded = await digestEmail\n const rendered = loaded\n ? renderLoadedSystemEmail(\n loaded,\n {\n ...brand.merge,\n 'digest.subject': subject,\n 'digest.body': composeCrmDigestEmailText({ ...digestInput, omitClosing: true }),\n ...crmDigestMergeValues(composeCrmDigestEmailParts(digestInput)),\n settingsUrl,\n },\n brand.options,\n )\n : null\n const result = await sendEmail({\n to: recipients,\n ...systemEmailContent(rendered, {\n subject,\n text: composeCrmDigestEmailText(digestInput),\n }),\n fromName: branding.fromName,\n context: 'crm-daily-digest',\n priority: 'bulk',\n })\n if (result.sent) {\n emailed = true\n await meterPlatformEmail().catch(() => undefined)\n } else if (rateLimitedRetryAtMs(result) !== null) {\n row.deferred = true\n report.deferred = true\n return report\n } else {\n emailReason = (result as { reason?: string }).reason ?? 'failed'\n }\n }\n\n let notified = false\n if (!muted) {\n await notifyUsers([uid], {\n type: 'content.crmDailyDigest',\n title: 'Your CRM today',\n body: composeCrmDigestSummary(counts),\n link: notificationLink(anchorHostId, section),\n orgId,\n ...(anchorHostId ? { hostId: anchorHostId } : {}),\n })\n notified = true\n }\n\n await markerRef.set(\n {\n day,\n members: {\n [uid]: { atMs: nowMs, ...counts, notified, emailed },\n },\n updatedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n row.notified = notified\n row.emailed = emailed\n if (emailReason) row.emailReason = emailReason\n if (notified) report.notified += 1\n if (emailed) report.emailed += 1\n }\n return report\n}\n\nexport async function crmDailyDigestRoute(request: Request): Promise<Response> {\n const { method, body, headers: rawHeaders, query } = await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'POST' && method !== 'GET') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n if (!process.env.CRON_SECRET) {\n return Response.json(\n { error: 'The CRM digest is not configured (CRON_SECRET).' },\n { status: 501 },\n )\n }\n if (!isCronAuthorized(headers)) {\n return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n }\n // A GET reports the plan and sends nothing; a bodyless POST — what the\n // scheduled workflow sends — is a real run. `isCronDryRun` keys the\n // default on the METHOD for exactly that reason.\n const dryRun = isCronDryRun({ method, body, query })\n // Stamped on the invocation, not on the work (AGL-1955), so the beat is\n // already there the day this job joins `SCHEDULED_JOBS`.\n if (method === 'POST') await recordCronBeat(CRM_DIGEST_JOB_ID)\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n const nowMs = Date.now()\n const ctx: SweepContext = {\n firestore,\n nowMs,\n day: utcDayKey(nowMs),\n timeZone: digestTimeZone(),\n dryRun,\n flagValues: await getServerReleaseFlagValues(),\n origin: consoleOrigin(),\n }\n\n // The resumable sweep, in the shape `usage-alerts` reads orgs (AGL-2220):\n // ordered by id, `limit + 1` to learn whether there is more, the cursor\n // validated as an org id before it becomes a reference.\n const requestedLimit = Number((body as { limit?: unknown } | undefined)?.limit)\n const pageSize =\n Number.isFinite(requestedLimit) && requestedLimit > 0\n ? Math.min(Math.floor(requestedLimit), CRM_DIGEST_ORG_CHUNK)\n : CRM_DIGEST_ORG_CHUNK\n const rawCursor = (body as { cursor?: unknown } | undefined)?.cursor\n const cursor =\n typeof rawCursor === 'string' && rawCursor.length > 0 && !rawCursor.includes('/')\n ? rawCursor\n : null\n let orgQuery = firestore\n .collection('orgs')\n .orderBy(firebaseAdmin.firestore.FieldPath.documentId())\n .limit(pageSize + 1)\n if (cursor) orgQuery = orgQuery.startAfter(firestore.collection('orgs').doc(cursor))\n const page = await orgQuery.get()\n const hasMore = page.docs.length > pageSize\n const orgDocs = hasMore ? page.docs.slice(0, pageSize) : page.docs\n\n const orgs: Record<string, OrgReport> = {}\n const failures: Record<string, string> = {}\n let digests = 0\n let notified = 0\n let emailed = 0\n let deferred = false\n for (const orgDoc of orgDocs) {\n try {\n const report = await digestOrg(ctx, orgDoc)\n orgs[orgDoc.id] = report\n digests += report.digests\n notified += report.notified\n emailed += report.emailed\n if (report.deferred) {\n deferred = true\n break\n }\n } catch (error) {\n console.error('[crm] daily digest failed for org', orgDoc.id, error)\n failures[orgDoc.id] = (error as Error)?.message ?? 'unknown'\n }\n }\n const failed = Object.keys(failures)\n return Response.json(\n {\n day: ctx.day,\n timeZone: ctx.timeZone,\n dryRun,\n swept: orgDocs.length,\n digests,\n notified,\n emailed,\n orgs,\n ...(deferred ? { deferred: true } : {}),\n ...(failed.length ? { failures } : {}),\n // A deferred run reports `done: true` — see the module comment —\n // so the workflow's loop does not re-POST into a full window.\n nextCursor: deferred || !hasMore ? null : (orgDocs[orgDocs.length - 1]?.id ?? null),\n done: deferred || !hasMore,\n },\n // 207: this chunk finished and some org in it needs a person, which is\n // what fails the workflow (its one alerting channel).\n { status: failed.length ? 207 : 200 },\n )\n } catch (error) {\n console.error('[crm] daily digest run failed', error)\n return Response.json({ error: 'CRM digest run failed' }, { status: 500 })\n }\n}\n"],"names":["buildRoute","CRM_COLLECTIONS","DIGEST_PREFS_FIELD","digestEnabled","hostRoleFor","isReleaseFlagOnForOrg","notificationMuted","parseOrgReleaseFlagOverrides","pluginRequestFromWeb","resolveBrandingProfile","resolveEffectivePlan","resolveOrgEntitlements","Route","buildMemberDigests","composeCrmDigestEmailParts","composeCrmDigestEmailText","crmDigestMergeValues","composeCrmDigestSubject","composeCrmDigestSummary","CRM_DIGEST_LEAD_AGE_MS","CRM_DIGEST_LEAD_WINDOW","CRM_DIGEST_TASK_CEILING","crmDigestCounts","crmDigestEntitled","crmDigestWindow","digestTimeZone","isNurturingLead","isUnworkedLead","utcDayKey","rateLimitedRetryAtMs","sendEmail","withoutMailWithheldAccounts","findUserByUidAcrossPools","firebaseAdmin","getServerReleaseFlagValues","listOrgMembers","memberHasOrgPermission","meterPlatformEmail","notifyUsers","filterSuppressedEmails","loadSystemEmail","renderLoadedSystemEmail","systemEmailBrand","systemEmailContent","isCronAuthorized","isCronDryRun","writeCronBeat","brandSupportLine","platformConsoleOrigin","consoleOrigin","scopeTokensForHost","recordCronBeat","jobId","app","firestore","CRM_DIGEST_JOB_ID","CRM_DIGEST_ORG_CHUNK","CRM_DIGEST_HOST_PAGE","CRM_DIGEST_HOST_CEILING","CRM_DAILY_DIGEST_KEY","CRM_DIGEST_MARKER_COLLECTION","CRM_DIGEST_EMAIL_MAX_UID_LOOKUPS","hostsOfOrg","orgId","docs","cursor","query","collection","where","orderBy","FieldPath","documentId","limit","startAfter","doc","page","get","push","length","truncated","console","warn","id","notificationLink","hostId","section","digestOrg","ctx","orgDoc","marker","org","nowMs","day","timeZone","dryRun","data","quiet","digests","notified","emailed","features","skipped","flagOn","flagValues","window","taskPage","ref","tasks","endMs","map","title","String","kind","dueAtMs","Number","assigneeUid","contactId","undefined","companyId","dealId","tasksTruncated","hosts","hostById","Map","leadCutoff","leads","nurturing","host","fields","into","email","name","ownerUid","firstSeenAtMs","listed","filter","member","orgSuspended","reachable","Set","$id","members","has","memberById","uid","reachesHost","size","markerRef","sentToday","uids","keys","sort","userDocs","getAll","userById","branding","orgSlug","workspaceName","trim","hostName","hubUrl","subdomain","origin","HOST_DASHBOARD","orgHubUrl","ORG_HOME","settingsUrl","MANAGE_NOTIFICATION_SETTINGS","supportLine","brand","digestEmail","report","hostsTruncated","rows","poolLookups","digest","userDoc","muted","counts","row","anchorHostId","overdue","today","find","task","hub","emailReason","address","toLowerCase","includes","pooled","catch","record","recipients","digestInput","productName","tasksUrl","leadsUrl","subject","loaded","rendered","merge","omitClosing","options","result","to","text","fromName","context","priority","sent","deferred","reason","type","body","link","set","atMs","updatedAt","FieldValue","serverTimestamp","crmDailyDigestRoute","request","method","headers","rawHeaders","Response","json","error","status","process","env","CRON_SECRET","orgDocs","Date","now","requestedLimit","pageSize","isFinite","Math","min","floor","rawCursor","orgQuery","hasMore","slice","orgs","failures","message","failed","Object","swept","nextCursor","done"],"mappings":"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"}
|
|
@@ -30,7 +30,7 @@ import { writeCronBeat } from "@aglyn/aglyn/app-utils/health-report";
|
|
|
30
30
|
import { brandSupportLine } from "@aglyn/aglyn/app-utils/brand-support-line";
|
|
31
31
|
import { platformConsoleOrigin as consoleOrigin } from "@aglyn/aglyn/app-utils/platform-brand";
|
|
32
32
|
import { digestTimeZone } from "./daily-digest-route.js";
|
|
33
|
-
import { composeCrmTaskReminderBody, composeCrmTaskReminderEmailText, composeCrmTaskReminderSubject, CRM_TASK_REMINDER_CEILING, crmTaskReminderDue, crmTaskReminderLink } from "../model/crm-task-reminders.js";
|
|
33
|
+
import { composeCrmTaskReminderBody, composeCrmTaskReminderEmailParts, composeCrmTaskReminderEmailText, crmTaskReminderMergeValues, composeCrmTaskReminderSubject, CRM_TASK_REMINDER_CEILING, crmTaskReminderDue, crmTaskReminderLink } from "../model/crm-task-reminders.js";
|
|
34
34
|
/**
|
|
35
35
|
* The mark a scheduled run leaves for `/api/health/crons` (AGL-1955), on the
|
|
36
36
|
* INVOCATION and before the work — the console's rule for every cron. Never
|
|
@@ -330,7 +330,8 @@ async function remindOrg(ctx, orgDoc) {
|
|
|
330
330
|
'reminder.subject': subject,
|
|
331
331
|
'reminder.body': composeCrmTaskReminderEmailText(_extends({}, reminderInput, {
|
|
332
332
|
omitClosing: true
|
|
333
|
-
}))
|
|
333
|
+
}))
|
|
334
|
+
}, crmTaskReminderMergeValues(composeCrmTaskReminderEmailParts(reminderInput)), {
|
|
334
335
|
settingsUrl
|
|
335
336
|
}), brand.options) : null;
|
|
336
337
|
const result = await sendEmail(_extends({
|
|
@@ -1 +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 CRM_COLLECTIONS,\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'\nimport {\n composeCrmTaskReminderBody,\n composeCrmTaskReminderEmailText,\n composeCrmTaskReminderSubject,\n CRM_TASK_REMINDER_CEILING,\n type CrmReminderTask,\n crmTaskReminderDue,\n crmTaskReminderLink,\n} from '../model/crm-task-reminders'\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 workspaceName = String(org['name'] ?? '').trim() || orgSlug\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 workspaceName,\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, workspaceName),\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","CRM_COLLECTIONS","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","composeCrmTaskReminderBody","composeCrmTaskReminderEmailText","composeCrmTaskReminderSubject","CRM_TASK_REMINDER_CEILING","crmTaskReminderDue","crmTaskReminderLink","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","workspaceName","trim","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","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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"}
|
|
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 CRM_COLLECTIONS,\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'\nimport {\n composeCrmTaskReminderBody,\n composeCrmTaskReminderEmailParts,\n composeCrmTaskReminderEmailText,\n crmTaskReminderMergeValues,\n composeCrmTaskReminderSubject,\n CRM_TASK_REMINDER_CEILING,\n type CrmReminderTask,\n crmTaskReminderDue,\n crmTaskReminderLink,\n} from '../model/crm-task-reminders'\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 workspaceName = String(org['name'] ?? '').trim() || orgSlug\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 workspaceName,\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 ...crmTaskReminderMergeValues(composeCrmTaskReminderEmailParts(reminderInput)),\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, workspaceName),\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","CRM_COLLECTIONS","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","composeCrmTaskReminderBody","composeCrmTaskReminderEmailParts","composeCrmTaskReminderEmailText","crmTaskReminderMergeValues","composeCrmTaskReminderSubject","CRM_TASK_REMINDER_CEILING","crmTaskReminderDue","crmTaskReminderLink","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","workspaceName","trim","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","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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"}
|