@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.
Files changed (33) hide show
  1. package/package.json +15 -15
  2. package/src/lib/components/campaign-detail-card.js +106 -36
  3. package/src/lib/components/campaign-detail-card.js.map +1 -1
  4. package/src/lib/components/campaign-queries.d.ts +11 -0
  5. package/src/lib/components/campaign-queries.js +13 -0
  6. package/src/lib/components/campaign-queries.js.map +1 -1
  7. package/src/lib/components/campaigns-card.d.ts +8 -6
  8. package/src/lib/components/campaigns-card.js +283 -125
  9. package/src/lib/components/campaigns-card.js.map +1 -1
  10. package/src/lib/components/emails-list-card.d.ts +11 -15
  11. package/src/lib/components/emails-list-card.js +139 -142
  12. package/src/lib/components/emails-list-card.js.map +1 -1
  13. package/src/lib/components/host-experiments-card.component.js +107 -78
  14. package/src/lib/components/host-experiments-card.component.js.map +1 -1
  15. package/src/lib/components/org-experiments-card.js +7 -3
  16. package/src/lib/components/org-experiments-card.js.map +1 -1
  17. package/src/lib/model/campaign-list-query.d.ts +99 -0
  18. package/src/lib/model/campaign-list-query.js +373 -0
  19. package/src/lib/model/campaign-list-query.js.map +1 -0
  20. package/src/lib/model/experiment-list-query.d.ts +22 -0
  21. package/src/lib/model/experiment-list-query.js +85 -0
  22. package/src/lib/model/experiment-list-query.js.map +1 -0
  23. package/src/lib/server/campaign-manage.d.ts +1 -1
  24. package/src/lib/server/campaign-manage.js +30 -18
  25. package/src/lib/server/campaign-manage.js.map +1 -1
  26. package/src/lib/server/campaign-send.d.ts +7 -0
  27. package/src/lib/server/campaign-send.js +17 -5
  28. package/src/lib/server/campaign-send.js.map +1 -1
  29. package/src/lib/server/email-events.js +3 -1
  30. package/src/lib/server/email-events.js.map +1 -1
  31. package/src/lib/components/email-send-filter.d.ts +0 -32
  32. package/src/lib/components/email-send-filter.js +0 -55
  33. 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
- * Clears {@link CAMPAIGN_SEND_CONTAINER_FIELD} from every send in a campaign.
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
- * The FIELD is removed, not set to an empty string. `campaignListRows`
155
- * adopts a send whose container id is falsy, so an empty string would
156
- * work by accident today — but the field is also what the campaign
157
- * detail page's `where` clause matches, and an equality query on `''`
158
- * is a different query from the absence the pre-container sends have.
159
- * One shape for "belongs to no campaign" is what keeps those two
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]: firebaseAdmin.firestore.FieldValue.delete()
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, campaignSendSiteStamp(siteId), {
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
- transaction.create(campaignRef, {
861
- name,
870
+ const visibleTo = campaignVisibleTo([
871
+ request.hostId
872
+ ]);
873
+ transaction.create(campaignRef, _extends({
874
+ name
875
+ }, campaignContainerSearchFields(name), {
862
876
  listIds: [],
863
- visibleTo: campaignVisibleTo([
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