@aglyn/plugins-marketing 1.0.0-beta.199 → 1.0.0-beta.201

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/package.json +15 -15
  2. package/src/lib/components/campaign-composer.js +14 -6
  3. package/src/lib/components/campaign-composer.js.map +1 -1
  4. package/src/lib/components/campaign-conversions-card.js +6 -0
  5. package/src/lib/components/campaign-conversions-card.js.map +1 -1
  6. package/src/lib/components/campaign-detail-card.js +107 -37
  7. package/src/lib/components/campaign-detail-card.js.map +1 -1
  8. package/src/lib/components/campaign-members-section.d.ts +16 -4
  9. package/src/lib/components/campaign-members-section.js +171 -105
  10. package/src/lib/components/campaign-members-section.js.map +1 -1
  11. package/src/lib/components/campaign-queries.d.ts +11 -0
  12. package/src/lib/components/campaign-queries.js +13 -0
  13. package/src/lib/components/campaign-queries.js.map +1 -1
  14. package/src/lib/components/campaigns-card.d.ts +8 -6
  15. package/src/lib/components/campaigns-card.js +296 -128
  16. package/src/lib/components/campaigns-card.js.map +1 -1
  17. package/src/lib/components/email-compose-card.js +4 -3
  18. package/src/lib/components/email-compose-card.js.map +1 -1
  19. package/src/lib/components/email-detail.js +16 -5
  20. package/src/lib/components/email-detail.js.map +1 -1
  21. package/src/lib/components/email-recipients-card.js +3 -1
  22. package/src/lib/components/email-recipients-card.js.map +1 -1
  23. package/src/lib/components/emails-list-card.d.ts +11 -15
  24. package/src/lib/components/emails-list-card.js +142 -143
  25. package/src/lib/components/emails-list-card.js.map +1 -1
  26. package/src/lib/components/host-experiments-card.component.js +110 -78
  27. package/src/lib/components/host-experiments-card.component.js.map +1 -1
  28. package/src/lib/components/org-experiments-card.js +7 -3
  29. package/src/lib/components/org-experiments-card.js.map +1 -1
  30. package/src/lib/model/campaign-list-query.d.ts +99 -0
  31. package/src/lib/model/campaign-list-query.js +373 -0
  32. package/src/lib/model/campaign-list-query.js.map +1 -0
  33. package/src/lib/model/campaign-members-query.d.ts +37 -0
  34. package/src/lib/model/campaign-members-query.js +120 -0
  35. package/src/lib/model/campaign-members-query.js.map +1 -0
  36. package/src/lib/model/experiment-list-query.d.ts +22 -0
  37. package/src/lib/model/experiment-list-query.js +85 -0
  38. package/src/lib/model/experiment-list-query.js.map +1 -0
  39. package/src/lib/server/campaign-form-flags.d.ts +35 -0
  40. package/src/lib/server/campaign-form-flags.js +52 -0
  41. package/src/lib/server/campaign-form-flags.js.map +1 -0
  42. package/src/lib/server/campaign-manage.d.ts +1 -1
  43. package/src/lib/server/campaign-manage.js +42 -20
  44. package/src/lib/server/campaign-manage.js.map +1 -1
  45. package/src/lib/server/campaign-send.d.ts +42 -1
  46. package/src/lib/server/campaign-send.js +247 -16
  47. package/src/lib/server/campaign-send.js.map +1 -1
  48. package/src/lib/server/email-events.js +13 -1
  49. package/src/lib/server/email-events.js.map +1 -1
  50. package/src/lib/components/email-send-filter.d.ts +0 -32
  51. package/src/lib/components/email-send-filter.js +0 -55
  52. package/src/lib/components/email-send-filter.js.map +0 -1
