@aglyn/plugins-outreach 1.0.0-beta.199 → 1.0.0-beta.200
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/enrollments-table.d.ts +4 -2
- package/src/lib/components/enrollments-table.js +70 -89
- package/src/lib/components/enrollments-table.js.map +1 -1
- package/src/lib/components/sequence-detail.js +3 -3
- package/src/lib/components/sequence-detail.js.map +1 -1
- package/src/lib/components/sequences-section.d.ts +9 -0
- package/src/lib/components/sequences-section.js +96 -82
- package/src/lib/components/sequences-section.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/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-list-query.d.ts +22 -0
- package/src/lib/model/enrollment-list-query.js +117 -0
- package/src/lib/model/enrollment-list-query.js.map +1 -0
- package/src/lib/model/outreach.types.d.ts +12 -0
- 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 +8 -0
- package/src/lib/routes/enroll-routes.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/storage/do-not-contact-store.js +6 -1
- package/src/lib/storage/do-not-contact-store.js.map +1 -1
|
@@ -16,10 +16,14 @@
|
|
|
16
16
|
*/ 'use client';
|
|
17
17
|
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
18
18
|
import { useFirestore } from "@aglyn/tenant-feature-instance";
|
|
19
|
-
import {
|
|
19
|
+
import { useListQuery } from "@aglyn/tenant-feature-instance/hooks/use-list-query";
|
|
20
|
+
import { collection, doc, getCountFromServer, onSnapshot, query, where } from "firebase/firestore";
|
|
20
21
|
import { useEffect, useMemo, useState } from "react";
|
|
21
22
|
import { OUTREACH_COLLECTIONS, OUTREACH_LINK_ROLLUP_PATH } from "../model/outreach.types.js";
|
|
22
23
|
import { readStoredOutreachEnrollment, readStoredOutreachSequence } from "../model/stored-records.js";
|
|
24
|
+
import { OUTREACH_DO_NOT_CONTACT_DOMAIN_LIST_QUERY } from "../model/do-not-contact-domain-list-query.js";
|
|
25
|
+
import { OUTREACH_ENROLLMENT_LIST_QUERY, outreachEnrollmentListBase } from "../model/enrollment-list-query.js";
|
|
26
|
+
import { OUTREACH_SEQUENCE_LIST_QUERY } from "../model/sequence-list-query.js";
|
|
23
27
|
const denied = (error)=>(error == null ? void 0 : error.code) === 'permission-denied';
|
|
24
28
|
function failed(data, error, what) {
|
|
25
29
|
if (denied(error)) return {
|
|
@@ -32,36 +36,62 @@ function failed(data, error, what) {
|
|
|
32
36
|
data
|
|
33
37
|
};
|
|
34
38
|
}
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
/**
|
|
40
|
+
* A list read through `useListQuery`, in this module's four states and with
|
|
41
|
+
* each document read through its stored-record reader. A document the reader
|
|
42
|
+
* cannot make sense of is left out, as every Outreach read leaves it out.
|
|
43
|
+
*/ function useOutreachListQuery(options) {
|
|
44
|
+
const listed = useListQuery({
|
|
45
|
+
collection: options.collection,
|
|
46
|
+
declaration: options.declaration,
|
|
47
|
+
request: options.request,
|
|
48
|
+
deps: options.deps,
|
|
49
|
+
idField: '$id'
|
|
41
50
|
});
|
|
51
|
+
const { read, what } = options;
|
|
52
|
+
const rows = useMemo(()=>listed.rows.map((row)=>read(String(row.$id), row)).filter((row)=>row !== null), [
|
|
53
|
+
listed.rows,
|
|
54
|
+
read
|
|
55
|
+
]);
|
|
56
|
+
const refused = listed.serverDenied || denied(listed.error);
|
|
42
57
|
useEffect(()=>{
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
});
|
|
47
|
-
if (!orgId) return undefined;
|
|
48
|
-
return onSnapshot(query(collection(firestore, 'orgs', orgId, OUTREACH_COLLECTIONS.sequences), // Ordered, so the cap keeps the newest rather than an arbitrary set;
|
|
49
|
-
// every sequence carries `createdAtMs`, stamped by the save route.
|
|
50
|
-
orderBy('createdAtMs', 'desc'), // One past the cap: the extra document says there are older
|
|
51
|
-
// sequences, and is never listed.
|
|
52
|
-
limit(OUTREACH_SEQUENCES_LIMIT + 1)), (snapshot)=>{
|
|
53
|
-
const sequences = snapshot.docs.slice(0, OUTREACH_SEQUENCES_LIMIT).map((entry)=>readStoredOutreachSequence(entry.id, entry.data())).filter((sequence)=>sequence !== null).sort((a, b)=>b.createdAtMs - a.createdAtMs);
|
|
54
|
-
setResult({
|
|
55
|
-
status: 'ready',
|
|
56
|
-
data: sequences,
|
|
57
|
-
truncated: snapshot.docs.length > OUTREACH_SEQUENCES_LIMIT
|
|
58
|
-
});
|
|
59
|
-
}, (error)=>setResult(failed([], error, 'sequences')));
|
|
58
|
+
if (listed.status === 'error' && !refused) {
|
|
59
|
+
console.error(`[outreach] ${what} could not be read`, listed.error);
|
|
60
|
+
}
|
|
60
61
|
}, [
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
listed.status,
|
|
63
|
+
listed.error,
|
|
64
|
+
refused,
|
|
65
|
+
what
|
|
63
66
|
]);
|
|
64
|
-
return
|
|
67
|
+
return {
|
|
68
|
+
status: listed.status === 'loading' ? 'loading' : listed.status === 'error' ? refused ? 'refused' : 'error' : 'ready',
|
|
69
|
+
rows,
|
|
70
|
+
hasMore: listed.hasMore,
|
|
71
|
+
page: listed.page,
|
|
72
|
+
setPage: listed.setPage,
|
|
73
|
+
pageSize: listed.pageSize,
|
|
74
|
+
setPageSize: listed.setPageSize,
|
|
75
|
+
plan: listed.plan
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const readSequence = (id, data)=>readStoredOutreachSequence(id, data);
|
|
79
|
+
/**
|
|
80
|
+
* The organization's sequences, newest first, one page at a time — each
|
|
81
|
+
* clause and the search on the query (`OUTREACH_SEQUENCE_LIST_QUERY`).
|
|
82
|
+
*/ export function useOutreachSequenceList(orgId, request) {
|
|
83
|
+
const firestore = useFirestore();
|
|
84
|
+
return useOutreachListQuery({
|
|
85
|
+
collection: orgId ? collection(firestore, 'orgs', orgId, OUTREACH_COLLECTIONS.sequences) : null,
|
|
86
|
+
declaration: OUTREACH_SEQUENCE_LIST_QUERY,
|
|
87
|
+
request,
|
|
88
|
+
deps: [
|
|
89
|
+
firestore,
|
|
90
|
+
orgId
|
|
91
|
+
],
|
|
92
|
+
read: readSequence,
|
|
93
|
+
what: 'sequences'
|
|
94
|
+
});
|
|
65
95
|
}
|
|
66
96
|
/** One sequence, or `null` once it is known not to exist. */ export function useOutreachSequence(orgId, sequenceId) {
|
|
67
97
|
const firestore = useFirestore();
|
|
@@ -109,91 +139,50 @@ export function useOutreachSequenceLinks(orgId, sequenceId) {
|
|
|
109
139
|
]);
|
|
110
140
|
return result;
|
|
111
141
|
}
|
|
142
|
+
const readDomain = (id, data)=>_extends({}, data, {
|
|
143
|
+
domain: id
|
|
144
|
+
});
|
|
112
145
|
/**
|
|
113
146
|
* The domains on the organization's do-not-contact list (AGL-3244),
|
|
114
|
-
* alphabetically
|
|
115
|
-
*
|
|
116
|
-
* gateway block.
|
|
117
|
-
|
|
147
|
+
* alphabetically — the document id is the domain — one page at a time, live:
|
|
148
|
+
* an add or a remove through the route shows up without a reload, and so
|
|
149
|
+
* does a domain the sending runtime files after a gateway block. Each clause
|
|
150
|
+
* and the search are on the query (`OUTREACH_DO_NOT_CONTACT_DOMAIN_LIST_QUERY`).
|
|
151
|
+
*/ export function useOutreachDoNotContactDomainList(orgId, request) {
|
|
118
152
|
const firestore = useFirestore();
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
153
|
+
return useOutreachListQuery({
|
|
154
|
+
collection: orgId ? collection(firestore, 'orgs', orgId, OUTREACH_COLLECTIONS.doNotContactDomains) : null,
|
|
155
|
+
declaration: OUTREACH_DO_NOT_CONTACT_DOMAIN_LIST_QUERY,
|
|
156
|
+
request,
|
|
157
|
+
deps: [
|
|
158
|
+
firestore,
|
|
159
|
+
orgId
|
|
160
|
+
],
|
|
161
|
+
read: readDomain,
|
|
162
|
+
what: 'the do-not-contact domains'
|
|
122
163
|
});
|
|
123
|
-
useEffect(()=>{
|
|
124
|
-
setResult({
|
|
125
|
-
status: 'loading',
|
|
126
|
-
data: []
|
|
127
|
-
});
|
|
128
|
-
if (!orgId) return undefined;
|
|
129
|
-
return onSnapshot(collection(firestore, 'orgs', orgId, OUTREACH_COLLECTIONS.doNotContactDomains), (snapshot)=>{
|
|
130
|
-
const domains = snapshot.docs.map((entry)=>_extends({}, entry.data(), {
|
|
131
|
-
domain: entry.id
|
|
132
|
-
})).sort((a, b)=>a.domain.localeCompare(b.domain));
|
|
133
|
-
setResult({
|
|
134
|
-
status: 'ready',
|
|
135
|
-
data: domains
|
|
136
|
-
});
|
|
137
|
-
}, (error)=>setResult(failed([], error, 'the do-not-contact domains')));
|
|
138
|
-
}, [
|
|
139
|
-
firestore,
|
|
140
|
-
orgId
|
|
141
|
-
]);
|
|
142
|
-
return result;
|
|
143
164
|
}
|
|
144
|
-
|
|
165
|
+
const readEnrollment = (id, data)=>readStoredOutreachEnrollment(id, data);
|
|
145
166
|
/**
|
|
146
|
-
* A sequence's enrollments, the most recently enrolled first
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
|
|
150
|
-
* table of up to a page reads in the order people were enrolled.
|
|
151
|
-
*/ export function useOutreachEnrollments(orgId, sequenceId) {
|
|
167
|
+
* A sequence's enrollments, the most recently enrolled first, one page at a
|
|
168
|
+
* time — the sequence is the query's base (`sequenceId ==`), and each clause
|
|
169
|
+
* and the search are on it beside that (`OUTREACH_ENROLLMENT_LIST_QUERY`).
|
|
170
|
+
*/ export function useOutreachEnrollmentList(orgId, sequenceId, request) {
|
|
152
171
|
const firestore = useFirestore();
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
172
|
+
return useOutreachListQuery({
|
|
173
|
+
collection: orgId && sequenceId ? collection(firestore, 'orgs', orgId, OUTREACH_COLLECTIONS.enrollments) : null,
|
|
174
|
+
declaration: OUTREACH_ENROLLMENT_LIST_QUERY,
|
|
175
|
+
request: _extends({}, request, {
|
|
176
|
+
base: sequenceId ? outreachEnrollmentListBase(sequenceId) : []
|
|
177
|
+
}),
|
|
178
|
+
deps: [
|
|
179
|
+
firestore,
|
|
180
|
+
orgId,
|
|
181
|
+
sequenceId
|
|
182
|
+
],
|
|
183
|
+
read: readEnrollment,
|
|
184
|
+
what: 'the enrollments'
|
|
158
185
|
});
|
|
159
|
-
useEffect(()=>setPages(1), [
|
|
160
|
-
orgId,
|
|
161
|
-
sequenceId
|
|
162
|
-
]);
|
|
163
|
-
useEffect(()=>{
|
|
164
|
-
if (!orgId || !sequenceId) {
|
|
165
|
-
setResult({
|
|
166
|
-
status: 'loading',
|
|
167
|
-
data: [],
|
|
168
|
-
size: 0
|
|
169
|
-
});
|
|
170
|
-
return undefined;
|
|
171
|
-
}
|
|
172
|
-
return onSnapshot(query(collection(firestore, 'orgs', orgId, OUTREACH_COLLECTIONS.enrollments), where('sequenceId', '==', sequenceId), orderBy(documentId()), limit(pages * OUTREACH_ENROLLMENTS_PAGE)), (snapshot)=>{
|
|
173
|
-
const enrollments = snapshot.docs.map((entry)=>readStoredOutreachEnrollment(entry.id, entry.data())).filter((enrollment)=>enrollment !== null).sort((a, b)=>b.createdAtMs - a.createdAtMs);
|
|
174
|
-
setResult({
|
|
175
|
-
status: 'ready',
|
|
176
|
-
data: enrollments,
|
|
177
|
-
size: snapshot.size
|
|
178
|
-
});
|
|
179
|
-
}, (error)=>setResult(_extends({}, failed([], error, 'the enrollments'), {
|
|
180
|
-
size: 0
|
|
181
|
-
})));
|
|
182
|
-
}, [
|
|
183
|
-
firestore,
|
|
184
|
-
orgId,
|
|
185
|
-
sequenceId,
|
|
186
|
-
pages
|
|
187
|
-
]);
|
|
188
|
-
return useMemo(()=>({
|
|
189
|
-
status: result.status,
|
|
190
|
-
data: result.data,
|
|
191
|
-
hasMore: result.size >= pages * OUTREACH_ENROLLMENTS_PAGE,
|
|
192
|
-
showMore: ()=>setPages((count)=>count + 1)
|
|
193
|
-
}), [
|
|
194
|
-
result,
|
|
195
|
-
pages
|
|
196
|
-
]);
|
|
197
186
|
}
|
|
198
187
|
const COUNTED = [
|
|
199
188
|
[
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/components/use-outreach-data.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'use client'\n\nimport type { CampaignLinkRollup } from '@aglyn/shared-ui-email-campaigns/model'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport {\n collection,\n doc,\n documentId,\n getCountFromServer,\n limit,\n onSnapshot,\n orderBy,\n query,\n where,\n} from 'firebase/firestore'\nimport { useEffect, useMemo, useState } from 'react'\nimport {\n OUTREACH_COLLECTIONS,\n OUTREACH_LINK_ROLLUP_PATH,\n type OutreachDoNotContactDomainEntry,\n type OutreachEnrollment,\n type OutreachEnrollmentStatus,\n type OutreachSequence,\n} from '../model/outreach.types'\nimport {\n readStoredOutreachEnrollment,\n readStoredOutreachSequence,\n} from '../model/stored-records'\n\n/**\n * The Outreach documents the console reads LIVE (AGL-2980): sequences,\n * enrollments, and the mailboxes they send from.\n *\n * Read straight from Firestore — the rules let an org-wide member holding\n * `outreach.use` on an entitled organization read them, and every write goes\n * through a route — so what a page shows is what the routes and the sending\n * runtime wrote, the moment it lands.\n *\n * Every hook answers one of four states, and a page renders each: still\n * loading, ready, failed, or REFUSED — the rules said no, which is a reader\n * who may not see Outreach here rather than something that went wrong.\n */\n\nexport type OutreachLoadStatus = 'loading' | 'ready' | 'error' | 'refused'\n\nexport interface OutreachLoad<T> {\n status: OutreachLoadStatus\n data: T\n}\n\nconst denied = (error: unknown) =>\n (error as { code?: unknown } | null)?.code === 'permission-denied'\n\nfunction failed<T>(data: T, error: unknown, what: string): OutreachLoad<T> {\n if (denied(error)) return { status: 'refused', data }\n console.error(`[outreach] ${what} could not be read`, error)\n return { status: 'error', data }\n}\n\n/** The most sequences the list reads. */\nexport const OUTREACH_SEQUENCES_LIMIT = 200\n\n/** The sequences read, and whether the organization holds older ones. */\nexport interface OutreachSequencesLoad extends OutreachLoad<OutreachSequence[]> {\n /** True when there are sequences older than the `OUTREACH_SEQUENCES_LIMIT` read. */\n truncated?: boolean\n}\n\n/** The organization's sequences, newest first. */\nexport function useOutreachSequences(\n orgId: string | null,\n): OutreachSequencesLoad {\n const firestore = useFirestore()\n const [result, setResult] = useState<OutreachSequencesLoad>({\n status: 'loading',\n data: [],\n })\n useEffect(() => {\n setResult({ status: 'loading', data: [] })\n if (!orgId) return undefined\n return onSnapshot(\n query(\n collection(firestore, 'orgs', orgId, OUTREACH_COLLECTIONS.sequences),\n // Ordered, so the cap keeps the newest rather than an arbitrary set;\n // every sequence carries `createdAtMs`, stamped by the save route.\n orderBy('createdAtMs', 'desc'),\n // One past the cap: the extra document says there are older\n // sequences, and is never listed.\n limit(OUTREACH_SEQUENCES_LIMIT + 1),\n ),\n (snapshot) => {\n const sequences = snapshot.docs\n .slice(0, OUTREACH_SEQUENCES_LIMIT)\n .map((entry) => readStoredOutreachSequence(entry.id, entry.data()))\n .filter((sequence): sequence is OutreachSequence => sequence !== null)\n .sort((a, b) => b.createdAtMs - a.createdAtMs)\n setResult({\n status: 'ready',\n data: sequences,\n truncated: snapshot.docs.length > OUTREACH_SEQUENCES_LIMIT,\n })\n },\n (error) => setResult(failed([], error, 'sequences')),\n )\n }, [firestore, orgId])\n return result\n}\n\n/** One sequence, or `null` once it is known not to exist. */\nexport function useOutreachSequence(\n orgId: string | null,\n sequenceId: string | null,\n): OutreachLoad<OutreachSequence | null> {\n const firestore = useFirestore()\n const [result, setResult] = useState<OutreachLoad<OutreachSequence | null>>({\n status: 'loading',\n data: null,\n })\n useEffect(() => {\n setResult({ status: 'loading', data: null })\n if (!orgId || !sequenceId) return undefined\n return onSnapshot(\n doc(firestore, 'orgs', orgId, OUTREACH_COLLECTIONS.sequences, sequenceId),\n (snapshot) =>\n setResult({\n status: 'ready',\n data: readStoredOutreachSequence(\n snapshot.id,\n snapshot.exists() ? snapshot.data() : undefined,\n ),\n }),\n (error) => setResult(failed(null, error, 'the sequence')),\n )\n }, [firestore, orgId, sequenceId])\n return result\n}\n\n/**\n * A sequence's per-destination click rollup (AGL-3239), or `null` until a\n * click has been counted.\n *\n * One document, whatever the sequence's size: the aggregate exists so the\n * table is not a read of every enrollment. A sequence that tracks nothing\n * never has one, and an absent document is not an error.\n */\nexport type OutreachSequenceLinksLoad = OutreachLoad<CampaignLinkRollup | null>\n\nexport function useOutreachSequenceLinks(\n orgId: string | null,\n sequenceId: string | null,\n): OutreachSequenceLinksLoad {\n const firestore = useFirestore()\n const [result, setResult] = useState<OutreachSequenceLinksLoad>({\n status: 'loading',\n data: null,\n })\n useEffect(() => {\n setResult({ status: 'loading', data: null })\n if (!orgId || !sequenceId) return undefined\n return onSnapshot(\n doc(\n firestore,\n 'orgs',\n orgId,\n OUTREACH_COLLECTIONS.sequences,\n sequenceId,\n OUTREACH_LINK_ROLLUP_PATH[0],\n OUTREACH_LINK_ROLLUP_PATH[1],\n ),\n (snapshot) =>\n setResult({\n status: 'ready',\n data: snapshot.exists() ? (snapshot.data() as CampaignLinkRollup) : null,\n }),\n (error) => setResult(failed(null, error, 'the link report')),\n )\n }, [firestore, orgId, sequenceId])\n return result\n}\n\n/**\n * The domains on the organization's do-not-contact list (AGL-3244),\n * alphabetically, live: an add or a remove through the route shows up here\n * without a reload, and so does a domain the sending runtime files after a\n * gateway block.\n */\nexport function useOutreachDoNotContactDomains(\n orgId: string | null,\n): OutreachLoad<OutreachDoNotContactDomainEntry[]> {\n const firestore = useFirestore()\n const [result, setResult] = useState<OutreachLoad<OutreachDoNotContactDomainEntry[]>>({\n status: 'loading',\n data: [],\n })\n useEffect(() => {\n setResult({ status: 'loading', data: [] })\n if (!orgId) return undefined\n return onSnapshot(\n collection(firestore, 'orgs', orgId, OUTREACH_COLLECTIONS.doNotContactDomains),\n (snapshot) => {\n const domains = snapshot.docs\n .map((entry) => ({ ...(entry.data() as OutreachDoNotContactDomainEntry), domain: entry.id }))\n .sort((a, b) => a.domain.localeCompare(b.domain))\n setResult({ status: 'ready', data: domains })\n },\n (error) => setResult(failed([], error, 'the do-not-contact domains')),\n )\n }, [firestore, orgId])\n return result\n}\n\n/** How many enrollments one page of the table reads; \"Show more\" reads another. */\nexport const OUTREACH_ENROLLMENTS_PAGE = 200\n\nexport interface OutreachEnrollmentsLoad extends OutreachLoad<\n OutreachEnrollment[]\n> {\n /** Whether a further page may exist. */\n hasMore: boolean\n showMore(): void\n}\n\n/**\n * A sequence's enrollments, the most recently enrolled first.\n *\n * Paged by document id — an equality on `sequenceId` in the id's own order\n * needs no composite index — and sorted by enrollment time once read, so a\n * table of up to a page reads in the order people were enrolled.\n */\nexport function useOutreachEnrollments(\n orgId: string | null,\n sequenceId: string | null,\n): OutreachEnrollmentsLoad {\n const firestore = useFirestore()\n const [pages, setPages] = useState(1)\n const [result, setResult] = useState<\n OutreachLoad<OutreachEnrollment[]> & { size: number }\n >({\n status: 'loading',\n data: [],\n size: 0,\n })\n useEffect(() => setPages(1), [orgId, sequenceId])\n useEffect(() => {\n if (!orgId || !sequenceId) {\n setResult({ status: 'loading', data: [], size: 0 })\n return undefined\n }\n return onSnapshot(\n query(\n collection(firestore, 'orgs', orgId, OUTREACH_COLLECTIONS.enrollments),\n where('sequenceId', '==', sequenceId),\n orderBy(documentId()),\n limit(pages * OUTREACH_ENROLLMENTS_PAGE),\n ),\n (snapshot) => {\n const enrollments = snapshot.docs\n .map((entry) => readStoredOutreachEnrollment(entry.id, entry.data()))\n .filter(\n (enrollment): enrollment is OutreachEnrollment =>\n enrollment !== null,\n )\n .sort((a, b) => b.createdAtMs - a.createdAtMs)\n setResult({ status: 'ready', data: enrollments, size: snapshot.size })\n },\n (error) =>\n setResult({ ...failed([], error, 'the enrollments'), size: 0 }),\n )\n }, [firestore, orgId, sequenceId, pages])\n return useMemo(\n () => ({\n status: result.status,\n data: result.data,\n hasMore: result.size >= pages * OUTREACH_ENROLLMENTS_PAGE,\n showMore: () => setPages((count) => count + 1),\n }),\n [result, pages],\n )\n}\n\n/** The counts the sequence list shows beside each sequence. */\nexport interface OutreachSequenceCounts {\n enrolled: number\n active: number\n replied: number\n bounced: number\n optedOut: number\n}\n\nconst COUNTED: ReadonlyArray<\n [keyof OutreachSequenceCounts, OutreachEnrollmentStatus | null]\n> = [\n ['enrolled', null],\n ['active', 'active'],\n ['replied', 'replied'],\n ['bounced', 'bounced'],\n ['optedOut', 'opted_out'],\n]\n\n/**\n * Each sequence's enrollment counts, by server-side aggregation — one count\n * per figure, never a read of the enrollments themselves. Recounted when the\n * sequences asked about change — the list asks about the page on screen —\n * and absent while counting, or if a count failed.\n */\nexport function useOutreachSequenceCounts(\n orgId: string | null,\n sequenceIds: readonly string[],\n): Record<string, OutreachSequenceCounts> {\n const firestore = useFirestore()\n const [counts, setCounts] = useState<Record<string, OutreachSequenceCounts>>(\n {},\n )\n const key = sequenceIds.join(',')\n useEffect(() => {\n if (!orgId || !key) return undefined\n let current = true\n const enrollments = collection(\n firestore,\n 'orgs',\n orgId,\n OUTREACH_COLLECTIONS.enrollments,\n )\n Promise.all(\n key.split(',').map(async (sequenceId) => {\n const figures = await Promise.all(\n COUNTED.map(async ([, status]) => {\n const counted = await getCountFromServer(\n status\n ? query(\n enrollments,\n where('sequenceId', '==', sequenceId),\n where('status', '==', status),\n )\n : query(enrollments, where('sequenceId', '==', sequenceId)),\n )\n return counted.data().count\n }),\n )\n return [\n sequenceId,\n Object.fromEntries(\n COUNTED.map(([name], index) => [name, figures[index]]),\n ) as unknown as OutreachSequenceCounts,\n ] as const\n }),\n )\n .then((entries) => current && setCounts(Object.fromEntries(entries)))\n .catch((error: unknown) =>\n console.error('[outreach] enrollment counts could not be read', error),\n )\n return () => {\n current = false\n }\n }, [firestore, orgId, key])\n return counts\n}\n"],"names":["useFirestore","collection","doc","documentId","getCountFromServer","limit","onSnapshot","orderBy","query","where","useEffect","useMemo","useState","OUTREACH_COLLECTIONS","OUTREACH_LINK_ROLLUP_PATH","readStoredOutreachEnrollment","readStoredOutreachSequence","denied","error","code","failed","data","what","status","console","OUTREACH_SEQUENCES_LIMIT","useOutreachSequences","orgId","firestore","result","setResult","undefined","sequences","snapshot","docs","slice","map","entry","id","filter","sequence","sort","a","b","createdAtMs","truncated","length","useOutreachSequence","sequenceId","exists","useOutreachSequenceLinks","useOutreachDoNotContactDomains","doNotContactDomains","domains","domain","localeCompare","OUTREACH_ENROLLMENTS_PAGE","useOutreachEnrollments","pages","setPages","size","enrollments","enrollment","hasMore","showMore","count","COUNTED","useOutreachSequenceCounts","sequenceIds","counts","setCounts","key","join","current","Promise","all","split","figures","counted","Object","fromEntries","name","index","then","entries","catch"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/components/use-outreach-data.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'use client'\n\nimport type { CampaignLinkRollup } from '@aglyn/shared-ui-email-campaigns/model'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport type {\n ListQueryDeclaration,\n ListQueryPlan,\n ListQueryRequest,\n} from '@aglyn/shared-ui-jsx/const/list-query-plan'\nimport { useListQuery } from '@aglyn/tenant-feature-instance/hooks/use-list-query'\nimport {\n collection,\n doc,\n type DocumentData,\n documentId,\n getCountFromServer,\n limit,\n onSnapshot,\n orderBy,\n type Query,\n query,\n where,\n} from 'firebase/firestore'\nimport { useEffect, useMemo, useState } from 'react'\nimport {\n OUTREACH_COLLECTIONS,\n OUTREACH_LINK_ROLLUP_PATH,\n type OutreachDoNotContactDomainEntry,\n type OutreachEnrollment,\n type OutreachEnrollmentStatus,\n type OutreachSequence,\n} from '../model/outreach.types'\nimport {\n readStoredOutreachEnrollment,\n readStoredOutreachSequence,\n} from '../model/stored-records'\nimport { OUTREACH_DO_NOT_CONTACT_DOMAIN_LIST_QUERY } from '../model/do-not-contact-domain-list-query'\nimport {\n OUTREACH_ENROLLMENT_LIST_QUERY,\n outreachEnrollmentListBase,\n} from '../model/enrollment-list-query'\nimport { OUTREACH_SEQUENCE_LIST_QUERY } from '../model/sequence-list-query'\n\n/**\n * The Outreach documents the console reads LIVE (AGL-2980): sequences,\n * enrollments, and the mailboxes they send from.\n *\n * Read straight from Firestore — the rules let an org-wide member holding\n * `outreach.use` on an entitled organization read them, and every write goes\n * through a route — so what a page shows is what the routes and the sending\n * runtime wrote, the moment it lands.\n *\n * Every hook answers one of four states, and a page renders each: still\n * loading, ready, failed, or REFUSED — the rules said no, which is a reader\n * who may not see Outreach here rather than something that went wrong.\n */\n\nexport type OutreachLoadStatus = 'loading' | 'ready' | 'error' | 'refused'\n\nexport interface OutreachLoad<T> {\n status: OutreachLoadStatus\n data: T\n}\n\nconst denied = (error: unknown) =>\n (error as { code?: unknown } | null)?.code === 'permission-denied'\n\nfunction failed<T>(data: T, error: unknown, what: string): OutreachLoad<T> {\n if (denied(error)) return { status: 'refused', data }\n console.error(`[outreach] ${what} could not be read`, error)\n return { status: 'error', data }\n}\n\n/**\n * One page of a list whose every filter and search word is on its Firestore\n * query (AGL-3321): the rows of the page on screen, whether another page\n * exists, the pager, and the plan — what the query holds and what it could\n * not take, which the list says above itself.\n */\nexport interface OutreachListPage<T> {\n status: OutreachLoadStatus\n rows: T[]\n hasMore: boolean\n page: number\n setPage(page: number): void\n pageSize: number\n setPageSize(pageSize: number): void\n plan: ListQueryPlan\n}\n\n/**\n * A list read through `useListQuery`, in this module's four states and with\n * each document read through its stored-record reader. A document the reader\n * cannot make sense of is left out, as every Outreach read leaves it out.\n */\nfunction useOutreachListQuery<T>(options: {\n collection: Query<DocumentData> | null\n declaration: ListQueryDeclaration\n request: ListQueryRequest\n deps: readonly unknown[]\n read(id: string, data: DocumentData): T | null\n what: string\n}): OutreachListPage<T> {\n const listed = useListQuery<DocumentData & { $id: string }>({\n collection: options.collection,\n declaration: options.declaration,\n request: options.request,\n deps: options.deps,\n idField: '$id',\n })\n const { read, what } = options\n const rows = useMemo(\n () =>\n listed.rows\n .map((row) => read(String(row.$id), row))\n .filter((row): row is NonNullable<typeof row> => row !== null) as T[],\n [listed.rows, read],\n )\n const refused = listed.serverDenied || denied(listed.error)\n useEffect(() => {\n if (listed.status === 'error' && !refused) {\n console.error(`[outreach] ${what} could not be read`, listed.error)\n }\n }, [listed.status, listed.error, refused, what])\n return {\n status:\n listed.status === 'loading'\n ? 'loading'\n : listed.status === 'error'\n ? refused\n ? 'refused'\n : 'error'\n : 'ready',\n rows,\n hasMore: listed.hasMore,\n page: listed.page,\n setPage: listed.setPage,\n pageSize: listed.pageSize,\n setPageSize: listed.setPageSize,\n plan: listed.plan,\n }\n}\n\nconst readSequence = (id: string, data: DocumentData) =>\n readStoredOutreachSequence(id, data)\n\n/**\n * The organization's sequences, newest first, one page at a time — each\n * clause and the search on the query (`OUTREACH_SEQUENCE_LIST_QUERY`).\n */\nexport function useOutreachSequenceList(\n orgId: string | null,\n request: ListQueryRequest,\n): OutreachListPage<OutreachSequence> {\n const firestore = useFirestore()\n return useOutreachListQuery({\n collection: orgId\n ? collection(firestore, 'orgs', orgId, OUTREACH_COLLECTIONS.sequences)\n : null,\n declaration: OUTREACH_SEQUENCE_LIST_QUERY,\n request,\n deps: [firestore, orgId],\n read: readSequence,\n what: 'sequences',\n })\n}\n\n/** One sequence, or `null` once it is known not to exist. */\nexport function useOutreachSequence(\n orgId: string | null,\n sequenceId: string | null,\n): OutreachLoad<OutreachSequence | null> {\n const firestore = useFirestore()\n const [result, setResult] = useState<OutreachLoad<OutreachSequence | null>>({\n status: 'loading',\n data: null,\n })\n useEffect(() => {\n setResult({ status: 'loading', data: null })\n if (!orgId || !sequenceId) return undefined\n return onSnapshot(\n doc(firestore, 'orgs', orgId, OUTREACH_COLLECTIONS.sequences, sequenceId),\n (snapshot) =>\n setResult({\n status: 'ready',\n data: readStoredOutreachSequence(\n snapshot.id,\n snapshot.exists() ? snapshot.data() : undefined,\n ),\n }),\n (error) => setResult(failed(null, error, 'the sequence')),\n )\n }, [firestore, orgId, sequenceId])\n return result\n}\n\n/**\n * A sequence's per-destination click rollup (AGL-3239), or `null` until a\n * click has been counted.\n *\n * One document, whatever the sequence's size: the aggregate exists so the\n * table is not a read of every enrollment. A sequence that tracks nothing\n * never has one, and an absent document is not an error.\n */\nexport type OutreachSequenceLinksLoad = OutreachLoad<CampaignLinkRollup | null>\n\nexport function useOutreachSequenceLinks(\n orgId: string | null,\n sequenceId: string | null,\n): OutreachSequenceLinksLoad {\n const firestore = useFirestore()\n const [result, setResult] = useState<OutreachSequenceLinksLoad>({\n status: 'loading',\n data: null,\n })\n useEffect(() => {\n setResult({ status: 'loading', data: null })\n if (!orgId || !sequenceId) return undefined\n return onSnapshot(\n doc(\n firestore,\n 'orgs',\n orgId,\n OUTREACH_COLLECTIONS.sequences,\n sequenceId,\n OUTREACH_LINK_ROLLUP_PATH[0],\n OUTREACH_LINK_ROLLUP_PATH[1],\n ),\n (snapshot) =>\n setResult({\n status: 'ready',\n data: snapshot.exists() ? (snapshot.data() as CampaignLinkRollup) : null,\n }),\n (error) => setResult(failed(null, error, 'the link report')),\n )\n }, [firestore, orgId, sequenceId])\n return result\n}\n\nconst readDomain = (id: string, data: DocumentData): OutreachDoNotContactDomainEntry => ({\n ...(data as OutreachDoNotContactDomainEntry),\n domain: id,\n})\n\n/**\n * The domains on the organization's do-not-contact list (AGL-3244),\n * alphabetically — the document id is the domain — one page at a time, live:\n * an add or a remove through the route shows up without a reload, and so\n * does a domain the sending runtime files after a gateway block. Each clause\n * and the search are on the query (`OUTREACH_DO_NOT_CONTACT_DOMAIN_LIST_QUERY`).\n */\nexport function useOutreachDoNotContactDomainList(\n orgId: string | null,\n request: ListQueryRequest,\n): OutreachListPage<OutreachDoNotContactDomainEntry> {\n const firestore = useFirestore()\n return useOutreachListQuery({\n collection: orgId\n ? collection(firestore, 'orgs', orgId, OUTREACH_COLLECTIONS.doNotContactDomains)\n : null,\n declaration: OUTREACH_DO_NOT_CONTACT_DOMAIN_LIST_QUERY,\n request,\n deps: [firestore, orgId],\n read: readDomain,\n what: 'the do-not-contact domains',\n })\n}\n\nconst readEnrollment = (id: string, data: DocumentData) =>\n readStoredOutreachEnrollment(id, data)\n\n/**\n * A sequence's enrollments, the most recently enrolled first, one page at a\n * time — the sequence is the query's base (`sequenceId ==`), and each clause\n * and the search are on it beside that (`OUTREACH_ENROLLMENT_LIST_QUERY`).\n */\nexport function useOutreachEnrollmentList(\n orgId: string | null,\n sequenceId: string | null,\n request: Omit<ListQueryRequest, 'base'>,\n): OutreachListPage<OutreachEnrollment> {\n const firestore = useFirestore()\n return useOutreachListQuery({\n collection:\n orgId && sequenceId\n ? collection(firestore, 'orgs', orgId, OUTREACH_COLLECTIONS.enrollments)\n : null,\n declaration: OUTREACH_ENROLLMENT_LIST_QUERY,\n request: { ...request, base: sequenceId ? outreachEnrollmentListBase(sequenceId) : [] },\n deps: [firestore, orgId, sequenceId],\n read: readEnrollment,\n what: 'the enrollments',\n })\n}\n\n/** The counts the sequence list shows beside each sequence. */\nexport interface OutreachSequenceCounts {\n enrolled: number\n active: number\n replied: number\n bounced: number\n optedOut: number\n}\n\nconst COUNTED: ReadonlyArray<\n [keyof OutreachSequenceCounts, OutreachEnrollmentStatus | null]\n> = [\n ['enrolled', null],\n ['active', 'active'],\n ['replied', 'replied'],\n ['bounced', 'bounced'],\n ['optedOut', 'opted_out'],\n]\n\n/**\n * Each sequence's enrollment counts, by server-side aggregation — one count\n * per figure, never a read of the enrollments themselves. Recounted when the\n * sequences asked about change — the list asks about the page on screen —\n * and absent while counting, or if a count failed.\n */\nexport function useOutreachSequenceCounts(\n orgId: string | null,\n sequenceIds: readonly string[],\n): Record<string, OutreachSequenceCounts> {\n const firestore = useFirestore()\n const [counts, setCounts] = useState<Record<string, OutreachSequenceCounts>>(\n {},\n )\n const key = sequenceIds.join(',')\n useEffect(() => {\n if (!orgId || !key) return undefined\n let current = true\n const enrollments = collection(\n firestore,\n 'orgs',\n orgId,\n OUTREACH_COLLECTIONS.enrollments,\n )\n Promise.all(\n key.split(',').map(async (sequenceId) => {\n const figures = await Promise.all(\n COUNTED.map(async ([, status]) => {\n const counted = await getCountFromServer(\n status\n ? query(\n enrollments,\n where('sequenceId', '==', sequenceId),\n where('status', '==', status),\n )\n : query(enrollments, where('sequenceId', '==', sequenceId)),\n )\n return counted.data().count\n }),\n )\n return [\n sequenceId,\n Object.fromEntries(\n COUNTED.map(([name], index) => [name, figures[index]]),\n ) as unknown as OutreachSequenceCounts,\n ] as const\n }),\n )\n .then((entries) => current && setCounts(Object.fromEntries(entries)))\n .catch((error: unknown) =>\n console.error('[outreach] enrollment counts could not be read', error),\n )\n return () => {\n current = false\n }\n }, [firestore, orgId, key])\n return counts\n}\n"],"names":["useFirestore","useListQuery","collection","doc","getCountFromServer","onSnapshot","query","where","useEffect","useMemo","useState","OUTREACH_COLLECTIONS","OUTREACH_LINK_ROLLUP_PATH","readStoredOutreachEnrollment","readStoredOutreachSequence","OUTREACH_DO_NOT_CONTACT_DOMAIN_LIST_QUERY","OUTREACH_ENROLLMENT_LIST_QUERY","outreachEnrollmentListBase","OUTREACH_SEQUENCE_LIST_QUERY","denied","error","code","failed","data","what","status","console","useOutreachListQuery","options","listed","declaration","request","deps","idField","read","rows","map","row","String","$id","filter","refused","serverDenied","hasMore","page","setPage","pageSize","setPageSize","plan","readSequence","id","useOutreachSequenceList","orgId","firestore","sequences","useOutreachSequence","sequenceId","result","setResult","undefined","snapshot","exists","useOutreachSequenceLinks","readDomain","domain","useOutreachDoNotContactDomainList","doNotContactDomains","readEnrollment","useOutreachEnrollmentList","enrollments","base","COUNTED","useOutreachSequenceCounts","sequenceIds","counts","setCounts","key","join","current","Promise","all","split","figures","counted","count","Object","fromEntries","name","index","then","entries","catch"],"mappings":"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"}
|
|
@@ -0,0 +1,32 @@
|
|
|
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 { OutreachEnrollment } from '../model/outreach.types';
|
|
18
|
+
/**
|
|
19
|
+
* What a sequence's enrollments table searches an enrollment by (AGL-3321):
|
|
20
|
+
* every prefix of each word of the person's name, and of their address, its
|
|
21
|
+
* domain and each piece of either — the same address derivation the
|
|
22
|
+
* platform's suppression search uses — so "casey", "morgan" and "example"
|
|
23
|
+
* all find Casey Morgan at casey.morgan@example.com.
|
|
24
|
+
*
|
|
25
|
+
* Both halves are captured once, at enrollment, and never rewritten (an
|
|
26
|
+
* erasure deletes the enrollment rather than editing it), so the enroll
|
|
27
|
+
* route stamps this on the document it creates and nothing else has to.
|
|
28
|
+
* `tools/scripts/backfill-outreach-list-search.mjs` restates it for the
|
|
29
|
+
* enrollments made before it; both are held to
|
|
30
|
+
* `tools/scripts/lib/outreach-search-tokens.fixtures.json`.
|
|
31
|
+
*/
|
|
32
|
+
export declare function outreachEnrollmentSearchTokens(enrollment: Pick<OutreachEnrollment, 'contactName' | 'email'>): string[];
|
|
@@ -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
|
+
*/ import { nameSearchTokens } from "@aglyn/aglyn/app-utils/name-search";
|
|
17
|
+
import { emailSearchTokens } from "@aglyn/tenant-data-admin/server/email-suppression";
|
|
18
|
+
/**
|
|
19
|
+
* What a sequence's enrollments table searches an enrollment by (AGL-3321):
|
|
20
|
+
* every prefix of each word of the person's name, and of their address, its
|
|
21
|
+
* domain and each piece of either — the same address derivation the
|
|
22
|
+
* platform's suppression search uses — so "casey", "morgan" and "example"
|
|
23
|
+
* all find Casey Morgan at casey.morgan@example.com.
|
|
24
|
+
*
|
|
25
|
+
* Both halves are captured once, at enrollment, and never rewritten (an
|
|
26
|
+
* erasure deletes the enrollment rather than editing it), so the enroll
|
|
27
|
+
* route stamps this on the document it creates and nothing else has to.
|
|
28
|
+
* `tools/scripts/backfill-outreach-list-search.mjs` restates it for the
|
|
29
|
+
* enrollments made before it; both are held to
|
|
30
|
+
* `tools/scripts/lib/outreach-search-tokens.fixtures.json`.
|
|
31
|
+
*/ export function outreachEnrollmentSearchTokens(enrollment) {
|
|
32
|
+
return [
|
|
33
|
+
...new Set([
|
|
34
|
+
...nameSearchTokens(enrollment.contactName),
|
|
35
|
+
...emailSearchTokens(enrollment.email)
|
|
36
|
+
])
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
//# sourceMappingURL=enrollment-search.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/enrollment/enrollment-search.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 { emailSearchTokens } from '@aglyn/tenant-data-admin/server/email-suppression'\nimport type { OutreachEnrollment } from '../model/outreach.types'\n\n/**\n * What a sequence's enrollments table searches an enrollment by (AGL-3321):\n * every prefix of each word of the person's name, and of their address, its\n * domain and each piece of either — the same address derivation the\n * platform's suppression search uses — so \"casey\", \"morgan\" and \"example\"\n * all find Casey Morgan at casey.morgan@example.com.\n *\n * Both halves are captured once, at enrollment, and never rewritten (an\n * erasure deletes the enrollment rather than editing it), so the enroll\n * route stamps this on the document it creates and nothing else has to.\n * `tools/scripts/backfill-outreach-list-search.mjs` restates it for the\n * enrollments made before it; both are held to\n * `tools/scripts/lib/outreach-search-tokens.fixtures.json`.\n */\nexport function outreachEnrollmentSearchTokens(\n enrollment: Pick<OutreachEnrollment, 'contactName' | 'email'>,\n): string[] {\n return [\n ...new Set([\n ...nameSearchTokens(enrollment.contactName),\n ...emailSearchTokens(enrollment.email),\n ]),\n ]\n}\n"],"names":["nameSearchTokens","emailSearchTokens","outreachEnrollmentSearchTokens","enrollment","Set","contactName","email"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,gBAAgB,QAAQ,qCAAoC;AACrE,SAASC,iBAAiB,QAAQ,oDAAmD;AAGrF;;;;;;;;;;;;;CAaC,GACD,OAAO,SAASC,+BACdC,UAA6D;IAE7D,OAAO;WACF,IAAIC,IAAI;eACNJ,iBAAiBG,WAAWE,WAAW;eACvCJ,kBAAkBE,WAAWG,KAAK;SACtC;KACF;AACH"}
|
|
@@ -0,0 +1,32 @@
|
|
|
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 } from '@aglyn/shared-ui-jsx/const/list-query-plan';
|
|
18
|
+
import type { OutreachDoNotContactDomainEntry } from './outreach.types';
|
|
19
|
+
/** The array the search box reads, stamped by the one writer that adds a domain. */
|
|
20
|
+
export declare const OUTREACH_DOMAIN_SEARCH_TOKENS = "searchTokens";
|
|
21
|
+
export declare const OUTREACH_DO_NOT_CONTACT_DOMAIN_LIST_QUERY: ListQueryDeclaration;
|
|
22
|
+
/**
|
|
23
|
+
* What the domains list searches an entry by: every prefix of the domain
|
|
24
|
+
* whole and of each shorter domain inside it, of each of its labels, and of
|
|
25
|
+
* each word of its detail — so "acme", "acme.com" and "mail.ac" all find
|
|
26
|
+
* `mail.acme.com`, and "barracuda" finds the domain a gateway block filed.
|
|
27
|
+
* An entry is created once and never edited, so its one writer stamps this
|
|
28
|
+
* and `tools/scripts/backfill-outreach-list-search.mjs` restates it for the
|
|
29
|
+
* entries made before it; both are held to
|
|
30
|
+
* `tools/scripts/lib/outreach-search-tokens.fixtures.json`.
|
|
31
|
+
*/
|
|
32
|
+
export declare function outreachDomainSearchTokens(entry: Pick<OutreachDoNotContactDomainEntry, 'domain' | 'detail'>): string[];
|
|
@@ -0,0 +1,107 @@
|
|
|
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
|
+
*/ import { nameSearchTokens } from "@aglyn/aglyn/app-utils/name-search";
|
|
17
|
+
import { LIST_QUERY_ID_PATH } from "@aglyn/shared-ui-jsx/const/list-query-plan";
|
|
18
|
+
/*
|
|
19
|
+
* WHAT THE DO-NOT-CONTACT DOMAINS LIST ASKS FIRESTORE (AGL-3321).
|
|
20
|
+
*
|
|
21
|
+
* One query over `orgs/{orgId}/outreachDoNotContactDomains`, alphabetical —
|
|
22
|
+
* the document id IS the domain — paged by that query:
|
|
23
|
+
*
|
|
24
|
+
* domain `equals` one domain, on the `domain` field every entry carries
|
|
25
|
+
* beside its id, lower-cased the way every domain is written; a
|
|
26
|
+
* prefix is the search box's job, which reads every label;
|
|
27
|
+
* reason the stored reason, which every entry carries;
|
|
28
|
+
* added the list's ONE range: when the entry was added, which every
|
|
29
|
+
* entry carries; a range leads the order, so while it applies
|
|
30
|
+
* the list is newest added first;
|
|
31
|
+
* search `searchTokens`: the domain, each shorter domain inside it and
|
|
32
|
+
* each of its labels, and the words of its detail, stamped when
|
|
33
|
+
* the entry is created.
|
|
34
|
+
*
|
|
35
|
+
* Without the range every shape is equalities and the one array clause,
|
|
36
|
+
* ordered by the document id, which Firestore serves by merging
|
|
37
|
+
* single-field indexes. The range's order needs one composite per
|
|
38
|
+
* equality field beside it: `reason` and `searchTokens`.
|
|
39
|
+
*/ /** The array the search box reads, stamped by the one writer that adds a domain. */ export const OUTREACH_DOMAIN_SEARCH_TOKENS = 'searchTokens';
|
|
40
|
+
export const OUTREACH_DO_NOT_CONTACT_DOMAIN_LIST_QUERY = {
|
|
41
|
+
fields: [
|
|
42
|
+
{
|
|
43
|
+
column: 'domain',
|
|
44
|
+
kind: 'text',
|
|
45
|
+
path: 'domain',
|
|
46
|
+
lowerPath: 'domain',
|
|
47
|
+
operators: [
|
|
48
|
+
'equals'
|
|
49
|
+
]
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
column: 'reason',
|
|
53
|
+
kind: 'exact',
|
|
54
|
+
path: 'reason',
|
|
55
|
+
presence: 'always',
|
|
56
|
+
operators: [
|
|
57
|
+
'equals',
|
|
58
|
+
'isAnyOf'
|
|
59
|
+
]
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
column: 'addedAtMs',
|
|
63
|
+
kind: 'date',
|
|
64
|
+
path: 'addedAtMs',
|
|
65
|
+
storedAs: 'millis',
|
|
66
|
+
presence: 'always',
|
|
67
|
+
operators: [
|
|
68
|
+
'is',
|
|
69
|
+
'after',
|
|
70
|
+
'onOrAfter',
|
|
71
|
+
'before',
|
|
72
|
+
'onOrBefore'
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
],
|
|
76
|
+
sorts: [
|
|
77
|
+
{
|
|
78
|
+
path: LIST_QUERY_ID_PATH,
|
|
79
|
+
direction: 'asc'
|
|
80
|
+
}
|
|
81
|
+
],
|
|
82
|
+
search: {
|
|
83
|
+
tokensPath: OUTREACH_DOMAIN_SEARCH_TOKENS
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* What the domains list searches an entry by: every prefix of the domain
|
|
88
|
+
* whole and of each shorter domain inside it, of each of its labels, and of
|
|
89
|
+
* each word of its detail — so "acme", "acme.com" and "mail.ac" all find
|
|
90
|
+
* `mail.acme.com`, and "barracuda" finds the domain a gateway block filed.
|
|
91
|
+
* An entry is created once and never edited, so its one writer stamps this
|
|
92
|
+
* and `tools/scripts/backfill-outreach-list-search.mjs` restates it for the
|
|
93
|
+
* entries made before it; both are held to
|
|
94
|
+
* `tools/scripts/lib/outreach-search-tokens.fixtures.json`.
|
|
95
|
+
*/ export function outreachDomainSearchTokens(entry) {
|
|
96
|
+
var _entry_domain, _entry_detail;
|
|
97
|
+
const domain = String((_entry_domain = entry.domain) != null ? _entry_domain : '');
|
|
98
|
+
const labels = domain.split('.');
|
|
99
|
+
const within = labels.map((_, at)=>labels.slice(at).join('.'));
|
|
100
|
+
return nameSearchTokens([
|
|
101
|
+
...within,
|
|
102
|
+
...domain.split(/[.-]+/),
|
|
103
|
+
(_entry_detail = entry.detail) != null ? _entry_detail : ''
|
|
104
|
+
].join(' '));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
//# sourceMappingURL=do-not-contact-domain-list-query.js.map
|
|
@@ -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"}
|
|
@@ -0,0 +1,22 @@
|
|
|
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
|
+
export declare const OUTREACH_ENROLLMENT_LIST_QUERY: ListQueryDeclaration;
|
|
21
|
+
/** The scope every page of the table carries: this sequence's people. */
|
|
22
|
+
export declare function outreachEnrollmentListBase(sequenceId: string): ListQueryFilter[];
|