@aglyn/plugins-outreach 1.0.0-beta.200 → 1.0.0-beta.201

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 (90) hide show
  1. package/package.json +14 -14
  2. package/src/lib/components/enrollment-actions.d.ts +33 -0
  3. package/src/lib/components/enrollment-actions.js +174 -0
  4. package/src/lib/components/enrollment-actions.js.map +1 -0
  5. package/src/lib/components/enrollment-detail.d.ts +21 -0
  6. package/src/lib/components/enrollment-detail.js +870 -0
  7. package/src/lib/components/enrollment-detail.js.map +1 -0
  8. package/src/lib/components/enrollment-filter-params.d.ts +58 -0
  9. package/src/lib/components/enrollment-filter-params.js +98 -0
  10. package/src/lib/components/enrollment-filter-params.js.map +1 -0
  11. package/src/lib/components/enrollments-table.d.ts +21 -0
  12. package/src/lib/components/enrollments-table.js +249 -210
  13. package/src/lib/components/enrollments-table.js.map +1 -1
  14. package/src/lib/components/outreach-console-page.js +10 -0
  15. package/src/lib/components/outreach-console-page.js.map +1 -1
  16. package/src/lib/components/sequence-detail.d.ts +2 -0
  17. package/src/lib/components/sequence-detail.js +70 -8
  18. package/src/lib/components/sequence-detail.js.map +1 -1
  19. package/src/lib/components/sequence-report-card.d.ts +17 -0
  20. package/src/lib/components/sequence-report-card.js +63 -15
  21. package/src/lib/components/sequence-report-card.js.map +1 -1
  22. package/src/lib/components/sequences-section.d.ts +4 -1
  23. package/src/lib/components/sequences-section.js +12 -1
  24. package/src/lib/components/sequences-section.js.map +1 -1
  25. package/src/lib/components/use-outreach-api.d.ts +3 -1
  26. package/src/lib/components/use-outreach-api.js +6 -0
  27. package/src/lib/components/use-outreach-api.js.map +1 -1
  28. package/src/lib/components/use-outreach-enrollment.d.ts +18 -0
  29. package/src/lib/components/use-outreach-enrollment.js +133 -0
  30. package/src/lib/components/use-outreach-enrollment.js.map +1 -0
  31. package/src/lib/constants/api-routes.d.ts +2 -0
  32. package/src/lib/constants/api-routes.js +1 -0
  33. package/src/lib/constants/api-routes.js.map +1 -1
  34. package/src/lib/engine/gates.d.ts +4 -3
  35. package/src/lib/engine/gates.js.map +1 -1
  36. package/src/lib/engine/gateway-block.d.ts +4 -8
  37. package/src/lib/engine/gateway-block.js +6 -26
  38. package/src/lib/engine/gateway-block.js.map +1 -1
  39. package/src/lib/engine/mail-gateway.d.ts +18 -73
  40. package/src/lib/engine/mail-gateway.js +32 -206
  41. package/src/lib/engine/mail-gateway.js.map +1 -1
  42. package/src/lib/model/enrollment-engagement.d.ts +26 -2
  43. package/src/lib/model/enrollment-engagement.js +40 -3
  44. package/src/lib/model/enrollment-engagement.js.map +1 -1
  45. package/src/lib/model/enrollment-history.d.ts +40 -0
  46. package/src/lib/model/enrollment-history.js +95 -0
  47. package/src/lib/model/enrollment-history.js.map +1 -0
  48. package/src/lib/model/enrollment-list-query.d.ts +6 -0
  49. package/src/lib/model/enrollment-list-query.js +35 -1
  50. package/src/lib/model/enrollment-list-query.js.map +1 -1
  51. package/src/lib/model/enrollment-timeline.d.ts +78 -0
  52. package/src/lib/model/enrollment-timeline.js +430 -0
  53. package/src/lib/model/enrollment-timeline.js.map +1 -0
  54. package/src/lib/model/outreach-api.d.ts +24 -0
  55. package/src/lib/model/outreach-api.js.map +1 -1
  56. package/src/lib/model/outreach.types.d.ts +78 -29
  57. package/src/lib/model/outreach.types.js +33 -8
  58. package/src/lib/model/outreach.types.js.map +1 -1
  59. package/src/lib/routes/enroll-routes.js +26 -3
  60. package/src/lib/routes/enroll-routes.js.map +1 -1
  61. package/src/lib/routes/enrollment-routes.d.ts +10 -0
  62. package/src/lib/routes/enrollment-routes.js +82 -1
  63. package/src/lib/routes/enrollment-routes.js.map +1 -1
  64. package/src/lib/routes/register-routes.js +14 -4
  65. package/src/lib/routes/register-routes.js.map +1 -1
  66. package/src/lib/routes/route-deps.d.ts +3 -1
  67. package/src/lib/routes/route-deps.js.map +1 -1
  68. package/src/lib/runtime/click-events.d.ts +10 -5
  69. package/src/lib/runtime/click-events.js +45 -6
  70. package/src/lib/runtime/click-events.js.map +1 -1
  71. package/src/lib/runtime/gateway-ledger.d.ts +6 -0
  72. package/src/lib/runtime/gateway-ledger.js +9 -3
  73. package/src/lib/runtime/gateway-ledger.js.map +1 -1
  74. package/src/lib/runtime/host-suspension.d.ts +30 -0
  75. package/src/lib/runtime/host-suspension.js +35 -0
  76. package/src/lib/runtime/host-suspension.js.map +1 -0
  77. package/src/lib/runtime/person-erasure.d.ts +4 -1
  78. package/src/lib/runtime/person-erasure.js +14 -3
  79. package/src/lib/runtime/person-erasure.js.map +1 -1
  80. package/src/lib/runtime/platform-runtime-deps.js +34 -3
  81. package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
  82. package/src/lib/runtime/runtime-deps.d.ts +27 -1
  83. package/src/lib/runtime/runtime-deps.js.map +1 -1
  84. package/src/lib/runtime/send-job.js +46 -6
  85. package/src/lib/runtime/send-job.js.map +1 -1
  86. package/src/lib/runtime/sync-job.js +4 -0
  87. package/src/lib/runtime/sync-job.js.map +1 -1
  88. package/src/lib/storage/domain-intel-store.d.ts +61 -43
  89. package/src/lib/storage/domain-intel-store.js +138 -226
  90. package/src/lib/storage/domain-intel-store.js.map +1 -1
