@aglyn/plugins-marketing 1.0.0-beta.198 → 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 +15 -15
- package/src/lib/components/campaign-detail-card.js +106 -36
- package/src/lib/components/campaign-detail-card.js.map +1 -1
- package/src/lib/components/campaign-queries.d.ts +11 -0
- package/src/lib/components/campaign-queries.js +13 -0
- package/src/lib/components/campaign-queries.js.map +1 -1
- package/src/lib/components/campaigns-card.d.ts +8 -6
- package/src/lib/components/campaigns-card.js +283 -125
- package/src/lib/components/campaigns-card.js.map +1 -1
- package/src/lib/components/emails-list-card.d.ts +11 -15
- package/src/lib/components/emails-list-card.js +139 -142
- package/src/lib/components/emails-list-card.js.map +1 -1
- package/src/lib/components/host-experiments-card.component.js +107 -78
- package/src/lib/components/host-experiments-card.component.js.map +1 -1
- package/src/lib/components/org-experiments-card.js +7 -3
- package/src/lib/components/org-experiments-card.js.map +1 -1
- package/src/lib/model/campaign-list-query.d.ts +99 -0
- package/src/lib/model/campaign-list-query.js +373 -0
- package/src/lib/model/campaign-list-query.js.map +1 -0
- package/src/lib/model/experiment-list-query.d.ts +22 -0
- package/src/lib/model/experiment-list-query.js +85 -0
- package/src/lib/model/experiment-list-query.js.map +1 -0
- package/src/lib/server/campaign-manage.d.ts +1 -1
- package/src/lib/server/campaign-manage.js +30 -18
- package/src/lib/server/campaign-manage.js.map +1 -1
- package/src/lib/server/campaign-send.d.ts +7 -0
- package/src/lib/server/campaign-send.js +17 -5
- package/src/lib/server/campaign-send.js.map +1 -1
- package/src/lib/server/email-events.js +3 -1
- package/src/lib/server/email-events.js.map +1 -1
- package/src/lib/components/email-send-filter.d.ts +0 -32
- package/src/lib/components/email-send-filter.js +0 -55
- package/src/lib/components/email-send-filter.js.map +0 -1
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ import { nameSearchKey, nameSearchTokens } from "@aglyn/aglyn/app-utils/name-search";
|
|
18
|
+
import { scopeTokensForHost } from "@aglyn/aglyn/app-utils/scope-tokens";
|
|
19
|
+
/*
|
|
20
|
+
* WHAT THE MARKETING LISTS ASK FIRESTORE (AGL-3321).
|
|
21
|
+
*
|
|
22
|
+
* Three tables read the org's campaign collections — the Campaigns list, a
|
|
23
|
+
* campaign's own emails, and the Emails list — and every clause their
|
|
24
|
+
* Filters panels offer, and their search boxes, is a predicate on ONE query,
|
|
25
|
+
* newest first, paged by that query. Nothing is matched over the rows a read
|
|
26
|
+
* happened to hold. Each field is here because a query can serve it.
|
|
27
|
+
*
|
|
28
|
+
* ## The fields the writers stamp
|
|
29
|
+
*
|
|
30
|
+
* A SEND (`orgs/{orgId}/campaigns`) carries:
|
|
31
|
+
*
|
|
32
|
+
* `createdAtMs` when the record was minted — the one date on every
|
|
33
|
+
* send (a sent one has `sentAt`, a scheduled one
|
|
34
|
+
* `sendAtMs`, a draft neither), so the order;
|
|
35
|
+
* `subjectTokens` the subject's word prefixes, for search and
|
|
36
|
+
* "Subject contains";
|
|
37
|
+
* `emailCampaignId` the container it is filed under, or `null` for a
|
|
38
|
+
* single send — stored null rather than left out,
|
|
39
|
+
* because a query can ask for `== null` and can never
|
|
40
|
+
* ask for a field that is missing.
|
|
41
|
+
*
|
|
42
|
+
* A CONTAINER (`orgs/{orgId}/emailCampaigns`) carries `createdAtMs`,
|
|
43
|
+
* `nameTokens` (the org hub's search) and `nameLower` (a site hub's search,
|
|
44
|
+
* as a prefix range — below).
|
|
45
|
+
*
|
|
46
|
+
* Every writer stamps them through the two helpers below, and
|
|
47
|
+
* `tools/scripts/backfill-campaign-list-fields.mjs` stamps the records
|
|
48
|
+
* written before them.
|
|
49
|
+
*
|
|
50
|
+
* ## What a site hub asks
|
|
51
|
+
*
|
|
52
|
+
* A send is sent AS one site, so a site hub's sends are `hostId == {site}` —
|
|
53
|
+
* an equality, which merges with every other clause on single-field
|
|
54
|
+
* indexes and leaves the query's one array clause for search. The security
|
|
55
|
+
* rules admit that read for the site's collaborators (a send's `hostId` is
|
|
56
|
+
* the site its `visibleTo` names). A container is placed on SEVERAL sites,
|
|
57
|
+
* so its scope stays `visibleTo array-contains-any [org, host:{site}]` —
|
|
58
|
+
* the query's one array clause, and the only one the rules can prove for a
|
|
59
|
+
* site collaborator. So a site hub's campaign search is not a token lookup:
|
|
60
|
+
* it is a PREFIX RANGE on `nameLower` beside the scope
|
|
61
|
+
* (`campaignSiteSearchClause`), which orders the page by name while it
|
|
62
|
+
* applies, and the list says search matches the start of a name. Folding
|
|
63
|
+
* the word into the scope clause through scoped tokens would be refused by
|
|
64
|
+
* the rules for such a reader: that query proves nothing about `visibleTo`.
|
|
65
|
+
*
|
|
66
|
+
* ## What is not offered
|
|
67
|
+
*
|
|
68
|
+
* - A send's STATE (Sending, Stopped, …) is derived from its counters at
|
|
69
|
+
* read time (`campaignSendDisplay`); no query can ask it. Its stored
|
|
70
|
+
* `status` is offered instead.
|
|
71
|
+
* - A campaign's WINDOW (Upcoming, Running, Ended) is derived from its dates
|
|
72
|
+
* against the clock; "running" is a range on two fields. Not offered.
|
|
73
|
+
* - The site NAME a row shows is not stored on the record; the Site filter
|
|
74
|
+
* asks by the site's id, and search reads the subject or the name only.
|
|
75
|
+
* - Ranges: ONE, on `createdAtMs`, the field every list is ordered by — so
|
|
76
|
+
* it needs no order of its own and no index beyond the ones above.
|
|
77
|
+
*/ /** The send's subject, as word prefixes. */ export const CAMPAIGN_SEND_SUBJECT_TOKENS = 'subjectTokens';
|
|
78
|
+
/** The container's name, as word prefixes. */ export const CAMPAIGN_NAME_TOKENS = 'nameTokens';
|
|
79
|
+
/** The container's name, lower-cased whole, for a site hub's prefix search. */ export const CAMPAIGN_NAME_LOWER = 'nameLower';
|
|
80
|
+
/** The one date on every send and container, and every list's order. */ export const CAMPAIGN_LIST_ORDER_FIELD = 'createdAtMs';
|
|
81
|
+
/**
|
|
82
|
+
* The Campaign filter's value for an email filed under no campaign. An
|
|
83
|
+
* empty value reads as "no value" to the panel, so the choice needs one of
|
|
84
|
+
* its own; the query asks `emailCampaignId == null` for it.
|
|
85
|
+
*/ export const SINGLE_SEND_FILTER_VALUE = '__single__';
|
|
86
|
+
/**
|
|
87
|
+
* The search field a send carries beside its `subject`, stamped by every
|
|
88
|
+
* write that sets the subject.
|
|
89
|
+
*/ export function campaignSendSearchFields(subject) {
|
|
90
|
+
return {
|
|
91
|
+
subjectTokens: nameSearchTokens(typeof subject === 'string' ? subject : '')
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The search fields a container carries beside its `name`, stamped by every
|
|
96
|
+
* write that sets the name.
|
|
97
|
+
*/ export function campaignContainerSearchFields(name) {
|
|
98
|
+
const text = typeof name === 'string' ? name : '';
|
|
99
|
+
return {
|
|
100
|
+
nameLower: nameSearchKey(text),
|
|
101
|
+
nameTokens: nameSearchTokens(text)
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const ORDER = [
|
|
105
|
+
{
|
|
106
|
+
path: CAMPAIGN_LIST_ORDER_FIELD,
|
|
107
|
+
direction: 'desc'
|
|
108
|
+
}
|
|
109
|
+
];
|
|
110
|
+
const CREATED = {
|
|
111
|
+
column: CAMPAIGN_LIST_ORDER_FIELD,
|
|
112
|
+
kind: 'date',
|
|
113
|
+
path: CAMPAIGN_LIST_ORDER_FIELD,
|
|
114
|
+
storedAs: 'millis',
|
|
115
|
+
operators: [
|
|
116
|
+
'is',
|
|
117
|
+
'after',
|
|
118
|
+
'onOrAfter',
|
|
119
|
+
'before',
|
|
120
|
+
'onOrBefore'
|
|
121
|
+
]
|
|
122
|
+
};
|
|
123
|
+
const SUBJECT = {
|
|
124
|
+
column: 'subject',
|
|
125
|
+
kind: 'text',
|
|
126
|
+
path: 'subject',
|
|
127
|
+
tokensPath: CAMPAIGN_SEND_SUBJECT_TOKENS,
|
|
128
|
+
operators: [
|
|
129
|
+
'contains'
|
|
130
|
+
]
|
|
131
|
+
};
|
|
132
|
+
const STATUS = {
|
|
133
|
+
column: 'status',
|
|
134
|
+
kind: 'exact',
|
|
135
|
+
path: 'status',
|
|
136
|
+
operators: [
|
|
137
|
+
'equals',
|
|
138
|
+
'isAnyOf'
|
|
139
|
+
]
|
|
140
|
+
};
|
|
141
|
+
/**
|
|
142
|
+
* The stored statuses a send can hold, for the Status filter. Stored
|
|
143
|
+
* strings, not the State column's display states: an email between batches
|
|
144
|
+
* is stored `scheduled` and SHOWN as Sending, which is why the filter is
|
|
145
|
+
* headed Status and not State.
|
|
146
|
+
*/ export const CAMPAIGN_SEND_STATUS_OPTIONS = [
|
|
147
|
+
{
|
|
148
|
+
value: 'draft',
|
|
149
|
+
label: 'Draft'
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
value: 'scheduled',
|
|
153
|
+
label: 'Scheduled'
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
value: 'sending',
|
|
157
|
+
label: 'Sending'
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
value: 'sent',
|
|
161
|
+
label: 'Sent'
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
value: 'canceled',
|
|
165
|
+
label: 'Canceled'
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
value: 'failed',
|
|
169
|
+
label: 'Failed'
|
|
170
|
+
}
|
|
171
|
+
];
|
|
172
|
+
/**
|
|
173
|
+
* The Emails list: every send, under a site or across the org.
|
|
174
|
+
*
|
|
175
|
+
* `site` is the org hub's only — under a site every row is that site's.
|
|
176
|
+
*/ export function campaignEmailsListQuery(orgHub) {
|
|
177
|
+
return orgHub ? EMAILS_ORG : EMAILS_SITE;
|
|
178
|
+
}
|
|
179
|
+
const EMAIL_CAMPAIGN = {
|
|
180
|
+
column: 'emailCampaignId',
|
|
181
|
+
kind: 'exact',
|
|
182
|
+
path: 'emailCampaignId',
|
|
183
|
+
presence: 'nullable',
|
|
184
|
+
// `is` only: "Single send" is `== null`, which cannot sit in an `in`
|
|
185
|
+
// beside campaign ids.
|
|
186
|
+
operators: [
|
|
187
|
+
'equals',
|
|
188
|
+
'isEmpty'
|
|
189
|
+
]
|
|
190
|
+
};
|
|
191
|
+
const EMAILS_SITE = {
|
|
192
|
+
fields: [
|
|
193
|
+
SUBJECT,
|
|
194
|
+
STATUS,
|
|
195
|
+
EMAIL_CAMPAIGN,
|
|
196
|
+
CREATED
|
|
197
|
+
],
|
|
198
|
+
sorts: ORDER,
|
|
199
|
+
search: {
|
|
200
|
+
tokensPath: CAMPAIGN_SEND_SUBJECT_TOKENS
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
const EMAILS_ORG = _extends({}, EMAILS_SITE, {
|
|
204
|
+
fields: [
|
|
205
|
+
...EMAILS_SITE.fields,
|
|
206
|
+
{
|
|
207
|
+
column: 'site',
|
|
208
|
+
kind: 'exact',
|
|
209
|
+
path: 'hostId',
|
|
210
|
+
operators: [
|
|
211
|
+
'equals',
|
|
212
|
+
'isAnyOf'
|
|
213
|
+
]
|
|
214
|
+
}
|
|
215
|
+
]
|
|
216
|
+
});
|
|
217
|
+
/** A campaign's own emails: the container is the query's base. */ export const CAMPAIGN_EMAILS_QUERY = {
|
|
218
|
+
fields: [
|
|
219
|
+
SUBJECT,
|
|
220
|
+
STATUS,
|
|
221
|
+
CREATED
|
|
222
|
+
],
|
|
223
|
+
sorts: ORDER,
|
|
224
|
+
search: {
|
|
225
|
+
tokensPath: CAMPAIGN_SEND_SUBJECT_TOKENS
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
/**
|
|
229
|
+
* The Campaigns list's CAMPAIGNS: the containers. The columns are the
|
|
230
|
+
* table's (`name`, `listIds`, `sitesLabel`); Lists and Sites are the org
|
|
231
|
+
* hub's only — under a site the scope clause is the query's one array
|
|
232
|
+
* clause, so an any-of on another array could never be served beside it.
|
|
233
|
+
*/ export function campaignContainersListQuery(orgHub) {
|
|
234
|
+
return orgHub ? CONTAINERS_ORG : CONTAINERS_SITE;
|
|
235
|
+
}
|
|
236
|
+
const CONTAINER_NAME = {
|
|
237
|
+
column: 'name',
|
|
238
|
+
kind: 'text',
|
|
239
|
+
path: 'name',
|
|
240
|
+
tokensPath: CAMPAIGN_NAME_TOKENS,
|
|
241
|
+
operators: [
|
|
242
|
+
'contains'
|
|
243
|
+
]
|
|
244
|
+
};
|
|
245
|
+
/** A site hub's Campaign field: the name's start, a range beside the scope. */ const CONTAINER_NAME_PREFIX = {
|
|
246
|
+
column: 'name',
|
|
247
|
+
kind: 'text',
|
|
248
|
+
path: 'name',
|
|
249
|
+
lowerPath: CAMPAIGN_NAME_LOWER,
|
|
250
|
+
operators: [
|
|
251
|
+
'startsWith'
|
|
252
|
+
]
|
|
253
|
+
};
|
|
254
|
+
const CONTAINERS_SITE = {
|
|
255
|
+
fields: [
|
|
256
|
+
CONTAINER_NAME_PREFIX,
|
|
257
|
+
CREATED
|
|
258
|
+
],
|
|
259
|
+
sorts: ORDER
|
|
260
|
+
};
|
|
261
|
+
const CONTAINERS_ORG = {
|
|
262
|
+
sorts: ORDER,
|
|
263
|
+
search: {
|
|
264
|
+
tokensPath: CAMPAIGN_NAME_TOKENS
|
|
265
|
+
},
|
|
266
|
+
fields: [
|
|
267
|
+
CONTAINER_NAME,
|
|
268
|
+
{
|
|
269
|
+
column: 'listIds',
|
|
270
|
+
kind: 'exact',
|
|
271
|
+
path: 'listIds',
|
|
272
|
+
tokensPath: 'listIds',
|
|
273
|
+
operators: [
|
|
274
|
+
'isAnyOf'
|
|
275
|
+
]
|
|
276
|
+
},
|
|
277
|
+
// Asked by scope token — `org` is "every site", `host:{id}` one site.
|
|
278
|
+
{
|
|
279
|
+
column: 'sitesLabel',
|
|
280
|
+
kind: 'exact',
|
|
281
|
+
path: 'visibleTo',
|
|
282
|
+
tokensPath: 'visibleTo',
|
|
283
|
+
operators: [
|
|
284
|
+
'isAnyOf'
|
|
285
|
+
]
|
|
286
|
+
},
|
|
287
|
+
CREATED
|
|
288
|
+
]
|
|
289
|
+
};
|
|
290
|
+
/**
|
|
291
|
+
* The Campaigns list's SINGLE SENDS: sends filed under no campaign, drawn
|
|
292
|
+
* in the same table. The `name` column is the send's subject; `sitesLabel`
|
|
293
|
+
* is the site it was sent as, on the org hub.
|
|
294
|
+
*/ export function campaignSingleSendsListQuery(orgHub) {
|
|
295
|
+
return orgHub ? SINGLE_ORG : SINGLE_SITE;
|
|
296
|
+
}
|
|
297
|
+
const SINGLE_NAME = _extends({}, SUBJECT, {
|
|
298
|
+
column: 'name'
|
|
299
|
+
});
|
|
300
|
+
const SINGLE_SITE = {
|
|
301
|
+
fields: [
|
|
302
|
+
SINGLE_NAME,
|
|
303
|
+
CREATED
|
|
304
|
+
],
|
|
305
|
+
sorts: ORDER,
|
|
306
|
+
search: {
|
|
307
|
+
tokensPath: CAMPAIGN_SEND_SUBJECT_TOKENS
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
const SINGLE_ORG = _extends({}, SINGLE_SITE, {
|
|
311
|
+
fields: [
|
|
312
|
+
SINGLE_NAME,
|
|
313
|
+
{
|
|
314
|
+
column: 'sitesLabel',
|
|
315
|
+
kind: 'exact',
|
|
316
|
+
path: 'hostId',
|
|
317
|
+
operators: [
|
|
318
|
+
'equals',
|
|
319
|
+
'isAnyOf'
|
|
320
|
+
]
|
|
321
|
+
},
|
|
322
|
+
CREATED
|
|
323
|
+
]
|
|
324
|
+
});
|
|
325
|
+
/** A site hub's sends: the ones sent as it. Nothing on the org hub. */ export function campaignSendsScope(hostId) {
|
|
326
|
+
return hostId ? [
|
|
327
|
+
{
|
|
328
|
+
path: 'hostId',
|
|
329
|
+
op: '==',
|
|
330
|
+
value: hostId
|
|
331
|
+
}
|
|
332
|
+
] : [];
|
|
333
|
+
}
|
|
334
|
+
/** Said above a site hub's campaigns while its search is applied. */ export const CAMPAIGN_SITE_SEARCH_NOTICE = 'Search on a site’s campaigns matches the start of a campaign’s name.';
|
|
335
|
+
/**
|
|
336
|
+
* A site hub's campaign search, as the clause its query can serve: the typed
|
|
337
|
+
* words as "Campaign starts with", or null when nothing was typed. The list
|
|
338
|
+
* passes it among its clauses and no search words — see the file header for
|
|
339
|
+
* why a site's search is a prefix range and not a token lookup.
|
|
340
|
+
*/ export function campaignSiteSearchClause(words) {
|
|
341
|
+
const typed = words.map((word)=>word.trim()).filter(Boolean).join(' ');
|
|
342
|
+
return typed ? {
|
|
343
|
+
field: 'name',
|
|
344
|
+
op: 'startsWith',
|
|
345
|
+
value: typed
|
|
346
|
+
} : null;
|
|
347
|
+
}
|
|
348
|
+
/** A site hub's containers: the ones placed on every site or on it. */ export function campaignContainersScope(hostId) {
|
|
349
|
+
return hostId ? [
|
|
350
|
+
{
|
|
351
|
+
path: 'visibleTo',
|
|
352
|
+
op: 'array-contains-any',
|
|
353
|
+
value: scopeTokensForHost(hostId)
|
|
354
|
+
}
|
|
355
|
+
] : [];
|
|
356
|
+
}
|
|
357
|
+
/** The sends filed under no campaign. */ export const SINGLE_SENDS_BASE = {
|
|
358
|
+
path: 'emailCampaignId',
|
|
359
|
+
op: '==',
|
|
360
|
+
value: null
|
|
361
|
+
};
|
|
362
|
+
/**
|
|
363
|
+
* The Campaign filter's clauses as the query asks them: "Single send" is
|
|
364
|
+
* `emailCampaignId == null`, which the grammar spells `isEmpty`. The chips
|
|
365
|
+
* keep reading the clause as the reader set it.
|
|
366
|
+
*/ export function emailCampaignQueryClauses(clauses) {
|
|
367
|
+
return clauses.map((clause)=>clause.field === 'emailCampaignId' && clause.op === 'equals' && clause.value === SINGLE_SEND_FILTER_VALUE ? _extends({}, clause, {
|
|
368
|
+
op: 'isEmpty',
|
|
369
|
+
value: ''
|
|
370
|
+
}) : clause);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
//# sourceMappingURL=campaign-list-query.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/campaign-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 { nameSearchKey, nameSearchTokens } from '@aglyn/aglyn/app-utils/name-search'\nimport { scopeTokensForHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport type { ListFilterField } from '@aglyn/shared-ui-jsx/const/list-filter'\nimport type { ListFilterClause } from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport type {\n ListQueryDeclaration,\n ListQueryFilter,\n} from '@aglyn/shared-ui-jsx/const/list-query-plan'\n\n/*\n * WHAT THE MARKETING LISTS ASK FIRESTORE (AGL-3321).\n *\n * Three tables read the org's campaign collections — the Campaigns list, a\n * campaign's own emails, and the Emails list — and every clause their\n * Filters panels offer, and their search boxes, is a predicate on ONE query,\n * newest first, paged by that query. Nothing is matched over the rows a read\n * happened to hold. Each field is here because a query can serve it.\n *\n * ## The fields the writers stamp\n *\n * A SEND (`orgs/{orgId}/campaigns`) carries:\n *\n * `createdAtMs` when the record was minted — the one date on every\n * send (a sent one has `sentAt`, a scheduled one\n * `sendAtMs`, a draft neither), so the order;\n * `subjectTokens` the subject's word prefixes, for search and\n * \"Subject contains\";\n * `emailCampaignId` the container it is filed under, or `null` for a\n * single send — stored null rather than left out,\n * because a query can ask for `== null` and can never\n * ask for a field that is missing.\n *\n * A CONTAINER (`orgs/{orgId}/emailCampaigns`) carries `createdAtMs`,\n * `nameTokens` (the org hub's search) and `nameLower` (a site hub's search,\n * as a prefix range — below).\n *\n * Every writer stamps them through the two helpers below, and\n * `tools/scripts/backfill-campaign-list-fields.mjs` stamps the records\n * written before them.\n *\n * ## What a site hub asks\n *\n * A send is sent AS one site, so a site hub's sends are `hostId == {site}` —\n * an equality, which merges with every other clause on single-field\n * indexes and leaves the query's one array clause for search. The security\n * rules admit that read for the site's collaborators (a send's `hostId` is\n * the site its `visibleTo` names). A container is placed on SEVERAL sites,\n * so its scope stays `visibleTo array-contains-any [org, host:{site}]` —\n * the query's one array clause, and the only one the rules can prove for a\n * site collaborator. So a site hub's campaign search is not a token lookup:\n * it is a PREFIX RANGE on `nameLower` beside the scope\n * (`campaignSiteSearchClause`), which orders the page by name while it\n * applies, and the list says search matches the start of a name. Folding\n * the word into the scope clause through scoped tokens would be refused by\n * the rules for such a reader: that query proves nothing about `visibleTo`.\n *\n * ## What is not offered\n *\n * - A send's STATE (Sending, Stopped, …) is derived from its counters at\n * read time (`campaignSendDisplay`); no query can ask it. Its stored\n * `status` is offered instead.\n * - A campaign's WINDOW (Upcoming, Running, Ended) is derived from its dates\n * against the clock; \"running\" is a range on two fields. Not offered.\n * - The site NAME a row shows is not stored on the record; the Site filter\n * asks by the site's id, and search reads the subject or the name only.\n * - Ranges: ONE, on `createdAtMs`, the field every list is ordered by — so\n * it needs no order of its own and no index beyond the ones above.\n */\n\n/** The send's subject, as word prefixes. */\nexport const CAMPAIGN_SEND_SUBJECT_TOKENS = 'subjectTokens'\n/** The container's name, as word prefixes. */\nexport const CAMPAIGN_NAME_TOKENS = 'nameTokens'\n/** The container's name, lower-cased whole, for a site hub's prefix search. */\nexport const CAMPAIGN_NAME_LOWER = 'nameLower'\n/** The one date on every send and container, and every list's order. */\nexport const CAMPAIGN_LIST_ORDER_FIELD = 'createdAtMs'\n\n/**\n * The Campaign filter's value for an email filed under no campaign. An\n * empty value reads as \"no value\" to the panel, so the choice needs one of\n * its own; the query asks `emailCampaignId == null` for it.\n */\nexport const SINGLE_SEND_FILTER_VALUE = '__single__'\n\n/**\n * The search field a send carries beside its `subject`, stamped by every\n * write that sets the subject.\n */\nexport function campaignSendSearchFields(subject: unknown): {\n subjectTokens: string[]\n} {\n return {\n subjectTokens: nameSearchTokens(typeof subject === 'string' ? subject : ''),\n }\n}\n\n/**\n * The search fields a container carries beside its `name`, stamped by every\n * write that sets the name.\n */\nexport function campaignContainerSearchFields(name: unknown): {\n nameLower: string\n nameTokens: string[]\n} {\n const text = typeof name === 'string' ? name : ''\n return { nameLower: nameSearchKey(text), nameTokens: nameSearchTokens(text) }\n}\n\nconst ORDER = [{ path: CAMPAIGN_LIST_ORDER_FIELD, direction: 'desc' as const }]\n\nconst CREATED: ListFilterField = {\n column: CAMPAIGN_LIST_ORDER_FIELD,\n kind: 'date',\n path: CAMPAIGN_LIST_ORDER_FIELD,\n storedAs: 'millis',\n operators: ['is', 'after', 'onOrAfter', 'before', 'onOrBefore'],\n}\n\nconst SUBJECT: ListFilterField = {\n column: 'subject',\n kind: 'text',\n path: 'subject',\n tokensPath: CAMPAIGN_SEND_SUBJECT_TOKENS,\n operators: ['contains'],\n}\n\nconst STATUS: ListFilterField = {\n column: 'status',\n kind: 'exact',\n path: 'status',\n operators: ['equals', 'isAnyOf'],\n}\n\n/**\n * The stored statuses a send can hold, for the Status filter. Stored\n * strings, not the State column's display states: an email between batches\n * is stored `scheduled` and SHOWN as Sending, which is why the filter is\n * headed Status and not State.\n */\nexport const CAMPAIGN_SEND_STATUS_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [\n { value: 'draft', label: 'Draft' },\n { value: 'scheduled', label: 'Scheduled' },\n { value: 'sending', label: 'Sending' },\n { value: 'sent', label: 'Sent' },\n { value: 'canceled', label: 'Canceled' },\n { value: 'failed', label: 'Failed' },\n]\n\n/**\n * The Emails list: every send, under a site or across the org.\n *\n * `site` is the org hub's only — under a site every row is that site's.\n */\nexport function campaignEmailsListQuery(orgHub: boolean): ListQueryDeclaration {\n return orgHub ? EMAILS_ORG : EMAILS_SITE\n}\n\nconst EMAIL_CAMPAIGN: ListFilterField = {\n column: 'emailCampaignId',\n kind: 'exact',\n path: 'emailCampaignId',\n presence: 'nullable',\n // `is` only: \"Single send\" is `== null`, which cannot sit in an `in`\n // beside campaign ids.\n operators: ['equals', 'isEmpty'],\n}\n\nconst EMAILS_SITE: ListQueryDeclaration = {\n fields: [SUBJECT, STATUS, EMAIL_CAMPAIGN, CREATED],\n sorts: ORDER,\n search: { tokensPath: CAMPAIGN_SEND_SUBJECT_TOKENS },\n}\nconst EMAILS_ORG: ListQueryDeclaration = {\n ...EMAILS_SITE,\n fields: [\n ...EMAILS_SITE.fields,\n { column: 'site', kind: 'exact', path: 'hostId', operators: ['equals', 'isAnyOf'] },\n ],\n}\n\n/** A campaign's own emails: the container is the query's base. */\nexport const CAMPAIGN_EMAILS_QUERY: ListQueryDeclaration = {\n fields: [SUBJECT, STATUS, CREATED],\n sorts: ORDER,\n search: { tokensPath: CAMPAIGN_SEND_SUBJECT_TOKENS },\n}\n\n/**\n * The Campaigns list's CAMPAIGNS: the containers. The columns are the\n * table's (`name`, `listIds`, `sitesLabel`); Lists and Sites are the org\n * hub's only — under a site the scope clause is the query's one array\n * clause, so an any-of on another array could never be served beside it.\n */\nexport function campaignContainersListQuery(orgHub: boolean): ListQueryDeclaration {\n return orgHub ? CONTAINERS_ORG : CONTAINERS_SITE\n}\n\nconst CONTAINER_NAME: ListFilterField = {\n column: 'name',\n kind: 'text',\n path: 'name',\n tokensPath: CAMPAIGN_NAME_TOKENS,\n operators: ['contains'],\n}\n/** A site hub's Campaign field: the name's start, a range beside the scope. */\nconst CONTAINER_NAME_PREFIX: ListFilterField = {\n column: 'name',\n kind: 'text',\n path: 'name',\n lowerPath: CAMPAIGN_NAME_LOWER,\n operators: ['startsWith'],\n}\nconst CONTAINERS_SITE: ListQueryDeclaration = {\n fields: [CONTAINER_NAME_PREFIX, CREATED],\n sorts: ORDER,\n}\nconst CONTAINERS_ORG: ListQueryDeclaration = {\n sorts: ORDER,\n search: { tokensPath: CAMPAIGN_NAME_TOKENS },\n fields: [\n CONTAINER_NAME,\n { column: 'listIds', kind: 'exact', path: 'listIds', tokensPath: 'listIds', operators: ['isAnyOf'] },\n // Asked by scope token — `org` is \"every site\", `host:{id}` one site.\n { column: 'sitesLabel', kind: 'exact', path: 'visibleTo', tokensPath: 'visibleTo', operators: ['isAnyOf'] },\n CREATED,\n ],\n}\n\n/**\n * The Campaigns list's SINGLE SENDS: sends filed under no campaign, drawn\n * in the same table. The `name` column is the send's subject; `sitesLabel`\n * is the site it was sent as, on the org hub.\n */\nexport function campaignSingleSendsListQuery(orgHub: boolean): ListQueryDeclaration {\n return orgHub ? SINGLE_ORG : SINGLE_SITE\n}\n\nconst SINGLE_NAME: ListFilterField = { ...SUBJECT, column: 'name' }\nconst SINGLE_SITE: ListQueryDeclaration = {\n fields: [SINGLE_NAME, CREATED],\n sorts: ORDER,\n search: { tokensPath: CAMPAIGN_SEND_SUBJECT_TOKENS },\n}\nconst SINGLE_ORG: ListQueryDeclaration = {\n ...SINGLE_SITE,\n fields: [\n SINGLE_NAME,\n { column: 'sitesLabel', kind: 'exact', path: 'hostId', operators: ['equals', 'isAnyOf'] },\n CREATED,\n ],\n}\n\n/** A site hub's sends: the ones sent as it. Nothing on the org hub. */\nexport function campaignSendsScope(hostId: string | null): ListQueryFilter[] {\n return hostId ? [{ path: 'hostId', op: '==', value: hostId }] : []\n}\n\n/** Said above a site hub's campaigns while its search is applied. */\nexport const CAMPAIGN_SITE_SEARCH_NOTICE =\n 'Search on a site’s campaigns matches the start of a campaign’s name.'\n\n/**\n * A site hub's campaign search, as the clause its query can serve: the typed\n * words as \"Campaign starts with\", or null when nothing was typed. The list\n * passes it among its clauses and no search words — see the file header for\n * why a site's search is a prefix range and not a token lookup.\n */\nexport function campaignSiteSearchClause(\n words: readonly string[],\n): ListFilterClause | null {\n const typed = words.map((word) => word.trim()).filter(Boolean).join(' ')\n return typed ? { field: 'name', op: 'startsWith', value: typed } : null\n}\n\n/** A site hub's containers: the ones placed on every site or on it. */\nexport function campaignContainersScope(hostId: string | null): ListQueryFilter[] {\n return hostId\n ? [{ path: 'visibleTo', op: 'array-contains-any', value: scopeTokensForHost(hostId) }]\n : []\n}\n\n/** The sends filed under no campaign. */\nexport const SINGLE_SENDS_BASE: ListQueryFilter = {\n path: 'emailCampaignId',\n op: '==',\n value: null,\n}\n\n/**\n * The Campaign filter's clauses as the query asks them: \"Single send\" is\n * `emailCampaignId == null`, which the grammar spells `isEmpty`. The chips\n * keep reading the clause as the reader set it.\n */\nexport function emailCampaignQueryClauses(\n clauses: readonly ListFilterClause[],\n): ListFilterClause[] {\n return clauses.map((clause) =>\n clause.field === 'emailCampaignId' &&\n clause.op === 'equals' &&\n clause.value === SINGLE_SEND_FILTER_VALUE\n ? { ...clause, op: 'isEmpty', value: '' }\n : clause,\n )\n}\n"],"names":["nameSearchKey","nameSearchTokens","scopeTokensForHost","CAMPAIGN_SEND_SUBJECT_TOKENS","CAMPAIGN_NAME_TOKENS","CAMPAIGN_NAME_LOWER","CAMPAIGN_LIST_ORDER_FIELD","SINGLE_SEND_FILTER_VALUE","campaignSendSearchFields","subject","subjectTokens","campaignContainerSearchFields","name","text","nameLower","nameTokens","ORDER","path","direction","CREATED","column","kind","storedAs","operators","SUBJECT","tokensPath","STATUS","CAMPAIGN_SEND_STATUS_OPTIONS","value","label","campaignEmailsListQuery","orgHub","EMAILS_ORG","EMAILS_SITE","EMAIL_CAMPAIGN","presence","fields","sorts","search","CAMPAIGN_EMAILS_QUERY","campaignContainersListQuery","CONTAINERS_ORG","CONTAINERS_SITE","CONTAINER_NAME","CONTAINER_NAME_PREFIX","lowerPath","campaignSingleSendsListQuery","SINGLE_ORG","SINGLE_SITE","SINGLE_NAME","campaignSendsScope","hostId","op","CAMPAIGN_SITE_SEARCH_NOTICE","campaignSiteSearchClause","words","typed","map","word","trim","filter","Boolean","join","field","campaignContainersScope","SINGLE_SENDS_BASE","emailCampaignQueryClauses","clauses","clause"],"mappings":"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"}
|
|
@@ -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 } from '@aglyn/shared-ui-jsx/const/list-query-plan';
|
|
18
|
+
/** The array `contains` and the search box read: every word's prefixes. */
|
|
19
|
+
export declare const EXPERIMENT_NAME_TOKENS = "nameTokens";
|
|
20
|
+
/** The lower-cased whole name `equals` reads. */
|
|
21
|
+
export declare const EXPERIMENT_NAME_LOWER = "nameLower";
|
|
22
|
+
export declare const EXPERIMENT_LIST_QUERY: ListQueryDeclaration;
|
|
@@ -0,0 +1,85 @@
|
|
|
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 SITE'S A/B TESTING LIST ASKS FIRESTORE (AGL-3321).
|
|
18
|
+
*
|
|
19
|
+
* Every clause the list's Filters panel offers, and its search box, is a
|
|
20
|
+
* predicate on ONE query over `hosts/{hostId}/experiments`, ordered by name,
|
|
21
|
+
* paged by that query — never a match over the experiments a read happened
|
|
22
|
+
* to hold. Each field is here because a query can serve it:
|
|
23
|
+
*
|
|
24
|
+
* name `contains` a word (the `nameTokens` array) or `equals` the whole
|
|
25
|
+
* name (`nameLower`). Every writer of an experiment stamps both
|
|
26
|
+
* through `nameSearchFields` — the console's editor, the besigner's
|
|
27
|
+
* "test this section" shortcut and the seeds — and
|
|
28
|
+
* `tools/scripts/backfill-experiments-name-search.mjs` stamps the
|
|
29
|
+
* ones saved before;
|
|
30
|
+
* target what the test splits (screen, section, email), which every
|
|
31
|
+
* writer sets;
|
|
32
|
+
* status draft, running, paused or done, which every writer sets.
|
|
33
|
+
*
|
|
34
|
+
* The one order is `name` ascending, as the list has always read. No range
|
|
35
|
+
* is offered: a range orders the list by the field it ranges over, and would
|
|
36
|
+
* cost one composite per field above under that order (four), for a list a
|
|
37
|
+
* site keeps a handful of tests in.
|
|
38
|
+
*/ /** The array `contains` and the search box read: every word's prefixes. */ export const EXPERIMENT_NAME_TOKENS = 'nameTokens';
|
|
39
|
+
/** The lower-cased whole name `equals` reads. */ export const EXPERIMENT_NAME_LOWER = 'nameLower';
|
|
40
|
+
export const EXPERIMENT_LIST_QUERY = {
|
|
41
|
+
fields: [
|
|
42
|
+
{
|
|
43
|
+
column: 'name',
|
|
44
|
+
kind: 'text',
|
|
45
|
+
path: 'name',
|
|
46
|
+
lowerPath: EXPERIMENT_NAME_LOWER,
|
|
47
|
+
tokensPath: EXPERIMENT_NAME_TOKENS,
|
|
48
|
+
operators: [
|
|
49
|
+
'contains',
|
|
50
|
+
'equals'
|
|
51
|
+
]
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
column: 'target',
|
|
55
|
+
kind: 'exact',
|
|
56
|
+
path: 'target',
|
|
57
|
+
presence: 'always',
|
|
58
|
+
operators: [
|
|
59
|
+
'equals',
|
|
60
|
+
'isAnyOf'
|
|
61
|
+
]
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
column: 'status',
|
|
65
|
+
kind: 'exact',
|
|
66
|
+
path: 'status',
|
|
67
|
+
presence: 'always',
|
|
68
|
+
operators: [
|
|
69
|
+
'equals',
|
|
70
|
+
'isAnyOf'
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
],
|
|
74
|
+
sorts: [
|
|
75
|
+
{
|
|
76
|
+
path: 'name',
|
|
77
|
+
direction: 'asc'
|
|
78
|
+
}
|
|
79
|
+
],
|
|
80
|
+
search: {
|
|
81
|
+
tokensPath: EXPERIMENT_NAME_TOKENS
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
//# sourceMappingURL=experiment-list-query.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/experiment-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 { ListQueryDeclaration } from '@aglyn/shared-ui-jsx/const/list-query-plan'\n\n/*\n * WHAT A SITE'S A/B TESTING LIST ASKS FIRESTORE (AGL-3321).\n *\n * Every clause the list's Filters panel offers, and its search box, is a\n * predicate on ONE query over `hosts/{hostId}/experiments`, ordered by name,\n * paged by that query — never a match over the experiments a read happened\n * to hold. Each field is here because a query can serve it:\n *\n * name `contains` a word (the `nameTokens` array) or `equals` the whole\n * name (`nameLower`). Every writer of an experiment stamps both\n * through `nameSearchFields` — the console's editor, the besigner's\n * \"test this section\" shortcut and the seeds — and\n * `tools/scripts/backfill-experiments-name-search.mjs` stamps the\n * ones saved before;\n * target what the test splits (screen, section, email), which every\n * writer sets;\n * status draft, running, paused or done, which every writer sets.\n *\n * The one order is `name` ascending, as the list has always read. No range\n * is offered: a range orders the list by the field it ranges over, and would\n * cost one composite per field above under that order (four), for a list a\n * site keeps a handful of tests in.\n */\n\n/** The array `contains` and the search box read: every word's prefixes. */\nexport const EXPERIMENT_NAME_TOKENS = 'nameTokens'\n/** The lower-cased whole name `equals` reads. */\nexport const EXPERIMENT_NAME_LOWER = 'nameLower'\n\nexport const EXPERIMENT_LIST_QUERY: ListQueryDeclaration = {\n fields: [\n {\n column: 'name',\n kind: 'text',\n path: 'name',\n lowerPath: EXPERIMENT_NAME_LOWER,\n tokensPath: EXPERIMENT_NAME_TOKENS,\n operators: ['contains', 'equals'],\n },\n { column: 'target', kind: 'exact', path: 'target', presence: 'always', operators: ['equals', 'isAnyOf'] },\n { column: 'status', kind: 'exact', path: 'status', presence: 'always', operators: ['equals', 'isAnyOf'] },\n ],\n sorts: [{ path: 'name', direction: 'asc' }],\n search: { tokensPath: EXPERIMENT_NAME_TOKENS },\n}\n"],"names":["EXPERIMENT_NAME_TOKENS","EXPERIMENT_NAME_LOWER","EXPERIMENT_LIST_QUERY","fields","column","kind","path","lowerPath","tokensPath","operators","presence","sorts","direction","search"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAID;;;;;;;;;;;;;;;;;;;;;;CAsBC,GAED,yEAAyE,GACzE,OAAO,MAAMA,yBAAyB,aAAY;AAClD,+CAA+C,GAC/C,OAAO,MAAMC,wBAAwB,YAAW;AAEhD,OAAO,MAAMC,wBAA8C;IACzDC,QAAQ;QACN;YACEC,QAAQ;YACRC,MAAM;YACNC,MAAM;YACNC,WAAWN;YACXO,YAAYR;YACZS,WAAW;gBAAC;gBAAY;aAAS;QACnC;QACA;YAAEL,QAAQ;YAAUC,MAAM;YAASC,MAAM;YAAUI,UAAU;YAAUD,WAAW;gBAAC;gBAAU;aAAU;QAAC;QACxG;YAAEL,QAAQ;YAAUC,MAAM;YAASC,MAAM;YAAUI,UAAU;YAAUD,WAAW;gBAAC;gBAAU;aAAU;QAAC;KACzG;IACDE,OAAO;QAAC;YAAEL,MAAM;YAAQM,WAAW;QAAM;KAAE;IAC3CC,QAAQ;QAAEL,YAAYR;IAAuB;AAC/C,EAAC"}
|
|
@@ -31,7 +31,7 @@ export declare const CAMPAIGN_DRAFT_ROLE_REFUSAL = "Not a site admin or editor";
|
|
|
31
31
|
/** The id of the one email inside a drafted campaign. */
|
|
32
32
|
export declare function campaignDraftEmailId(campaignId: string): string;
|
|
33
33
|
/** Every field a drafted email is written with, and so every field it can hold. */
|
|
34
|
-
export declare const CAMPAIGN_DRAFT_EMAIL_FIELDS: readonly ["subject", "preheader", "templateScreenId", "subjectVariants", "preheaderVariants", "emailCampaignId", "hostId", "visibleTo", "status", "createdAtMs", "draftedAt", "draftedBy"];
|
|
34
|
+
export declare const CAMPAIGN_DRAFT_EMAIL_FIELDS: readonly ["subject", "subjectTokens", "preheader", "templateScreenId", "subjectVariants", "preheaderVariants", "emailCampaignId", "hostId", "visibleTo", "status", "createdAtMs", "draftedAt", "draftedBy"];
|
|
35
35
|
/** What a caller sends as `content`. */
|
|
36
36
|
export interface CampaignDraftContent {
|
|
37
37
|
/** The email design the email sends: a `kind: 'email'` screen on the site. */
|
|
@@ -88,6 +88,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
88
88
|
*/ import firebaseAdmin from "@aglyn/tenant-data-admin/server/firebase-admin";
|
|
89
89
|
import { isDocumentId } from "@aglyn/tenant-data-admin/server/document-id";
|
|
90
90
|
import { logResourceDuplicated } from "@aglyn/tenant-data-admin/server/duplicate-activity";
|
|
91
|
+
import { restampCrmListFieldsOf } from "@aglyn/tenant-data-admin/server/crm-records";
|
|
91
92
|
import { DUPLICATE_BUSY_MESSAGE, duplicateDisplayName, uniqueDuplicateName } from "@aglyn/aglyn/app-utils/duplicate-resource";
|
|
92
93
|
import { claimAttempt, createResourceUid } from "@aglyn/aglyn/server";
|
|
93
94
|
/*
|
|
@@ -100,6 +101,7 @@ import { CAMPAIGN_MEMBER_HOST_COLLECTIONS, CAMPAIGN_MEMBERSHIP_FIELD, contactCam
|
|
|
100
101
|
import { CAMPAIGN_SEND_CONTAINER_FIELD, CAMPAIGN_SEND_HOST_FIELD, campaignPlacedOnHost, campaignSiteIds, campaignVisibleTo } from "@aglyn/shared-ui-email-campaigns/model";
|
|
101
102
|
import { campaignSendSiteStamp, orgCampaignSends, orgEmailCampaigns, sendHostId, sendIsOnHost } from "./campaign-org-refs.js";
|
|
102
103
|
import { SCREEN_KIND_EMAIL } from "@aglyn/aglyn/app-utils/screen-route";
|
|
104
|
+
import { campaignContainerSearchFields, campaignSendSearchFields } from "../model/campaign-list-query.js";
|
|
103
105
|
import { registerPluginResourceDraftWriter } from "@aglyn/aglyn/plugin-manager/plugin-resource-drafts";
|
|
104
106
|
import { runPluginMembershipDetachers } from "@aglyn/aglyn/plugin-manager/plugin-membership-detach";
|
|
105
107
|
import { isPluginEnabled } from "@aglyn/aglyn/plugin-manager/enabled-plugins";
|
|
@@ -132,7 +134,8 @@ import { isPluginEnabled } from "@aglyn/aglyn/plugin-manager/enabled-plugins";
|
|
|
132
134
|
* removing a container some sites' records would still name.
|
|
133
135
|
*/ const ORG_SITES_MAX = 200;
|
|
134
136
|
/**
|
|
135
|
-
*
|
|
137
|
+
* Files every send in a campaign under NO campaign: {@link
|
|
138
|
+
* CAMPAIGN_SEND_CONTAINER_FIELD} set to `null`.
|
|
136
139
|
*
|
|
137
140
|
* Equality on one field with a `limit` — no `orderBy` — which Firestore's
|
|
138
141
|
* automatic single-field index serves. Ordering would need a composite index
|
|
@@ -151,15 +154,14 @@ import { isPluginEnabled } from "@aglyn/aglyn/plugin-manager/enabled-plugins";
|
|
|
151
154
|
const batch = firestore.batch();
|
|
152
155
|
for (const send of page.docs){
|
|
153
156
|
/*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
* readers agreeing.
|
|
157
|
+
* `null`, not an empty string and not a removed field. "Belongs to no
|
|
158
|
+
* campaign" has ONE stored shape, because it is a query: the lists ask
|
|
159
|
+
* `emailCampaignId == null` for single sends (AGL-3321), which an
|
|
160
|
+
* empty string would not answer and a missing field cannot — a query
|
|
161
|
+
* never finds a field that is not there. `campaignListRows` adopts any
|
|
162
|
+
* falsy container id, so every reader agrees.
|
|
161
163
|
*/ batch.update(send.ref, {
|
|
162
|
-
[CAMPAIGN_SEND_CONTAINER_FIELD]:
|
|
164
|
+
[CAMPAIGN_SEND_CONTAINER_FIELD]: null
|
|
163
165
|
});
|
|
164
166
|
}
|
|
165
167
|
await batch.commit();
|
|
@@ -204,6 +206,11 @@ import { isPluginEnabled } from "@aglyn/aglyn/plugin-manager/enabled-plugins";
|
|
|
204
206
|
});
|
|
205
207
|
}
|
|
206
208
|
await batch.commit();
|
|
209
|
+
// A lead's campaigns are what the Leads list's Campaign filter reads
|
|
210
|
+
// under a site (AGL-3321): restamped from each lead as it now stands.
|
|
211
|
+
if (collectionRef.id === 'leads') {
|
|
212
|
+
await restampCrmListFieldsOf(page.docs.map((member)=>member.ref), 'leads');
|
|
213
|
+
}
|
|
207
214
|
detached += page.size;
|
|
208
215
|
if (page.size < DETACH_BATCH) return {
|
|
209
216
|
detached,
|
|
@@ -549,7 +556,7 @@ import { isPluginEnabled } from "@aglyn/aglyn/plugin-manager/enabled-plugins";
|
|
|
549
556
|
};
|
|
550
557
|
}
|
|
551
558
|
const outcome = await firestore.runTransaction(async (transaction)=>{
|
|
552
|
-
var _source_data, _ref, _data_displayName;
|
|
559
|
+
var _source_data, _ref, _data_displayName, _copy_CAMPAIGN_SEND_CONTAINER_FIELD;
|
|
553
560
|
const [source, siblings] = await Promise.all([
|
|
554
561
|
transaction.get(messages.doc(emailId)),
|
|
555
562
|
transaction.get(messages.select('displayName', 'subject').where(CAMPAIGN_SEND_HOST_FIELD, '==', siteId))
|
|
@@ -573,7 +580,9 @@ import { isPluginEnabled } from "@aglyn/aglyn/plugin-manager/enabled-plugins";
|
|
|
573
580
|
if (data[field] !== undefined && data[field] !== null) copy[field] = data[field];
|
|
574
581
|
}
|
|
575
582
|
const nowMs = Date.now();
|
|
576
|
-
transaction.create(messages.doc(id), _extends({}, copy,
|
|
583
|
+
transaction.create(messages.doc(id), _extends({}, copy, campaignSendSearchFields(copy['subject']), {
|
|
584
|
+
[CAMPAIGN_SEND_CONTAINER_FIELD]: (_copy_CAMPAIGN_SEND_CONTAINER_FIELD = copy[CAMPAIGN_SEND_CONTAINER_FIELD]) != null ? _copy_CAMPAIGN_SEND_CONTAINER_FIELD : null
|
|
585
|
+
}, campaignSendSiteStamp(siteId), {
|
|
577
586
|
displayName: name,
|
|
578
587
|
status: 'draft',
|
|
579
588
|
createdAtMs: nowMs,
|
|
@@ -662,6 +671,7 @@ import { isPluginEnabled } from "@aglyn/aglyn/plugin-manager/enabled-plugins";
|
|
|
662
671
|
}
|
|
663
672
|
/** Every field a drafted email is written with, and so every field it can hold. */ export const CAMPAIGN_DRAFT_EMAIL_FIELDS = [
|
|
664
673
|
'subject',
|
|
674
|
+
'subjectTokens',
|
|
665
675
|
'preheader',
|
|
666
676
|
'templateScreenId',
|
|
667
677
|
'subjectVariants',
|
|
@@ -857,19 +867,21 @@ export function createCampaignDraftWriter(deps = {}) {
|
|
|
857
867
|
const nowMs = request.now.getTime();
|
|
858
868
|
// A site hub's create drawer's own document: a name, no dates, no
|
|
859
869
|
// lists, placed on the site it was drafted for.
|
|
860
|
-
|
|
861
|
-
|
|
870
|
+
const visibleTo = campaignVisibleTo([
|
|
871
|
+
request.hostId
|
|
872
|
+
]);
|
|
873
|
+
transaction.create(campaignRef, _extends({
|
|
874
|
+
name
|
|
875
|
+
}, campaignContainerSearchFields(name), {
|
|
862
876
|
listIds: [],
|
|
863
|
-
visibleTo
|
|
864
|
-
request.hostId
|
|
865
|
-
]),
|
|
877
|
+
visibleTo,
|
|
866
878
|
createdAtMs: nowMs,
|
|
867
879
|
createdBy: request.uid
|
|
868
|
-
});
|
|
880
|
+
}));
|
|
869
881
|
const { value } = read;
|
|
870
882
|
transaction.create(emailRef, _extends({
|
|
871
883
|
subject: value.subject
|
|
872
|
-
}, value.preheader ? {
|
|
884
|
+
}, campaignSendSearchFields(value.subject), value.preheader ? {
|
|
873
885
|
preheader: value.preheader
|
|
874
886
|
} : {}, {
|
|
875
887
|
templateScreenId: value.templateScreenId
|