@@ -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"}
@@ -0,0 +1,35 @@
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
+ /**
18
+ * `inCampaign` restamped from what each form's `campaignIds` now holds
19
+ * (AGL-3330) — for the one writer of `campaignIds` that cannot say what it
20
+ * is leaving behind.
21
+ *
22
+ * Every other writer sets the ids through `formCampaignFields`, which
23
+ * returns the boolean beside them. A campaign's deletion takes its one id out
24
+ * of every form holding it with `arrayRemove`, which is what keeps it from
25
+ * clobbering a campaign added to the same form in the meantime — and which
26
+ * means the write does not know whether the array came out empty. So each
27
+ * form is read back inside a transaction and its flag set from the array as
28
+ * it stands, which is exactly what a concurrent picker save would also
29
+ * leave.
30
+ *
31
+ * Idempotent: a form whose flag already agrees is not written.
32
+ *
33
+ * @returns how many forms were restamped.
34
+ */
35
+ export declare function restampFormsInCampaign(firestore: FirebaseFirestore.Firestore, refs: readonly FirebaseFirestore.DocumentReference[]): Promise<number>;
@@ -0,0 +1,52 @@
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 { formCampaignFields } from "@aglyn/aglyn/app-utils/forms";
17
+ /**
18
+ * `inCampaign` restamped from what each form's `campaignIds` now holds
19
+ * (AGL-3330) — for the one writer of `campaignIds` that cannot say what it
20
+ * is leaving behind.
21
+ *
22
+ * Every other writer sets the ids through `formCampaignFields`, which
23
+ * returns the boolean beside them. A campaign's deletion takes its one id out
24
+ * of every form holding it with `arrayRemove`, which is what keeps it from
25
+ * clobbering a campaign added to the same form in the meantime — and which
26
+ * means the write does not know whether the array came out empty. So each
27
+ * form is read back inside a transaction and its flag set from the array as
28
+ * it stands, which is exactly what a concurrent picker save would also
29
+ * leave.
30
+ *
31
+ * Idempotent: a form whose flag already agrees is not written.
32
+ *
33
+ * @returns how many forms were restamped.
34
+ */ export async function restampFormsInCampaign(firestore, refs) {
35
+ let restamped = 0;
36
+ for (const formRef of refs){
37
+ const wrote = await firestore.runTransaction(async (tx)=>{
38
+ const form = await tx.get(formRef);
39
+ if (!form.exists) return false;
40
+ const { inCampaign } = formCampaignFields(form.get('campaignIds'));
41
+ if (form.get('inCampaign') === inCampaign) return false;
42
+ tx.update(formRef, {
43
+ inCampaign
44
+ });
45
+ return true;
46
+ });
47
+ if (wrote) restamped += 1;
48
+ }
49
+ return restamped;
50
+ }
51
+
52
+ //# sourceMappingURL=campaign-form-flags.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/server/campaign-form-flags.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 { formCampaignFields } from '@aglyn/aglyn/app-utils/forms'\n\n/**\n * `inCampaign` restamped from what each form's `campaignIds` now holds\n * (AGL-3330) — for the one writer of `campaignIds` that cannot say what it\n * is leaving behind.\n *\n * Every other writer sets the ids through `formCampaignFields`, which\n * returns the boolean beside them. A campaign's deletion takes its one id out\n * of every form holding it with `arrayRemove`, which is what keeps it from\n * clobbering a campaign added to the same form in the meantime — and which\n * means the write does not know whether the array came out empty. So each\n * form is read back inside a transaction and its flag set from the array as\n * it stands, which is exactly what a concurrent picker save would also\n * leave.\n *\n * Idempotent: a form whose flag already agrees is not written.\n *\n * @returns how many forms were restamped.\n */\nexport async function restampFormsInCampaign(\n firestore: FirebaseFirestore.Firestore,\n refs: readonly FirebaseFirestore.DocumentReference[],\n): Promise<number> {\n let restamped = 0\n for (const formRef of refs) {\n const wrote = await firestore.runTransaction(async (tx) => {\n const form = await tx.get(formRef)\n if (!form.exists) return false\n const { inCampaign } = formCampaignFields(form.get('campaignIds'))\n if (form.get('inCampaign') === inCampaign) return false\n tx.update(formRef, { inCampaign })\n return true\n })\n if (wrote) restamped += 1\n }\n return restamped\n}\n"],"names":["formCampaignFields","restampFormsInCampaign","firestore","refs","restamped","formRef","wrote","runTransaction","tx","form","get","exists","inCampaign","update"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,kBAAkB,QAAQ,+BAA8B;AAEjE;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,eAAeC,uBACpBC,SAAsC,EACtCC,IAAoD;IAEpD,IAAIC,YAAY;IAChB,KAAK,MAAMC,WAAWF,KAAM;QAC1B,MAAMG,QAAQ,MAAMJ,UAAUK,cAAc,CAAC,OAAOC;YAClD,MAAMC,OAAO,MAAMD,GAAGE,GAAG,CAACL;YAC1B,IAAI,CAACI,KAAKE,MAAM,EAAE,OAAO;YACzB,MAAM,EAAEC,UAAU,EAAE,GAAGZ,mBAAmBS,KAAKC,GAAG,CAAC;YACnD,IAAID,KAAKC,GAAG,CAAC,kBAAkBE,YAAY,OAAO;YAClDJ,GAAGK,MAAM,CAACR,SAAS;gBAAEO;YAAW;YAChC,OAAO;QACT;QACA,IAAIN,OAAOF,aAAa;IAC1B;IACA,OAAOA;AACT"}
@@ -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,8 @@ 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";
92
+ import { restampFormsInCampaign } from "./campaign-form-flags.js";
91
93
  import { DUPLICATE_BUSY_MESSAGE, duplicateDisplayName, uniqueDuplicateName } from "@aglyn/aglyn/app-utils/duplicate-resource";