@@ -14,213 +14,39 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */ /*==========================================
17
- * THE MX RECORD PREDICTS THE GATEWAY, AND THE GATEWAY'S HISTORY PREDICTS
18
- * THE BLOCK (AGL-3326).
19
- *
20
- * Two Barracuda gateways refused the same sender three days apart, and each
21
- * refusal was read only after the email had left: a hard bounce, a paused
22
- * mailbox, ten first emails still queued. Both domains had Barracuda ESS as
23
- * their MX, and a third enrolled domain with the same MX was stopped by hand
24
- * before it sent. So the signal is cheap and it is available BEFORE a send:
25
- * the domain's MX names the gateway in front of it, and what that gateway
26
- * has done with this organization's mail lately says what it will do next.
27
- *
28
- * ## The kinds
29
- *
30
- * A gateway is named by the MX host's suffix. `barracuda`, `proofpoint`
31
- * and `mimecast` are the security gateways that refuse a SENDER on policy
32
- * and reputation; `google` and `microsoft` are the two hosted providers,
33
- * which judge mail one message at a time; `other` is every self-hosted or
34
- * unrecognized exchange; `none` is a domain with no MX at all, which cannot
35
- * receive mail and blocks the enrollment outright.
36
- *
37
- * ## The ledger and the hold
38
- *
39
- * Every send, every delivery and every block is counted per gateway per
40
- * organization, by UTC day, and a gateway that has REFUSED this
41
- * organization twice in the last {@link OUTREACH_GATEWAY_WINDOW_DAYS} days
42
- * without delivering once holds the next send into it: nothing goes into a
43
- * gateway that has refused us twice unless a member says so. `other` never
44
- * holds, because it is a bucket of unrelated exchanges rather than one
45
- * gateway; `none` blocks instead.
46
- *==========================================*/ export const OUTREACH_MAIL_GATEWAYS = [
47
- 'barracuda',
48
- 'proofpoint',
49
- 'mimecast',
50
- 'google',
51
- 'microsoft',
52
- 'other',
53
- 'none'
54
- ];
55
- /** The gateway as the Check-people chip and the enrollment row name it. */ export const OUTREACH_MAIL_GATEWAY_LABELS = {
56
- barracuda: 'Barracuda',
57
- proofpoint: 'Proofpoint',
58
- mimecast: 'Mimecast',
59
- google: 'Google Workspace',
60
- microsoft: 'Microsoft 365',
61
- other: 'Other mail gateway',
62
- none: 'No MX record'
63
- };
64
- /**
65
- * The security gateways that stand in front of a whole company's mail and
66
- * refuse a sender for everyone behind them: the ones the enroll dialog
67
- * counts as "gateway-fronted".
68
- */ export const OUTREACH_GATEWAY_FRONTED = [
69
- 'barracuda',
70
- 'proofpoint',
71
- 'mimecast'
72
- ];
73
- /** Blocks in the window, with no delivery beside them, that hold the next send. */ export const OUTREACH_GATEWAY_HOLD_BLOCKS = 2;
74
- /** The days the hold is judged over. */ export const OUTREACH_GATEWAY_WINDOW_DAYS = 30;
75
- /** The days the Check-people chip counts. */ export const OUTREACH_GATEWAY_CHIP_DAYS = 7;
76
- /** How long a domain's MX answer is trusted before it is looked up again. */ export const OUTREACH_DOMAIN_INTEL_TTL_MS = 7 * 24 * 60 * 60 * 1000;
77
- /** A send with no bounce this long after it counts as delivered. */ export const OUTREACH_GATEWAY_DELIVERED_AFTER_MS = 24 * 60 * 60 * 1000;
78
- /** The MX host suffixes each named gateway answers as, lowercase, no trailing dot. */ const GATEWAY_HOST_SUFFIXES = [
79
- [
80
- 'barracuda',
81
- [
82
- '.ess.barracudanetworks.com',
83
- '.barracudanetworks.com'
84
- ]
85
- ],
86
- [
87
- 'proofpoint',
88
- [
89
- '.pphosted.com',
90
- '.ppe-hosted.com',
91
- '.proofpoint.com'
92
- ]
93
- ],
94
- [
95
- 'mimecast',
96
- [
97
- '.mimecast.com',
98
- '.mimecast.co.za',
99
- '.mimecast-offshore.com'
100
- ]
101
- ],
102
- [
103
- 'google',
104
- [
105
- '.google.com',
106
- '.googlemail.com',
107
- '.gmail.com'
108
- ]
109
- ],
110
- [
111
- 'microsoft',
112
- [
113
- '.mail.protection.outlook.com',
114
- '.outlook.com',
115
- '.protection.outlook.com'
116
- ]
117
- ]
118
- ];
119
- /** A host as the suffix table reads it: lowercase, trimmed, no trailing dot. */ function normalizeHost(host) {
120
- return String(host != null ? host : '').trim().toLowerCase().replace(/\.$/, '');
121
- }
122
- /**
123
- * The gateway ONE host names, or `null` when it names none of the known
124
- * ones — a Remote-MTA in a bounce, or one MX exchange.
125
- */ export function outreachMailGatewayOfHost(host) {
126
- const name = normalizeHost(host);
127
- if (!name) return null;
128
- for (const [gateway, suffixes] of GATEWAY_HOST_SUFFIXES){
129
- if (suffixes.some((suffix)=>name === suffix.slice(1) || name.endsWith(suffix))) return gateway;
130
- }
131
- return null;
132
- }
133
- /**
134
- * The gateway a domain's MX records name — see the module note. A domain
135
- * whose exchanges name more than one is read as the first named one in MX
136
- * order (the list is handed over lowest-priority-first); one that names
137
- * none is `other`; an empty list is `none`.
138
- */ export function classifyOutreachMailGateway(mxHosts) {
139
- const hosts = mxHosts.map(normalizeHost).filter(Boolean);
140
- // A lone "." (or an empty exchange) is RFC 7505's null MX: the domain
141
- // says outright that it takes no mail.
142
- if (!hosts.length) return 'none';
143
- for (const host of hosts){
144
- const gateway = outreachMailGatewayOfHost(host);
145
- if (gateway) return gateway;
146
- }
147
- return 'other';
148
- }
149
- /** Whether the gateway is one of the security gateways that refuse a sender for a whole company. */ export function isOutreachGatewayFronted(gateway) {
150
- return gateway !== null && gateway !== undefined && OUTREACH_GATEWAY_FRONTED.includes(gateway);
151
- }
152
- /** `YYYY-MM-DD` in UTC: the day a ledger entry is filed under. */ export function outreachGatewayDay(atMs) {
153
- return new Date(atMs).toISOString().slice(0, 10);
154
- }
155
- const count = (value)=>{
156
- const number = Number(value);
157
- return Number.isFinite(number) ? Math.max(0, Math.floor(number)) : 0;
158
- };
159
- /** The ledger's counts summed over the last `days` UTC days, today included. */ export function outreachGatewayWindow(days, nowMs, windowDays) {
160
- const summed = {
161
- sent: 0,
162
- delivered: 0,
163
- blocked: 0
164
- };
165
- if (!days) return summed;
166
- const oldest = outreachGatewayDay(nowMs - (windowDays - 1) * 86400000);
167
- const newest = outreachGatewayDay(nowMs);
168
- for (const [day, counts] of Object.entries(days)){
169
- if (day < oldest || day > newest) continue;
170
- summed.sent += count(counts == null ? void 0 : counts.sent);
171
- summed.delivered += count(counts == null ? void 0 : counts.delivered);
172
- summed.blocked += count(counts == null ? void 0 : counts.blocked);
173
- }
174
- return summed;
175
- }
176
- /** The ledger's days with everything older than `keepDays` dropped. */ export function pruneOutreachGatewayDays(days, nowMs, keepDays) {
177
- const oldest = outreachGatewayDay(nowMs - (keepDays - 1) * 86400000);
178
- return Object.fromEntries(Object.entries(days != null ? days : {}).filter(([day])=>day >= oldest));
179
- }
180
- /**
181
- * Whether the gateway holds the next send — see the module note. A named
182
- * gateway only: `other` is many exchanges, and `none` blocks instead.
183
- */ export function outreachGatewayHolds(standing) {
184
- if (standing.gateway === 'other' || standing.gateway === 'none') return false;
185
- return count(standing.blocked30) >= OUTREACH_GATEWAY_HOLD_BLOCKS && count(standing.delivered30) === 0;
186
- }
17
+ * THE MAIL GATEWAY, AS SEQUENCES NAMES IT (AGL-3326).
18
+ *
19
+ * The engine is the platform's — `@aglyn/shared-util-email`'s
20
+ * `mail-gateway` (AGL-3328), which the Resend path reads too, so one table
21
+ * names a gateway and one rule holds a send for both. What stays here is
22
+ * the vocabulary Sequences already speaks, and the one sentence it adds: a
23
+ * held enrollment is resumed by a member.
24
+ *
25
+ * ## The hold, as Sequences applies it
26
+ *
27
+ * A gateway that REFUSED the mailbox's sending domain twice in the last
28
+ * {@link OUTREACH_GATEWAY_WINDOW_DAYS} days without delivering once holds
29
+ * the next send into it: nothing goes into a gateway that has refused us
30
+ * twice unless a member says so. `other` never holds; `none` blocks instead.
31
+ *==========================================*/ import { classifyMailGateway, isMailGatewayFronted, MAIL_DOMAIN_INTEL_TTL_MS, MAIL_GATEWAY_CHIP_DAYS, MAIL_GATEWAY_DELIVERED_AFTER_MS, MAIL_GATEWAY_HOLD_BLOCKS, MAIL_GATEWAY_LABELS, MAIL_GATEWAY_WINDOW_DAYS, MAIL_GATEWAYS, MAIL_GATEWAYS_FRONTED, mailGatewayChip, mailGatewayDay, mailGatewayHolds, mailGatewayHoldSentence, mailGatewayOfHost, mailGatewayWindow, pruneMailGatewayDays } from "@aglyn/shared-util-email/mail-gateway";
32
+ export const OUTREACH_MAIL_GATEWAYS = MAIL_GATEWAYS;
33
+ /** The gateway as the Check-people chip and the enrollment row name it. */ export const OUTREACH_MAIL_GATEWAY_LABELS = MAIL_GATEWAY_LABELS;
34
+ /** The security gateways the enroll dialog counts as "gateway-fronted". */ export const OUTREACH_GATEWAY_FRONTED = MAIL_GATEWAYS_FRONTED;
35
+ export const OUTREACH_GATEWAY_HOLD_BLOCKS = MAIL_GATEWAY_HOLD_BLOCKS;
36
+ export const OUTREACH_GATEWAY_WINDOW_DAYS = MAIL_GATEWAY_WINDOW_DAYS;
37
+ export const OUTREACH_GATEWAY_CHIP_DAYS = MAIL_GATEWAY_CHIP_DAYS;
38
+ export const OUTREACH_DOMAIN_INTEL_TTL_MS = MAIL_DOMAIN_INTEL_TTL_MS;
39
+ export const OUTREACH_GATEWAY_DELIVERED_AFTER_MS = MAIL_GATEWAY_DELIVERED_AFTER_MS;
40
+ export const outreachMailGatewayOfHost = mailGatewayOfHost;
41
+ export const classifyOutreachMailGateway = classifyMailGateway;
42
+ export const isOutreachGatewayFronted = isMailGatewayFronted;
43
+ export const outreachGatewayDay = mailGatewayDay;
44
+ export const outreachGatewayWindow = mailGatewayWindow;
45
+ export const pruneOutreachGatewayDays = pruneMailGatewayDays;
46
+ export const outreachGatewayHolds = mailGatewayHolds;
47
+ export const outreachGatewayChip = mailGatewayChip;
187
48
  /** The sentence the hold is written with, on the enrollment and in the engine's reason. */ export function outreachGatewayHoldReason(gateway, blocked30) {
188
- const name = OUTREACH_MAIL_GATEWAY_LABELS[gateway];
189
- return `${name} refused this sender ${blocked30 === 2 ? 'twice' : `${blocked30} times`} in the last ` + `${OUTREACH_GATEWAY_WINDOW_DAYS} days and delivered nothing, so this email is held. ` + 'Resume the enrollment to send it anyway.';
190
- }
191
- /**
192
- * The Check-people chip for one person, or `null` when there is nothing
193
- * worth a chip: an unrecognized exchange with no verdict this week. Red for
194
- * a gateway that refused this week, green for one that delivered, neutral
195
- * for the hosted providers and the security gateways with no verdict yet,
196
- * and blocked for a domain with no MX.
197
- */ export function outreachGatewayChip(standing) {
198
- if (!standing) return null;
199
- const name = OUTREACH_MAIL_GATEWAY_LABELS[standing.gateway];
200
- if (standing.gateway === 'none') return {
201
- label: 'No MX record — cannot receive mail',
202
- tone: 'blocked'
203
- };
204
- const blocked = count(standing.blocked7);
205
- const delivered = count(standing.delivered7);
206
- const settled = blocked + delivered;
207
- if (blocked > 0) {
208
- return {
209
- label: `${name} · ${blocked} of ${settled} ${settled === 1 ? 'send' : 'sends'} refused this week`,
210
- tone: 'refused'
211
- };
212
- }
213
- if (delivered > 0) {
214
- return {
215
- label: `${name} · ${delivered} of ${settled} delivered this week`,
216
- tone: 'delivered'
217
- };
218
- }
219
- if (standing.gateway === 'other') return null;
220
- return {
221
- label: name,
222
- tone: 'neutral'
223
- };
49
+ return `${mailGatewayHoldSentence(gateway, blocked30)}, so this email is held. Resume the enrollment to send it anyway.`;
224
50
  }
