@aglyn/aglyn 1.0.0-beta.200 → 1.0.0-beta.202

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 (85) hide show
  1. package/package.json +11 -11
  2. package/src/lib/app-utils/activity-presenter.d.ts +29 -3
  3. package/src/lib/app-utils/activity-presenter.js +32 -4
  4. package/src/lib/app-utils/activity-presenter.js.map +1 -1
  5. package/src/lib/app-utils/api-plugins.d.ts +20 -0
  6. package/src/lib/app-utils/api-plugins.js +9 -0
  7. package/src/lib/app-utils/api-plugins.js.map +1 -1
  8. package/src/lib/app-utils/artifact-list-keys.js +10 -0
  9. package/src/lib/app-utils/artifact-list-keys.js.map +1 -1
  10. package/src/lib/app-utils/card-payment-velocity.d.ts +57 -0
  11. package/src/lib/app-utils/card-payment-velocity.js +90 -0
  12. package/src/lib/app-utils/card-payment-velocity.js.map +1 -0
  13. package/src/lib/app-utils/crm-digest.d.ts +6 -0
  14. package/src/lib/app-utils/crm-digest.js +1 -0
  15. package/src/lib/app-utils/crm-digest.js.map +1 -1
  16. package/src/lib/app-utils/crm-task-reminders.d.ts +6 -0
  17. package/src/lib/app-utils/crm-task-reminders.js +1 -0
  18. package/src/lib/app-utils/crm-task-reminders.js.map +1 -1
  19. package/src/lib/app-utils/crm.d.ts +19 -0
  20. package/src/lib/app-utils/crm.js +27 -1
  21. package/src/lib/app-utils/crm.js.map +1 -1
  22. package/src/lib/app-utils/docs-help.generated.d.ts +4 -4
  23. package/src/lib/app-utils/docs-help.generated.js +12 -0
  24. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  25. package/src/lib/app-utils/docs-index.generated.js +85 -64
  26. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  27. package/src/lib/app-utils/email-state.d.ts +7 -3
  28. package/src/lib/app-utils/email-state.js +22 -6
  29. package/src/lib/app-utils/email-state.js.map +1 -1
  30. package/src/lib/app-utils/forms.d.ts +101 -9
  31. package/src/lib/app-utils/forms.js +137 -7
  32. package/src/lib/app-utils/forms.js.map +1 -1
  33. package/src/lib/app-utils/health-report.d.ts +3 -3
  34. package/src/lib/app-utils/health-report.js +9 -9
  35. package/src/lib/app-utils/health-report.js.map +1 -1
  36. package/src/lib/app-utils/host-naming.d.ts +12 -7
  37. package/src/lib/app-utils/host-naming.js +18 -3
  38. package/src/lib/app-utils/host-naming.js.map +1 -1
  39. package/src/lib/app-utils/lockdown.d.ts +19 -0
  40. package/src/lib/app-utils/lockdown.js +21 -0
  41. package/src/lib/app-utils/lockdown.js.map +1 -1
  42. package/src/lib/app-utils/marketplace-listing-visibility.js +5 -5
  43. package/src/lib/app-utils/marketplace-listing-visibility.js.map +1 -1
  44. package/src/lib/app-utils/media-embedded-fields.d.ts +40 -0
  45. package/src/lib/app-utils/media-embedded-fields.js +77 -0
  46. package/src/lib/app-utils/media-embedded-fields.js.map +1 -1
  47. package/src/lib/app-utils/media-metadata.d.ts +17 -1
  48. package/src/lib/app-utils/media-metadata.js +22 -2
  49. package/src/lib/app-utils/media-metadata.js.map +1 -1
  50. package/src/lib/app-utils/notifications.d.ts +35 -0
  51. package/src/lib/app-utils/notifications.js +57 -1
  52. package/src/lib/app-utils/notifications.js.map +1 -1
  53. package/src/lib/app-utils/payment-risk.d.ts +75 -0
  54. package/src/lib/app-utils/payment-risk.js +139 -0
  55. package/src/lib/app-utils/payment-risk.js.map +1 -0
  56. package/src/lib/app-utils/platform-brand.d.ts +8 -2
  57. package/src/lib/app-utils/platform-brand.js +9 -3
  58. package/src/lib/app-utils/platform-brand.js.map +1 -1
  59. package/src/lib/app-utils/plugin-api-request-host.d.ts +29 -0
  60. package/src/lib/app-utils/plugin-api-request-host.js +90 -0
  61. package/src/lib/app-utils/plugin-api-request-host.js.map +1 -0
  62. package/src/lib/app-utils/plugin-bundle-checks.d.ts +4 -2
  63. package/src/lib/app-utils/plugin-bundle-checks.js +111 -5
  64. package/src/lib/app-utils/plugin-bundle-checks.js.map +1 -1
  65. package/src/lib/app-utils/site-return-url.d.ts +55 -0
  66. package/src/lib/app-utils/site-return-url.js +129 -0
  67. package/src/lib/app-utils/site-return-url.js.map +1 -0
  68. package/src/lib/app-utils/webhook-delivery.js +4 -1
  69. package/src/lib/app-utils/webhook-delivery.js.map +1 -1
  70. package/src/lib/plugin-manager/billing-webhook-hooks.d.ts +18 -0
  71. package/src/lib/plugin-manager/billing-webhook-hooks.js +9 -1
  72. package/src/lib/plugin-manager/billing-webhook-hooks.js.map +1 -1
  73. package/src/lib/plugin-manager/plugin-activity-actions.d.ts +9 -1
  74. package/src/lib/plugin-manager/plugin-activity-actions.js +11 -1
  75. package/src/lib/plugin-manager/plugin-activity-actions.js.map +1 -1
  76. package/src/lib/plugin-manager/plugin-contact-capture.d.ts +8 -0
  77. package/src/lib/plugin-manager/plugin-contact-capture.js.map +1 -1
  78. package/src/lib/plugin-manager/plugin-events.d.ts +22 -0
  79. package/src/lib/plugin-manager/plugin-events.js.map +1 -1
  80. package/src/lib/plugin-manager/plugin-jobs.d.ts +8 -4
  81. package/src/lib/plugin-manager/plugin-jobs.js +8 -4
  82. package/src/lib/plugin-manager/plugin-jobs.js.map +1 -1
  83. package/src/lib/plugin-manager/plugin-recurring-charges.d.ts +39 -0
  84. package/src/lib/plugin-manager/plugin-recurring-charges.js +49 -0
  85. package/src/lib/plugin-manager/plugin-recurring-charges.js.map +1 -0
