@aglyn/plugins-crm 1.0.0-beta.222 → 1.0.0-beta.224

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/package.json +13 -13
  2. package/src/lib/components/crm-next-activity-column.js +1 -1
  3. package/src/lib/components/crm-next-activity-column.js.map +1 -1
  4. package/src/lib/components/reports/pipeline-card.js +2 -1
  5. package/src/lib/components/reports/pipeline-card.js.map +1 -1
  6. package/src/lib/declarations.server.js +5 -0
  7. package/src/lib/declarations.server.js.map +1 -1
  8. package/src/lib/model/contact-record.js +2 -1
  9. package/src/lib/model/contact-record.js.map +1 -1
  10. package/src/lib/model/crm-next-activity.d.ts +107 -0
  11. package/src/lib/model/crm-next-activity.js +140 -0
  12. package/src/lib/model/crm-next-activity.js.map +1 -0
  13. package/src/lib/model/crm-recipes.js +33 -18
  14. package/src/lib/model/crm-recipes.js.map +1 -1
  15. package/src/lib/model/crm-task-reminders.d.ts +152 -0
  16. package/src/lib/model/crm-task-reminders.js +161 -0
  17. package/src/lib/model/crm-task-reminders.js.map +1 -0
  18. package/src/lib/model/next-activity.d.ts +1 -1
  19. package/src/lib/model/next-activity.js.map +1 -1
  20. package/src/lib/server/api-v1/crm-tasks.js +4 -2
  21. package/src/lib/server/api-v1/crm-tasks.js.map +1 -1
  22. package/src/lib/server/assign-contact-owner.d.ts +27 -1
  23. package/src/lib/server/assign-contact-owner.js +136 -30
  24. package/src/lib/server/assign-contact-owner.js.map +1 -1
  25. package/src/lib/server/automation-steps.d.ts +2 -1
  26. package/src/lib/server/automation-steps.js +210 -13
  27. package/src/lib/server/automation-steps.js.map +1 -1
  28. package/src/lib/server/capture-contact.d.ts +17 -0
  29. package/src/lib/server/capture-contact.js +31 -6
  30. package/src/lib/server/capture-contact.js.map +1 -1
  31. package/src/lib/server/crm-email-activity.d.ts +118 -0
  32. package/src/lib/server/crm-email-activity.js +148 -0
  33. package/src/lib/server/crm-email-activity.js.map +1 -0
  34. package/src/lib/server/crm-inbound-email.js +1 -1
  35. package/src/lib/server/crm-inbound-email.js.map +1 -1
  36. package/src/lib/server/crm-next-activity.d.ts +74 -0
  37. package/src/lib/server/crm-next-activity.js +194 -0
  38. package/src/lib/server/crm-next-activity.js.map +1 -0
  39. package/src/lib/server/email-send.js +2 -1
  40. package/src/lib/server/email-send.js.map +1 -1
  41. package/src/lib/server/merge-contacts.js +1 -1
  42. package/src/lib/server/merge-contacts.js.map +1 -1
  43. package/src/lib/server/next-activity-routes.js +2 -1
  44. package/src/lib/server/next-activity-routes.js.map +1 -1
  45. package/src/lib/server/recipe-routes.js +7 -1
  46. package/src/lib/server/recipe-routes.js.map +1 -1
  47. package/src/lib/server/record-timeline.js +26 -2
  48. package/src/lib/server/record-timeline.js.map +1 -1
  49. package/src/lib/server/task-reminders-route.js +2 -1
  50. package/src/lib/server/task-reminders-route.js.map +1 -1
  51. package/src/lib/server/task-routes.js +4 -2
  52. package/src/lib/server/task-routes.js.map +1 -1