225
51
 
226
52
  //# sourceMappingURL=mail-gateway.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/mail-gateway.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 MX RECORD PREDICTS THE GATEWAY, AND THE GATEWAY'S HISTORY PREDICTS\n * THE BLOCK (AGL-3326).\n *\n * Two Barracuda gateways refused the same sender three days apart, and each\n * refusal was read only after the email had left: a hard bounce, a paused\n * mailbox, ten first emails still queued. Both domains had Barracuda ESS as\n * their MX, and a third enrolled domain with the same MX was stopped by hand\n * before it sent. So the signal is cheap and it is available BEFORE a send:\n * the domain's MX names the gateway in front of it, and what that gateway\n * has done with this organization's mail lately says what it will do next.\n *\n * ## The kinds\n *\n * A gateway is named by the MX host's suffix. `barracuda`, `proofpoint`\n * and `mimecast` are the security gateways that refuse a SENDER on policy\n * and reputation; `google` and `microsoft` are the two hosted providers,\n * which judge mail one message at a time; `other` is every self-hosted or\n * unrecognized exchange; `none` is a domain with no MX at all, which cannot\n * receive mail and blocks the enrollment outright.\n *\n * ## The ledger and the hold\n *\n * Every send, every delivery and every block is counted per gateway per\n * organization, by UTC day, and a gateway that has REFUSED this\n * organization twice in the last {@link OUTREACH_GATEWAY_WINDOW_DAYS} days\n * without delivering once holds the next send into it: nothing goes into a\n * gateway that has refused us twice unless a member says so. `other` never\n * holds, because it is a bucket of unrelated exchanges rather than one\n * gateway; `none` blocks instead.\n *==========================================*/\n\nexport type OutreachMailGateway =\n | 'barracuda'\n | 'proofpoint'\n | 'mimecast'\n | 'google'\n | 'microsoft'\n | 'other'\n | 'none'\n\nexport const OUTREACH_MAIL_GATEWAYS: readonly OutreachMailGateway[] = [\n 'barracuda',\n 'proofpoint',\n 'mimecast',\n 'google',\n 'microsoft',\n 'other',\n 'none',\n]\n\n/** The gateway as the Check-people chip and the enrollment row name it. */\nexport const OUTREACH_MAIL_GATEWAY_LABELS: Record<OutreachMailGateway, string> = {\n barracuda: 'Barracuda',\n proofpoint: 'Proofpoint',\n mimecast: 'Mimecast',\n google: 'Google Workspace',\n microsoft: 'Microsoft 365',\n other: 'Other mail gateway',\n none: 'No MX record',\n}\n\n/**\n * The security gateways that stand in front of a whole company's mail and\n * refuse a sender for everyone behind them: the ones the enroll dialog\n * counts as \"gateway-fronted\".\n */\nexport const OUTREACH_GATEWAY_FRONTED: readonly OutreachMailGateway[] = ['barracuda', 'proofpoint', 'mimecast']\n\n/** Blocks in the window, with no delivery beside them, that hold the next send. */\nexport const OUTREACH_GATEWAY_HOLD_BLOCKS = 2\n\n/** The days the hold is judged over. */\nexport const OUTREACH_GATEWAY_WINDOW_DAYS = 30\n\n/** The days the Check-people chip counts. */\nexport const OUTREACH_GATEWAY_CHIP_DAYS = 7\n\n/** How long a domain's MX answer is trusted before it is looked up again. */\nexport const OUTREACH_DOMAIN_INTEL_TTL_MS = 7 * 24 * 60 * 60 * 1000\n\n/** A send with no bounce this long after it counts as delivered. */\nexport const OUTREACH_GATEWAY_DELIVERED_AFTER_MS = 24 * 60 * 60 * 1000\n\n/** The MX host suffixes each named gateway answers as, lowercase, no trailing dot. */\nconst GATEWAY_HOST_SUFFIXES: ReadonlyArray<readonly [OutreachMailGateway, readonly string[]]> = [\n ['barracuda', ['.ess.barracudanetworks.com', '.barracudanetworks.com']],\n ['proofpoint', ['.pphosted.com', '.ppe-hosted.com', '.proofpoint.com']],\n ['mimecast', ['.mimecast.com', '.mimecast.co.za', '.mimecast-offshore.com']],\n ['google', ['.google.com', '.googlemail.com', '.gmail.com']],\n ['microsoft', ['.mail.protection.outlook.com', '.outlook.com', '.protection.outlook.com']],\n]\n\n/** A host as the suffix table reads it: lowercase, trimmed, no trailing dot. */\nfunction normalizeHost(host: unknown): string {\n return String(host ?? '')\n .trim()\n .toLowerCase()\n .replace(/\\.$/, '')\n}\n\n/**\n * The gateway ONE host names, or `null` when it names none of the known\n * ones — a Remote-MTA in a bounce, or one MX exchange.\n */\nexport function outreachMailGatewayOfHost(host: unknown): OutreachMailGateway | null {\n const name = normalizeHost(host)\n if (!name) return null\n for (const [gateway, suffixes] of GATEWAY_HOST_SUFFIXES) {\n if (suffixes.some((suffix) => name === suffix.slice(1) || name.endsWith(suffix))) return gateway\n }\n return null\n}\n\n/**\n * The gateway a domain's MX records name — see the module note. A domain\n * whose exchanges name more than one is read as the first named one in MX\n * order (the list is handed over lowest-priority-first); one that names\n * none is `other`; an empty list is `none`.\n */\nexport function classifyOutreachMailGateway(mxHosts: readonly string[]): OutreachMailGateway {\n const hosts = mxHosts.map(normalizeHost).filter(Boolean)\n // A lone \".\" (or an empty exchange) is RFC 7505's null MX: the domain\n // says outright that it takes no mail.\n if (!hosts.length) return 'none'\n for (const host of hosts) {\n const gateway = outreachMailGatewayOfHost(host)\n if (gateway) return gateway\n }\n return 'other'\n}\n\n/** Whether the gateway is one of the security gateways that refuse a sender for a whole company. */\nexport function isOutreachGatewayFronted(gateway: OutreachMailGateway | null | undefined): boolean {\n return gateway !== null && gateway !== undefined && OUTREACH_GATEWAY_FRONTED.includes(gateway)\n}\n\n/** The counts one UTC day holds on a gateway's ledger. */\nexport interface OutreachGatewayDayCounts {\n sent: number\n delivered: number\n blocked: number\n}\n\n/** `YYYY-MM-DD` in UTC: the day a ledger entry is filed under. */\nexport function outreachGatewayDay(atMs: number): string {\n return new Date(atMs).toISOString().slice(0, 10)\n}\n\nconst count = (value: unknown): number => {\n const number = Number(value)\n return Number.isFinite(number) ? Math.max(0, Math.floor(number)) : 0\n}\n\n/** The ledger's counts summed over the last `days` UTC days, today included. */\nexport function outreachGatewayWindow(\n days: Record<string, Partial<OutreachGatewayDayCounts>> | null | undefined,\n nowMs: number,\n windowDays: number,\n): OutreachGatewayDayCounts {\n const summed: OutreachGatewayDayCounts = { sent: 0, delivered: 0, blocked: 0 }\n if (!days) return summed\n const oldest = outreachGatewayDay(nowMs - (windowDays - 1) * 86_400_000)\n const newest = outreachGatewayDay(nowMs)\n for (const [day, counts] of Object.entries(days)) {\n if (day < oldest || day > newest) continue\n summed.sent += count(counts?.sent)\n summed.delivered += count(counts?.delivered)\n summed.blocked += count(counts?.blocked)\n }\n return summed\n}\n\n/** The ledger's days with everything older than `keepDays` dropped. */\nexport function pruneOutreachGatewayDays<T>(\n days: Record<string, T> | null | undefined,\n nowMs: number,\n keepDays: number,\n): Record<string, T> {\n const oldest = outreachGatewayDay(nowMs - (keepDays - 1) * 86_400_000)\n return Object.fromEntries(Object.entries(days ?? {}).filter(([day]) => day >= oldest))\n}\n\n/**\n * What the gates and the enroll dialog know about the gateway in front of\n * one address: which it is, and what it did with this organization's mail\n * over the chip's week and the hold's month.\n */\nexport interface OutreachGatewayStanding {\n gateway: OutreachMailGateway\n /** Blocks and deliveries in the last {@link OUTREACH_GATEWAY_CHIP_DAYS} days. */\n blocked7: number\n delivered7: number\n /** Blocks and deliveries in the last {@link OUTREACH_GATEWAY_WINDOW_DAYS} days. */\n blocked30: number\n delivered30: number\n}\n\n/**\n * Whether the gateway holds the next send — see the module note. A named\n * gateway only: `other` is many exchanges, and `none` blocks instead.\n */\nexport function outreachGatewayHolds(standing: Pick<OutreachGatewayStanding, 'gateway' | 'blocked30' | 'delivered30'>): boolean {\n if (standing.gateway === 'other' || standing.gateway === 'none') return false\n return count(standing.blocked30) >= OUTREACH_GATEWAY_HOLD_BLOCKS && count(standing.delivered30) === 0\n}\n\n/** The sentence the hold is written with, on the enrollment and in the engine's reason. */\nexport function outreachGatewayHoldReason(gateway: OutreachMailGateway, blocked30: number): string {\n const name = OUTREACH_MAIL_GATEWAY_LABELS[gateway]\n return (\n `${name} refused this sender ${blocked30 === 2 ? 'twice' : `${blocked30} times`} in the last ` +\n `${OUTREACH_GATEWAY_WINDOW_DAYS} days and delivered nothing, so this email is held. ` +\n 'Resume the enrollment to send it anyway.'\n )\n}\n\n/** The chip's tone: what color the Check-people step gives it. */\nexport type OutreachGatewayChipTone = 'refused' | 'delivered' | 'neutral' | 'blocked'\n\nexport interface OutreachGatewayChip {\n label: string\n tone: OutreachGatewayChipTone\n}\n\n/**\n * The Check-people chip for one person, or `null` when there is nothing\n * worth a chip: an unrecognized exchange with no verdict this week. Red for\n * a gateway that refused this week, green for one that delivered, neutral\n * for the hosted providers and the security gateways with no verdict yet,\n * and blocked for a domain with no MX.\n */\nexport function outreachGatewayChip(standing: OutreachGatewayStanding | null | undefined): OutreachGatewayChip | null {\n if (!standing) return null\n const name = OUTREACH_MAIL_GATEWAY_LABELS[standing.gateway]\n if (standing.gateway === 'none') return { label: 'No MX record — cannot receive mail', tone: 'blocked' }\n const blocked = count(standing.blocked7)\n const delivered = count(standing.delivered7)\n const settled = blocked + delivered\n if (blocked > 0) {\n return {\n label: `${name} · ${blocked} of ${settled} ${settled === 1 ? 'send' : 'sends'} refused this week`,\n tone: 'refused',\n }\n }\n if (delivered > 0) {\n return { label: `${name} · ${delivered} of ${settled} delivered this week`, tone: 'delivered' }\n }\n if (standing.gateway === 'other') return null\n return { label: name, tone: 'neutral' }\n}\n"],"names":["OUTREACH_MAIL_GATEWAYS","OUTREACH_MAIL_GATEWAY_LABELS","barracuda","proofpoint","mimecast","google","microsoft","other","none","OUTREACH_GATEWAY_FRONTED","OUTREACH_GATEWAY_HOLD_BLOCKS","OUTREACH_GATEWAY_WINDOW_DAYS","OUTREACH_GATEWAY_CHIP_DAYS","OUTREACH_DOMAIN_INTEL_TTL_MS","OUTREACH_GATEWAY_DELIVERED_AFTER_MS","GATEWAY_HOST_SUFFIXES","normalizeHost","host","String","trim","toLowerCase","replace","outreachMailGatewayOfHost","name","gateway","suffixes","some","suffix","slice","endsWith","classifyOutreachMailGateway","mxHosts","hosts","map","filter","Boolean","length","isOutreachGatewayFronted","undefined","includes","outreachGatewayDay","atMs","Date","toISOString","count","value","number","Number","isFinite","Math","max","floor","outreachGatewayWindow","days","nowMs","windowDays","summed","sent","delivered","blocked","oldest","newest","day","counts","Object","entries","pruneOutreachGatewayDays","keepDays","fromEntries","outreachGatewayHolds","standing","blocked30","delivered30","outreachGatewayHoldReason","outreachGatewayChip","label","tone","blocked7","delivered7","settled"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/mail-gateway.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 MAIL GATEWAY, AS SEQUENCES NAMES IT (AGL-3326).\n *\n * The engine is the platform's — `@aglyn/shared-util-email`'s\n * `mail-gateway` (AGL-3328), which the Resend path reads too, so one table\n * names a gateway and one rule holds a send for both. What stays here is\n * the vocabulary Sequences already speaks, and the one sentence it adds: a\n * held enrollment is resumed by a member.\n *\n * ## The hold, as Sequences applies it\n *\n * A gateway that REFUSED the mailbox's sending domain twice in the last\n * {@link OUTREACH_GATEWAY_WINDOW_DAYS} days without delivering once holds\n * the next send into it: nothing goes into a gateway that has refused us\n * twice unless a member says so. `other` never holds; `none` blocks instead.\n *==========================================*/\n\nimport {\n classifyMailGateway,\n isMailGatewayFronted,\n MAIL_DOMAIN_INTEL_TTL_MS,\n MAIL_GATEWAY_CHIP_DAYS,\n MAIL_GATEWAY_DELIVERED_AFTER_MS,\n MAIL_GATEWAY_HOLD_BLOCKS,\n MAIL_GATEWAY_LABELS,\n MAIL_GATEWAY_WINDOW_DAYS,\n MAIL_GATEWAYS,\n MAIL_GATEWAYS_FRONTED,\n type MailGateway,\n mailGatewayChip,\n type MailGatewayChip,\n type MailGatewayChipTone,\n mailGatewayDay,\n type MailGatewayDayCounts,\n mailGatewayHolds,\n mailGatewayHoldSentence,\n mailGatewayOfHost,\n type MailGatewayStanding,\n mailGatewayWindow,\n pruneMailGatewayDays,\n} from '@aglyn/shared-util-email/mail-gateway'\n\nexport type OutreachMailGateway = MailGateway\nexport type OutreachGatewayDayCounts = MailGatewayDayCounts\nexport type OutreachGatewayStanding = MailGatewayStanding\nexport type OutreachGatewayChipTone = MailGatewayChipTone\nexport type OutreachGatewayChip = MailGatewayChip\n\nexport const OUTREACH_MAIL_GATEWAYS = MAIL_GATEWAYS\n/** The gateway as the Check-people chip and the enrollment row name it. */\nexport const OUTREACH_MAIL_GATEWAY_LABELS = MAIL_GATEWAY_LABELS\n/** The security gateways the enroll dialog counts as \"gateway-fronted\". */\nexport const OUTREACH_GATEWAY_FRONTED = MAIL_GATEWAYS_FRONTED\nexport const OUTREACH_GATEWAY_HOLD_BLOCKS = MAIL_GATEWAY_HOLD_BLOCKS\nexport const OUTREACH_GATEWAY_WINDOW_DAYS = MAIL_GATEWAY_WINDOW_DAYS\nexport const OUTREACH_GATEWAY_CHIP_DAYS = MAIL_GATEWAY_CHIP_DAYS\nexport const OUTREACH_DOMAIN_INTEL_TTL_MS = MAIL_DOMAIN_INTEL_TTL_MS\nexport const OUTREACH_GATEWAY_DELIVERED_AFTER_MS = MAIL_GATEWAY_DELIVERED_AFTER_MS\n\nexport const outreachMailGatewayOfHost = mailGatewayOfHost\nexport const classifyOutreachMailGateway = classifyMailGateway\nexport const isOutreachGatewayFronted = isMailGatewayFronted\nexport const outreachGatewayDay = mailGatewayDay\nexport const outreachGatewayWindow = mailGatewayWindow\nexport const pruneOutreachGatewayDays = pruneMailGatewayDays\nexport const outreachGatewayHolds = mailGatewayHolds\nexport const outreachGatewayChip = mailGatewayChip\n\n/** The sentence the hold is written with, on the enrollment and in the engine's reason. */\nexport function outreachGatewayHoldReason(gateway: OutreachMailGateway, blocked30: number): string {\n return `${mailGatewayHoldSentence(gateway, blocked30)}, so this email is held. Resume the enrollment to send it anyway.`\n}\n"],"names":["classifyMailGateway","isMailGatewayFronted","MAIL_DOMAIN_INTEL_TTL_MS","MAIL_GATEWAY_CHIP_DAYS","MAIL_GATEWAY_DELIVERED_AFTER_MS","MAIL_GATEWAY_HOLD_BLOCKS","MAIL_GATEWAY_LABELS","MAIL_GATEWAY_WINDOW_DAYS","MAIL_GATEWAYS","MAIL_GATEWAYS_FRONTED","mailGatewayChip","mailGatewayDay","mailGatewayHolds","mailGatewayHoldSentence","mailGatewayOfHost","mailGatewayWindow","pruneMailGatewayDays","OUTREACH_MAIL_GATEWAYS","OUTREACH_MAIL_GATEWAY_LABELS","OUTREACH_GATEWAY_FRONTED","OUTREACH_GATEWAY_HOLD_BLOCKS","OUTREACH_GATEWAY_WINDOW_DAYS","OUTREACH_GATEWAY_CHIP_DAYS","OUTREACH_DOMAIN_INTEL_TTL_MS","OUTREACH_GATEWAY_DELIVERED_AFTER_MS","outreachMailGatewayOfHost","classifyOutreachMailGateway","isOutreachGatewayFronted","outreachGatewayDay","outreachGatewayWindow","pruneOutreachGatewayDays","outreachGatewayHolds","outreachGatewayChip","outreachGatewayHoldReason","gateway","blocked30"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;4CAe4C,GAE5C,SACEA,mBAAmB,EACnBC,oBAAoB,EACpBC,wBAAwB,EACxBC,sBAAsB,EACtBC,+BAA+B,EAC/BC,wBAAwB,EACxBC,mBAAmB,EACnBC,wBAAwB,EACxBC,aAAa,EACbC,qBAAqB,EAErBC,eAAe,EAGfC,cAAc,EAEdC,gBAAgB,EAChBC,uBAAuB,EACvBC,iBAAiB,EAEjBC,iBAAiB,EACjBC,oBAAoB,QACf,wCAAuC;AAQ9C,OAAO,MAAMC,yBAAyBT,cAAa;AACnD,yEAAyE,GACzE,OAAO,MAAMU,+BAA+BZ,oBAAmB;AAC/D,yEAAyE,GACzE,OAAO,MAAMa,2BAA2BV,sBAAqB;AAC7D,OAAO,MAAMW,+BAA+Bf,yBAAwB;AACpE,OAAO,MAAMgB,+BAA+Bd,yBAAwB;AACpE,OAAO,MAAMe,6BAA6BnB,uBAAsB;AAChE,OAAO,MAAMoB,+BAA+BrB,yBAAwB;AACpE,OAAO,MAAMsB,sCAAsCpB,gCAA+B;AAElF,OAAO,MAAMqB,4BAA4BX,kBAAiB;AAC1D,OAAO,MAAMY,8BAA8B1B,oBAAmB;AAC9D,OAAO,MAAM2B,2BAA2B1B,qBAAoB;AAC5D,OAAO,MAAM2B,qBAAqBjB,eAAc;AAChD,OAAO,MAAMkB,wBAAwBd,kBAAiB;AACtD,OAAO,MAAMe,2BAA2Bd,qBAAoB;AAC5D,OAAO,MAAMe,uBAAuBnB,iBAAgB;AACpD,OAAO,MAAMoB,sBAAsBtB,gBAAe;AAElD,yFAAyF,GACzF,OAAO,SAASuB,0BAA0BC,OAA4B,EAAEC,SAAiB;IACvF,OAAO,GAAGtB,wBAAwBqB,SAASC,WAAW,iEAAiE,CAAC;AAC1H"}