92
94
  import { claimAttempt, createResourceUid } from "@aglyn/aglyn/server";
93
95
  /*
@@ -100,6 +102,7 @@ import { CAMPAIGN_MEMBER_HOST_COLLECTIONS, CAMPAIGN_MEMBERSHIP_FIELD, contactCam
100
102
  import { CAMPAIGN_SEND_CONTAINER_FIELD, CAMPAIGN_SEND_HOST_FIELD, campaignPlacedOnHost, campaignSiteIds, campaignVisibleTo } from "@aglyn/shared-ui-email-campaigns/model";
101
103
  import { campaignSendSiteStamp, orgCampaignSends, orgEmailCampaigns, sendHostId, sendIsOnHost } from "./campaign-org-refs.js";
102
104
  import { SCREEN_KIND_EMAIL } from "@aglyn/aglyn/app-utils/screen-route";
105
+ import { campaignContainerSearchFields, campaignSendSearchFields } from "../model/campaign-list-query.js";
103
106
  import { registerPluginResourceDraftWriter } from "@aglyn/aglyn/plugin-manager/plugin-resource-drafts";
104
107
  import { runPluginMembershipDetachers } from "@aglyn/aglyn/plugin-manager/plugin-membership-detach";
105
108
  import { isPluginEnabled } from "@aglyn/aglyn/plugin-manager/enabled-plugins";
@@ -132,7 +135,8 @@ import { isPluginEnabled } from "@aglyn/aglyn/plugin-manager/enabled-plugins";
132
135
  * removing a container some sites' records would still name.
133
136
  */ const ORG_SITES_MAX = 200;
134
137
  /**
135
- * Clears {@link CAMPAIGN_SEND_CONTAINER_FIELD} from every send in a campaign.
138
+ * Files every send in a campaign under NO campaign: {@link
139
+ * CAMPAIGN_SEND_CONTAINER_FIELD} set to `null`.
136
140
  *
137
141
  * Equality on one field with a `limit` — no `orderBy` — which Firestore's
138
142
  * automatic single-field index serves. Ordering would need a composite index
@@ -151,15 +155,14 @@ import { isPluginEnabled } from "@aglyn/aglyn/plugin-manager/enabled-plugins";
151
155
  const batch = firestore.batch();
152
156
  for (const send of page.docs){
153
157
  /*
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.
158
+ * `null`, not an empty string and not a removed field. "Belongs to no
159
+ * campaign" has ONE stored shape, because it is a query: the lists ask
160
+ * `emailCampaignId == null` for single sends (AGL-3321), which an
161
+ * empty string would not answer and a missing field cannot — a query
162
+ * never finds a field that is not there. `campaignListRows` adopts any
163
+ * falsy container id, so every reader agrees.
161
164
  */ batch.update(send.ref, {
162
- [CAMPAIGN_SEND_CONTAINER_FIELD]: firebaseAdmin.firestore.FieldValue.delete()
165
+ [CAMPAIGN_SEND_CONTAINER_FIELD]: null
163
166
  });
164
167
  }
165
168
  await batch.commit();
@@ -198,12 +201,26 @@ import { isPluginEnabled } from "@aglyn/aglyn/plugin-manager/enabled-plugins";
198
201
  remaining: false
199
202
  };
200
203
  const batch = firestore.batch();
