@aglyn/plugins-marketing 1.0.0-beta.200 → 1.0.0-beta.201
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +15 -15
- package/src/lib/components/campaign-composer.js +14 -6
- package/src/lib/components/campaign-composer.js.map +1 -1
- package/src/lib/components/campaign-conversions-card.js +6 -0
- package/src/lib/components/campaign-conversions-card.js.map +1 -1
- package/src/lib/components/campaign-detail-card.js +1 -1
- package/src/lib/components/campaign-detail-card.js.map +1 -1
- package/src/lib/components/campaign-members-section.d.ts +16 -4
- package/src/lib/components/campaign-members-section.js +171 -105
- package/src/lib/components/campaign-members-section.js.map +1 -1
- package/src/lib/components/campaigns-card.js +19 -9
- package/src/lib/components/campaigns-card.js.map +1 -1
- package/src/lib/components/email-compose-card.js +4 -3
- package/src/lib/components/email-compose-card.js.map +1 -1
- package/src/lib/components/email-detail.js +16 -5
- package/src/lib/components/email-detail.js.map +1 -1
- package/src/lib/components/email-recipients-card.js +3 -1
- package/src/lib/components/email-recipients-card.js.map +1 -1
- package/src/lib/components/emails-list-card.js +3 -1
- package/src/lib/components/emails-list-card.js.map +1 -1
- package/src/lib/components/host-experiments-card.component.js +3 -0
- package/src/lib/components/host-experiments-card.component.js.map +1 -1
- package/src/lib/model/campaign-members-query.d.ts +37 -0
- package/src/lib/model/campaign-members-query.js +120 -0
- package/src/lib/model/campaign-members-query.js.map +1 -0
- package/src/lib/server/campaign-form-flags.d.ts +35 -0
- package/src/lib/server/campaign-form-flags.js +52 -0
- package/src/lib/server/campaign-form-flags.js.map +1 -0
- package/src/lib/server/campaign-manage.js +12 -2
- package/src/lib/server/campaign-manage.js.map +1 -1
- package/src/lib/server/campaign-send.d.ts +35 -1
- package/src/lib/server/campaign-send.js +230 -11
- package/src/lib/server/campaign-send.js.map +1 -1
- package/src/lib/server/email-events.js +10 -0
- package/src/lib/server/email-events.js.map +1 -1
|
@@ -0,0 +1,37 @@
|
|
|
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 { ListFilterClause } from '@aglyn/shared-ui-jsx/const/list-grid-filter';
|
|
18
|
+
import { type ListQueryDeclaration, type ListQueryFilter } from '@aglyn/shared-ui-jsx/const/list-query-plan';
|
|
19
|
+
/** A campaign's screens or forms: the name's start, walked by document name. */
|
|
20
|
+
export declare const CAMPAIGN_MEMBERS_QUERY: ListQueryDeclaration;
|
|
21
|
+
/** The collections a campaign's members are read from. */
|
|
22
|
+
export type CampaignMemberCollection = 'screens' | 'forms';
|
|
23
|
+
/** A screen that has not been deleted: `deletedAt` stored as `null`. */
|
|
24
|
+
export declare const SCREEN_NOT_DELETED: ListQueryFilter;
|
|
25
|
+
/**
|
|
26
|
+
* The predicates every read of a campaign's members carries: the
|
|
27
|
+
* membership, and for screens the soft delete.
|
|
28
|
+
*/
|
|
29
|
+
export declare function campaignMembersBase(collection: CampaignMemberCollection, campaignId: string): ListQueryFilter[];
|
|
30
|
+
/** Said above a member table while its search applies. */
|
|
31
|
+
export declare const CAMPAIGN_MEMBERS_SEARCH_NOTICE = "Search here matches the start of a name.";
|
|
32
|
+
/**
|
|
33
|
+
* The search box's words as the clause the query serves: "Name starts
|
|
34
|
+
* with", or null when nothing was typed. The table passes it among its
|
|
35
|
+
* clauses and no search words — see the file header for why.
|
|
36
|
+
*/
|
|
37
|
+
export declare function campaignMembersSearchClause(words: readonly string[]): ListFilterClause | null;
|
|
@@ -0,0 +1,120 @@
|
|
|
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 { CAMPAIGN_MEMBERSHIP_FIELD } from "@aglyn/aglyn/app-utils/campaign-membership";
|
|
17
|
+
import { LIST_QUERY_ID_PATH } from "@aglyn/shared-ui-jsx/const/list-query-plan";
|
|
18
|
+
/*
|
|
19
|
+
* WHAT A CAMPAIGN'S SCREENS AND FORMS LISTS ASK FIRESTORE (AGL-3321).
|
|
20
|
+
*
|
|
21
|
+
* A campaign's page lists the screens and the forms filed under it, each a
|
|
22
|
+
* table paged by its own query over `hosts/{hostId}/screens` or `/forms`.
|
|
23
|
+
* The search box and the soft delete are predicates on that query, so a
|
|
24
|
+
* member on the fourth page is found and a deleted one is never a row.
|
|
25
|
+
*
|
|
26
|
+
* ## The base: the membership, and (for screens) not deleted
|
|
27
|
+
*
|
|
28
|
+
* `campaignIds array-contains {campaign}` is the join — the member's own
|
|
29
|
+
* field, read from the other end — and it is the query's ONE array clause.
|
|
30
|
+
*
|
|
31
|
+
* A screen is soft-deleted: a delete stamps `deletedAt` and leaves the
|
|
32
|
+
* document, with its campaigns, in place. So the screens query also asks
|
|
33
|
+
* `deletedAt == null`, which matches only a document that HOLDS the field:
|
|
34
|
+
* every screen create stamps `deletedAt: null` (`artifactCreateListKeys`),
|
|
35
|
+
* and `tools/scripts/backfill-artifacts-list-keys.mjs` stamps the screens
|
|
36
|
+
* written before it. A form has no soft delete — it is deleted outright —
|
|
37
|
+
* so its base is the membership alone, and a retired form stays a member.
|
|
38
|
+
*
|
|
39
|
+
* Unsearched, both lists walk the document name, which every record has:
|
|
40
|
+
* the equality and the array clause merge on Firestore's single-field
|
|
41
|
+
* indexes, so the default read needs no composite.
|
|
42
|
+
*
|
|
43
|
+
* ## The search is a PREFIX RANGE on `nameLower`
|
|
44
|
+
*
|
|
45
|
+
* A word search is an `array-contains` on `nameTokens`, and the membership
|
|
46
|
+
* has already spent the query's one array clause. So the search matches the
|
|
47
|
+
* START of the name — `nameLower` from the typed text to the text followed
|
|
48
|
+
* by the highest character — which is a range, orders the page by name while
|
|
49
|
+
* it applies, and is said above the table (`CAMPAIGN_MEMBERS_SEARCH_NOTICE`),
|
|
50
|
+
* as a site's campaigns list says it.
|
|
51
|
+
*
|
|
52
|
+
* ## The composites: two
|
|
53
|
+
*
|
|
54
|
+
* A range beside equalities needs a composite. Under index merging this
|
|
55
|
+
* shape would take one `(predicate, nameLower)` pair per predicate — three
|
|
56
|
+
* across the two collections — so each collection instead carries the ONE
|
|
57
|
+
* composite that serves its whole search shape:
|
|
58
|
+
*
|
|
59
|
+
* screens campaignIds CONTAINS, deletedAt ASC, nameLower ASC
|
|
60
|
+
* forms campaignIds CONTAINS, nameLower ASC
|
|
61
|
+
*
|
|
62
|
+
* Pinned by `list-query-indexes.spec.ts`. Nothing else is offered: a column
|
|
63
|
+
* filter would be one more predicate beside the range, and so one more
|
|
64
|
+
* composite per collection, for lists that hold a campaign's handful of
|
|
65
|
+
* pages and forms.
|
|
66
|
+
*/ /** The name field the search ranges over, as the planner reads it. */ const MEMBER_NAME = {
|
|
67
|
+
column: 'name',
|
|
68
|
+
kind: 'text',
|
|
69
|
+
path: 'displayName',
|
|
70
|
+
lowerPath: 'nameLower',
|
|
71
|
+
operators: [
|
|
72
|
+
'startsWith'
|
|
73
|
+
]
|
|
74
|
+
};
|
|
75
|
+
/** A campaign's screens or forms: the name's start, walked by document name. */ export const CAMPAIGN_MEMBERS_QUERY = {
|
|
76
|
+
fields: [
|
|
77
|
+
MEMBER_NAME
|
|
78
|
+
],
|
|
79
|
+
sorts: [
|
|
80
|
+
{
|
|
81
|
+
path: LIST_QUERY_ID_PATH,
|
|
82
|
+
direction: 'asc'
|
|
83
|
+
}
|
|
84
|
+
]
|
|
85
|
+
};
|
|
86
|
+
/** A screen that has not been deleted: `deletedAt` stored as `null`. */ export const SCREEN_NOT_DELETED = {
|
|
87
|
+
path: 'deletedAt',
|
|
88
|
+
op: '==',
|
|
89
|
+
value: null
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* The predicates every read of a campaign's members carries: the
|
|
93
|
+
* membership, and for screens the soft delete.
|
|
94
|
+
*/ export function campaignMembersBase(collection, campaignId) {
|
|
95
|
+
return [
|
|
96
|
+
{
|
|
97
|
+
path: CAMPAIGN_MEMBERSHIP_FIELD,
|
|
98
|
+
op: 'array-contains',
|
|
99
|
+
value: campaignId
|
|
100
|
+
},
|
|
101
|
+
...collection === 'screens' ? [
|
|
102
|
+
SCREEN_NOT_DELETED
|
|
103
|
+
] : []
|
|
104
|
+
];
|
|
105
|
+
}
|
|
106
|
+
/** Said above a member table while its search applies. */ export const CAMPAIGN_MEMBERS_SEARCH_NOTICE = 'Search here matches the start of a name.';
|
|
107
|
+
/**
|
|
108
|
+
* The search box's words as the clause the query serves: "Name starts
|
|
109
|
+
* with", or null when nothing was typed. The table passes it among its
|
|
110
|
+
* clauses and no search words — see the file header for why.
|
|
111
|
+
*/ export function campaignMembersSearchClause(words) {
|
|
112
|
+
const typed = words.map((word)=>word.trim()).filter(Boolean).join(' ');
|
|
113
|
+
return typed ? {
|
|
114
|
+
field: 'name',
|
|
115
|
+
op: 'startsWith',
|
|
116
|
+
value: typed
|
|
117
|
+
} : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
//# sourceMappingURL=campaign-members-query.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/campaign-members-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 { CAMPAIGN_MEMBERSHIP_FIELD } from '@aglyn/aglyn/app-utils/campaign-membership'\nimport type { ListFilterField } from '@aglyn/shared-ui-jsx/const/list-filter'\nimport type { ListFilterClause } from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport {\n LIST_QUERY_ID_PATH,\n type ListQueryDeclaration,\n type ListQueryFilter,\n} from '@aglyn/shared-ui-jsx/const/list-query-plan'\n\n/*\n * WHAT A CAMPAIGN'S SCREENS AND FORMS LISTS ASK FIRESTORE (AGL-3321).\n *\n * A campaign's page lists the screens and the forms filed under it, each a\n * table paged by its own query over `hosts/{hostId}/screens` or `/forms`.\n * The search box and the soft delete are predicates on that query, so a\n * member on the fourth page is found and a deleted one is never a row.\n *\n * ## The base: the membership, and (for screens) not deleted\n *\n * `campaignIds array-contains {campaign}` is the join — the member's own\n * field, read from the other end — and it is the query's ONE array clause.\n *\n * A screen is soft-deleted: a delete stamps `deletedAt` and leaves the\n * document, with its campaigns, in place. So the screens query also asks\n * `deletedAt == null`, which matches only a document that HOLDS the field:\n * every screen create stamps `deletedAt: null` (`artifactCreateListKeys`),\n * and `tools/scripts/backfill-artifacts-list-keys.mjs` stamps the screens\n * written before it. A form has no soft delete — it is deleted outright —\n * so its base is the membership alone, and a retired form stays a member.\n *\n * Unsearched, both lists walk the document name, which every record has:\n * the equality and the array clause merge on Firestore's single-field\n * indexes, so the default read needs no composite.\n *\n * ## The search is a PREFIX RANGE on `nameLower`\n *\n * A word search is an `array-contains` on `nameTokens`, and the membership\n * has already spent the query's one array clause. So the search matches the\n * START of the name — `nameLower` from the typed text to the text followed\n * by the highest character — which is a range, orders the page by name while\n * it applies, and is said above the table (`CAMPAIGN_MEMBERS_SEARCH_NOTICE`),\n * as a site's campaigns list says it.\n *\n * ## The composites: two\n *\n * A range beside equalities needs a composite. Under index merging this\n * shape would take one `(predicate, nameLower)` pair per predicate — three\n * across the two collections — so each collection instead carries the ONE\n * composite that serves its whole search shape:\n *\n * screens campaignIds CONTAINS, deletedAt ASC, nameLower ASC\n * forms campaignIds CONTAINS, nameLower ASC\n *\n * Pinned by `list-query-indexes.spec.ts`. Nothing else is offered: a column\n * filter would be one more predicate beside the range, and so one more\n * composite per collection, for lists that hold a campaign's handful of\n * pages and forms.\n */\n\n/** The name field the search ranges over, as the planner reads it. */\nconst MEMBER_NAME: ListFilterField = {\n column: 'name',\n kind: 'text',\n path: 'displayName',\n lowerPath: 'nameLower',\n operators: ['startsWith'],\n}\n\n/** A campaign's screens or forms: the name's start, walked by document name. */\nexport const CAMPAIGN_MEMBERS_QUERY: ListQueryDeclaration = {\n fields: [MEMBER_NAME],\n sorts: [{ path: LIST_QUERY_ID_PATH, direction: 'asc' }],\n}\n\n/** The collections a campaign's members are read from. */\nexport type CampaignMemberCollection = 'screens' | 'forms'\n\n/** A screen that has not been deleted: `deletedAt` stored as `null`. */\nexport const SCREEN_NOT_DELETED: ListQueryFilter = {\n path: 'deletedAt',\n op: '==',\n value: null,\n}\n\n/**\n * The predicates every read of a campaign's members carries: the\n * membership, and for screens the soft delete.\n */\nexport function campaignMembersBase(\n collection: CampaignMemberCollection,\n campaignId: string,\n): ListQueryFilter[] {\n return [\n { path: CAMPAIGN_MEMBERSHIP_FIELD, op: 'array-contains', value: campaignId },\n ...(collection === 'screens' ? [SCREEN_NOT_DELETED] : []),\n ]\n}\n\n/** Said above a member table while its search applies. */\nexport const CAMPAIGN_MEMBERS_SEARCH_NOTICE =\n 'Search here matches the start of a name.'\n\n/**\n * The search box's words as the clause the query serves: \"Name starts\n * with\", or null when nothing was typed. The table passes it among its\n * clauses and no search words — see the file header for why.\n */\nexport function campaignMembersSearchClause(\n words: readonly string[],\n): ListFilterClause | null {\n const typed = words\n .map((word) => word.trim())\n .filter(Boolean)\n .join(' ')\n return typed ? { field: 'name', op: 'startsWith', value: typed } : null\n}\n"],"names":["CAMPAIGN_MEMBERSHIP_FIELD","LIST_QUERY_ID_PATH","MEMBER_NAME","column","kind","path","lowerPath","operators","CAMPAIGN_MEMBERS_QUERY","fields","sorts","direction","SCREEN_NOT_DELETED","op","value","campaignMembersBase","collection","campaignId","CAMPAIGN_MEMBERS_SEARCH_NOTICE","campaignMembersSearchClause","words","typed","map","word","trim","filter","Boolean","join","field"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,yBAAyB,QAAQ,6CAA4C;AAGtF,SACEC,kBAAkB,QAGb,6CAA4C;AAEnD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgDC,GAED,oEAAoE,GACpE,MAAMC,cAA+B;IACnCC,QAAQ;IACRC,MAAM;IACNC,MAAM;IACNC,WAAW;IACXC,WAAW;QAAC;KAAa;AAC3B;AAEA,8EAA8E,GAC9E,OAAO,MAAMC,yBAA+C;IAC1DC,QAAQ;QAACP;KAAY;IACrBQ,OAAO;QAAC;YAAEL,MAAMJ;YAAoBU,WAAW;QAAM;KAAE;AACzD,EAAC;AAKD,sEAAsE,GACtE,OAAO,MAAMC,qBAAsC;IACjDP,MAAM;IACNQ,IAAI;IACJC,OAAO;AACT,EAAC;AAED;;;CAGC,GACD,OAAO,SAASC,oBACdC,UAAoC,EACpCC,UAAkB;IAElB,OAAO;QACL;YAAEZ,MAAML;YAA2Ba,IAAI;YAAkBC,OAAOG;QAAW;WACvED,eAAe,YAAY;YAACJ;SAAmB,GAAG,EAAE;KACzD;AACH;AAEA,wDAAwD,GACxD,OAAO,MAAMM,iCACX,2CAA0C;AAE5C;;;;CAIC,GACD,OAAO,SAASC,4BACdC,KAAwB;IAExB,MAAMC,QAAQD,MACXE,GAAG,CAAC,CAACC,OAASA,KAAKC,IAAI,IACvBC,MAAM,CAACC,SACPC,IAAI,CAAC;IACR,OAAON,QAAQ;QAAEO,OAAO;QAAQf,IAAI;QAAcC,OAAOO;IAAM,IAAI;AACrE"}
|
|
@@ -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"}
|
|
@@ -89,6 +89,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
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
91
|
import { restampCrmListFieldsOf } from "@aglyn/tenant-data-admin/server/crm-records";
|
|
92
|
+
import { restampFormsInCampaign } from "./campaign-form-flags.js";
|
|
92
93
|
import { DUPLICATE_BUSY_MESSAGE, duplicateDisplayName, uniqueDuplicateName } from "@aglyn/aglyn/app-utils/duplicate-resource";
|
|
93
94
|
import { claimAttempt, createResourceUid } from "@aglyn/aglyn/server";
|
|
94
95
|
/*
|
|
@@ -200,10 +201,13 @@ import { isPluginEnabled } from "@aglyn/aglyn/plugin-manager/enabled-plugins";
|
|
|
200
201
|
remaining: false
|
|
201
202
|
};
|
|
202
203
|
const batch = firestore.batch();
|
|
204
|
+
const isForms = collectionRef.id === 'forms';
|
|
203
205
|
for (const member of page.docs){
|
|
204
|
-
batch.update(member.ref, {
|
|
206
|
+
batch.update(member.ref, _extends({
|
|
205
207
|
[fieldPath]: firebaseAdmin.firestore.FieldValue.arrayRemove(campaignId)
|
|
206
|
-
}
|
|
208
|
+
}, isForms ? {
|
|
209
|
+
updatedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp()
|
|
210
|
+
} : {}));
|
|
207
211
|
}
|
|
208
212
|
await batch.commit();
|
|
209
213
|
// A lead's campaigns are what the Leads list's Campaign filter reads
|
|
@@ -211,6 +215,12 @@ import { isPluginEnabled } from "@aglyn/aglyn/plugin-manager/enabled-plugins";
|
|
|
211
215
|
if (collectionRef.id === 'leads') {
|
|
212
216
|
await restampCrmListFieldsOf(page.docs.map((member)=>member.ref), 'leads');
|
|
213
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
|
+
}
|
|
214
224
|
detached += page.size;
|
|
215
225
|
if (page.size < DETACH_BATCH) return {
|
|
216
226
|
detached,
|