@@ -14,7 +14,7 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
- import type { OutreachEnrollmentEngagement } from './outreach.types';
17
+ import { type OutreachEnrollmentEngagement } from './outreach.types';
18
18
  /**
19
19
  * WHAT ONE PERSON DID WITH THE LINKS THEY WERE SENT (AGL-3239), read back
20
20
  * from the stored map.
@@ -28,4 +28,28 @@ import type { OutreachEnrollmentEngagement } from './outreach.types';
28
28
  * every enrollment of every sequence that does not track clicks, so it is
29
29
  * the common case rather than the edge one.
30
30
  */
31
- export declare function readOutreachEngagement(raw: unknown): OutreachEnrollmentEngagement;
31
+ export declare function readOutreachEngagement(raw: unknown): Required<OutreachEnrollmentEngagement>;
32
+ /**
33
+ * What the enrollments table and the detail view say about one person's
34
+ * clicks (AGL-3332), from the engagement alone.
35
+ *
36
+ * `linkCount` is the number of distinct destinations they followed when that
37
+ * number is KNOWN, and `null` when it is not: a click from before the
38
+ * per-click history kept only the last destination, so a person with two
39
+ * such clicks followed one link or two, and neither is claimed.
40
+ * `linksCapped` says the list stopped at {@link OUTREACH_ENGAGEMENT_LINKS_MAX}.
41
+ */
42
+ export interface OutreachClickSummary {
43
+ clicks: number;
44
+ machineClicks: number;
45
+ /** Every destination known to have been followed: the history's, and the last one. */
46
+ followed: string[];
47
+ linkCount: number | null;
48
+ linksCapped: boolean;
49
+ /** Human clicks counted as totals only, with no row of their own. */
50
+ unloggedClicks: number;
51
+ unloggedMachineClicks: number;
52
+ }
53
+ export declare function outreachClickSummary(raw: unknown): OutreachClickSummary;
54
+ /** The Clicks cell: "2 · 1 link", or the count alone when the links are not known. */
55
+ export declare function outreachClickCountLabel(summary: OutreachClickSummary): string;
@@ -13,7 +13,8 @@
13
13
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
- */ /**
16
+ */ import { OUTREACH_ENGAGEMENT_LINKS_MAX } from "./outreach.types.js";
17
+ /**
17
18
  * WHAT ONE PERSON DID WITH THE LINKS THEY WERE SENT (AGL-3239), read back
18
19
  * from the stored map.
19
20
  *
@@ -32,13 +33,49 @@
32
33
  return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0;
33
34
  };
34
35
  const ms = (value)=>typeof value === 'number' && Number.isFinite(value) ? value : null;
36
+ const clicks = count(data['clicks']);
37
+ const machineClicks = count(data['machineClicks']);
38
+ const links = Array.isArray(data['links']) ? [
39
+ ...new Set(data['links'].filter((entry)=>typeof entry === 'string' && entry !== ''))
40
+ ] : [];
35
41
  return {
36
- clicks: count(data['clicks']),
42
+ clicks,
37
43
  firstClickAtMs: ms(data['firstClickAtMs']),
38
44
  lastClickAtMs: ms(data['lastClickAtMs']),
39
45
  lastClickUrl: typeof data['lastClickUrl'] === 'string' ? data['lastClickUrl'] : null,
40
- machineClicks: count(data['machineClicks'])
46
+ machineClicks,
47
+ links: links.slice(0, OUTREACH_ENGAGEMENT_LINKS_MAX),
48
+ // Never more than the totals they are part of: a history row is written
49
+ // in the same transaction as the count it itemizes.
50
+ loggedClicks: Math.min(count(data['loggedClicks']), clicks),
51
+ loggedMachineClicks: Math.min(count(data['loggedMachineClicks']), machineClicks)
41
52
  };
42
53
  }
54
+ export function outreachClickSummary(raw) {
55
+ const engagement = readOutreachEngagement(raw);
56
+ const followed = [
57
+ ...engagement.links
58
+ ];
59
+ if (engagement.lastClickUrl && !followed.includes(engagement.lastClickUrl)) followed.push(engagement.lastClickUrl);
60
+ const unloggedClicks = engagement.clicks - engagement.loggedClicks;
61
+ const linksCapped = engagement.links.length >= OUTREACH_ENGAGEMENT_LINKS_MAX;
62
+ // Exact when every human click has a row, or when there was only one.
63
+ const exact = unloggedClicks === 0 || engagement.clicks === 1 && engagement.lastClickUrl !== null;
64
+ return {
65
+ clicks: engagement.clicks,
66
+ machineClicks: engagement.machineClicks,
67
+ followed,
68
+ linkCount: engagement.clicks === 0 ? 0 : exact ? followed.length : null,
69
+ linksCapped,
70
+ unloggedClicks,
71
+ unloggedMachineClicks: engagement.machineClicks - engagement.loggedMachineClicks
72
+ };
73
+ }
74
+ /** The Clicks cell: "2 · 1 link", or the count alone when the links are not known. */ export function outreachClickCountLabel(summary) {
75
+ if (!summary.clicks) return '—';
76
+ if (summary.linkCount === null) return String(summary.clicks);
77
+ const links = `${summary.linkCount}${summary.linksCapped ? '+' : ''}`;
78
+ return `${summary.clicks} · ${links} ${summary.linkCount === 1 && !summary.linksCapped ? 'link' : 'links'}`;
79
+ }
43
80
 
