@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
@@ -34,12 +34,29 @@
34
34
  * search `searchTokens`: every prefix of the person's name and of
35
35
  * their address and its parts, stamped at enrollment by the
36
36
  * enroll route and by `tools/scripts/backfill-outreach-list-search.mjs`
37
- * for the enrollments made before it.
37
+ * for the enrollments made before it;
38
+ * clicked (AGL-3332) whether the person clicked a link: `clicked`,
39
+ * `false` at enrollment and set by the click route's
40
+ * transaction on a person's click (never a scanner's), and
41
+ * stamped from `engagement.clicks` by the same backfill;
42
+ * link (AGL-3332) a destination they followed, whole:
43
+ * `engagement.links`, the capped array the click route keeps,
44
+ * asked with `array-contains-any` — the query's one array
45
+ * clause, so it and the search cannot stand together and the
46
+ * plan refuses the pair by name. The backfill seeds it with an
47
+ * earlier click's `lastClickUrl`, the one destination such a
48
+ * click is known by.
38
49
  *
39
50
  * The step, the next send and the last activity are not offered: each is
40
51
  * derived from the sequence's steps or from several fields, and a query can
41
52
  * ask none of them.
42
53
  */ /** The array the search box reads, stamped by the enroll route. */ export const OUTREACH_ENROLLMENT_SEARCH_TOKENS = 'searchTokens';