@@ -0,0 +1,90 @@
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
+ * CARD-TESTING VELOCITY ON A VISITOR'S PAYMENT DOOR (AGL-3363).
18
+ *
19
+ * The pure policy; the durable half is `card-payment-velocity.ts` in
20
+ * tenant-data-admin, beside the visitor-write limiter it tightens.
21
+ *
22
+ * A public storefront checkout is the classic card-testing target: a script
23
+ * opens payment after payment from one address, or from many addresses
24
+ * against one shop, to learn which stolen cards still authorize. The
25
+ * dispatcher's visitor-write limit (120 writes a minute per site and
26
+ * address, `plugin-api-rate-limit.ts`) is sized for a shopper typing into a
27
+ * quantity box and never meant to bound payments. So a route that OPENS A
28
+ * CARD PAYMENT — a Checkout Session, a PaymentIntent, a SetupIntent —
29
+ * declares itself (`registerPluginApiRoute(path, handler, { cardPayment:
30
+ * true })`) and meets three more counters in the tenant dispatcher:
31
+ *
32
+ * 1. PER VISITOR, per (site, address): {@link CARD_PAYMENT_VELOCITY}
33
+ * `perVisitor`. A shopper opens checkout, backs out, changes the cart
34
+ * and opens it again; ten in ten minutes is several times that, and a
35
+ * tester wants hundreds. A young workspace's site gets the tighter
36
+ * `perVisitorYoung`: the 9/26 incident's workspace was days old.
37
+ * 2. PER ADDRESS, across every site: `perAddress`. Testers rotate merchants
38
+ * so no one shop sees the volume. The visitor-write policy dropped a
39
+ * platform-wide per-address limiter because it tripled the cost of every
40
+ * add-to-cart; a payment door is a small share of writes and each one it
41
+ * admits is a real Stripe object, so the second counter is paid for here.
42
+ * 3. PER SITE, every address together: `perSite`. It REFUSES NOTHING. A
43
+ * site-wide refusal is a denial of service any stranger can aim at a
44
+ * merchant (the visitor-write policy's own argument), and a busy shop's
45
+ * launch looks like this too. Crossing it files one urgent abuse-queue
46
+ * row for the site, beside the seller-pattern row, so staff see a site
47
+ * under attack — or a merchant testing cards through their own shop.
48
+ *
49
+ * What it cannot see: the card. Hosted Checkout collects it on Stripe's
50
+ * page, so a per-card-fingerprint velocity is Radar's to enforce (Checkout
51
+ * applies Stripe's card-testing protection by default; see the staff doc).
52
+ *=========================================*/ /** One fixed window. */ const TEN_MINUTES = 10 * 60000;
53
+ export const CARD_PAYMENT_VELOCITY = {
54
+ /** Payment doors one address may open on one site. */ perVisitor: {
55
+ limit: 10,
56
+ windowMs: TEN_MINUTES
57
+ },
58
+ /** …on a site whose workspace is young (`isYoungWorkspaceAge`). */ perVisitorYoung: {
59
+ limit: 5,
60
+ windowMs: TEN_MINUTES
61
+ },
62
+ /** Payment doors one address may open across every site. */ perAddress: {
63
+ limit: 30,
64
+ windowMs: TEN_MINUTES
65
+ },
66
+ /** Payment doors one site may see before staff are told. Refuses nothing. */ perSite: {
67
+ limit: 150,
68
+ windowMs: TEN_MINUTES
69
+ }
70
+ };
71
+ /** The per-visitor key: one bucket per (site, address). */ export function cardPaymentVisitorKey(hostId, ip) {
72
+ return `cardpay:visitor:${hostId || '-'}:${ip || 'unknown'}`;
73
+ }
74
+ /** The per-address key, across every site. */ export function cardPaymentAddressKey(ip) {
75
+ return `cardpay:address:${ip || 'unknown'}`;
76
+ }
77
+ /**
78
+ * The per-site key. `''` answers null: a request that named no site has no
79
+ * site to alarm about, and the two counters above still hold it.
80
+ */ export function cardPaymentSiteKey(hostId) {
81
+ return hostId ? `cardpay:site:${hostId}` : null;
82
+ }
83
+ /**
84
+ * The abuse-queue row id for a site's alarm: one per site per UTC day, so a
85
+ * sustained attack is one row that counts, not a row per window.
86
+ */ export function cardPaymentAlarmDay(nowMs) {
87
+ return new Date(nowMs).toISOString().slice(0, 10);
88
+ }
89
+
90
+ //# sourceMappingURL=card-payment-velocity.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/card-payment-velocity.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 * CARD-TESTING VELOCITY ON A VISITOR'S PAYMENT DOOR (AGL-3363).\n *\n * The pure policy; the durable half is `card-payment-velocity.ts` in\n * tenant-data-admin, beside the visitor-write limiter it tightens.\n *\n * A public storefront checkout is the classic card-testing target: a script\n * opens payment after payment from one address, or from many addresses\n * against one shop, to learn which stolen cards still authorize. The\n * dispatcher's visitor-write limit (120 writes a minute per site and\n * address, `plugin-api-rate-limit.ts`) is sized for a shopper typing into a\n * quantity box and never meant to bound payments. So a route that OPENS A\n * CARD PAYMENT — a Checkout Session, a PaymentIntent, a SetupIntent —\n * declares itself (`registerPluginApiRoute(path, handler, { cardPayment:\n * true })`) and meets three more counters in the tenant dispatcher:\n *\n * 1. PER VISITOR, per (site, address): {@link CARD_PAYMENT_VELOCITY}\n * `perVisitor`. A shopper opens checkout, backs out, changes the cart\n * and opens it again; ten in ten minutes is several times that, and a\n * tester wants hundreds. A young workspace's site gets the tighter\n * `perVisitorYoung`: the 9/26 incident's workspace was days old.\n * 2. PER ADDRESS, across every site: `perAddress`. Testers rotate merchants\n * so no one shop sees the volume. The visitor-write policy dropped a\n * platform-wide per-address limiter because it tripled the cost of every\n * add-to-cart; a payment door is a small share of writes and each one it\n * admits is a real Stripe object, so the second counter is paid for here.\n * 3. PER SITE, every address together: `perSite`. It REFUSES NOTHING. A\n * site-wide refusal is a denial of service any stranger can aim at a\n * merchant (the visitor-write policy's own argument), and a busy shop's\n * launch looks like this too. Crossing it files one urgent abuse-queue\n * row for the site, beside the seller-pattern row, so staff see a site\n * under attack — or a merchant testing cards through their own shop.\n *\n * What it cannot see: the card. Hosted Checkout collects it on Stripe's\n * page, so a per-card-fingerprint velocity is Radar's to enforce (Checkout\n * applies Stripe's card-testing protection by default; see the staff doc).\n *=========================================*/\n\n/** One fixed window. */\nexport interface CardPaymentVelocityWindow {\n limit: number\n windowMs: number\n}\n\nconst TEN_MINUTES = 10 * 60_000\n\nexport const CARD_PAYMENT_VELOCITY = {\n /** Payment doors one address may open on one site. */\n perVisitor: { limit: 10, windowMs: TEN_MINUTES },\n /** …on a site whose workspace is young (`isYoungWorkspaceAge`). */\n perVisitorYoung: { limit: 5, windowMs: TEN_MINUTES },\n /** Payment doors one address may open across every site. */\n perAddress: { limit: 30, windowMs: TEN_MINUTES },\n /** Payment doors one site may see before staff are told. Refuses nothing. */\n perSite: { limit: 150, windowMs: TEN_MINUTES },\n} as const satisfies Record<string, CardPaymentVelocityWindow>\n\n/** The per-visitor key: one bucket per (site, address). */\nexport function cardPaymentVisitorKey(hostId: string, ip: string): string {\n return `cardpay:visitor:${hostId || '-'}:${ip || 'unknown'}`\n}\n\n/** The per-address key, across every site. */\nexport function cardPaymentAddressKey(ip: string): string {\n return `cardpay:address:${ip || 'unknown'}`\n}\n\n/**\n * The per-site key. `''` answers null: a request that named no site has no\n * site to alarm about, and the two counters above still hold it.\n */\nexport function cardPaymentSiteKey(hostId: string): string | null {\n return hostId ? `cardpay:site:${hostId}` : null\n}\n\n/**\n * The abuse-queue row id for a site's alarm: one per site per UTC day, so a\n * sustained attack is one row that counts, not a row per window.\n */\nexport function cardPaymentAlarmDay(nowMs: number): string {\n return new Date(nowMs).toISOString().slice(0, 10)\n}\n"],"names":["TEN_MINUTES","CARD_PAYMENT_VELOCITY","perVisitor","limit","windowMs","perVisitorYoung","perAddress","perSite","cardPaymentVisitorKey","hostId","ip","cardPaymentAddressKey","cardPaymentSiteKey","cardPaymentAlarmDay","nowMs","Date","toISOString","slice"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;2CAoC2C,GAE3C,sBAAsB,GAMtB,MAAMA,cAAc,KAAK;AAEzB,OAAO,MAAMC,wBAAwB;IACnC,oDAAoD,GACpDC,YAAY;QAAEC,OAAO;QAAIC,UAAUJ;IAAY;IAC/C,iEAAiE,GACjEK,iBAAiB;QAAEF,OAAO;QAAGC,UAAUJ;IAAY;IACnD,0DAA0D,GAC1DM,YAAY;QAAEH,OAAO;QAAIC,UAAUJ;IAAY;IAC/C,2EAA2E,GAC3EO,SAAS;QAAEJ,OAAO;QAAKC,UAAUJ;IAAY;AAC/C,EAA8D;AAE9D,yDAAyD,GACzD,OAAO,SAASQ,sBAAsBC,MAAc,EAAEC,EAAU;IAC9D,OAAO,CAAC,gBAAgB,EAAED,UAAU,IAAI,CAAC,EAAEC,MAAM,WAAW;AAC9D;AAEA,4CAA4C,GAC5C,OAAO,SAASC,sBAAsBD,EAAU;IAC9C,OAAO,CAAC,gBAAgB,EAAEA,MAAM,WAAW;AAC7C;AAEA;;;CAGC,GACD,OAAO,SAASE,mBAAmBH,MAAc;IAC/C,OAAOA,SAAS,CAAC,aAAa,EAAEA,QAAQ,GAAG;AAC7C;AAEA;;;CAGC,GACD,OAAO,SAASI,oBAAoBC,KAAa;IAC/C,OAAO,IAAIC,KAAKD,OAAOE,WAAW,GAAGC,KAAK,CAAC,GAAG;AAChD"}
@@ -169,6 +169,12 @@ export interface CrmDigestEmailInput {
169
169
  hostName: (hostId: string) => string;
170
170
  /** The brand's support line, already prefixed, or empty. */
171
171
  supportLine?: string;
172
+ /**
173
+ * Leave out the closing paragraph and the support line (AGL-3367), for the
174
+ * copy that goes inside a system email: its footer says why the mail came
175
+ * and where to get help, and its body links the settings page.
176
+ */
177
+ omitClosing?: boolean;
172
178
  }
173
179
  /**
174
180
  * The plain-text email. Text rather than a designed template because the
@@ -264,6 +264,7 @@ function section(heading, lines) {
264
264
  for (const hostId of leadHosts){
265
265
  lines.push(`Leads on ${input.hostName(hostId)}: ${input.leadsUrl(hostId)}`);
266
266
  }
267
+ if (input.omitClosing) return lines.join('\n').trimEnd();
267
268
  lines.push('', 'You get this each morning because the Daily CRM digest is on in your ' + `notification settings: ${input.settingsUrl}`);
268
269
  return lines.join('\n') + ((_input_supportLine = input.supportLine) != null ? _input_supportLine : '');
269
270
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/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 './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/** 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\n/** One member's morning. */\nexport interface CrmMemberDigest {\n overdue: CrmDigestTask[]\n today: CrmDigestTask[]\n leads: 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 const firstSeen = Number(lead.firstSeenAtMs)\n return Number.isFinite(firstSeen) && firstSeen > 0 && firstSeen <= nowMs - ageMs\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.\n */\nexport function buildMemberDigests(input: {\n tasks: readonly CrmDigestTask[]\n leads: readonly CrmDigestLead[]\n members: ReadonlyArray<{ uid: string; reachesHost: (hostId: string) => boolean }>\n window: CrmDigestWindow\n}): Map<string, CrmMemberDigest> {\n const { tasks, leads, 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: [] }\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 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 }\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(counts: CrmDigestCounts): 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\nexport function composeCrmDigestSubject(counts: CrmDigestCounts): string {\n return `Your 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 /** 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\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 })\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 lines: string[] = [\n `Here is your ${productName} CRM for ${day}: ${composeCrmDigestSummary(counts)}.`,\n '',\n ]\n const taskLine = (task: CrmDigestTask) =>\n `${task.title} · ${whenInZone(task.dueAtMs, timeZone)}`\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 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 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","CRM_DIGEST_LEAD_AGE_MS","CRM_DIGEST_TASK_CEILING","CRM_DIGEST_LEAD_WINDOW","CRM_DIGEST_LIST_MAX","startOfDayInZone","ms","timeZone","startOfNextDayInZone","utcDayKey","Date","toISOString","slice","crmDigestWindow","nowMs","startMs","endMs","crmDigestTaskState","dueAtMs","window","isUnworkedLead","lead","ageMs","firstSeen","Number","firstSeenAtMs","isFinite","leadIsForMember","member","ownerUid","uid","reachesHost","hostId","crmDigestEntitled","features","buildMemberDigests","input","tasks","leads","members","digests","Map","digestFor","digest","get","overdue","today","set","roster","map","task","assigneeUid","has","state","push","values","sort","a","b","crmDigestCounts","length","plural","count","noun","composeCrmDigestSummary","counts","segments","join","composeCrmDigestSubject","whenInZone","toLocaleString","weekday","month","day","hour","minute","dateInZone","toLocaleDateString","section","heading","lines","shown","rest","line","composeCrmDigestEmailText","productName","taskLine","title","who","name","email","hostName","tasksUrl","leadHosts","Set","leadsUrl","settingsUrl","supportLine"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/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 './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/** 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\n/** One member's morning. */\nexport interface CrmMemberDigest {\n overdue: CrmDigestTask[]\n today: CrmDigestTask[]\n leads: 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 const firstSeen = Number(lead.firstSeenAtMs)\n return Number.isFinite(firstSeen) && firstSeen > 0 && firstSeen <= nowMs - ageMs\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.\n */\nexport function buildMemberDigests(input: {\n tasks: readonly CrmDigestTask[]\n leads: readonly CrmDigestLead[]\n members: ReadonlyArray<{ uid: string; reachesHost: (hostId: string) => boolean }>\n window: CrmDigestWindow\n}): Map<string, CrmMemberDigest> {\n const { tasks, leads, 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: [] }\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 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 }\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(counts: CrmDigestCounts): 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\nexport function composeCrmDigestSubject(counts: CrmDigestCounts): string {\n return `Your 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 /** 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\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 })\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 lines: string[] = [\n `Here is your ${productName} CRM for ${day}: ${composeCrmDigestSummary(counts)}.`,\n '',\n ]\n const taskLine = (task: CrmDigestTask) =>\n `${task.title} · ${whenInZone(task.dueAtMs, timeZone)}`\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 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","CRM_DIGEST_LEAD_AGE_MS","CRM_DIGEST_TASK_CEILING","CRM_DIGEST_LEAD_WINDOW","CRM_DIGEST_LIST_MAX","startOfDayInZone","ms","timeZone","startOfNextDayInZone","utcDayKey","Date","toISOString","slice","crmDigestWindow","nowMs","startMs","endMs","crmDigestTaskState","dueAtMs","window","isUnworkedLead","lead","ageMs","firstSeen","Number","firstSeenAtMs","isFinite","leadIsForMember","member","ownerUid","uid","reachesHost","hostId","crmDigestEntitled","features","buildMemberDigests","input","tasks","leads","members","digests","Map","digestFor","digest","get","overdue","today","set","roster","map","task","assigneeUid","has","state","push","values","sort","a","b","crmDigestCounts","length","plural","count","noun","composeCrmDigestSummary","counts","segments","join","composeCrmDigestSubject","whenInZone","toLocaleString","weekday","month","day","hour","minute","dateInZone","toLocaleDateString","section","heading","lines","shown","rest","line","composeCrmDigestEmailText","productName","taskLine","title","who","name","email","hostName","tasksUrl","leadHosts","Set","leadsUrl","omitClosing","trimEnd","settingsUrl","supportLine"],"mappings":"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"}
@@ -119,6 +119,12 @@ export interface CrmTaskReminderEmailInput {
119
119
  settingsUrl: string;
120
120
  /** The brand's support line, already prefixed, or empty. */
121
121
  supportLine?: string;
122
+ /**
123
+ * Leave out the closing paragraph and the support line (AGL-3367), for the
124
+ * copy that goes inside a system email: its footer says why the mail came
125
+ * and where to get help, and its body links the settings page.
126
+ */
127
+ omitClosing?: boolean;
122
128
  }
123
129
  /**
124
130
  * The plain-text email. Text rather than a designed template for the
@@ -132,6 +132,7 @@
132
132
  lines.push(`- ${composeCrmTaskReminderBody(task, timeZone)}`);
133
133
  lines.push(` ${input.taskUrl(task)}`);
134
134
  }
135
+ if (input.omitClosing) return lines.join('\n').trimEnd();
135
136
  lines.push('', 'You get task reminders because the Forms & bookings category is on in ' + `your notification settings: ${input.settingsUrl}`);
136
137
  return lines.join('\n') + ((_input_supportLine = input.supportLine) != null ? _input_supportLine : '');
137
138
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/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 './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/** `Wed, Sep 9, 3:00 PM` in a named zone — the words a reminder says a time with. */\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 })\n}\n\n/**\n * The one line the console notification is: the title and when the task is\n * due. A reminder set on a task with no due date says only the title —\n * the person chose the time; the task itself has none.\n */\nexport function composeCrmTaskReminderBody(\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\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.\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 are due`\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 /** 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\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 const lines: string[] = [\n ordered.length === 1\n ? `A reminder from ${productName}: this task is due.`\n : `A reminder from ${productName}: ${ordered.length} tasks are due.`,\n '',\n ]\n for (const task of ordered) {\n lines.push(`- ${composeCrmTaskReminderBody(task, timeZone)}`)\n lines.push(` ${input.taskUrl(task)}`)\n }\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","composeCrmTaskReminderBody","title","composeCrmTaskReminderSubject","tasks","length","composeCrmTaskReminderEmailText","productName","ordered","sort","a","b","lines","push","taskUrl","settingsUrl","join","supportLine"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/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 './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/** `Wed, Sep 9, 3:00 PM` in a named zone — the words a reminder says a time with. */\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 })\n}\n\n/**\n * The one line the console notification is: the title and when the task is\n * due. A reminder set on a task with no due date says only the title —\n * the person chose the time; the task itself has none.\n */\nexport function composeCrmTaskReminderBody(\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\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.\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 are due`\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 /** 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 const lines: string[] = [\n ordered.length === 1\n ? `A reminder from ${productName}: this task is due.`\n : `A reminder from ${productName}: ${ordered.length} tasks are due.`,\n '',\n ]\n for (const task of ordered) {\n lines.push(`- ${composeCrmTaskReminderBody(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","composeCrmTaskReminderBody","title","composeCrmTaskReminderSubject","tasks","length","composeCrmTaskReminderEmailText","productName","ordered","sort","a","b","lines","push","taskUrl","omitClosing","join","trimEnd","settingsUrl","supportLine"],"mappings":"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"}
@@ -866,6 +866,25 @@ export declare function isCrmFieldObject(value: unknown): value is CrmFieldObjec
866
866
  * under its key sit on contacts.
867
867
  */
868
868
  export declare function fieldDefinitionObject(definition: Pick<ContactFieldDefinition, 'object'> | null | undefined): CrmFieldObject;
869
+ /**
870
+ * What the CRM › Fields table's query reads on a definition (AGL-3335):
871
+ *
872
+ * object which tab it belongs to, stored even for a contact's, because
873
+ * a query cannot find a field's absence;
874
+ * required a boolean on every definition, for the Required filter;
875
+ * searchTokens every word prefix of its name and of its key, the key read
876
+ * whole and split at `_`, `-` and `.`, so "plan" finds
877
+ * `plan_interest` and "interest" does too.
878
+ *
879
+ * Its script-side twin is `crmFieldListFields` in
880
+ * `tools/scripts/lib/org-record-list-fields.mjs`; both answer the
881
+ * `fieldDefinitions` of `org-record-list-fields.fixtures.json`.
882
+ */
883
+ export declare function crmFieldListFields(definition: Record<string, unknown> | null | undefined): {
884
+ object: CrmFieldObject;
885
+ required: boolean;
886
+ searchTokens: string[];
887
+ };
869
888
  /**
870
889
  * The definition type under the name new code uses (AGL-2661). The type,
871
890
  * the collection (`contactFields`) and the rules match keep the contact
@@ -47,7 +47,7 @@ import { consentGroupScope } from "./consent-groups.js";
47
47
  import { CONTACT_LIFECYCLE_STAGES, isContactLifecycleStage } from "./crm-kinds.js";
48
48
  import { CONTACT_ALTERNATE_EMAILS_FIELD, CONTACT_FACETS_FIELD, CONTACT_SOURCE_LABELS, normalizeContactEmail, readContactFacet } from "./contacts.js";
49
49
  import { isEmailStateStatus } from "./email-state.js";
50
- import { NAME_TOKEN_MAX_PREFIX, nameSearchKey, SCOPED_SEARCH_JOIN, scopedSearchTokens } from "./name-search.js";
50
+ import { NAME_TOKEN_MAX_PREFIX, nameSearchKey, nameSearchTokens, SCOPED_SEARCH_JOIN, scopedSearchTokens } from "./name-search.js";
51
51
  import { MAX_SCOPE_HOSTS, ORG_SCOPE_TOKEN } from "./scope-tokens.js";
52
52
  // The fixed vocabularies and their guards live in a leaf module; every name
53
53
  // stays importable from here.
@@ -592,6 +592,32 @@ export function isCrmFieldObject(value) {
592
592
  */ export function fieldDefinitionObject(definition) {
593
593
  return isCrmFieldObject(definition == null ? void 0 : definition.object) ? definition.object : 'contact';
594
594
  }
595
+ /**
596
+ * What the CRM › Fields table's query reads on a definition (AGL-3335):
597
+ *
598
+ * object which tab it belongs to, stored even for a contact's, because
599
+ * a query cannot find a field's absence;
600
+ * required a boolean on every definition, for the Required filter;
601
+ * searchTokens every word prefix of its name and of its key, the key read
602
+ * whole and split at `_`, `-` and `.`, so "plan" finds
603
+ * `plan_interest` and "interest" does too.
604
+ *
605
+ * Its script-side twin is `crmFieldListFields` in
606
+ * `tools/scripts/lib/org-record-list-fields.mjs`; both answer the
607
+ * `fieldDefinitions` of `org-record-list-fields.fixtures.json`.
608
+ */ export function crmFieldListFields(definition) {
609
+ const label = typeof (definition == null ? void 0 : definition['label']) === 'string' ? definition['label'] : '';
610
+ const key = typeof (definition == null ? void 0 : definition['key']) === 'string' ? definition['key'] : '';
611
+ return {
612
+ object: fieldDefinitionObject(definition),
613
+ required: (definition == null ? void 0 : definition['required']) === true,
614
+ searchTokens: nameSearchTokens([
615
+ label,
616
+ key,
617
+ key.replace(/[_.-]+/g, ' ')
618
+ ].join(' '))
619
+ };
620
+ }
595
621
  /** What a stored field key must look like — a letter, then up to 39 of `[a-z0-9_]`. */ export const CONTACT_FIELD_KEY_PATTERN = /^[a-z][a-z0-9_]{0,39}$/;
596
622
  /**
597
623
  * A typed label, as the key it would be stored under, or `null` when nothing