204
+ const isForms = collectionRef.id === 'forms';
201
205
  for (const member of page.docs){
202
- batch.update(member.ref, {
206
+ batch.update(member.ref, _extends({
203
207
  [fieldPath]: firebaseAdmin.firestore.FieldValue.arrayRemove(campaignId)
204
- });
208
+ }, isForms ? {
209
+ updatedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp()
210
+ } : {}));
205
211
  }
206
212
  await batch.commit();
213
+ // A lead's campaigns are what the Leads list's Campaign filter reads
214
+ // under a site (AGL-3321): restamped from each lead as it now stands.
215
+ if (collectionRef.id === 'leads') {
216
+ await restampCrmListFieldsOf(page.docs.map((member)=>member.ref), 'leads');
217
+ }
218
+ // A form's `inCampaign` is what the Forms list's "In a campaign" filter
219
+ // asks (AGL-3330), and `arrayRemove` cannot say whether it emptied the
220
+ // array: restamped from each form as it now stands.
221
+ if (isForms) {
222
+ await restampFormsInCampaign(firestore, page.docs.map((member)=>member.ref));
223
+ }
207
224
  detached += page.size;
208
225
  if (page.size < DETACH_BATCH) return {
209
226
  detached,
@@ -549,7 +566,7 @@ import { isPluginEnabled } from "@aglyn/aglyn/plugin-manager/enabled-plugins";
549
566
  };
550
567
  }
551
568
  const outcome = await firestore.runTransaction(async (transaction)=>{
552
- var _source_data, _ref, _data_displayName;
569
+ var _source_data, _ref, _data_displayName, _copy_CAMPAIGN_SEND_CONTAINER_FIELD;
553
570
  const [source, siblings] = await Promise.all([
554
571
  transaction.get(messages.doc(emailId)),
555
572
  transaction.get(messages.select('displayName', 'subject').where(CAMPAIGN_SEND_HOST_FIELD, '==', siteId))
@@ -573,7 +590,9 @@ import { isPluginEnabled } from "@aglyn/aglyn/plugin-manager/enabled-plugins";
573
590
  if (data[field] !== undefined && data[field] !== null) copy[field] = data[field];
574
591
  }
575
592
  const nowMs = Date.now();
576
- transaction.create(messages.doc(id), _extends({}, copy, campaignSendSiteStamp(siteId), {
593
+ transaction.create(messages.doc(id), _extends({}, copy, campaignSendSearchFields(copy['subject']), {
594
+ [CAMPAIGN_SEND_CONTAINER_FIELD]: (_copy_CAMPAIGN_SEND_CONTAINER_FIELD = copy[CAMPAIGN_SEND_CONTAINER_FIELD]) != null ? _copy_CAMPAIGN_SEND_CONTAINER_FIELD : null
595
+ }, campaignSendSiteStamp(siteId), {
577
596
  displayName: name,
578
597
  status: 'draft',
579
598
  createdAtMs: nowMs,
@@ -662,6 +681,7 @@ import { isPluginEnabled } from "@aglyn/aglyn/plugin-manager/enabled-plugins";
662
681
  }
663
682
  /** Every field a drafted email is written with, and so every field it can hold. */ export const CAMPAIGN_DRAFT_EMAIL_FIELDS = [
664
683
  'subject',
684
+ 'subjectTokens',
665
685
  'preheader',
666
686
  'templateScreenId',
667
687
  'subjectVariants',
@@ -857,19 +877,21 @@ export function createCampaignDraftWriter(deps = {}) {
857
877
  const nowMs = request.now.getTime();
858
878
  // A site hub's create drawer's own document: a name, no dates, no
859
879
  // lists, placed on the site it was drafted for.
860
- transaction.create(campaignRef, {
861
- name,
880
+ const visibleTo = campaignVisibleTo([
881
+ request.hostId
882
+ ]);
883
+ transaction.create(campaignRef, _extends({
884
+ name
885
+ }, campaignContainerSearchFields(name), {
862
886
  listIds: [],
863
- visibleTo: campaignVisibleTo([
864
- request.hostId
865
- ]),
887
+ visibleTo,
866
888
  createdAtMs: nowMs,
867
889
  createdBy: request.uid
868
- });
890
+ }));
869
891
  const { value } = read;
870
892
  transaction.create(emailRef, _extends({
871
893
  subject: value.subject
872
- }, value.preheader ? {
894
+ }, campaignSendSearchFields(value.subject), value.preheader ? {
873
895
  preheader: value.preheader
874
896
  } : {}, {
875
897
  templateScreenId: value.templateScreenId