54
+ /** Whether the person clicked (AGL-3332): what the "Clicked" filter asks. */ export const OUTREACH_ENROLLMENT_CLICKED = 'clicked';
55
+ /** The destinations followed (AGL-3332): what the "Link followed" filter asks. */ export const OUTREACH_ENROLLMENT_LINKS_PATH = 'engagement.links';
56
+ /** The fields the table offers only for a sequence that counts clicks. */ export const OUTREACH_ENROLLMENT_CLICK_FIELDS = [
57
+ 'clicked',
58
+ 'link'
59
+ ];
43
60
  export const OUTREACH_ENROLLMENT_LIST_QUERY = {
44
61
  fields: [
45
62
  {
@@ -92,6 +109,23 @@ export const OUTREACH_ENROLLMENT_LIST_QUERY = {
92
109
  'before',
93
110
  'onOrBefore'
94
111
  ]
112
+ },
113
+ {
114
+ column: 'clicked',
115
+ kind: 'boolean',
116
+ path: OUTREACH_ENROLLMENT_CLICKED,
117
+ operators: [
118
+ 'equals'
119
+ ]
120
+ },
121
+ {
122
+ column: 'link',
123
+ kind: 'exact',
124
+ path: OUTREACH_ENROLLMENT_LINKS_PATH,
125
+ tokensPath: OUTREACH_ENROLLMENT_LINKS_PATH,
126
+ operators: [
127
+ 'isAnyOf'
128
+ ]
95
129
  }
96
130
  ],
97
131
  sorts: [
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/model/enrollment-list-query.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 {\n ListQueryDeclaration,\n ListQueryFilter,\n} from '@aglyn/shared-ui-jsx/const/list-query-plan'\n\n/*\n * WHAT A SEQUENCE'S ENROLLMENTS TABLE ASKS FIRESTORE (AGL-3321).\n *\n * One query over `orgs/{orgId}/outreachEnrollments`, narrowed to the\n * sequence (`sequenceId ==`, the list's base) and ordered newest enrolled\n * first, paged by that query. Every clause the Filters panel offers and the\n * search box are predicates on it:\n *\n * status the stored status, on every enrollment;\n * target `contact` or `lead`, on every enrollment (the ones made\n * before leads could be sequenced gain `contact` from the\n * backfill);\n * stopReason the stored reason code — the column shows its label and the\n * stop detail, the query asks by code;\n * email the whole address, stored normalized, so `equals` is exact;\n * enrolled the table's ONE range: when the person was enrolled, the\n * field the table is already ordered by, so it costs no index\n * of its own;\n * search `searchTokens`: every prefix of the person's name and of\n * their address and its parts, stamped at enrollment by the\n * enroll route and by `tools/scripts/backfill-outreach-list-search.mjs`\n * for the enrollments made before it.\n *\n * The step, the next send and the last activity are not offered: each is\n * derived from the sequence's steps or from several fields, and a query can\n * ask none of them.\n */\n\n/** The array the search box reads, stamped by the enroll route. */\nexport const OUTREACH_ENROLLMENT_SEARCH_TOKENS = 'searchTokens'\n\nexport const OUTREACH_ENROLLMENT_LIST_QUERY: ListQueryDeclaration = {\n fields: [\n { column: 'status', kind: 'exact', path: 'status', presence: 'always', operators: ['equals', 'isAnyOf'] },\n { column: 'target', kind: 'exact', path: 'target', presence: 'always', operators: ['equals'] },\n { column: 'stopReason', kind: 'exact', path: 'stopReason', operators: ['equals', 'isAnyOf'] },\n { column: 'email', kind: 'text', path: 'email', lowerPath: 'email', operators: ['equals'] },\n {\n column: 'enrolled',\n kind: 'date',\n path: 'createdAtMs',\n storedAs: 'millis',\n presence: 'always',\n operators: ['is', 'after', 'onOrAfter', 'before', 'onOrBefore'],\n },\n ],\n sorts: [{ path: 'createdAtMs', direction: 'desc' }],\n search: { tokensPath: OUTREACH_ENROLLMENT_SEARCH_TOKENS },\n}\n\n/** The scope every page of the table carries: this sequence's people. */\nexport function outreachEnrollmentListBase(sequenceId: string): ListQueryFilter[] {\n return [{ path: 'sequenceId', op: '==', value: sequenceId }]\n}\n"],"names":["OUTREACH_ENROLLMENT_SEARCH_TOKENS","OUTREACH_ENROLLMENT_LIST_QUERY","fields","column","kind","path","presence","operators","lowerPath","storedAs","sorts","direction","search","tokensPath","outreachEnrollmentListBase","sequenceId","op","value"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAOD;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GAED,iEAAiE,GACjE,OAAO,MAAMA,oCAAoC,eAAc;AAE/D,OAAO,MAAMC,iCAAuD;IAClEC,QAAQ;QACN;YAAEC,QAAQ;YAAUC,MAAM;YAASC,MAAM;YAAUC,UAAU;YAAUC,WAAW;gBAAC;gBAAU;aAAU;QAAC;QACxG;YAAEJ,QAAQ;YAAUC,MAAM;YAASC,MAAM;YAAUC,UAAU;YAAUC,WAAW;gBAAC;aAAS;QAAC;QAC7F;YAAEJ,QAAQ;YAAcC,MAAM;YAASC,MAAM;YAAcE,WAAW;gBAAC;gBAAU;aAAU;QAAC;QAC5F;YAAEJ,QAAQ;YAASC,MAAM;YAAQC,MAAM;YAASG,WAAW;YAASD,WAAW;gBAAC;aAAS;QAAC;QAC1F;YACEJ,QAAQ;YACRC,MAAM;YACNC,MAAM;YACNI,UAAU;YACVH,UAAU;YACVC,WAAW;gBAAC;gBAAM;gBAAS;gBAAa;gBAAU;aAAa;QACjE;KACD;IACDG,OAAO;QAAC;YAAEL,MAAM;YAAeM,WAAW;QAAO;KAAE;IACnDC,QAAQ;QAAEC,YAAYb;IAAkC;AAC1D,EAAC;AAED,uEAAuE,GACvE,OAAO,SAASc,2BAA2BC,UAAkB;IAC3D,OAAO;QAAC;YAAEV,MAAM;YAAcW,IAAI;YAAMC,OAAOF;QAAW;KAAE;AAC9D"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/model/enrollment-list-query.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 {\n ListQueryDeclaration,\n ListQueryFilter,\n} from '@aglyn/shared-ui-jsx/const/list-query-plan'\n\n/*\n * WHAT A SEQUENCE'S ENROLLMENTS TABLE ASKS FIRESTORE (AGL-3321).\n *\n * One query over `orgs/{orgId}/outreachEnrollments`, narrowed to the\n * sequence (`sequenceId ==`, the list's base) and ordered newest enrolled\n * first, paged by that query. Every clause the Filters panel offers and the\n * search box are predicates on it:\n *\n * status the stored status, on every enrollment;\n * target `contact` or `lead`, on every enrollment (the ones made\n * before leads could be sequenced gain `contact` from the\n * backfill);\n * stopReason the stored reason code — the column shows its label and the\n * stop detail, the query asks by code;\n * email the whole address, stored normalized, so `equals` is exact;\n * enrolled the table's ONE range: when the person was enrolled, the\n * field the table is already ordered by, so it costs no index\n * of its own;\n * search `searchTokens`: every prefix of the person's name and of\n * their address and its parts, stamped at enrollment by the\n * enroll route and by `tools/scripts/backfill-outreach-list-search.mjs`\n * for the enrollments made before it;\n * clicked (AGL-3332) whether the person clicked a link: `clicked`,\n * `false` at enrollment and set by the click route's\n * transaction on a person's click (never a scanner's), and\n * stamped from `engagement.clicks` by the same backfill;\n * link (AGL-3332) a destination they followed, whole:\n * `engagement.links`, the capped array the click route keeps,\n * asked with `array-contains-any` — the query's one array\n * clause, so it and the search cannot stand together and the\n * plan refuses the pair by name. The backfill seeds it with an\n * earlier click's `lastClickUrl`, the one destination such a\n * click is known by.\n *\n * The step, the next send and the last activity are not offered: each is\n * derived from the sequence's steps or from several fields, and a query can\n * ask none of them.\n */\n\n/** The array the search box reads, stamped by the enroll route. */\nexport const OUTREACH_ENROLLMENT_SEARCH_TOKENS = 'searchTokens'\n\n/** Whether the person clicked (AGL-3332): what the \"Clicked\" filter asks. */\nexport const OUTREACH_ENROLLMENT_CLICKED = 'clicked'\n\n/** The destinations followed (AGL-3332): what the \"Link followed\" filter asks. */\nexport const OUTREACH_ENROLLMENT_LINKS_PATH = 'engagement.links'\n\n/** The fields the table offers only for a sequence that counts clicks. */\nexport const OUTREACH_ENROLLMENT_CLICK_FIELDS = ['clicked', 'link'] as const\n\nexport const OUTREACH_ENROLLMENT_LIST_QUERY: ListQueryDeclaration = {\n fields: [\n { column: 'status', kind: 'exact', path: 'status', presence: 'always', operators: ['equals', 'isAnyOf'] },\n { column: 'target', kind: 'exact', path: 'target', presence: 'always', operators: ['equals'] },\n { column: 'stopReason', kind: 'exact', path: 'stopReason', operators: ['equals', 'isAnyOf'] },\n { column: 'email', kind: 'text', path: 'email', lowerPath: 'email', operators: ['equals'] },\n {\n column: 'enrolled',\n kind: 'date',\n path: 'createdAtMs',\n storedAs: 'millis',\n presence: 'always',\n operators: ['is', 'after', 'onOrAfter', 'before', 'onOrBefore'],\n },\n { column: 'clicked', kind: 'boolean', path: OUTREACH_ENROLLMENT_CLICKED, operators: ['equals'] },\n {\n column: 'link',\n kind: 'exact',\n path: OUTREACH_ENROLLMENT_LINKS_PATH,\n tokensPath: OUTREACH_ENROLLMENT_LINKS_PATH,\n operators: ['isAnyOf'],\n },\n ],\n sorts: [{ path: 'createdAtMs', direction: 'desc' }],\n search: { tokensPath: OUTREACH_ENROLLMENT_SEARCH_TOKENS },\n}\n\n/** The scope every page of the table carries: this sequence's people. */\nexport function outreachEnrollmentListBase(sequenceId: string): ListQueryFilter[] {\n return [{ path: 'sequenceId', op: '==', value: sequenceId }]\n}\n"],"names":["OUTREACH_ENROLLMENT_SEARCH_TOKENS","OUTREACH_ENROLLMENT_CLICKED","OUTREACH_ENROLLMENT_LINKS_PATH","OUTREACH_ENROLLMENT_CLICK_FIELDS","OUTREACH_ENROLLMENT_LIST_QUERY","fields","column","kind","path","presence","operators","lowerPath","storedAs","tokensPath","sorts","direction","search","outreachEnrollmentListBase","sequenceId","op","value"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAOD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAqCC,GAED,iEAAiE,GACjE,OAAO,MAAMA,oCAAoC,eAAc;AAE/D,2EAA2E,GAC3E,OAAO,MAAMC,8BAA8B,UAAS;AAEpD,gFAAgF,GAChF,OAAO,MAAMC,iCAAiC,mBAAkB;AAEhE,wEAAwE,GACxE,OAAO,MAAMC,mCAAmC;IAAC;IAAW;CAAO,CAAS;AAE5E,OAAO,MAAMC,iCAAuD;IAClEC,QAAQ;QACN;YAAEC,QAAQ;YAAUC,MAAM;YAASC,MAAM;YAAUC,UAAU;YAAUC,WAAW;gBAAC;gBAAU;aAAU;QAAC;QACxG;YAAEJ,QAAQ;YAAUC,MAAM;YAASC,MAAM;YAAUC,UAAU;YAAUC,WAAW;gBAAC;aAAS;QAAC;QAC7F;YAAEJ,QAAQ;YAAcC,MAAM;YAASC,MAAM;YAAcE,WAAW;gBAAC;gBAAU;aAAU;QAAC;QAC5F;YAAEJ,QAAQ;YAASC,MAAM;YAAQC,MAAM;YAASG,WAAW;YAASD,WAAW;gBAAC;aAAS;QAAC;QAC1F;YACEJ,QAAQ;YACRC,MAAM;YACNC,MAAM;YACNI,UAAU;YACVH,UAAU;YACVC,WAAW;gBAAC;gBAAM;gBAAS;gBAAa;gBAAU;aAAa;QACjE;QACA;YAAEJ,QAAQ;YAAWC,MAAM;YAAWC,MAAMP;YAA6BS,WAAW;gBAAC;aAAS;QAAC;QAC/F;YACEJ,QAAQ;YACRC,MAAM;YACNC,MAAMN;YACNW,YAAYX;YACZQ,WAAW;gBAAC;aAAU;QACxB;KACD;IACDI,OAAO;QAAC;YAAEN,MAAM;YAAeO,WAAW;QAAO;KAAE;IACnDC,QAAQ;QAAEH,YAAYb;IAAkC;AAC1D,EAAC;AAED,uEAAuE,GACvE,OAAO,SAASiB,2BAA2BC,UAAkB;IAC3D,OAAO;QAAC;YAAEV,MAAM;YAAcW,IAAI;YAAMC,OAAOF;QAAW;KAAE;AAC9D"}
@@ -0,0 +1,78 @@
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 OutreachMailGateway } from '../engine/mail-gateway';
19
+ import { type OutreachEnrollment, type OutreachEnrollmentHistoryEntry, type OutreachSequenceStep } from './outreach.types';
20
+ /** What kind of thing an entry is, which the view draws an icon for. */
21
+ export type OutreachTimelineKind = 'enrolled' | 'curated' | 'sent' | 'task' | 'click' | 'scanner' | 'earlier-clicks' | 'paused' | 'resumed' | 'stopped' | 'do-not-contact' | 'replied' | 'bounced' | 'opted-out' | 'held' | 'released' | 'failed' | 'finished';
22
+ /** How loudly the view draws an entry. */
23
+ export type OutreachTimelineTone = 'default' | 'success' | 'info' | 'warning' | 'error';
24
+ /** One line of the timeline. */
25
+ export interface OutreachTimelineEntry {
26
+ /** Stable across renders, for React and for a spec. */
27
+ key: string;
28
+ kind: OutreachTimelineKind;
29
+ atMs: number;
30
+ /** What happened, in a few words. */
31
+ title: string;
32
+ /** The one fact that matters most, shown in full: a subject, a reason, a diagnostic. */
33
+ detail?: string | null;
34
+ /** The rest, each short: the step, who, a scanner's reason. */
35
+ facts: string[];
36
+ /** A destination the person followed, shown whole and linkable. */
37
+ url?: string | null;
38
+ /** What `url` is, when it is not simply "the link they followed". */
39
+ urlLabel?: string;
40
+ /** The Gmail thread this entry opened, for an "Open in Gmail" link. */
41
+ gmailThreadId?: string | null;
42
+ tone: OutreachTimelineTone;
43
+ }
44
+ export interface OutreachTimelineInput {
45
+ enrollment: OutreachEnrollment;
46
+ steps: readonly OutreachSequenceStep[];
47
+ /** The history rows, newest or oldest first; `null` while unread or unreadable. */
48
+ history: readonly OutreachEnrollmentHistoryEntry[] | null;
49
+ /** A member's name for a stored uid; the uid itself when the roster does not know it. */
50
+ memberName: (uid: string) => string;
51
+ /** A moment as the reader reads it — in the mailbox's zone. */
52
+ formatTime: (ms: number) => string;
53
+ /** The mail gateway in front of the person's domain, when it is known. */
54
+ gateway?: OutreachMailGateway | null;
55
+ }
56
+ /** "Email 2 · step 3" for an email step, "Step 2 · Call" for a task, "Step 4" for one the sequence no longer has. */
57
+ export declare function outreachStepName(steps: readonly OutreachSequenceStep[], stepIndex: number): string;
58
+ /** Why a click was read as a scanner's, as the timeline says it. */
59
+ export declare const OUTREACH_MACHINE_REASON_LABELS: Record<OutreachClickMachineReason, string>;
60
+ /**
61
+ * The timeline, newest first — see the module note.
62
+ */
63
+ export declare function outreachEnrollmentTimeline(input: OutreachTimelineInput): OutreachTimelineEntry[];
64
+ /** The five figures above the timeline. */
65
+ export interface OutreachEnrollmentFigures {
66
+ emailsSent: number;
67
+ clicks: number;
68
+ /** Distinct destinations followed; `atLeast` when only a floor is known. */
69
+ linksFollowed: {
70
+ count: number;
71
+ atLeast: boolean;
72
+ };
73
+ scannerClicks: number;
74
+ replies: number;
75
+ /** Whether anything has happened to them beyond being enrolled. */
76
+ anything: boolean;
77
+ }
78
+ export declare function outreachEnrollmentFigures(enrollment: OutreachEnrollment): OutreachEnrollmentFigures;
@@ -0,0 +1,430 @@
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
+ * EVERYTHING THAT HAPPENED TO ONE PERSON IN ONE SEQUENCE (AGL-3332).
18
+ *
19
+ * The enrollment's detail view is one list, newest first, and this builds
20
+ * it from what is stored — the enrollment itself, and its history rows —
21
+ * without inventing anything that is not:
22
+ *
23
+ * - **Each send** is a step record: the step, the time, the subject as
24
+ * sent, and whether it went out as the person's curated copy.
25
+ * - **Each click** is a history row: the destination, the step whose email
26
+ * carried it, and a person's or a scanner's. Clicks from before the
27
+ * history began exist only as totals, and are ONE row saying so — never
28
+ * a row per click, which would be a fabrication with a timestamp on it.
29
+ * - **Each member's act** — pause, resume, stop, do-not-contact — is a
30
+ * history row with who and why. An enrollment holds only its current
31
+ * stop, so the row is the only record of a pause that was resumed.
32
+ * - **The current stop** — a reply, a bounce, an opt-out, a hold — comes
33
+ * from the enrollment's own fields, with its full reason, unless a
34
+ * history row already says it.
35
+ * - **The gateway hold** (AGL-3326), and its release.
36
+ * - **The enrollment**, with who enrolled them, and each curated step.
37
+ *
38
+ * Client-safe and free of React: the view renders these entries, and the
39
+ * spec reads them.
40
+ *=========================================*/ import { _ as _extends } from "@swc/helpers/_/_extends";
41
+ import { OUTREACH_MAIL_GATEWAY_LABELS } from "../engine/mail-gateway.js";
42
+ import { outreachClickSummary } from "./enrollment-engagement.js";
43
+ import { OUTREACH_ENROLLMENT_HISTORY_MAX, OUTREACH_TASK_KIND_LABELS } from "./outreach.types.js";
44
+ /** "Email 2 · step 3" for an email step, "Step 2 · Call" for a task, "Step 4" for one the sequence no longer has. */ export function outreachStepName(steps, stepIndex) {
45
+ const step = steps[stepIndex];
46
+ if ((step == null ? void 0 : step.kind) === 'email') {
47
+ const position = steps.slice(0, stepIndex + 1).filter((entry)=>entry.kind === 'email').length;
48
+ return `Email ${position} · step ${stepIndex + 1}`;
49
+ }
50
+ if ((step == null ? void 0 : step.kind) === 'task') return `Step ${stepIndex + 1} · ${OUTREACH_TASK_KIND_LABELS[step.taskKind]}`;
51
+ return `Step ${stepIndex + 1}`;
52
+ }
53
+ /** Why a click was read as a scanner's, as the timeline says it. */ export const OUTREACH_MACHINE_REASON_LABELS = {
54
+ agent: 'the request named itself as a scanner or a link preview',
55
+ too_soon: 'it came within 30 seconds of the email arriving, faster than anyone reads',
56
+ method: 'it checked the link without opening the page'
57
+ };
58
+ const ACTION_TITLES = {
59
+ pause: 'Paused',
60
+ resume: 'Resumed',
61
+ stop: 'Stopped by a member',
62
+ do_not_contact: 'Marked do-not-contact'
63
+ };
64
+ const ACTION_KINDS = {
65
+ pause: 'paused',
66
+ resume: 'resumed',
67
+ stop: 'stopped',
68
+ do_not_contact: 'do-not-contact'
69
+ };
70
+ /** Which of two entries at the same moment reads first: the outcome above what led to it. */ const TIE_ORDER = {
71
+ finished: 0,
72
+ replied: 1,
73
+ bounced: 1,
74
+ 'opted-out': 1,
75
+ failed: 1,
76
+ stopped: 2,
77
+ 'do-not-contact': 2,
78
+ paused: 2,
79
+ held: 2,
80
+ released: 3,
81
+ resumed: 3,
82
+ click: 4,
83
+ scanner: 4,
84
+ 'earlier-clicks': 4,
85
+ task: 5,
86
+ sent: 5,
87
+ curated: 6,
88
+ enrolled: 7
89
+ };
90
+ const plural = (count, one, many)=>`${count} ${count === 1 ? one : many}`;
91
+ const byWhom = (uid, memberName)=>uid ? `By ${memberName(uid)}` : null;
92
+ /** The step records that are emails, in the order they went. */ function emailRecords(enrollment) {
93
+ var _enrollment_stepRecords;
94
+ return ((_enrollment_stepRecords = enrollment.stepRecords) != null ? _enrollment_stepRecords : []).filter((record)=>(record == null ? void 0 : record.kind) === 'email');
95
+ }
96
+ /**
97
+ * The timeline, newest first — see the module note.
98
+ */ export function outreachEnrollmentTimeline(input) {
99
+ var _input_history, _enrollment_stepOverrides, _enrollment_stepRecords;
100
+ const { enrollment, steps, memberName } = input;
101
+ const entries = [];
102
+ const history = (_input_history = input.history) != null ? _input_history : [];
103
+ const gatewayName = input.gateway ? OUTREACH_MAIL_GATEWAY_LABELS[input.gateway] : null;
104
+ // ── The enrollment ──────────────────────────────────────────────────────
105
+ if (enrollment.createdAtMs) {
106
+ entries.push({
107
+ key: 'enrolled',
108
+ kind: 'enrolled',
109
+ atMs: enrollment.createdAtMs,
110
+ title: 'Enrolled',
111
+ facts: [
112
+ byWhom(enrollment.enrolledByUid, memberName),
113
+ enrollment.target === 'lead' ? 'As a lead' : 'As a contact',
114
+ enrollment.cold ? 'Cold — confirmed by the member who enrolled them' : null
115
+ ].filter((fact)=>Boolean(fact)),
116
+ tone: 'default'
117
+ });
118
+ }
119
+ // ── Curated copies (AGL-3324) ───────────────────────────────────────────
120
+ for (const [key, override] of Object.entries((_enrollment_stepOverrides = enrollment.stepOverrides) != null ? _enrollment_stepOverrides : {})){
121
+ if (!override.draftedAtMs) continue;
122
+ entries.push({
123
+ key: `curated-${key}`,
124
+ kind: 'curated',
125
+ atMs: override.draftedAtMs,
126
+ title: `Curated ${outreachStepName(steps, Number(key))}`,
127
+ facts: [
128
+ override.source === 'ai' ? override.edited ? 'AI draft, edited' : 'AI draft' : 'Written by a member',
129
+ byWhom(override.draftedByUid, memberName)
130
+ ].filter((fact)=>Boolean(fact)),
131
+ tone: 'default'
132
+ });
133
+ }
134
+ // ── Sends and tasks ─────────────────────────────────────────────────────
135
+ let previousThread = null;
136
+ ((_enrollment_stepRecords = enrollment.stepRecords) != null ? _enrollment_stepRecords : []).forEach((record, index)=>{
137
+ var _record_gmailThreadId;
138
+ var _record_links;
139
+ if (!record || typeof record.atMs !== 'number') return;
140
+ if (record.kind === 'task') {
141
+ var _steps_find;
142
+ const step = (_steps_find = steps.find((entry)=>entry.id === record.stepId)) != null ? _steps_find : steps[record.stepIndex];
143
+ entries.push({
144
+ key: `task-${index}`,
145
+ kind: 'task',
146
+ atMs: record.atMs,
147
+ title: (step == null ? void 0 : step.kind) === 'task' ? `${OUTREACH_TASK_KIND_LABELS[step.taskKind]} task filed` : 'Task filed',
148
+ detail: (step == null ? void 0 : step.kind) === 'task' ? step.title : null,
149
+ facts: [
150
+ outreachStepName(steps, record.stepIndex),
151
+ record.taskId === null ? 'The CRM could not file it' : null
152
+ ].filter((fact)=>Boolean(fact)),
153
+ tone: 'default'
154
+ });
155
+ return;
156
+ }
157
+ const thread = (_record_gmailThreadId = record.gmailThreadId) != null ? _record_gmailThreadId : null;
158
+ // The link to Gmail sits on the email that STARTED a thread, once.
159
+ const startsThread = Boolean(thread) && thread !== previousThread;
160
+ previousThread = thread != null ? thread : previousThread;
161
+ entries.push({
162
+ key: `sent-${index}`,
163
+ kind: 'sent',
164
+ atMs: record.atMs,
165
+ title: `${outreachStepName(steps, record.stepIndex)} sent`,
166
+ detail: record.subject ? `Subject: ${record.subject}` : null,
167
+ facts: [
168
+ record.curated === 'ai' ? 'Curated copy — AI draft' : record.curated === 'member' ? 'Curated copy — written by a member' : null,
169
+ ((_record_links = record.links) == null ? void 0 : _record_links.length) ? plural(record.links.length, 'tracked link', 'tracked links') : null
170
+ ].filter((fact)=>Boolean(fact)),
171
+ gmailThreadId: startsThread ? thread : null,
172
+ tone: 'default'
173
+ });
174
+ });
175
+ // ── Clicks ──────────────────────────────────────────────────────────────
176
+ for (const row of history){
177
+ if (row.kind !== 'click') continue;
178
+ entries.push(row.human ? {
179
+ key: `history-${row.id}`,
180
+ kind: 'click',
181
+ atMs: row.atMs,
182
+ title: 'Clicked a link',
183
+ url: row.url,
184
+ facts: [
185
+ `From ${outreachStepName(steps, row.stepIndex)}`
186
+ ],
187
+ tone: 'success'
188
+ } : {
189
+ key: `history-${row.id}`,
190
+ kind: 'scanner',
191
+ atMs: row.atMs,
192
+ title: 'A scanner followed a link — not counted',
193
+ url: row.url,
194
+ facts: [
195
+ `From ${outreachStepName(steps, row.stepIndex)}`,
196
+ row.machineReason ? `Read as a scanner: ${OUTREACH_MACHINE_REASON_LABELS[row.machineReason]}` : null
197
+ ].filter((fact)=>Boolean(fact)),
198
+ tone: 'default'
199
+ });
200
+ }
201
+ /*
202
+ * Clicks with no row of their own: counted before the per-click history
203
+ * began, or past its limit. ONE entry, placed at the first of them when
204
+ * that is known, saying what the totals hold and nothing more.
205
+ */ const clicks = outreachClickSummary(enrollment.engagement);
206
+ const engagement = enrollment.engagement;
207
+ if (clicks.unloggedClicks > 0 || clicks.unloggedMachineClicks > 0) {
208
+ var _ref, _ref1, _ref2, _ref3, _ref4, _ref5;
209
+ var _emailRecords_;
210
+ const everyHumanUnlogged = clicks.unloggedClicks === clicks.clicks;
211
+ const loggedRows = clicks.clicks - clicks.unloggedClicks + (clicks.machineClicks - clicks.unloggedMachineClicks);
212
+ const facts = [];
213
+ const first = (_ref = engagement == null ? void 0 : engagement.firstClickAtMs) != null ? _ref : null;
214
+ const last = (_ref1 = engagement == null ? void 0 : engagement.lastClickAtMs) != null ? _ref1 : null;
215
+ if (clicks.unloggedClicks > 0 && first !== null && everyHumanUnlogged && last !== null) {
216
+ facts.push(clicks.unloggedClicks === 1 ? `At ${input.formatTime(first)}` : first === last ? `First and last at ${input.formatTime(first)}` : `First at ${input.formatTime(first)}, last at ${input.formatTime(last)}`);
217
+ } else if (clicks.unloggedClicks > 0 && first !== null) {
218
+ // Later clicks have rows of their own, so only the first is one of these.
219
+ facts.push(`First at ${input.formatTime(first)}`);
220
+ }
221
+ if (clicks.unloggedMachineClicks > 0) {
222
+ facts.push(plural(clicks.unloggedMachineClicks, 'scanner click, not counted', 'scanner clicks, not counted'));
223
+ }
224
+ entries.push({
225
+ key: 'earlier-clicks',
226
+ kind: 'earlier-clicks',
227
+ atMs: (_ref2 = (_ref3 = (_ref4 = clicks.unloggedClicks > 0 ? engagement == null ? void 0 : engagement.firstClickAtMs : null) != null ? _ref4 : engagement == null ? void 0 : engagement.lastClickAtMs) != null ? _ref3 : (_emailRecords_ = emailRecords(enrollment)[0]) == null ? void 0 : _emailRecords_.atMs) != null ? _ref2 : enrollment.createdAtMs,
228
+ title: clicks.unloggedClicks > 0 ? `${plural(clicks.unloggedClicks, 'earlier click', 'earlier clicks')}, kept as a total` : `${plural(clicks.unloggedMachineClicks, 'earlier scanner click', 'earlier scanner clicks')}, kept as a total`,
229
+ detail: loggedRows >= OUTREACH_ENROLLMENT_HISTORY_MAX ? `Each click is listed on its own up to ${OUTREACH_ENROLLMENT_HISTORY_MAX}; these came before per-click history began, or after this person reached that limit.` : 'These came before each click was recorded on its own, so only their count and times were kept.',
230
+ // The last destination is theirs only when every counted click was.
231
+ url: clicks.unloggedClicks > 0 && everyHumanUnlogged ? (_ref5 = engagement == null ? void 0 : engagement.lastClickUrl) != null ? _ref5 : null : null,
232
+ urlLabel: clicks.unloggedClicks === 1 ? 'Link' : 'Last link',
233
+ facts,
234
+ tone: clicks.unloggedClicks > 0 ? 'success' : 'default'
235
+ });
236
+ }
237
+ // ── Members' acts ───────────────────────────────────────────────────────
238
+ const actionTimes = new Set();
239
+ for (const row of history){
240
+ var _enrollment_gatewayHold;
241
+ if (row.kind !== 'action') continue;
242
+ actionTimes.add(row.atMs);
243
+ const released = row.action === 'resume' && ((_enrollment_gatewayHold = enrollment.gatewayHold) == null ? void 0 : _enrollment_gatewayHold.releasedAtMs) === row.atMs;
244
+ if (released) continue;
245
+ entries.push({
246
+ key: `history-${row.id}`,
247
+ kind: ACTION_KINDS[row.action],
248
+ atMs: row.atMs,
249
+ title: ACTION_TITLES[row.action],
250
+ detail: row.detail,
251
+ facts: [
252
+ byWhom(row.byUid, memberName)
253
+ ].filter((fact)=>Boolean(fact)),
254
+ tone: row.action === 'resume' ? 'default' : row.action === 'pause' ? 'warning' : 'default'
255
+ });
256
+ }
257
+ // ── The gateway hold (AGL-3326) ─────────────────────────────────────────
258
+ const hold = enrollment.gatewayHold;
259
+ const holdGateway = hold ? OUTREACH_MAIL_GATEWAY_LABELS[hold.gateway] : null;
260
+ if (hold == null ? void 0 : hold.heldAtMs) {
261
+ entries.push({
262
+ key: 'held',
263
+ kind: 'held',
264
+ atMs: hold.heldAtMs,
265
+ title: `Held — ${holdGateway} refused this sender`,
266
+ detail: enrollment.stopReason === 'gateway_blocked_here' && enrollment.stoppedAtMs === hold.heldAtMs ? enrollment.stopDetail : null,
267
+ facts: [
268
+ 'Nothing was sent to them while it was held'
269
+ ],
270
+ tone: 'warning'
271
+ });
272
+ }
273
+ if (hold == null ? void 0 : hold.releasedAtMs) {
274
+ entries.push({
275
+ key: 'released',
276
+ kind: 'released',
277
+ atMs: hold.releasedAtMs,
278
+ title: hold.heldAtMs ? `Resumed past the ${holdGateway} hold — sent anyway` : `Enrolled past the ${holdGateway} warning`,
279
+ facts: [
280
+ byWhom(hold.releasedByUid, memberName)
281
+ ].filter((fact)=>Boolean(fact)),
282
+ tone: 'default'
283
+ });
284
+ }
285
+ // ── The current stop, from the enrollment ───────────────────────────────
286
+ const stoppedAtMs = enrollment.stoppedAtMs;
287
+ const alreadySaid = stoppedAtMs !== null && (actionTimes.has(stoppedAtMs) || enrollment.stopReason === 'gateway_blocked_here' && (hold == null ? void 0 : hold.heldAtMs) === stoppedAtMs);
288
+ if (stoppedAtMs && !alreadySaid && enrollment.status !== 'active' && enrollment.status !== 'finished') {
289
+ const stop = currentStop(enrollment, memberName, gatewayName);
290
+ if (stop) entries.push(_extends({
291
+ key: 'stop',
292
+ atMs: stoppedAtMs
293
+ }, stop));
294
+ }
295
+ // ── Finished ────────────────────────────────────────────────────────────
296
+ if (enrollment.status === 'finished') {
297
+ var _enrollment_stepRecords1, _ref6;
298
+ const last = ((_enrollment_stepRecords1 = enrollment.stepRecords) != null ? _enrollment_stepRecords1 : []).at(-1);
299
+ entries.push({
300
+ key: 'finished',
301
+ kind: 'finished',
302
+ atMs: (_ref6 = last == null ? void 0 : last.atMs) != null ? _ref6 : enrollment.updatedAtMs,
303
+ title: 'Finished — every step ran',
304
+ facts: [],
305
+ tone: 'success'
306
+ });
307
+ }
308
+ return entries.sort((a, b)=>b.atMs - a.atMs || TIE_ORDER[a.kind] - TIE_ORDER[b.kind]);
309
+ }
310
+ /** The entry for the stop the enrollment is in now. */ function currentStop(enrollment, memberName, gatewayName) {
311
+ const detail = enrollment.stopDetail;
312
+ const by = byWhom(enrollment.stoppedByUid, memberName);
313
+ const facts = (...values)=>values.filter((fact)=>Boolean(fact));
314
+ switch(enrollment.stopReason){
315
+ case 'reply':
316
+ var _ref, _enrollment_gmailThreadId;
317
+ return {
318
+ kind: 'replied',
319
+ title: 'Replied',
320
+ detail,
321
+ facts: [
322
+ 'The sequence stopped for them'
323
+ ],
324
+ gmailThreadId: (_ref = (_enrollment_gmailThreadId = enrollment.gmailThreadId) != null ? _enrollment_gmailThreadId : enrollment.gmailThreadIds.at(-1)) != null ? _ref : null,
325
+ tone: 'info'
326
+ };
327
+ case 'hard_bounce':
328
+ return {
329
+ kind: 'bounced',
330
+ title: 'Bounced',
331
+ detail,
332
+ facts: facts(gatewayName ? `Their mail gateway: ${gatewayName}` : null, 'The address is on the do-not-contact list'),
333
+ tone: 'error'
334
+ };
335
+ case 'opt_out_reply':
336
+ return {
337
+ kind: 'opted-out',
338
+ title: 'Asked not to be emailed, in a reply',
339
+ detail,
340
+ facts: [],
341
+ tone: 'warning'
342
+ };
343
+ case 'unsubscribe':
344
+ return {
345
+ kind: 'opted-out',
346
+ title: 'Unsubscribed',
347
+ detail,
348
+ facts: [],
349
+ tone: 'warning'
350
+ };
351
+ case 'do_not_contact':
352
+ return {
353
+ kind: 'do-not-contact',
354
+ title: 'Put on the do-not-contact list',
355
+ detail,
356
+ facts: facts(by),
357
+ tone: 'warning'
358
+ };
359
+ case 'manual':
360
+ return enrollment.status === 'paused' ? {
361
+ kind: 'paused',
362
+ title: 'Paused',
363
+ detail,
364
+ facts: facts(by),
365
+ tone: 'warning'
366
+ } : {
367
+ kind: 'stopped',
368
+ title: 'Stopped by a member',
369
+ detail,
370
+ facts: facts(by),
371
+ tone: 'default'
372
+ };
373
+ case 'gate':
374
+ return {
375
+ kind: 'stopped',
376
+ title: 'Stopped — no longer eligible',
377
+ detail,
378
+ facts: [],
379
+ tone: 'default'
380
+ };
381
+ case 'sequence_archived':
382
+ return {
383
+ kind: 'stopped',
384
+ title: 'Stopped — the sequence was archived',
385
+ detail,
386
+ facts: [],
387
+ tone: 'default'
388
+ };
389
+ case 'gateway_blocked_here':
390
+ return {
391
+ kind: 'held',
392
+ title: 'Held — their mail gateway refused this sender',
393
+ detail,
394
+ facts: [],
395
+ tone: 'warning'
396
+ };
397
+ case 'send_failed':
398
+ return {
399
+ kind: 'failed',
400
+ title: 'An email couldn’t be sent',
401
+ detail,
402
+ facts: [],
403
+ tone: 'error'
404
+ };
405
+ default:
406
+ return null;
407
+ }
408
+ }
409
+ export function outreachEnrollmentFigures(enrollment) {
410
+ var _enrollment_stepRecords;
411
+ const clicks = outreachClickSummary(enrollment.engagement);
412
+ const emailsSent = emailRecords(enrollment).length;
413
+ const replies = enrollment.status === 'replied' || enrollment.stopReason === 'reply' || enrollment.stopReason === 'opt_out_reply' ? 1 : 0;
414
+ return {
415
+ emailsSent,
416
+ clicks: clicks.clicks,
417
+ linksFollowed: clicks.linkCount === null ? {
418
+ count: clicks.followed.length,
419
+ atLeast: true
420
+ } : {
421
+ count: clicks.linkCount,
422
+ atLeast: clicks.linksCapped
423
+ },
424
+ scannerClicks: clicks.machineClicks,
425
+ replies,
426
+ anything: emailsSent > 0 || ((_enrollment_stepRecords = enrollment.stepRecords) != null ? _enrollment_stepRecords : []).length > 0 || clicks.clicks > 0 || clicks.machineClicks > 0 || enrollment.stoppedAtMs !== null
427
+ };
428
+ }
429
+
430
+ //# sourceMappingURL=enrollment-timeline.js.map