44
81
  //# sourceMappingURL=enrollment-engagement.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/model/enrollment-engagement.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 type { OutreachEnrollmentEngagement } from './outreach.types'\n\n/**\n * WHAT ONE PERSON DID WITH THE LINKS THEY WERE SENT (AGL-3239), read back\n * from the stored map.\n *\n * Client-safe, and here rather than beside the writer for that reason: the\n * enrollments table renders this in the browser and the click route writes\n * it on the server, and both must agree on what an absent field means.\n *\n * Read defensively, a field at a time. `readOutreachEngagement(undefined)`\n * is what an enrollment that has never been clicked reads as — which is\n * every enrollment of every sequence that does not track clicks, so it is\n * the common case rather than the edge one.\n */\nexport function readOutreachEngagement(raw: unknown): OutreachEnrollmentEngagement {\n const data = (raw ?? {}) as Record<string, unknown>\n const count = (value: unknown): number => {\n const parsed = Number(value)\n return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0\n }\n const ms = (value: unknown): number | null =>\n typeof value === 'number' && Number.isFinite(value) ? value : null\n return {\n clicks: count(data['clicks']),\n firstClickAtMs: ms(data['firstClickAtMs']),\n lastClickAtMs: ms(data['lastClickAtMs']),\n lastClickUrl: typeof data['lastClickUrl'] === 'string' ? data['lastClickUrl'] : null,\n machineClicks: count(data['machineClicks']),\n }\n}\n"],"names":["readOutreachEngagement","raw","data","count","value","parsed","Number","isFinite","Math","floor","ms","clicks","firstClickAtMs","lastClickAtMs","lastClickUrl","machineClicks"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAID;;;;;;;;;;;;CAYC,GACD,OAAO,SAASA,uBAAuBC,GAAY;IACjD,MAAMC,OAAQD,cAAAA,MAAO,CAAC;IACtB,MAAME,QAAQ,CAACC;QACb,MAAMC,SAASC,OAAOF;QACtB,OAAOE,OAAOC,QAAQ,CAACF,WAAWA,SAAS,IAAIG,KAAKC,KAAK,CAACJ,UAAU;IACtE;IACA,MAAMK,KAAK,CAACN,QACV,OAAOA,UAAU,YAAYE,OAAOC,QAAQ,CAACH,SAASA,QAAQ;IAChE,OAAO;QACLO,QAAQR,MAAMD,IAAI,CAAC,SAAS;QAC5BU,gBAAgBF,GAAGR,IAAI,CAAC,iBAAiB;QACzCW,eAAeH,GAAGR,IAAI,CAAC,gBAAgB;QACvCY,cAAc,OAAOZ,IAAI,CAAC,eAAe,KAAK,WAAWA,IAAI,CAAC,eAAe,GAAG;QAChFa,eAAeZ,MAAMD,IAAI,CAAC,gBAAgB;IAC5C;AACF"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/model/enrollment-engagement.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 { OUTREACH_ENGAGEMENT_LINKS_MAX, type OutreachEnrollmentEngagement } from './outreach.types'\n\n/**\n * WHAT ONE PERSON DID WITH THE LINKS THEY WERE SENT (AGL-3239), read back\n * from the stored map.\n *\n * Client-safe, and here rather than beside the writer for that reason: the\n * enrollments table renders this in the browser and the click route writes\n * it on the server, and both must agree on what an absent field means.\n *\n * Read defensively, a field at a time. `readOutreachEngagement(undefined)`\n * is what an enrollment that has never been clicked reads as — which is\n * every enrollment of every sequence that does not track clicks, so it is\n * the common case rather than the edge one.\n */\nexport function readOutreachEngagement(raw: unknown): Required<OutreachEnrollmentEngagement> {\n const data = (raw ?? {}) as Record<string, unknown>\n const count = (value: unknown): number => {\n const parsed = Number(value)\n return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0\n }\n const ms = (value: unknown): number | null =>\n typeof value === 'number' && Number.isFinite(value) ? value : null\n const clicks = count(data['clicks'])\n const machineClicks = count(data['machineClicks'])\n const links = Array.isArray(data['links'])\n ? [...new Set(data['links'].filter((entry): entry is string => typeof entry === 'string' && entry !== ''))]\n : []\n return {\n clicks,\n firstClickAtMs: ms(data['firstClickAtMs']),\n lastClickAtMs: ms(data['lastClickAtMs']),\n lastClickUrl: typeof data['lastClickUrl'] === 'string' ? data['lastClickUrl'] : null,\n machineClicks,\n links: links.slice(0, OUTREACH_ENGAGEMENT_LINKS_MAX),\n // Never more than the totals they are part of: a history row is written\n // in the same transaction as the count it itemizes.\n loggedClicks: Math.min(count(data['loggedClicks']), clicks),\n loggedMachineClicks: Math.min(count(data['loggedMachineClicks']), machineClicks),\n }\n}\n\n/**\n * What the enrollments table and the detail view say about one person's\n * clicks (AGL-3332), from the engagement alone.\n *\n * `linkCount` is the number of distinct destinations they followed when that\n * number is KNOWN, and `null` when it is not: a click from before the\n * per-click history kept only the last destination, so a person with two\n * such clicks followed one link or two, and neither is claimed.\n * `linksCapped` says the list stopped at {@link OUTREACH_ENGAGEMENT_LINKS_MAX}.\n */\nexport interface OutreachClickSummary {\n clicks: number\n machineClicks: number\n /** Every destination known to have been followed: the history's, and the last one. */\n followed: string[]\n linkCount: number | null\n linksCapped: boolean\n /** Human clicks counted as totals only, with no row of their own. */\n unloggedClicks: number\n unloggedMachineClicks: number\n}\n\nexport function outreachClickSummary(raw: unknown): OutreachClickSummary {\n const engagement = readOutreachEngagement(raw)\n const followed = [...engagement.links]\n if (engagement.lastClickUrl && !followed.includes(engagement.lastClickUrl)) followed.push(engagement.lastClickUrl)\n const unloggedClicks = engagement.clicks - engagement.loggedClicks\n const linksCapped = engagement.links.length >= OUTREACH_ENGAGEMENT_LINKS_MAX\n // Exact when every human click has a row, or when there was only one.\n const exact = unloggedClicks === 0 || (engagement.clicks === 1 && engagement.lastClickUrl !== null)\n return {\n clicks: engagement.clicks,\n machineClicks: engagement.machineClicks,\n followed,\n linkCount: engagement.clicks === 0 ? 0 : exact ? followed.length : null,\n linksCapped,\n unloggedClicks,\n unloggedMachineClicks: engagement.machineClicks - engagement.loggedMachineClicks,\n }\n}\n\n/** The Clicks cell: \"2 · 1 link\", or the count alone when the links are not known. */\nexport function outreachClickCountLabel(summary: OutreachClickSummary): string {\n if (!summary.clicks) return '—'\n if (summary.linkCount === null) return String(summary.clicks)\n const links = `${summary.linkCount}${summary.linksCapped ? '+' : ''}`\n return `${summary.clicks} · ${links} ${summary.linkCount === 1 && !summary.linksCapped ? 'link' : 'links'}`\n}\n"],"names":["OUTREACH_ENGAGEMENT_LINKS_MAX","readOutreachEngagement","raw","data","count","value","parsed","Number","isFinite","Math","floor","ms","clicks","machineClicks","links","Array","isArray","Set","filter","entry","firstClickAtMs","lastClickAtMs","lastClickUrl","slice","loggedClicks","min","loggedMachineClicks","outreachClickSummary","engagement","followed","includes","push","unloggedClicks","linksCapped","length","exact","linkCount","unloggedMachineClicks","outreachClickCountLabel","summary","String"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,6BAA6B,QAA2C,sBAAkB;AAEnG;;;;;;;;;;;;CAYC,GACD,OAAO,SAASC,uBAAuBC,GAAY;IACjD,MAAMC,OAAQD,cAAAA,MAAO,CAAC;IACtB,MAAME,QAAQ,CAACC;QACb,MAAMC,SAASC,OAAOF;QACtB,OAAOE,OAAOC,QAAQ,CAACF,WAAWA,SAAS,IAAIG,KAAKC,KAAK,CAACJ,UAAU;IACtE;IACA,MAAMK,KAAK,CAACN,QACV,OAAOA,UAAU,YAAYE,OAAOC,QAAQ,CAACH,SAASA,QAAQ;IAChE,MAAMO,SAASR,MAAMD,IAAI,CAAC,SAAS;IACnC,MAAMU,gBAAgBT,MAAMD,IAAI,CAAC,gBAAgB;IACjD,MAAMW,QAAQC,MAAMC,OAAO,CAACb,IAAI,CAAC,QAAQ,IACrC;WAAI,IAAIc,IAAId,IAAI,CAAC,QAAQ,CAACe,MAAM,CAAC,CAACC,QAA2B,OAAOA,UAAU,YAAYA,UAAU;KAAK,GACzG,EAAE;IACN,OAAO;QACLP;QACAQ,gBAAgBT,GAAGR,IAAI,CAAC,iBAAiB;QACzCkB,eAAeV,GAAGR,IAAI,CAAC,gBAAgB;QACvCmB,cAAc,OAAOnB,IAAI,CAAC,eAAe,KAAK,WAAWA,IAAI,CAAC,eAAe,GAAG;QAChFU;QACAC,OAAOA,MAAMS,KAAK,CAAC,GAAGvB;QACtB,wEAAwE;QACxE,oDAAoD;QACpDwB,cAAcf,KAAKgB,GAAG,CAACrB,MAAMD,IAAI,CAAC,eAAe,GAAGS;QACpDc,qBAAqBjB,KAAKgB,GAAG,CAACrB,MAAMD,IAAI,CAAC,sBAAsB,GAAGU;IACpE;AACF;AAwBA,OAAO,SAASc,qBAAqBzB,GAAY;IAC/C,MAAM0B,aAAa3B,uBAAuBC;IAC1C,MAAM2B,WAAW;WAAID,WAAWd,KAAK;KAAC;IACtC,IAAIc,WAAWN,YAAY,IAAI,CAACO,SAASC,QAAQ,CAACF,WAAWN,YAAY,GAAGO,SAASE,IAAI,CAACH,WAAWN,YAAY;IACjH,MAAMU,iBAAiBJ,WAAWhB,MAAM,GAAGgB,WAAWJ,YAAY;IAClE,MAAMS,cAAcL,WAAWd,KAAK,CAACoB,MAAM,IAAIlC;IAC/C,sEAAsE;IACtE,MAAMmC,QAAQH,mBAAmB,KAAMJ,WAAWhB,MAAM,KAAK,KAAKgB,WAAWN,YAAY,KAAK;IAC9F,OAAO;QACLV,QAAQgB,WAAWhB,MAAM;QACzBC,eAAee,WAAWf,aAAa;QACvCgB;QACAO,WAAWR,WAAWhB,MAAM,KAAK,IAAI,IAAIuB,QAAQN,SAASK,MAAM,GAAG;QACnED;QACAD;QACAK,uBAAuBT,WAAWf,aAAa,GAAGe,WAAWF,mBAAmB;IAClF;AACF;AAEA,oFAAoF,GACpF,OAAO,SAASY,wBAAwBC,OAA6B;IACnE,IAAI,CAACA,QAAQ3B,MAAM,EAAE,OAAO;IAC5B,IAAI2B,QAAQH,SAAS,KAAK,MAAM,OAAOI,OAAOD,QAAQ3B,MAAM;IAC5D,MAAME,QAAQ,GAAGyB,QAAQH,SAAS,GAAGG,QAAQN,WAAW,GAAG,MAAM,IAAI;IACrE,OAAO,GAAGM,QAAQ3B,MAAM,CAAC,GAAG,EAAEE,MAAM,CAAC,EAAEyB,QAAQH,SAAS,KAAK,KAAK,CAACG,QAAQN,WAAW,GAAG,SAAS,SAAS;AAC7G"}
