@aglyn/plugins-outreach 1.0.0-beta.199 → 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.
- package/package.json +14 -14
- package/src/lib/components/do-not-contact-domains.js +60 -42
- package/src/lib/components/do-not-contact-domains.js.map +1 -1
- package/src/lib/components/enrollment-actions.d.ts +33 -0
- package/src/lib/components/enrollment-actions.js +174 -0
- package/src/lib/components/enrollment-actions.js.map +1 -0
- package/src/lib/components/enrollment-detail.d.ts +21 -0
- package/src/lib/components/enrollment-detail.js +870 -0
- package/src/lib/components/enrollment-detail.js.map +1 -0
- package/src/lib/components/enrollment-filter-params.d.ts +58 -0
- package/src/lib/components/enrollment-filter-params.js +98 -0
- package/src/lib/components/enrollment-filter-params.js.map +1 -0
- package/src/lib/components/enrollments-table.d.ts +25 -2
- package/src/lib/components/enrollments-table.js +303 -283
- package/src/lib/components/enrollments-table.js.map +1 -1
- package/src/lib/components/outreach-console-page.js +10 -0
- package/src/lib/components/outreach-console-page.js.map +1 -1
- package/src/lib/components/sequence-detail.d.ts +2 -0
- package/src/lib/components/sequence-detail.js +73 -11
- package/src/lib/components/sequence-detail.js.map +1 -1
- package/src/lib/components/sequence-report-card.d.ts +17 -0
- package/src/lib/components/sequence-report-card.js +63 -15
- package/src/lib/components/sequence-report-card.js.map +1 -1
- package/src/lib/components/sequences-section.d.ts +13 -1
- package/src/lib/components/sequences-section.js +108 -83
- package/src/lib/components/sequences-section.js.map +1 -1
- package/src/lib/components/use-outreach-api.d.ts +3 -1
- package/src/lib/components/use-outreach-api.js +6 -0
- package/src/lib/components/use-outreach-api.js.map +1 -1
- package/src/lib/components/use-outreach-data.d.ts +30 -25
- package/src/lib/components/use-outreach-data.js +93 -104
- package/src/lib/components/use-outreach-data.js.map +1 -1
- package/src/lib/components/use-outreach-enrollment.d.ts +18 -0
- package/src/lib/components/use-outreach-enrollment.js +133 -0
- package/src/lib/components/use-outreach-enrollment.js.map +1 -0
- package/src/lib/constants/api-routes.d.ts +2 -0
- package/src/lib/constants/api-routes.js +1 -0
- package/src/lib/constants/api-routes.js.map +1 -1
- package/src/lib/engine/gates.d.ts +4 -3
- package/src/lib/engine/gates.js.map +1 -1
- package/src/lib/engine/gateway-block.d.ts +4 -8
- package/src/lib/engine/gateway-block.js +6 -26
- package/src/lib/engine/gateway-block.js.map +1 -1
- package/src/lib/engine/mail-gateway.d.ts +18 -73
- package/src/lib/engine/mail-gateway.js +32 -206
- package/src/lib/engine/mail-gateway.js.map +1 -1
- package/src/lib/enrollment/enrollment-search.d.ts +32 -0
- package/src/lib/enrollment/enrollment-search.js +40 -0
- package/src/lib/enrollment/enrollment-search.js.map +1 -0
- package/src/lib/model/do-not-contact-domain-list-query.d.ts +32 -0
- package/src/lib/model/do-not-contact-domain-list-query.js +107 -0
- package/src/lib/model/do-not-contact-domain-list-query.js.map +1 -0
- package/src/lib/model/enrollment-engagement.d.ts +26 -2
- package/src/lib/model/enrollment-engagement.js +40 -3
- package/src/lib/model/enrollment-engagement.js.map +1 -1
- package/src/lib/model/enrollment-history.d.ts +40 -0
- package/src/lib/model/enrollment-history.js +95 -0
- package/src/lib/model/enrollment-history.js.map +1 -0
- package/src/lib/model/enrollment-list-query.d.ts +28 -0
- package/src/lib/model/enrollment-list-query.js +151 -0
- package/src/lib/model/enrollment-list-query.js.map +1 -0
- package/src/lib/model/enrollment-timeline.d.ts +78 -0
- package/src/lib/model/enrollment-timeline.js +430 -0
- package/src/lib/model/enrollment-timeline.js.map +1 -0
- package/src/lib/model/outreach-api.d.ts +24 -0
- package/src/lib/model/outreach-api.js.map +1 -1
- package/src/lib/model/outreach.types.d.ts +90 -29
- package/src/lib/model/outreach.types.js +33 -8
- package/src/lib/model/outreach.types.js.map +1 -1
- package/src/lib/model/sequence-list-query.d.ts +22 -0
- package/src/lib/model/sequence-list-query.js +99 -0
- package/src/lib/model/sequence-list-query.js.map +1 -0
- package/src/lib/routes/enroll-routes.js +34 -3
- package/src/lib/routes/enroll-routes.js.map +1 -1
- package/src/lib/routes/enrollment-routes.d.ts +10 -0
- package/src/lib/routes/enrollment-routes.js +82 -1
- package/src/lib/routes/enrollment-routes.js.map +1 -1
- package/src/lib/routes/register-routes.js +14 -4
- package/src/lib/routes/register-routes.js.map +1 -1
- package/src/lib/routes/route-deps.d.ts +3 -1
- package/src/lib/routes/route-deps.js.map +1 -1
- package/src/lib/routes/sequence-routes.js +6 -1
- package/src/lib/routes/sequence-routes.js.map +1 -1
- package/src/lib/runtime/click-events.d.ts +10 -5
- package/src/lib/runtime/click-events.js +45 -6
- package/src/lib/runtime/click-events.js.map +1 -1
- package/src/lib/runtime/gateway-ledger.d.ts +6 -0
- package/src/lib/runtime/gateway-ledger.js +9 -3
- package/src/lib/runtime/gateway-ledger.js.map +1 -1
- package/src/lib/runtime/host-suspension.d.ts +30 -0
- package/src/lib/runtime/host-suspension.js +35 -0
- package/src/lib/runtime/host-suspension.js.map +1 -0
- package/src/lib/runtime/person-erasure.d.ts +4 -1
- package/src/lib/runtime/person-erasure.js +14 -3
- package/src/lib/runtime/person-erasure.js.map +1 -1
- package/src/lib/runtime/platform-runtime-deps.js +34 -3
- package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
- package/src/lib/runtime/runtime-deps.d.ts +27 -1
- package/src/lib/runtime/runtime-deps.js.map +1 -1
- package/src/lib/runtime/send-job.js +46 -6
- package/src/lib/runtime/send-job.js.map +1 -1
- package/src/lib/runtime/sync-job.js +4 -0
- package/src/lib/runtime/sync-job.js.map +1 -1
- package/src/lib/storage/do-not-contact-store.js +6 -1
- package/src/lib/storage/do-not-contact-store.js.map +1 -1
- package/src/lib/storage/domain-intel-store.d.ts +61 -43
- package/src/lib/storage/domain-intel-store.js +138 -226
- package/src/lib/storage/domain-intel-store.js.map +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/model/do-not-contact-domain-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 { nameSearchTokens } from '@aglyn/aglyn/app-utils/name-search'\nimport {\n LIST_QUERY_ID_PATH,\n type ListQueryDeclaration,\n} from '@aglyn/shared-ui-jsx/const/list-query-plan'\nimport type { OutreachDoNotContactDomainEntry } from './outreach.types'\n\n/*\n * WHAT THE DO-NOT-CONTACT DOMAINS LIST ASKS FIRESTORE (AGL-3321).\n *\n * One query over `orgs/{orgId}/outreachDoNotContactDomains`, alphabetical —\n * the document id IS the domain — paged by that query:\n *\n * domain `equals` one domain, on the `domain` field every entry carries\n * beside its id, lower-cased the way every domain is written; a\n * prefix is the search box's job, which reads every label;\n * reason the stored reason, which every entry carries;\n * added the list's ONE range: when the entry was added, which every\n * entry carries; a range leads the order, so while it applies\n * the list is newest added first;\n * search `searchTokens`: the domain, each shorter domain inside it and\n * each of its labels, and the words of its detail, stamped when\n * the entry is created.\n *\n * Without the range every shape is equalities and the one array clause,\n * ordered by the document id, which Firestore serves by merging\n * single-field indexes. The range's order needs one composite per\n * equality field beside it: `reason` and `searchTokens`.\n */\n\n/** The array the search box reads, stamped by the one writer that adds a domain. */\nexport const OUTREACH_DOMAIN_SEARCH_TOKENS = 'searchTokens'\n\nexport const OUTREACH_DO_NOT_CONTACT_DOMAIN_LIST_QUERY: ListQueryDeclaration = {\n fields: [\n { column: 'domain', kind: 'text', path: 'domain', lowerPath: 'domain', operators: ['equals'] },\n { column: 'reason', kind: 'exact', path: 'reason', presence: 'always', operators: ['equals', 'isAnyOf'] },\n {\n column: 'addedAtMs',\n kind: 'date',\n path: 'addedAtMs',\n storedAs: 'millis',\n presence: 'always',\n operators: ['is', 'after', 'onOrAfter', 'before', 'onOrBefore'],\n },\n ],\n sorts: [{ path: LIST_QUERY_ID_PATH, direction: 'asc' }],\n search: { tokensPath: OUTREACH_DOMAIN_SEARCH_TOKENS },\n}\n\n/**\n * What the domains list searches an entry by: every prefix of the domain\n * whole and of each shorter domain inside it, of each of its labels, and of\n * each word of its detail — so \"acme\", \"acme.com\" and \"mail.ac\" all find\n * `mail.acme.com`, and \"barracuda\" finds the domain a gateway block filed.\n * An entry is created once and never edited, so its one writer stamps this\n * and `tools/scripts/backfill-outreach-list-search.mjs` restates it for the\n * entries made before it; both are held to\n * `tools/scripts/lib/outreach-search-tokens.fixtures.json`.\n */\nexport function outreachDomainSearchTokens(\n entry: Pick<OutreachDoNotContactDomainEntry, 'domain' | 'detail'>,\n): string[] {\n const domain = String(entry.domain ?? '')\n const labels = domain.split('.')\n const within = labels.map((_, at) => labels.slice(at).join('.'))\n return nameSearchTokens([...within, ...domain.split(/[.-]+/), entry.detail ?? ''].join(' '))\n}\n"],"names":["nameSearchTokens","LIST_QUERY_ID_PATH","OUTREACH_DOMAIN_SEARCH_TOKENS","OUTREACH_DO_NOT_CONTACT_DOMAIN_LIST_QUERY","fields","column","kind","path","lowerPath","operators","presence","storedAs","sorts","direction","search","tokensPath","outreachDomainSearchTokens","entry","domain","String","labels","split","within","map","_","at","slice","join","detail"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,gBAAgB,QAAQ,qCAAoC;AACrE,SACEC,kBAAkB,QAEb,6CAA4C;AAGnD;;;;;;;;;;;;;;;;;;;;;CAqBC,GAED,kFAAkF,GAClF,OAAO,MAAMC,gCAAgC,eAAc;AAE3D,OAAO,MAAMC,4CAAkE;IAC7EC,QAAQ;QACN;YAAEC,QAAQ;YAAUC,MAAM;YAAQC,MAAM;YAAUC,WAAW;YAAUC,WAAW;gBAAC;aAAS;QAAC;QAC7F;YAAEJ,QAAQ;YAAUC,MAAM;YAASC,MAAM;YAAUG,UAAU;YAAUD,WAAW;gBAAC;gBAAU;aAAU;QAAC;QACxG;YACEJ,QAAQ;YACRC,MAAM;YACNC,MAAM;YACNI,UAAU;YACVD,UAAU;YACVD,WAAW;gBAAC;gBAAM;gBAAS;gBAAa;gBAAU;aAAa;QACjE;KACD;IACDG,OAAO;QAAC;YAAEL,MAAMN;YAAoBY,WAAW;QAAM;KAAE;IACvDC,QAAQ;QAAEC,YAAYb;IAA8B;AACtD,EAAC;AAED;;;;;;;;;CASC,GACD,OAAO,SAASc,2BACdC,KAAiE;QAE3CA,eAGwCA;IAH9D,MAAMC,SAASC,QAAOF,gBAAAA,MAAMC,MAAM,YAAZD,gBAAgB;IACtC,MAAMG,SAASF,OAAOG,KAAK,CAAC;IAC5B,MAAMC,SAASF,OAAOG,GAAG,CAAC,CAACC,GAAGC,KAAOL,OAAOM,KAAK,CAACD,IAAIE,IAAI,CAAC;IAC3D,OAAO3B,iBAAiB;WAAIsB;WAAWJ,OAAOG,KAAK,CAAC;SAAUJ,gBAAAA,MAAMW,MAAM,YAAZX,gBAAgB;KAAG,CAACU,IAAI,CAAC;AACzF"}
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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"}
|
|
@@ -0,0 +1,28 @@
|
|
|
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 { ListQueryDeclaration, ListQueryFilter } from '@aglyn/shared-ui-jsx/const/list-query-plan';
|
|
18
|
+
/** The array the search box reads, stamped by the enroll route. */
|
|
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"];
|
|
26
|
+
export declare const OUTREACH_ENROLLMENT_LIST_QUERY: ListQueryDeclaration;
|
|
27
|
+
/** The scope every page of the table carries: this sequence's people. */
|
|
28
|
+
export declare function outreachEnrollmentListBase(sequenceId: string): ListQueryFilter[];
|
|
@@ -0,0 +1,151 @@
|
|
|
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
|
+
* WHAT A SEQUENCE'S ENROLLMENTS TABLE ASKS FIRESTORE (AGL-3321).
|
|
18
|
+
*
|
|
19
|
+
* One query over `orgs/{orgId}/outreachEnrollments`, narrowed to the
|
|
20
|
+
* sequence (`sequenceId ==`, the list's base) and ordered newest enrolled
|
|
21
|
+
* first, paged by that query. Every clause the Filters panel offers and the
|
|
22
|
+
* search box are predicates on it:
|
|
23
|
+
*
|
|
24
|
+
* status the stored status, on every enrollment;
|
|
25
|
+
* target `contact` or `lead`, on every enrollment (the ones made
|
|
26
|
+
* before leads could be sequenced gain `contact` from the
|
|
27
|
+
* backfill);
|
|
28
|
+
* stopReason the stored reason code — the column shows its label and the
|
|
29
|
+
* stop detail, the query asks by code;
|
|
30
|
+
* email the whole address, stored normalized, so `equals` is exact;
|
|
31
|
+
* enrolled the table's ONE range: when the person was enrolled, the
|
|
32
|
+
* field the table is already ordered by, so it costs no index
|
|
33
|
+
* of its own;
|
|
34
|
+
* search `searchTokens`: every prefix of the person's name and of
|
|
35
|
+
* their address and its parts, stamped at enrollment by the
|
|
36
|
+
* enroll route and by `tools/scripts/backfill-outreach-list-search.mjs`
|
|
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.
|
|
49
|
+
*
|
|
50
|
+
* The step, the next send and the last activity are not offered: each is
|
|
51
|
+
* derived from the sequence's steps or from several fields, and a query can
|
|
52
|
+
* ask none of them.
|
|
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
|
+
];
|
|
60
|
+
export const OUTREACH_ENROLLMENT_LIST_QUERY = {
|
|
61
|
+
fields: [
|
|
62
|
+
{
|
|
63
|
+
column: 'status',
|
|
64
|
+
kind: 'exact',
|
|
65
|
+
path: 'status',
|
|
66
|
+
presence: 'always',
|
|
67
|
+
operators: [
|
|
68
|
+
'equals',
|
|
69
|
+
'isAnyOf'
|
|
70
|
+
]
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
column: 'target',
|
|
74
|
+
kind: 'exact',
|
|
75
|
+
path: 'target',
|
|
76
|
+
presence: 'always',
|
|
77
|
+
operators: [
|
|
78
|
+
'equals'
|
|
79
|
+
]
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
column: 'stopReason',
|
|
83
|
+
kind: 'exact',
|
|
84
|
+
path: 'stopReason',
|
|
85
|
+
operators: [
|
|
86
|
+
'equals',
|
|
87
|
+
'isAnyOf'
|
|
88
|
+
]
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
column: 'email',
|
|
92
|
+
kind: 'text',
|
|
93
|
+
path: 'email',
|
|
94
|
+
lowerPath: 'email',
|
|
95
|
+
operators: [
|
|
96
|
+
'equals'
|
|
97
|
+
]
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
column: 'enrolled',
|
|
101
|
+
kind: 'date',
|
|
102
|
+
path: 'createdAtMs',
|
|
103
|
+
storedAs: 'millis',
|
|
104
|
+
presence: 'always',
|
|
105
|
+
operators: [
|
|
106
|
+
'is',
|
|
107
|
+
'after',
|
|
108
|
+
'onOrAfter',
|
|
109
|
+
'before',
|
|
110
|
+
'onOrBefore'
|
|
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
|
+
]
|
|
129
|
+
}
|
|
130
|
+
],
|
|
131
|
+
sorts: [
|
|
132
|
+
{
|
|
133
|
+
path: 'createdAtMs',
|
|
134
|
+
direction: 'desc'
|
|
135
|
+
}
|
|
136
|
+
],
|
|
137
|
+
search: {
|
|
138
|
+
tokensPath: OUTREACH_ENROLLMENT_SEARCH_TOKENS
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
/** The scope every page of the table carries: this sequence's people. */ export function outreachEnrollmentListBase(sequenceId) {
|
|
142
|
+
return [
|
|
143
|
+
{
|
|
144
|
+
path: 'sequenceId',
|
|
145
|
+
op: '==',
|
|
146
|
+
value: sequenceId
|
|
147
|
+
}
|
|
148
|
+
];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
//# sourceMappingURL=enrollment-list-query.js.map
|
|
@@ -0,0 +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 * 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;
|