@@ -0,0 +1,140 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /**
17
+ * NEXT ACTIVITY (AGL-2661): when the earliest open task against a record
18
+ * is due, denormalized onto the record as `nextTaskAtMs`.
19
+ *
20
+ * The question a sales manager asks of a LIST — "which deals have nothing
21
+ * scheduled?" — cannot be answered by a task query per row, so every
22
+ * contact, company and deal carries the answer. This module is the pure
23
+ * half: what the value IS given a record's open tasks, how a stored one is
24
+ * read back, the view clause a list filters on, and the report figure. The
25
+ * admin library's `recomputeCrmNextTaskAt` is the writer; the console's
26
+ * route is its door for client-direct task writes.
27
+ */ import { _ as _extends } from "@swc/helpers/_/_extends";
28
+ /** The field on a contact, a company and a deal — the one name every list reads. */ export const CRM_NEXT_ACTIVITY_FIELD = 'nextTaskAtMs';
29
+ /** How the collections are keyed on a task, in the order a recompute walks them. */ export const CRM_NEXT_ACTIVITY_LINK_FIELDS = [
30
+ 'contactId',
31
+ 'companyId',
32
+ 'dealId'
33
+ ];
34
+ /**
35
+ * When the earliest OPEN task in the list is due, or `null`.
36
+ *
37
+ * Only an open task with a finite due time counts: a done task is history,
38
+ * and an open one with no date is a to-do with no "next" about it — the
39
+ * record's next activity is unknown rather than immediate, and `null`
40
+ * says so. A pre-change writer never produced a time outside the finite
41
+ * range, but a stored `dueAtMs` is trusted no further than that.
42
+ */ export function nextTaskAtMsOf(tasks) {
43
+ let earliest = null;
44
+ for (const task of tasks){
45
+ if (task.status !== 'open') continue;
46
+ const due = task.dueAtMs;
47
+ if (typeof due !== 'number' || !Number.isFinite(due)) continue;
48
+ if (earliest === null || due < earliest) earliest = due;
49
+ }
50
+ return earliest;
51
+ }
52
+ /**
53
+ * A stored `nextTaskAtMs`, read back: a finite positive number, else
54
+ * `null`. Absent — a record written before the field existed — reads as
55
+ * `null` too, because to every reader "not yet computed" and "nothing
56
+ * scheduled" draw the same dash; the Fields section's recompute is what
57
+ * closes the gap for a filter that asks the store rather than the row.
58
+ */ export function readNextTaskAtMs(record) {
59
+ const value = record == null ? void 0 : record.nextTaskAtMs;
60
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null;
61
+ }
62
+ /** Whether nothing is scheduled against the record — the "stuck" test. */ export function hasNoNextActivity(record) {
63
+ return readNextTaskAtMs(record) === null;
64
+ }
65
+ /** The links one task carries, with the blanks left out. */ export function nextActivityLinksOfTask(task) {
66
+ const link = {};
67
+ for (const field of CRM_NEXT_ACTIVITY_LINK_FIELDS){
68
+ var _ref;
69
+ const id = String((_ref = task == null ? void 0 : task[field]) != null ? _ref : '').trim();
70
+ if (id) link[field] = id;
71
+ }
72
+ return link;
73
+ }
74
+ /**
75
+ * Several links as one per record, so a bulk action over forty tasks on
76
+ * one deal recomputes that deal once. Order is first-seen.
77
+ */ export function mergeNextActivityLinks(links) {
78
+ const seen = new Set();
79
+ const merged = [];
80
+ for (const link of links){
81
+ for (const field of CRM_NEXT_ACTIVITY_LINK_FIELDS){
82
+ var _ref;
83
+ const id = String((_ref = link == null ? void 0 : link[field]) != null ? _ref : '').trim();
84
+ if (!id) continue;
85
+ const key = `${field}:${id}`;
86
+ if (seen.has(key)) continue;
87
+ seen.add(key);
88
+ merged.push({
89
+ field,
90
+ id
91
+ });
92
+ }
93
+ }
94
+ return merged;
95
+ }
96
+ /**
97
+ * The saved-view clause "No next activity" is stored as — one clause,
98
+ * spelled once, so the toggle a list draws and the matcher it runs agree
99
+ * on the name. `isEmpty` is honest here: a recompute writes `null` for a
100
+ * record with nothing scheduled, and a record never written reads blank
101
+ * too (see `readNextTaskAtMs`).
102
+ */ export const CRM_NO_NEXT_ACTIVITY_CLAUSE = Object.freeze({
103
+ field: CRM_NEXT_ACTIVITY_FIELD,
104
+ op: 'isEmpty',
105
+ value: ''
106
+ });
107
+ export function isNoNextActivityClause(clause) {
108
+ return clause.field === CRM_NEXT_ACTIVITY_FIELD && clause.op === 'isEmpty';
109
+ }
110
+ /** A view's filters with the clause switched on or off, the rest untouched. */ export function withNoNextActivity(filters, on) {
111
+ const rest = filters.filter((clause)=>!isNoNextActivityClause(clause));
112
+ return on ? [
113
+ ...rest,
114
+ _extends({}, CRM_NO_NEXT_ACTIVITY_CLAUSE)
115
+ ] : rest;
116
+ }
117
+ /**
118
+ * The rows a "No next activity" clause keeps: every one when the view does
119
+ * not carry it, else those with nothing scheduled. What a list that
120
+ * filters through a query — companies, deals — runs over the page it
121
+ * loaded, the way the contacts list runs its window-only clauses.
122
+ */ export function filterByNextActivity(rows, filters) {
123
+ return filters.some(isNoNextActivityClause) ? rows.filter(hasNoNextActivity) : [
124
+ ...rows
125
+ ];
126
+ }
127
+ /**
128
+ * The report figure: how many OPEN deals have nothing scheduled, and which
129
+ * (AGL-2661). A won deal needs no next step and a lost one has none, so
130
+ * only open deals are stuck; the caller hands in the window it already
131
+ * reads for the pipeline card, so this costs no read of its own.
132
+ */ export function stuckDeals(deals) {
133
+ const stuck = deals.filter((deal)=>deal.status === 'open' && hasNoNextActivity(deal));
134
+ return {
135
+ count: stuck.length,
136
+ deals: stuck
137
+ };
138
+ }
139
+
140
+ //# sourceMappingURL=crm-next-activity.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/crm-next-activity.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 * NEXT ACTIVITY (AGL-2661): when the earliest open task against a record\n * is due, denormalized onto the record as `nextTaskAtMs`.\n *\n * The question a sales manager asks of a LIST — \"which deals have nothing\n * scheduled?\" — cannot be answered by a task query per row, so every\n * contact, company and deal carries the answer. This module is the pure\n * half: what the value IS given a record's open tasks, how a stored one is\n * read back, the view clause a list filters on, and the report figure. The\n * admin library's `recomputeCrmNextTaskAt` is the writer; the console's\n * route is its door for client-direct task writes.\n */\n\nimport type { CrmDeal, CrmTask, CrmViewFilterClause } from '@aglyn/aglyn/app-utils/crm'\n\n/** The field on a contact, a company and a deal — the one name every list reads. */\nexport const CRM_NEXT_ACTIVITY_FIELD = 'nextTaskAtMs'\n\n/**\n * The records one task can be filed against — the ids a recompute reads\n * off a task, and the three collections it writes to.\n */\nexport interface CrmNextActivityLink {\n contactId?: string\n companyId?: string\n dealId?: string\n}\n\n/** How the collections are keyed on a task, in the order a recompute walks them. */\nexport const CRM_NEXT_ACTIVITY_LINK_FIELDS = ['contactId', 'companyId', 'dealId'] as const\n\nexport type CrmNextActivityLinkField = (typeof CRM_NEXT_ACTIVITY_LINK_FIELDS)[number]\n\n/**\n * When the earliest OPEN task in the list is due, or `null`.\n *\n * Only an open task with a finite due time counts: a done task is history,\n * and an open one with no date is a to-do with no \"next\" about it — the\n * record's next activity is unknown rather than immediate, and `null`\n * says so. A pre-change writer never produced a time outside the finite\n * range, but a stored `dueAtMs` is trusted no further than that.\n */\nexport function nextTaskAtMsOf(\n tasks: readonly Pick<CrmTask, 'status' | 'dueAtMs'>[],\n): number | null {\n let earliest: number | null = null\n for (const task of tasks) {\n if (task.status !== 'open') continue\n const due = task.dueAtMs\n if (typeof due !== 'number' || !Number.isFinite(due)) continue\n if (earliest === null || due < earliest) earliest = due\n }\n return earliest\n}\n\n/**\n * A stored `nextTaskAtMs`, read back: a finite positive number, else\n * `null`. Absent — a record written before the field existed — reads as\n * `null` too, because to every reader \"not yet computed\" and \"nothing\n * scheduled\" draw the same dash; the Fields section's recompute is what\n * closes the gap for a filter that asks the store rather than the row.\n */\nexport function readNextTaskAtMs(\n record: { nextTaskAtMs?: unknown } | null | undefined,\n): number | null {\n const value = record?.nextTaskAtMs\n return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null\n}\n\n/** Whether nothing is scheduled against the record — the \"stuck\" test. */\nexport function hasNoNextActivity(\n record: { nextTaskAtMs?: unknown } | null | undefined,\n): boolean {\n return readNextTaskAtMs(record) === null\n}\n\n/** The links one task carries, with the blanks left out. */\nexport function nextActivityLinksOfTask(\n task: Partial<Pick<CrmTask, CrmNextActivityLinkField>> | null | undefined,\n): CrmNextActivityLink {\n const link: CrmNextActivityLink = {}\n for (const field of CRM_NEXT_ACTIVITY_LINK_FIELDS) {\n const id = String(task?.[field] ?? '').trim()\n if (id) link[field] = id\n }\n return link\n}\n\n/**\n * Several links as one per record, so a bulk action over forty tasks on\n * one deal recomputes that deal once. Order is first-seen.\n */\nexport function mergeNextActivityLinks(\n links: readonly (CrmNextActivityLink | null | undefined)[],\n): Array<{ field: CrmNextActivityLinkField; id: string }> {\n const seen = new Set<string>()\n const merged: Array<{ field: CrmNextActivityLinkField; id: string }> = []\n for (const link of links) {\n for (const field of CRM_NEXT_ACTIVITY_LINK_FIELDS) {\n const id = String(link?.[field] ?? '').trim()\n if (!id) continue\n const key = `${field}:${id}`\n if (seen.has(key)) continue\n seen.add(key)\n merged.push({ field, id })\n }\n }\n return merged\n}\n\n/**\n * The saved-view clause \"No next activity\" is stored as — one clause,\n * spelled once, so the toggle a list draws and the matcher it runs agree\n * on the name. `isEmpty` is honest here: a recompute writes `null` for a\n * record with nothing scheduled, and a record never written reads blank\n * too (see `readNextTaskAtMs`).\n */\nexport const CRM_NO_NEXT_ACTIVITY_CLAUSE: Readonly<CrmViewFilterClause> = Object.freeze({\n field: CRM_NEXT_ACTIVITY_FIELD,\n op: 'isEmpty',\n value: '',\n})\n\nexport function isNoNextActivityClause(clause: Pick<CrmViewFilterClause, 'field' | 'op'>): boolean {\n return clause.field === CRM_NEXT_ACTIVITY_FIELD && clause.op === 'isEmpty'\n}\n\n/** A view's filters with the clause switched on or off, the rest untouched. */\nexport function withNoNextActivity(\n filters: readonly CrmViewFilterClause[],\n on: boolean,\n): CrmViewFilterClause[] {\n const rest = filters.filter((clause) => !isNoNextActivityClause(clause))\n return on ? [...rest, { ...CRM_NO_NEXT_ACTIVITY_CLAUSE }] : rest\n}\n\n/**\n * The rows a \"No next activity\" clause keeps: every one when the view does\n * not carry it, else those with nothing scheduled. What a list that\n * filters through a query — companies, deals — runs over the page it\n * loaded, the way the contacts list runs its window-only clauses.\n */\nexport function filterByNextActivity<T extends { nextTaskAtMs?: unknown }>(\n rows: readonly T[],\n filters: readonly CrmViewFilterClause[],\n): T[] {\n return filters.some(isNoNextActivityClause) ? rows.filter(hasNoNextActivity) : [...rows]\n}\n\n/**\n * The report figure: how many OPEN deals have nothing scheduled, and which\n * (AGL-2661). A won deal needs no next step and a lost one has none, so\n * only open deals are stuck; the caller hands in the window it already\n * reads for the pipeline card, so this costs no read of its own.\n */\nexport function stuckDeals<T extends Pick<CrmDeal, 'status' | 'nextTaskAtMs'>>(\n deals: readonly T[],\n): { count: number; deals: T[] } {\n const stuck = deals.filter((deal) => deal.status === 'open' && hasNoNextActivity(deal))\n return { count: stuck.length, deals: stuck }\n}\n"],"names":["CRM_NEXT_ACTIVITY_FIELD","CRM_NEXT_ACTIVITY_LINK_FIELDS","nextTaskAtMsOf","tasks","earliest","task","status","due","dueAtMs","Number","isFinite","readNextTaskAtMs","record","value","nextTaskAtMs","hasNoNextActivity","nextActivityLinksOfTask","link","field","id","String","trim","mergeNextActivityLinks","links","seen","Set","merged","key","has","add","push","CRM_NO_NEXT_ACTIVITY_CLAUSE","Object","freeze","op","isNoNextActivityClause","clause","withNoNextActivity","filters","on","rest","filter","filterByNextActivity","rows","some","stuckDeals","deals","stuck","deal","count","length"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;CAWC;AAID,kFAAkF,GAClF,OAAO,MAAMA,0BAA0B,eAAc;AAYrD,kFAAkF,GAClF,OAAO,MAAMC,gCAAgC;IAAC;IAAa;IAAa;CAAS,CAAS;AAI1F;;;;;;;;CAQC,GACD,OAAO,SAASC,eACdC,KAAqD;IAErD,IAAIC,WAA0B;IAC9B,KAAK,MAAMC,QAAQF,MAAO;QACxB,IAAIE,KAAKC,MAAM,KAAK,QAAQ;QAC5B,MAAMC,MAAMF,KAAKG,OAAO;QACxB,IAAI,OAAOD,QAAQ,YAAY,CAACE,OAAOC,QAAQ,CAACH,MAAM;QACtD,IAAIH,aAAa,QAAQG,MAAMH,UAAUA,WAAWG;IACtD;IACA,OAAOH;AACT;AAEA;;;;;;CAMC,GACD,OAAO,SAASO,iBACdC,MAAqD;IAErD,MAAMC,QAAQD,0BAAAA,OAAQE,YAAY;IAClC,OAAO,OAAOD,UAAU,YAAYJ,OAAOC,QAAQ,CAACG,UAAUA,QAAQ,IAAIA,QAAQ;AACpF;AAEA,wEAAwE,GACxE,OAAO,SAASE,kBACdH,MAAqD;IAErD,OAAOD,iBAAiBC,YAAY;AACtC;AAEA,0DAA0D,GAC1D,OAAO,SAASI,wBACdX,IAAyE;IAEzE,MAAMY,OAA4B,CAAC;IACnC,KAAK,MAAMC,SAASjB,8BAA+B;;QACjD,MAAMkB,KAAKC,eAAOf,wBAAAA,IAAM,CAACa,MAAM,mBAAI,IAAIG,IAAI;QAC3C,IAAIF,IAAIF,IAAI,CAACC,MAAM,GAAGC;IACxB;IACA,OAAOF;AACT;AAEA;;;CAGC,GACD,OAAO,SAASK,uBACdC,KAA0D;IAE1D,MAAMC,OAAO,IAAIC;IACjB,MAAMC,SAAiE,EAAE;IACzE,KAAK,MAAMT,QAAQM,MAAO;QACxB,KAAK,MAAML,SAASjB,8BAA+B;;YACjD,MAAMkB,KAAKC,eAAOH,wBAAAA,IAAM,CAACC,MAAM,mBAAI,IAAIG,IAAI;YAC3C,IAAI,CAACF,IAAI;YACT,MAAMQ,MAAM,GAAGT,MAAM,CAAC,EAAEC,IAAI;YAC5B,IAAIK,KAAKI,GAAG,CAACD,MAAM;YACnBH,KAAKK,GAAG,CAACF;YACTD,OAAOI,IAAI,CAAC;gBAAEZ;gBAAOC;YAAG;QAC1B;IACF;IACA,OAAOO;AACT;AAEA;;;;;;CAMC,GACD,OAAO,MAAMK,8BAA6DC,OAAOC,MAAM,CAAC;IACtFf,OAAOlB;IACPkC,IAAI;IACJrB,OAAO;AACT,GAAE;AAEF,OAAO,SAASsB,uBAAuBC,MAAiD;IACtF,OAAOA,OAAOlB,KAAK,KAAKlB,2BAA2BoC,OAAOF,EAAE,KAAK;AACnE;AAEA,6EAA6E,GAC7E,OAAO,SAASG,mBACdC,OAAuC,EACvCC,EAAW;IAEX,MAAMC,OAAOF,QAAQG,MAAM,CAAC,CAACL,SAAW,CAACD,uBAAuBC;IAChE,OAAOG,KAAK;WAAIC;QAAM,aAAKT;KAA8B,GAAGS;AAC9D;AAEA;;;;;CAKC,GACD,OAAO,SAASE,qBACdC,IAAkB,EAClBL,OAAuC;IAEvC,OAAOA,QAAQM,IAAI,CAACT,0BAA0BQ,KAAKF,MAAM,CAAC1B,qBAAqB;WAAI4B;KAAK;AAC1F;AAEA;;;;;CAKC,GACD,OAAO,SAASE,WACdC,KAAmB;IAEnB,MAAMC,QAAQD,MAAML,MAAM,CAAC,CAACO,OAASA,KAAK1C,MAAM,KAAK,UAAUS,kBAAkBiC;IACjF,OAAO;QAAEC,OAAOF,MAAMG,MAAM;QAAEJ,OAAOC;IAAM;AAC7C"}
@@ -64,19 +64,28 @@ export const CRM_ACTION_RECIPES = [
64
64
  {
65
65
  id: 'welcomeNewLead',
66
66
  title: 'Welcome a new lead',
67
- description: 'When a form makes a new contact: rotate in an owner, book a call for ' + 'tomorrow, send a thank-you, and tag them website.',
67
+ description: 'When a form makes a new lead: rotate in an owner, book a call for ' + 'tomorrow, send a thank-you, and tag them website.',
68
68
  /*
69
+ * ON A NEW LEAD (AGL-3458), because that is the record a lead-routed form
70
+ * makes: under the one-record model (AGL-3232) a form with lead routing
71
+ * on files a LEAD and no contact, so a new-contact trigger would never
72
+ * reach the people this recipe is named for. `formId` is on the `lead`
73
+ * event exactly when a form filed the lead, so the condition keeps the
74
+ * recipe to forms and leaves a booking request to the booking's own
75
+ * confirmation. Every step below acts on the lead the event names when
76
+ * the workspace holds no contact for the person.
77
+ *
69
78
  * The owner first, because the task that follows names no assignee and
70
- * so goes to whoever owns the contact when it is created — the member
71
- * the rotation just chose. Round robin rather than a named member: a
72
- * recipe cannot know who is on the team, and the pool under CRM →
73
- * Settings is the one place that does. On a workspace with no pool the
74
- * step fails and the run continues, so the call, the email and the tag
75
- * still land and the run history says who was not assigned.
79
+ * so goes to whoever owns the lead when it is created — the member the
80
+ * rotation just chose. Round robin rather than a named member: a recipe
81
+ * cannot know who is on the team, and the pool under CRM → Settings is
82
+ * the one place that does. On a workspace with no pool the step fails
83
+ * and the run continues, so the call, the email and the tag still land
84
+ * and the run history says who was not assigned.
76
85
  *
77
86
  * The email comes before any wait, which makes it an immediate reply to
78
- * what the visitor just did — transactional, sent from the org's own
79
- * identity, to the address the event carries.
87
+ * what the visitor just did — a transactional reply, sent from the org's
88
+ * own identity to the address the event carries, with no unsubscribe.
80
89
  *
81
90
  * Its words promise no response time. The org hub installs this recipe
82
91
  * into a site without its editor opening, so the business never reads
@@ -86,12 +95,11 @@ export const CRM_ACTION_RECIPES = [
86
95
  recipe: 'welcomeNewLead',
87
96
  name: 'Welcome a new lead',
88
97
  trigger: {
89
- event: 'contactCreated',
98
+ event: 'lead',
90
99
  conditions: [
91
100
  {
92
- field: 'source',
93
- op: 'equals',
94
- value: 'form'
101
+ field: 'formId',
102
+ op: 'notEmpty'
95
103
  }
96
104
  ],
97
105
  combinator: 'and'
@@ -110,7 +118,7 @@ export const CRM_ACTION_RECIPES = [
110
118
  {
111
119
  type: 'sendEmail',
112
120
  subject: 'Thanks for getting in touch',
113
- body: 'Thanks for reaching out. We have your message and will reply ' + 'to this email address.'
121
+ body: 'Hi {{firstName|there}},\n\nThanks for reaching out. We have your ' + 'message and will reply to this email address.'
114
122
  },
115
123
  {
116
124
  type: 'addContactTag',
@@ -200,22 +208,29 @@ export const CRM_ACTION_RECIPES = [
200
208
  {
201
209
  id: 'tagByForm',
202
210
  title: 'Tag by form',
203
- description: 'When a form you pick makes a new contact: tag them with the form’s ' + 'name.',
211
+ description: 'When a form you pick makes a new lead or contact: tag them with the ' + 'form’s name.',
204
212
  picks: {
205
213
  kind: 'form',
206
214
  label: 'Form',
207
215
  plural: 'forms',
208
- prompt: 'Pick the form whose new contacts get the tag.',
216
+ prompt: 'Pick the form whose new leads or contacts get the tag.',
209
217
  none: 'This site has no forms yet. Add one in the besigner, then come back.'
210
218
  },
211
- build: (input)=>{
219
+ /*
220
+ * The event follows the form's routing (AGL-3458): a lead-routed form
221
+ * makes a lead and no contact, so keyed on `contactCreated` it would
222
+ * never fire. The Forms plugin shares the routing as the picked form's
223
+ * `routesLeads` fact; both events carry `formId` when a form made the
224
+ * record.
225
+ */ build: (input)=>{
212
226
  var _ref;
227
+ var _form_facts;
213
228
  const form = input == null ? void 0 : input.picked;
214
229
  return {
215
230
  recipe: 'tagByForm',
216
231
  name: form ? `Tag ${form.name.trim()} submissions` : 'Tag by form',
217
232
  trigger: {
218
- event: 'contactCreated',
233
+ event: (form == null ? void 0 : (_form_facts = form.facts) == null ? void 0 : _form_facts['routesLeads']) === true ? 'lead' : 'contactCreated',
219
234
  conditions: [
220
235
  {
221
236
  field: 'formId',
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/crm-recipes.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 { CONTACT_TAG_MAX_LENGTH } from '@aglyn/aglyn/app-utils/crm-kinds'\nimport type {\n InteractionRecipe,\n InteractionRecipeInput,\n} from '@aglyn/aglyn/plugin-manager/interaction-recipes'\n\n/*\n * THE CRM'S RECIPES (AGL-2626).\n *\n * A recipe is a ready-to-edit automation — a trigger, its conditions and an\n * ordered step list — built from the vocabulary the automation editor already\n * offers and handed to that editor as a draft. Nothing is written until the\n * person saves, and every field is theirs to change first: the recipe decides\n * where the editor starts, not what the site runs.\n *\n * The CRM's own, because what each one is FOR is the CRM's: who a new lead is,\n * when a deal is won, when a lead has gone quiet. They reach the automation\n * editor through the platform's recipe seam\n * (`plugin-manager/interaction-recipes`, AGL-3080), registered from this\n * plugin's declarations; the org hub's Recipes card and its install routes\n * read them here. Their ids are declared in `plugins.config.json`\n * (`interactionRecipes`) because a stored automation names one.\n *\n * Definitions only. Three of the four are complete as written. `tagByForm`\n * needs a form, and a form belongs to a site, so the picker that supplies it\n * is the editor's business and host-scoped by nature. That is the seam an\n * org-level mount keeps: the recipes are the same at either level, and only\n * the form picker knows where it is.\n */\nexport const CRM_ACTION_RECIPE_IDS = [\n 'welcomeNewLead',\n 'followUpWonDeal',\n 'reengageStaleLead',\n 'tagByForm',\n] as const\n\nexport type CrmActionRecipeId = (typeof CRM_ACTION_RECIPE_IDS)[number]\n\n/** What a recipe is handed before it builds — today only the form `tagByForm` reads. */\nexport type CrmActionRecipeInput = InteractionRecipeInput\n\nexport interface CrmActionRecipe extends InteractionRecipe {\n id: CrmActionRecipeId\n}\n\n/**\n * The field a resumed wait carries in its scope when it ended on the CLOCK\n * rather than on the event it watched for — the automation engine's word for\n * it, written into the step guard the stale-lead recipe stores.\n */\nconst WAIT_TIMED_OUT_FIELD = '_waitTimedOut'\n\n/**\n * How long the stale-lead recipe holds before it decides a lead has gone\n * quiet. A week: long enough that a rep who is working the lead has had a\n * chance to move its stage, short enough that a lead nobody touched is\n * still warm when the reminder lands.\n */\nexport const STALE_LEAD_WAIT_MINUTES = 7 * 24 * 60\n\n/**\n * The tag a form's captures get: the form's own name, cut to the tag cap.\n * The name is what the person calls the form, so it is the tag they would\n * have typed; the editor is open to change it before anything is saved.\n */\nexport function crmRecipeTagForForm(formName: string): string {\n return formName.trim().slice(0, CONTACT_TAG_MAX_LENGTH)\n}\n\nexport const CRM_ACTION_RECIPES: readonly CrmActionRecipe[] = [\n {\n id: 'welcomeNewLead',\n title: 'Welcome a new lead',\n description:\n 'When a form makes a new contact: rotate in an owner, book a call for ' +\n 'tomorrow, send a thank-you, and tag them website.',\n /*\n * The owner first, because the task that follows names no assignee and\n * so goes to whoever owns the contact when it is created — the member\n * the rotation just chose. Round robin rather than a named member: a\n * recipe cannot know who is on the team, and the pool under CRM →\n * Settings is the one place that does. On a workspace with no pool the\n * step fails and the run continues, so the call, the email and the tag\n * still land and the run history says who was not assigned.\n *\n * The email comes before any wait, which makes it an immediate reply to\n * what the visitor just did — transactional, sent from the org's own\n * identity, to the address the event carries.\n *\n * Its words promise no response time. The org hub installs this recipe\n * into a site without its editor opening, so the business never reads\n * the sentence it is sending, and a \"within a day\" written here would be\n * a commitment made on its behalf that nothing in the run keeps.\n */\n build: () => ({\n recipe: 'welcomeNewLead',\n name: 'Welcome a new lead',\n trigger: {\n event: 'contactCreated',\n conditions: [{ field: 'source', op: 'equals', value: 'form' }],\n combinator: 'and',\n },\n steps: [\n { type: 'assignContactOwner', roundRobin: true },\n {\n type: 'createCrmTask',\n title: 'Call the new lead',\n kind: 'call',\n dueInDays: 1,\n },\n {\n type: 'sendEmail',\n subject: 'Thanks for getting in touch',\n body:\n 'Thanks for reaching out. We have your message and will reply ' +\n 'to this email address.',\n },\n { type: 'addContactTag', tag: 'website' },\n ],\n enabled: true,\n }),\n },\n {\n id: 'followUpWonDeal',\n title: 'Follow up a won deal',\n description:\n 'When a deal is won — which makes the contact a Customer on its own — ' +\n 'book a check-in call a week out.',\n /*\n * No stage step (AGL-2641): the win itself floors the contact at\n * `customer` before `dealWon` is announced, so a step setting the stage\n * here would at best repeat the write and at worst move an evangelist\n * back to customer — a SET, which is what the step is, and not the\n * floor the win applies. The recipe books the follow-up and nothing\n * else.\n */\n build: () => ({\n recipe: 'followUpWonDeal',\n name: 'Follow up a won deal',\n trigger: { event: 'dealWon' },\n steps: [\n {\n type: 'createCrmTask',\n title: 'Check in with the new customer',\n kind: 'call',\n dueInDays: 7,\n },\n ],\n enabled: true,\n }),\n },\n {\n id: 'reengageStaleLead',\n title: 'Re-engage a stale lead',\n description:\n 'When a contact becomes a lead: wait a week, and if their stage has ' +\n 'not moved, book a call to bring them back.',\n /*\n * \"Unless the stage moved on\" is the wait's own event: the flow watches\n * for the next stage change on this person and gives up after a week.\n * Resumed by the clock, the run carries `_waitTimedOut`, and the task\n * step's guard reads it; resumed by the event, the field is absent and\n * the task is skipped — the lead was worked, and nobody is told to\n * re-engage somebody who just moved to Sales qualified.\n */\n build: () => ({\n recipe: 'reengageStaleLead',\n name: 'Re-engage a stale lead',\n trigger: {\n event: 'contactStageChanged',\n conditions: [{ field: 'lifecycleStage', op: 'equals', value: 'lead' }],\n combinator: 'and',\n },\n steps: [\n {\n type: 'waitForEvent',\n eventName: 'contactStageChanged',\n timeoutMinutes: STALE_LEAD_WAIT_MINUTES,\n },\n {\n type: 'createCrmTask',\n title: 'Re-engage a lead that has gone quiet',\n kind: 'call',\n dueInDays: 1,\n when: {\n conditions: [{ field: WAIT_TIMED_OUT_FIELD, op: 'notEmpty' }],\n },\n },\n ],\n enabled: true,\n }),\n },\n {\n id: 'tagByForm',\n title: 'Tag by form',\n description:\n 'When a form you pick makes a new contact: tag them with the form’s ' +\n 'name.',\n picks: {\n kind: 'form',\n label: 'Form',\n plural: 'forms',\n prompt: 'Pick the form whose new contacts get the tag.',\n none: 'This site has no forms yet. Add one in the besigner, then come back.',\n },\n build: (input) => {\n const form = input?.picked\n return {\n recipe: 'tagByForm',\n name: form ? `Tag ${form.name.trim()} submissions` : 'Tag by form',\n trigger: {\n event: 'contactCreated',\n conditions: [{ field: 'formId', op: 'equals', value: form?.id ?? '' }],\n combinator: 'and',\n },\n steps: [{ type: 'addContactTag', tag: form ? crmRecipeTagForForm(form.name) : '' }],\n enabled: true,\n }\n },\n },\n]\n\n/** The recipe with this id, or null for a string that names none. */\nexport function crmActionRecipe(id: unknown): CrmActionRecipe | null {\n return CRM_ACTION_RECIPES.find((recipe) => recipe.id === id) ?? null\n}\n"],"names":["CONTACT_TAG_MAX_LENGTH","CRM_ACTION_RECIPE_IDS","WAIT_TIMED_OUT_FIELD","STALE_LEAD_WAIT_MINUTES","crmRecipeTagForForm","formName","trim","slice","CRM_ACTION_RECIPES","id","title","description","build","recipe","name","trigger","event","conditions","field","op","value","combinator","steps","type","roundRobin","kind","dueInDays","subject","body","tag","enabled","eventName","timeoutMinutes","when","picks","label","plural","prompt","none","input","form","picked","crmActionRecipe","find"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/crm-recipes.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 { CONTACT_TAG_MAX_LENGTH } from '@aglyn/aglyn/app-utils/crm-kinds'\nimport type {\n InteractionRecipe,\n InteractionRecipeInput,\n} from '@aglyn/aglyn/plugin-manager/interaction-recipes'\n\n/*\n * THE CRM'S RECIPES (AGL-2626).\n *\n * A recipe is a ready-to-edit automation — a trigger, its conditions and an\n * ordered step list — built from the vocabulary the automation editor already\n * offers and handed to that editor as a draft. Nothing is written until the\n * person saves, and every field is theirs to change first: the recipe decides\n * where the editor starts, not what the site runs.\n *\n * The CRM's own, because what each one is FOR is the CRM's: who a new lead is,\n * when a deal is won, when a lead has gone quiet. They reach the automation\n * editor through the platform's recipe seam\n * (`plugin-manager/interaction-recipes`, AGL-3080), registered from this\n * plugin's declarations; the org hub's Recipes card and its install routes\n * read them here. Their ids are declared in `plugins.config.json`\n * (`interactionRecipes`) because a stored automation names one.\n *\n * Definitions only. Three of the four are complete as written. `tagByForm`\n * needs a form, and a form belongs to a site, so the picker that supplies it\n * is the editor's business and host-scoped by nature. That is the seam an\n * org-level mount keeps: the recipes are the same at either level, and only\n * the form picker knows where it is.\n */\nexport const CRM_ACTION_RECIPE_IDS = [\n 'welcomeNewLead',\n 'followUpWonDeal',\n 'reengageStaleLead',\n 'tagByForm',\n] as const\n\nexport type CrmActionRecipeId = (typeof CRM_ACTION_RECIPE_IDS)[number]\n\n/** What a recipe is handed before it builds — today only the form `tagByForm` reads. */\nexport type CrmActionRecipeInput = InteractionRecipeInput\n\nexport interface CrmActionRecipe extends InteractionRecipe {\n id: CrmActionRecipeId\n}\n\n/**\n * The field a resumed wait carries in its scope when it ended on the CLOCK\n * rather than on the event it watched for — the automation engine's word for\n * it, written into the step guard the stale-lead recipe stores.\n */\nconst WAIT_TIMED_OUT_FIELD = '_waitTimedOut'\n\n/**\n * How long the stale-lead recipe holds before it decides a lead has gone\n * quiet. A week: long enough that a rep who is working the lead has had a\n * chance to move its stage, short enough that a lead nobody touched is\n * still warm when the reminder lands.\n */\nexport const STALE_LEAD_WAIT_MINUTES = 7 * 24 * 60\n\n/**\n * The tag a form's captures get: the form's own name, cut to the tag cap.\n * The name is what the person calls the form, so it is the tag they would\n * have typed; the editor is open to change it before anything is saved.\n */\nexport function crmRecipeTagForForm(formName: string): string {\n return formName.trim().slice(0, CONTACT_TAG_MAX_LENGTH)\n}\n\nexport const CRM_ACTION_RECIPES: readonly CrmActionRecipe[] = [\n {\n id: 'welcomeNewLead',\n title: 'Welcome a new lead',\n description:\n 'When a form makes a new lead: rotate in an owner, book a call for ' +\n 'tomorrow, send a thank-you, and tag them website.',\n /*\n * ON A NEW LEAD (AGL-3458), because that is the record a lead-routed form\n * makes: under the one-record model (AGL-3232) a form with lead routing\n * on files a LEAD and no contact, so a new-contact trigger would never\n * reach the people this recipe is named for. `formId` is on the `lead`\n * event exactly when a form filed the lead, so the condition keeps the\n * recipe to forms and leaves a booking request to the booking's own\n * confirmation. Every step below acts on the lead the event names when\n * the workspace holds no contact for the person.\n *\n * The owner first, because the task that follows names no assignee and\n * so goes to whoever owns the lead when it is created — the member the\n * rotation just chose. Round robin rather than a named member: a recipe\n * cannot know who is on the team, and the pool under CRM → Settings is\n * the one place that does. On a workspace with no pool the step fails\n * and the run continues, so the call, the email and the tag still land\n * and the run history says who was not assigned.\n *\n * The email comes before any wait, which makes it an immediate reply to\n * what the visitor just did — a transactional reply, sent from the org's\n * own identity to the address the event carries, with no unsubscribe.\n *\n * Its words promise no response time. The org hub installs this recipe\n * into a site without its editor opening, so the business never reads\n * the sentence it is sending, and a \"within a day\" written here would be\n * a commitment made on its behalf that nothing in the run keeps.\n */\n build: () => ({\n recipe: 'welcomeNewLead',\n name: 'Welcome a new lead',\n trigger: {\n event: 'lead',\n conditions: [{ field: 'formId', op: 'notEmpty' }],\n combinator: 'and',\n },\n steps: [\n { type: 'assignContactOwner', roundRobin: true },\n {\n type: 'createCrmTask',\n title: 'Call the new lead',\n kind: 'call',\n dueInDays: 1,\n },\n {\n type: 'sendEmail',\n subject: 'Thanks for getting in touch',\n body:\n 'Hi {{firstName|there}},\\n\\nThanks for reaching out. We have your ' +\n 'message and will reply to this email address.',\n },\n { type: 'addContactTag', tag: 'website' },\n ],\n enabled: true,\n }),\n },\n {\n id: 'followUpWonDeal',\n title: 'Follow up a won deal',\n description:\n 'When a deal is won — which makes the contact a Customer on its own — ' +\n 'book a check-in call a week out.',\n /*\n * No stage step (AGL-2641): the win itself floors the contact at\n * `customer` before `dealWon` is announced, so a step setting the stage\n * here would at best repeat the write and at worst move an evangelist\n * back to customer — a SET, which is what the step is, and not the\n * floor the win applies. The recipe books the follow-up and nothing\n * else.\n */\n build: () => ({\n recipe: 'followUpWonDeal',\n name: 'Follow up a won deal',\n trigger: { event: 'dealWon' },\n steps: [\n {\n type: 'createCrmTask',\n title: 'Check in with the new customer',\n kind: 'call',\n dueInDays: 7,\n },\n ],\n enabled: true,\n }),\n },\n {\n id: 'reengageStaleLead',\n title: 'Re-engage a stale lead',\n description:\n 'When a contact becomes a lead: wait a week, and if their stage has ' +\n 'not moved, book a call to bring them back.',\n /*\n * \"Unless the stage moved on\" is the wait's own event: the flow watches\n * for the next stage change on this person and gives up after a week.\n * Resumed by the clock, the run carries `_waitTimedOut`, and the task\n * step's guard reads it; resumed by the event, the field is absent and\n * the task is skipped — the lead was worked, and nobody is told to\n * re-engage somebody who just moved to Sales qualified.\n */\n build: () => ({\n recipe: 'reengageStaleLead',\n name: 'Re-engage a stale lead',\n trigger: {\n event: 'contactStageChanged',\n conditions: [{ field: 'lifecycleStage', op: 'equals', value: 'lead' }],\n combinator: 'and',\n },\n steps: [\n {\n type: 'waitForEvent',\n eventName: 'contactStageChanged',\n timeoutMinutes: STALE_LEAD_WAIT_MINUTES,\n },\n {\n type: 'createCrmTask',\n title: 'Re-engage a lead that has gone quiet',\n kind: 'call',\n dueInDays: 1,\n when: {\n conditions: [{ field: WAIT_TIMED_OUT_FIELD, op: 'notEmpty' }],\n },\n },\n ],\n enabled: true,\n }),\n },\n {\n id: 'tagByForm',\n title: 'Tag by form',\n description:\n 'When a form you pick makes a new lead or contact: tag them with the ' +\n 'form’s name.',\n picks: {\n kind: 'form',\n label: 'Form',\n plural: 'forms',\n prompt: 'Pick the form whose new leads or contacts get the tag.',\n none: 'This site has no forms yet. Add one in the besigner, then come back.',\n },\n /*\n * The event follows the form's routing (AGL-3458): a lead-routed form\n * makes a lead and no contact, so keyed on `contactCreated` it would\n * never fire. The Forms plugin shares the routing as the picked form's\n * `routesLeads` fact; both events carry `formId` when a form made the\n * record.\n */\n build: (input) => {\n const form = input?.picked\n return {\n recipe: 'tagByForm',\n name: form ? `Tag ${form.name.trim()} submissions` : 'Tag by form',\n trigger: {\n event: form?.facts?.['routesLeads'] === true ? 'lead' : 'contactCreated',\n conditions: [{ field: 'formId', op: 'equals', value: form?.id ?? '' }],\n combinator: 'and',\n },\n steps: [{ type: 'addContactTag', tag: form ? crmRecipeTagForForm(form.name) : '' }],\n enabled: true,\n }\n },\n },\n]\n\n/** The recipe with this id, or null for a string that names none. */\nexport function crmActionRecipe(id: unknown): CrmActionRecipe | null {\n return CRM_ACTION_RECIPES.find((recipe) => recipe.id === id) ?? null\n}\n"],"names":["CONTACT_TAG_MAX_LENGTH","CRM_ACTION_RECIPE_IDS","WAIT_TIMED_OUT_FIELD","STALE_LEAD_WAIT_MINUTES","crmRecipeTagForForm","formName","trim","slice","CRM_ACTION_RECIPES","id","title","description","build","recipe","name","trigger","event","conditions","field","op","combinator","steps","type","roundRobin","kind","dueInDays","subject","body","tag","enabled","value","eventName","timeoutMinutes","when","picks","label","plural","prompt","none","input","form","picked","facts","crmActionRecipe","find"],"mappings":"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"}
@@ -0,0 +1,152 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * A task's reminder at its own time (AGL-2659).
19
+ *
20
+ * The daily digest says once a morning what is late and what is due today;
21
+ * nothing said "the 3:00 call is at 3:00". A task now carries `remindAtMs`
22
+ * — the due time unless a person moved it, `null` for none — and an hourly
23
+ * route reads every open task whose reminder has come due and tells the
24
+ * assignee once, in the console and by mail. Every judgment here is pure so
25
+ * the three writers of the field (the task routes, the REST resource, the
26
+ * drawer) and the route that reads it agree on what a reminder is.
27
+ *
28
+ * ## Which reminder a save leaves
29
+ *
30
+ * A caller that SAYS what the reminder is gets what it said. A caller that
31
+ * says nothing gets the rule: a new task's reminder is its due time, and an
32
+ * edit that moves the due date moves a reminder that still sat on the old
33
+ * due time — the reminder was never a choice of its own, so it follows —
34
+ * while a reminder somebody set to a time of their own stays where they put
35
+ * it. The one thing an edit never does is invent a reminder for a task that
36
+ * has none.
37
+ */
38
+ import type { CrmTask } from '@aglyn/aglyn/app-utils/crm';
39
+ /**
40
+ * The most due reminders one org's sweep reads per run.
41
+ *
42
+ * NEWEST FIRST, which is the whole reason a ceiling is safe here. A sent
43
+ * reminder keeps its `remindAtMs` — the drawer shows when it fired — so the
44
+ * range "reminders up to now" holds every reminder an open task ever had,
45
+ * and a window of the OLDEST five hundred would fill with ones already sent
46
+ * and never reach a new one. The newest five hundred always begin with what
47
+ * fell due since the last run; the sent ones sit behind them.
48
+ */
49
+ export declare const CRM_TASK_REMINDER_CEILING = 500;
50
+ /** The two fields the rule reads off a stored task, or off nothing on create. */
51
+ export interface CrmTaskReminderState {
52
+ dueAtMs: number | null;
53
+ remindAtMs: number | null;
54
+ }
55
+ /**
56
+ * The reminder a save leaves on the task — see the module comment.
57
+ *
58
+ * `remindAtMs` is what the caller SAID: a time, `null` for "no reminder",
59
+ * `undefined` for nothing said. `previous` is the stored task, or `null`
60
+ * when the task is being created. A stored task from before reminders
61
+ * existed carries no `remindAtMs` at all; it reads as `null` here — no
62
+ * reminder — rather than as one that follows the due date, because a task
63
+ * nobody ever set a reminder on should not start reminding the day its
64
+ * title is corrected.
65
+ */
66
+ export declare function crmTaskReminderAfterEdit(input: {
67
+ dueAtMs: number | null;
68
+ remindAtMs?: number | null;
69
+ previous: CrmTaskReminderState | null;
70
+ }): number | null;
71
+ /** Whether a task's reminder is still owed: set, and not yet handled. */
72
+ export declare function crmTaskReminderPending(task: Pick<CrmTask, 'remindAtMs' | 'reminderSentAtMs'>): boolean;
73
+ /**
74
+ * Whether the runner owes this task a reminder now: open, pending, and the
75
+ * reminder's time has come. The query reads the first and third off the
76
+ * index; the second is a fact about a field's absence, which a query cannot
77
+ * select on, so the runner asks it here over the page it read.
78
+ */
79
+ export declare function crmTaskReminderDue(task: Pick<CrmTask, 'status' | 'remindAtMs' | 'reminderSentAtMs'>, nowMs: number): boolean;
80
+ /** A due reminder as the runner carries it: the fields a line and a link need. */
81
+ export interface CrmReminderTask extends Pick<CrmTask, 'title' | 'kind' | 'contactId' | 'companyId' | 'dealId'> {
82
+ id: string;
83
+ dueAtMs: number | null;
84
+ remindAtMs: number;
85
+ assigneeUid: string;
86
+ /** The site the task was created on, or `''` for the organization's own. */
87
+ hostId: string;
88
+ }
89
+ /**
90
+ * The console path a reminder opens: the record the task is for, in the
91
+ * hub, or the tasks list when it is for nobody in particular — the same
92
+ * page the assignment notification opens, in the `/{hostDocId}/rest` shape
93
+ * every host notification uses and the `/org/rest` shape the console
94
+ * rewrites onto the organization's own hub for a task with no site.
95
+ */
96
+ export declare function crmTaskReminderLink(hostId: string | null, task: Pick<CrmTask, 'contactId' | 'companyId' | 'dealId'>): string;
97
+ /**
98
+ * `Wed, Sep 9, 3:00 PM CDT` — the words a reminder says a time with. The zone
99
+ * is the server's choice, not the reader's, so it is printed: a reader an
100
+ * hour away would otherwise take it for their own clock.
101
+ */
102
+ export declare function crmTaskReminderWhen(ms: number, timeZone: string): string;
103
+ /**
104
+ * One task as the reminder email lists it: its title and when it is due, or
105
+ * that it has no due date — a reminder can sit on a task with none, at a time
106
+ * the person chose.
107
+ */
108
+ export declare function crmTaskReminderLine(task: Pick<CrmReminderTask, 'title' | 'dueAtMs'>, timeZone: string): string;
109
+ /**
110
+ * The console notification's body, one per task, which is also what a
111
+ * preview of it shows. It says it is a reminder and names the workspace, so
112
+ * it reads whole without the "Task reminder" title above it.
113
+ */
114
+ export declare function composeCrmTaskReminderBody(task: Pick<CrmReminderTask, 'title' | 'dueAtMs'>, timeZone: string, workspaceName?: string): string;
115
+ /**
116
+ * One mail per member per run, so five tasks due at nine o'clock are one
117
+ * message and not five. The subject names the task when there is one, and
118
+ * never says a task is due: a reminder can be set ahead of the due time, or
119
+ * on a task with none.
120
+ */
121
+ export declare function composeCrmTaskReminderSubject(tasks: ReadonlyArray<Pick<CrmReminderTask, 'title'>>): string;
122
+ /** What the reminder email needs from the route besides the tasks. */
123
+ export interface CrmTaskReminderEmailInput {
124
+ tasks: readonly CrmReminderTask[];
125
+ timeZone: string;
126
+ /** The product name the mail reads as — the org's brand when white-labeled. */
127
+ productName: string;
128
+ /**
129
+ * The workspace the tasks are in. The system email shows only the body, so
130
+ * the body names it: a member of two workspaces gets reminders from each.
131
+ */
132
+ workspaceName?: string;
133
+ /** The console page a task opens on, absolute. */
134
+ taskUrl: (task: CrmReminderTask) => string;
135
+ /** Account settings → Notifications, where the category can be muted. */
136
+ settingsUrl: string;
137
+ /** The brand's support line, already prefixed, or empty. */
138
+ supportLine?: string;
139
+ /**
140
+ * Leave out the closing paragraph and the support line (AGL-3367), for the
141
+ * copy that goes inside a system email: its footer says why the mail came
142
+ * and where to get help, and its body links the settings page.
143
+ */
144
+ omitClosing?: boolean;
145
+ }
146
+ /**
147
+ * The plain-text email. Text rather than a designed template for the
148
+ * reason the digest gives: the body is a list, and `sendEmail` synthesizes
149
+ * the HTML part that makes its links live. Soonest due first, each task on
150
+ * its own line with its link under it, and the way out last.
151
+ */
152
+ export declare function composeCrmTaskReminderEmailText(input: CrmTaskReminderEmailInput): string;
@@ -0,0 +1,161 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /**
17
+ * A task's reminder at its own time (AGL-2659).
18
+ *
19
+ * The daily digest says once a morning what is late and what is due today;
20
+ * nothing said "the 3:00 call is at 3:00". A task now carries `remindAtMs`
21
+ * — the due time unless a person moved it, `null` for none — and an hourly
22
+ * route reads every open task whose reminder has come due and tells the
23
+ * assignee once, in the console and by mail. Every judgment here is pure so
24
+ * the three writers of the field (the task routes, the REST resource, the
25
+ * drawer) and the route that reads it agree on what a reminder is.
26
+ *
27
+ * ## Which reminder a save leaves
28
+ *
29
+ * A caller that SAYS what the reminder is gets what it said. A caller that
30
+ * says nothing gets the rule: a new task's reminder is its due time, and an
31
+ * edit that moves the due date moves a reminder that still sat on the old
32
+ * due time — the reminder was never a choice of its own, so it follows —
33
+ * while a reminder somebody set to a time of their own stays where they put
34
+ * it. The one thing an edit never does is invent a reminder for a task that
35
+ * has none.
36
+ */ /**
37
+ * The most due reminders one org's sweep reads per run.
38
+ *
39
+ * NEWEST FIRST, which is the whole reason a ceiling is safe here. A sent
40
+ * reminder keeps its `remindAtMs` — the drawer shows when it fired — so the
41
+ * range "reminders up to now" holds every reminder an open task ever had,
42
+ * and a window of the OLDEST five hundred would fill with ones already sent
43
+ * and never reach a new one. The newest five hundred always begin with what
44
+ * fell due since the last run; the sent ones sit behind them.
45
+ */ export const CRM_TASK_REMINDER_CEILING = 500;
46
+ /**
47
+ * The reminder a save leaves on the task — see the module comment.
48
+ *
49
+ * `remindAtMs` is what the caller SAID: a time, `null` for "no reminder",
50
+ * `undefined` for nothing said. `previous` is the stored task, or `null`
51
+ * when the task is being created. A stored task from before reminders
52
+ * existed carries no `remindAtMs` at all; it reads as `null` here — no
53
+ * reminder — rather than as one that follows the due date, because a task
54
+ * nobody ever set a reminder on should not start reminding the day its
55
+ * title is corrected.
56
+ */ export function crmTaskReminderAfterEdit(input) {
57
+ var _previous_remindAtMs;
58
+ const { dueAtMs, remindAtMs, previous } = input;
59
+ if (remindAtMs !== undefined) return remindAtMs;
60
+ if (!previous) return dueAtMs;
61
+ const followed = typeof previous.remindAtMs === 'number' && previous.remindAtMs === previous.dueAtMs;
62
+ if (followed && dueAtMs !== previous.dueAtMs) return dueAtMs;
63
+ return (_previous_remindAtMs = previous.remindAtMs) != null ? _previous_remindAtMs : null;
64
+ }
65
+ /** Whether a task's reminder is still owed: set, and not yet handled. */ export function crmTaskReminderPending(task) {
66
+ return typeof task.remindAtMs === 'number' && task.reminderSentAtMs === undefined;
67
+ }
68
+ /**
69
+ * Whether the runner owes this task a reminder now: open, pending, and the
70
+ * reminder's time has come. The query reads the first and third off the
71
+ * index; the second is a fact about a field's absence, which a query cannot
72
+ * select on, so the runner asks it here over the page it read.
73
+ */ export function crmTaskReminderDue(task, nowMs) {
74
+ return task.status === 'open' && crmTaskReminderPending(task) && task.remindAtMs <= nowMs;
75
+ }
76
+ /**
77
+ * The console path a reminder opens: the record the task is for, in the
78
+ * hub, or the tasks list when it is for nobody in particular — the same
79
+ * page the assignment notification opens, in the `/{hostDocId}/rest` shape
80
+ * every host notification uses and the `/org/rest` shape the console
81
+ * rewrites onto the organization's own hub for a task with no site.
82
+ */ export function crmTaskReminderLink(hostId, task) {
83
+ const base = hostId ? `/${hostId}/crm` : '/org/crm';
84
+ if (task.contactId) return `${base}/contacts/${encodeURIComponent(task.contactId)}`;
85
+ if (task.dealId) return `${base}/deals/${encodeURIComponent(task.dealId)}`;
86
+ if (task.companyId) return `${base}/companies/${encodeURIComponent(task.companyId)}`;
87
+ return `${base}/tasks`;
88
+ }
89
+ /**
90
+ * `Wed, Sep 9, 3:00 PM CDT` — the words a reminder says a time with. The zone
91
+ * is the server's choice, not the reader's, so it is printed: a reader an
92
+ * hour away would otherwise take it for their own clock.
93
+ */ export function crmTaskReminderWhen(ms, timeZone) {
94
+ return new Date(ms).toLocaleString('en-US', {
95
+ timeZone,
96
+ weekday: 'short',
97
+ month: 'short',
98
+ day: 'numeric',
99
+ hour: 'numeric',
100
+ minute: '2-digit',
101
+ timeZoneName: 'short'
102
+ });
103
+ }
104
+ /**
105
+ * One task as the reminder email lists it: its title and when it is due, or
106
+ * that it has no due date — a reminder can sit on a task with none, at a time
107
+ * the person chose.
108
+ */ export function crmTaskReminderLine(task, timeZone) {
109
+ return typeof task.dueAtMs === 'number' ? `${task.title} · due ${crmTaskReminderWhen(task.dueAtMs, timeZone)}` : `${task.title} · no due date`;
110
+ }
111
+ /**
112
+ * The console notification's body, one per task, which is also what a
113
+ * preview of it shows. It says it is a reminder and names the workspace, so
114
+ * it reads whole without the "Task reminder" title above it.
115
+ */ export function composeCrmTaskReminderBody(task, timeZone, workspaceName) {
116
+ const workspace = workspaceName == null ? void 0 : workspaceName.trim();
117
+ const when = typeof task.dueAtMs === 'number' ? `, due ${crmTaskReminderWhen(task.dueAtMs, timeZone)}.` : ' (no due date).';
118
+ return `Reminder for your task${workspace ? ` in ${workspace}` : ''}: ${task.title}${when}`;
119
+ }
120
+ /**
121
+ * One mail per member per run, so five tasks due at nine o'clock are one
122
+ * message and not five. The subject names the task when there is one, and
123
+ * never says a task is due: a reminder can be set ahead of the due time, or
124
+ * on a task with none.
125
+ */ export function composeCrmTaskReminderSubject(tasks) {
126
+ if (tasks.length === 1) return `Reminder: ${tasks[0].title}`;
127
+ return `Reminder: ${tasks.length} tasks`;
128
+ }
129
+ /**
130
+ * The plain-text email. Text rather than a designed template for the
131
+ * reason the digest gives: the body is a list, and `sendEmail` synthesizes
132
+ * the HTML part that makes its links live. Soonest due first, each task on
133
+ * its own line with its link under it, and the way out last.
134
+ */ export function composeCrmTaskReminderEmailText(input) {
135
+ var _input_supportLine;
136
+ var _input_workspaceName;
137
+ const { tasks, timeZone, productName } = input;
138
+ const ordered = [
139
+ ...tasks
140
+ ].sort((a, b)=>{
141
+ var _a_dueAtMs, _b_dueAtMs;
142
+ return ((_a_dueAtMs = a.dueAtMs) != null ? _a_dueAtMs : a.remindAtMs) - ((_b_dueAtMs = b.dueAtMs) != null ? _b_dueAtMs : b.remindAtMs);
143
+ });
144
+ // "About", never "is due": a reminder fires at the time somebody chose,
145
+ // which may be a day ahead of the due date or on a task with none, and
146
+ // each line below says when its task is actually due.
147
+ const where = ((_input_workspaceName = input.workspaceName) == null ? void 0 : _input_workspaceName.trim()) ? ` in ${input.workspaceName.trim()}` : '';
148
+ const lines = [
149
+ ordered.length === 1 ? `A reminder from ${productName} about your task${where}:` : `A reminder from ${productName} about ${ordered.length} of your tasks${where}:`,
150
+ ''
151
+ ];
152
+ for (const task of ordered){
153
+ lines.push(`- ${crmTaskReminderLine(task, timeZone)}`);
154
+ lines.push(` ${input.taskUrl(task)}`);
155
+ }
156
+ if (input.omitClosing) return lines.join('\n').trimEnd();
157
+ lines.push('', 'You get task reminders because the Forms & bookings category is on in ' + `your notification settings: ${input.settingsUrl}`);
158
+ return lines.join('\n') + ((_input_supportLine = input.supportLine) != null ? _input_supportLine : '');
159
+ }
160
+
161
+ //# sourceMappingURL=crm-task-reminders.js.map