@@ -0,0 +1,40 @@
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
+ import type { OutreachClickMachineReason } from '../engine/click-tracking';
18
+ import type { OutreachEnrollmentHistoryEntry, OutreachHistoryAction } from './outreach.types';
19
+ /** A click row as the click route writes it. */
20
+ export declare function outreachClickHistoryRow(input: {
21
+ atMs: number;
22
+ url: string | null;
23
+ stepIndex: number;
24
+ human: boolean;
25
+ machineReason: OutreachClickMachineReason | null;
26
+ }): Record<string, unknown>;
27
+ /** A member's act as the action route writes it. */
28
+ export declare function outreachActionHistoryRow(input: {
29
+ atMs: number;
30
+ action: OutreachHistoryAction;
31
+ byUid: string;
32
+ detail: string | null;
33
+ }): Record<string, unknown>;
34
+ /**
35
+ * A row id that sorts by time and cannot collide: two clicks in the same
36
+ * millisecond — a scanner fetching every link at once — are two rows.
37
+ */
38
+ export declare function outreachHistoryEntryId(atMs: number, random?: () => number): string;
39
+ /** A stored history row in its model shape, or `null` for one nothing can be said about. */
40
+ export declare function readOutreachHistoryEntry(id: string, data: Record<string, unknown> | undefined): OutreachEnrollmentHistoryEntry | null;
@@ -0,0 +1,95 @@
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
+ * ONE ROW OF AN ENROLLMENT'S HISTORY (AGL-3332), as stored and as read back.
18
+ *
19
+ * Client-safe, beside the other readers, because the click route and the
20
+ * member-action route write these rows on the server and the detail view
21
+ * reads them in the browser, and both must agree on the shape. Read a field
22
+ * at a time: a row that names no time, or no kind this model knows, is
23
+ * dropped rather than shown as something that happened.
24
+ */ const MACHINE_REASONS = [
25
+ 'agent',
26
+ 'too_soon',
27
+ 'method'
28
+ ];
29
+ const ACTIONS = [
30
+ 'pause',
31
+ 'resume',
32
+ 'stop',
33
+ 'do_not_contact'
34
+ ];
35
+ /** A click row as the click route writes it. */ export function outreachClickHistoryRow(input) {
36
+ return {
37
+ kind: 'click',
38
+ atMs: input.atMs,
39
+ url: input.url,
40
+ stepIndex: input.stepIndex,
41
+ human: input.human,
42
+ machineReason: input.human ? null : input.machineReason
43
+ };
44
+ }
45
+ /** A member's act as the action route writes it. */ export function outreachActionHistoryRow(input) {
46
+ const detail = typeof input.detail === 'string' ? input.detail.replace(/\s+/g, ' ').trim().slice(0, 500) : '';
47
+ return {
48
+ kind: 'action',
49
+ atMs: input.atMs,
50
+ action: input.action,
51
+ byUid: input.byUid,
52
+ detail: detail || null
53
+ };
54
+ }
55
+ /**
56
+ * A row id that sorts by time and cannot collide: two clicks in the same
57
+ * millisecond — a scanner fetching every link at once — are two rows.
58
+ */ export function outreachHistoryEntryId(atMs, random = Math.random) {
59
+ const suffix = Math.floor(random() * 36 ** 6).toString(36).padStart(6, '0');
60
+ return `${String(Math.max(0, Math.floor(atMs))).padStart(15, '0')}-${suffix}`;
61
+ }
62
+ /** A stored history row in its model shape, or `null` for one nothing can be said about. */ export function readOutreachHistoryEntry(id, data) {
63
+ if (!data) return null;
64
+ const atMs = data['atMs'];
65
+ if (typeof atMs !== 'number' || !Number.isFinite(atMs)) return null;
66
+ if (data['kind'] === 'click') {
67
+ const stepIndex = Number(data['stepIndex']);
68
+ const human = data['human'] === true;
69
+ const reason = data['machineReason'];
70
+ return {
71
+ id,
72
+ kind: 'click',
73
+ atMs,
74
+ url: typeof data['url'] === 'string' && data['url'] ? data['url'] : null,
75
+ stepIndex: Number.isInteger(stepIndex) && stepIndex >= 0 ? stepIndex : 0,
76
+ human,
77
+ machineReason: human ? null : MACHINE_REASONS.includes(reason) ? reason : null
78
+ };
79
+ }
80
+ if (data['kind'] === 'action') {
81
+ const action = data['action'];
82
+ if (!ACTIONS.includes(action)) return null;
83
+ return {
84
+ id,
85
+ kind: 'action',
86
+ atMs,
87
+ action: action,
88
+ byUid: typeof data['byUid'] === 'string' ? data['byUid'] : '',
89
+ detail: typeof data['detail'] === 'string' && data['detail'] ? data['detail'] : null
90
+ };
91
+ }
92
+ return null;
93
+ }
94
+
95
+ //# sourceMappingURL=enrollment-history.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/model/enrollment-history.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 type { OutreachClickMachineReason } from '../engine/click-tracking'\nimport type {\n OutreachEnrollmentHistoryEntry,\n OutreachHistoryAction,\n} from './outreach.types'\n\n/**\n * ONE ROW OF AN ENROLLMENT'S HISTORY (AGL-3332), as stored and as read back.\n *\n * Client-safe, beside the other readers, because the click route and the\n * member-action route write these rows on the server and the detail view\n * reads them in the browser, and both must agree on the shape. Read a field\n * at a time: a row that names no time, or no kind this model knows, is\n * dropped rather than shown as something that happened.\n */\n\nconst MACHINE_REASONS: readonly OutreachClickMachineReason[] = ['agent', 'too_soon', 'method']\nconst ACTIONS: readonly OutreachHistoryAction[] = ['pause', 'resume', 'stop', 'do_not_contact']\n\n/** A click row as the click route writes it. */\nexport function outreachClickHistoryRow(input: {\n atMs: number\n url: string | null\n stepIndex: number\n human: boolean\n machineReason: OutreachClickMachineReason | null\n}): Record<string, unknown> {\n return {\n kind: 'click',\n atMs: input.atMs,\n url: input.url,\n stepIndex: input.stepIndex,\n human: input.human,\n machineReason: input.human ? null : input.machineReason,\n }\n}\n\n/** A member's act as the action route writes it. */\nexport function outreachActionHistoryRow(input: {\n atMs: number\n action: OutreachHistoryAction\n byUid: string\n detail: string | null\n}): Record<string, unknown> {\n const detail = typeof input.detail === 'string' ? input.detail.replace(/\\s+/g, ' ').trim().slice(0, 500) : ''\n return {\n kind: 'action',\n atMs: input.atMs,\n action: input.action,\n byUid: input.byUid,\n detail: detail || null,\n }\n}\n\n/**\n * A row id that sorts by time and cannot collide: two clicks in the same\n * millisecond — a scanner fetching every link at once — are two rows.\n */\nexport function outreachHistoryEntryId(atMs: number, random: () => number = Math.random): string {\n const suffix = Math.floor(random() * 36 ** 6)\n .toString(36)\n .padStart(6, '0')\n return `${String(Math.max(0, Math.floor(atMs))).padStart(15, '0')}-${suffix}`\n}\n\n/** A stored history row in its model shape, or `null` for one nothing can be said about. */\nexport function readOutreachHistoryEntry(\n id: string,\n data: Record<string, unknown> | undefined,\n): OutreachEnrollmentHistoryEntry | null {\n if (!data) return null\n const atMs = data['atMs']\n if (typeof atMs !== 'number' || !Number.isFinite(atMs)) return null\n if (data['kind'] === 'click') {\n const stepIndex = Number(data['stepIndex'])\n const human = data['human'] === true\n const reason = data['machineReason']\n return {\n id,\n kind: 'click',\n atMs,\n url: typeof data['url'] === 'string' && data['url'] ? data['url'] : null,\n stepIndex: Number.isInteger(stepIndex) && stepIndex >= 0 ? stepIndex : 0,\n human,\n machineReason: human\n ? null\n : (MACHINE_REASONS as readonly unknown[]).includes(reason)\n ? (reason as OutreachClickMachineReason)\n : null,\n }\n }\n if (data['kind'] === 'action') {\n const action = data['action']\n if (!(ACTIONS as readonly unknown[]).includes(action)) return null\n return {\n id,\n kind: 'action',\n atMs,\n action: action as OutreachHistoryAction,\n byUid: typeof data['byUid'] === 'string' ? data['byUid'] : '',\n detail: typeof data['detail'] === 'string' && data['detail'] ? data['detail'] : null,\n }\n }\n return null\n}\n"],"names":["MACHINE_REASONS","ACTIONS","outreachClickHistoryRow","input","kind","atMs","url","stepIndex","human","machineReason","outreachActionHistoryRow","detail","replace","trim","slice","action","byUid","outreachHistoryEntryId","random","Math","suffix","floor","toString","padStart","String","max","readOutreachHistoryEntry","id","data","Number","isFinite","reason","isInteger","includes"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAQD;;;;;;;;CAQC,GAED,MAAMA,kBAAyD;IAAC;IAAS;IAAY;CAAS;AAC9F,MAAMC,UAA4C;IAAC;IAAS;IAAU;IAAQ;CAAiB;AAE/F,8CAA8C,GAC9C,OAAO,SAASC,wBAAwBC,KAMvC;IACC,OAAO;QACLC,MAAM;QACNC,MAAMF,MAAME,IAAI;QAChBC,KAAKH,MAAMG,GAAG;QACdC,WAAWJ,MAAMI,SAAS;QAC1BC,OAAOL,MAAMK,KAAK;QAClBC,eAAeN,MAAMK,KAAK,GAAG,OAAOL,MAAMM,aAAa;IACzD;AACF;AAEA,kDAAkD,GAClD,OAAO,SAASC,yBAAyBP,KAKxC;IACC,MAAMQ,SAAS,OAAOR,MAAMQ,MAAM,KAAK,WAAWR,MAAMQ,MAAM,CAACC,OAAO,CAAC,QAAQ,KAAKC,IAAI,GAAGC,KAAK,CAAC,GAAG,OAAO;IAC3G,OAAO;QACLV,MAAM;QACNC,MAAMF,MAAME,IAAI;QAChBU,QAAQZ,MAAMY,MAAM;QACpBC,OAAOb,MAAMa,KAAK;QAClBL,QAAQA,UAAU;IACpB;AACF;AAEA;;;CAGC,GACD,OAAO,SAASM,uBAAuBZ,IAAY,EAAEa,SAAuBC,KAAKD,MAAM;IACrF,MAAME,SAASD,KAAKE,KAAK,CAACH,WAAW,MAAM,GACxCI,QAAQ,CAAC,IACTC,QAAQ,CAAC,GAAG;IACf,OAAO,GAAGC,OAAOL,KAAKM,GAAG,CAAC,GAAGN,KAAKE,KAAK,CAAChB,QAAQkB,QAAQ,CAAC,IAAI,KAAK,CAAC,EAAEH,QAAQ;AAC/E;AAEA,0FAA0F,GAC1F,OAAO,SAASM,yBACdC,EAAU,EACVC,IAAyC;IAEzC,IAAI,CAACA,MAAM,OAAO;IAClB,MAAMvB,OAAOuB,IAAI,CAAC,OAAO;IACzB,IAAI,OAAOvB,SAAS,YAAY,CAACwB,OAAOC,QAAQ,CAACzB,OAAO,OAAO;IAC/D,IAAIuB,IAAI,CAAC,OAAO,KAAK,SAAS;QAC5B,MAAMrB,YAAYsB,OAAOD,IAAI,CAAC,YAAY;QAC1C,MAAMpB,QAAQoB,IAAI,CAAC,QAAQ,KAAK;QAChC,MAAMG,SAASH,IAAI,CAAC,gBAAgB;QACpC,OAAO;YACLD;YACAvB,MAAM;YACNC;YACAC,KAAK,OAAOsB,IAAI,CAAC,MAAM,KAAK,YAAYA,IAAI,CAAC,MAAM,GAAGA,IAAI,CAAC,MAAM,GAAG;YACpErB,WAAWsB,OAAOG,SAAS,CAACzB,cAAcA,aAAa,IAAIA,YAAY;YACvEC;YACAC,eAAeD,QACX,OACA,AAACR,gBAAuCiC,QAAQ,CAACF,UAC9CA,SACD;QACR;IACF;IACA,IAAIH,IAAI,CAAC,OAAO,KAAK,UAAU;QAC7B,MAAMb,SAASa,IAAI,CAAC,SAAS;QAC7B,IAAI,CAAC,AAAC3B,QAA+BgC,QAAQ,CAAClB,SAAS,OAAO;QAC9D,OAAO;YACLY;YACAvB,MAAM;YACNC;YACAU,QAAQA;YACRC,OAAO,OAAOY,IAAI,CAAC,QAAQ,KAAK,WAAWA,IAAI,CAAC,QAAQ,GAAG;YAC3DjB,QAAQ,OAAOiB,IAAI,CAAC,SAAS,KAAK,YAAYA,IAAI,CAAC,SAAS,GAAGA,IAAI,CAAC,SAAS,GAAG;QAClF;IACF;IACA,OAAO;AACT"}
@@ -17,6 +17,12 @@
17
17
  import type { ListQueryDeclaration, ListQueryFilter } from '@aglyn/shared-ui-jsx/const/list-query-plan';
18
18
  /** The array the search box reads, stamped by the enroll route. */
19
19
  export declare const OUTREACH_ENROLLMENT_SEARCH_TOKENS = "searchTokens";
20
+ /** Whether the person clicked (AGL-3332): what the "Clicked" filter asks. */
21
+ export declare const OUTREACH_ENROLLMENT_CLICKED = "clicked";
22
+ /** The destinations followed (AGL-3332): what the "Link followed" filter asks. */
23
+ export declare const OUTREACH_ENROLLMENT_LINKS_PATH = "engagement.links";
24
+ /** The fields the table offers only for a sequence that counts clicks. */
25
+ export declare const OUTREACH_ENROLLMENT_CLICK_FIELDS: readonly ["clicked", "link"];
20
26
  export declare const OUTREACH_ENROLLMENT_LIST_QUERY: ListQueryDeclaration;
21
27
  /** The scope every page of the table carries: this sequence's people. */
22
28
  export declare function outreachEnrollmentListBase(sequenceId: string): ListQueryFilter[];