@aglyn/plugins-marketing 1.0.0-beta.143

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 (183) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +7 -0
  3. package/package.json +56 -0
  4. package/src/index.d.ts +19 -0
  5. package/src/index.js +20 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/announcement-bar-card.component.d.ts +20 -0
  8. package/src/lib/components/announcement-bar-card.component.js +292 -0
  9. package/src/lib/components/announcement-bar-card.component.js.map +1 -0
  10. package/src/lib/components/campaign-composer.d.ts +84 -0
  11. package/src/lib/components/campaign-composer.js +1643 -0
  12. package/src/lib/components/campaign-composer.js.map +1 -0
  13. package/src/lib/components/campaign-conversions-card.d.ts +22 -0
  14. package/src/lib/components/campaign-conversions-card.js +642 -0
  15. package/src/lib/components/campaign-conversions-card.js.map +1 -0
  16. package/src/lib/components/campaign-detail-card.d.ts +55 -0
  17. package/src/lib/components/campaign-detail-card.js +771 -0
  18. package/src/lib/components/campaign-detail-card.js.map +1 -0
  19. package/src/lib/components/campaign-edit-drawer.d.ts +60 -0
  20. package/src/lib/components/campaign-edit-drawer.js +252 -0
  21. package/src/lib/components/campaign-edit-drawer.js.map +1 -0
  22. package/src/lib/components/campaign-email-zones.d.ts +111 -0
  23. package/src/lib/components/campaign-email-zones.js +54 -0
  24. package/src/lib/components/campaign-email-zones.js.map +1 -0
  25. package/src/lib/components/campaign-glance-card.component.d.ts +18 -0
  26. package/src/lib/components/campaign-glance-card.component.js +151 -0
  27. package/src/lib/components/campaign-glance-card.component.js.map +1 -0
  28. package/src/lib/components/campaign-members-section.d.ts +87 -0
  29. package/src/lib/components/campaign-members-section.js +499 -0
  30. package/src/lib/components/campaign-members-section.js.map +1 -0
  31. package/src/lib/components/campaign-reach-sections.d.ts +137 -0
  32. package/src/lib/components/campaign-reach-sections.js +694 -0
  33. package/src/lib/components/campaign-reach-sections.js.map +1 -0
  34. package/src/lib/components/campaign-report-card.d.ts +91 -0
  35. package/src/lib/components/campaign-report-card.js +667 -0
  36. package/src/lib/components/campaign-report-card.js.map +1 -0
  37. package/src/lib/components/campaign-test-send-drawer.d.ts +45 -0
  38. package/src/lib/components/campaign-test-send-drawer.js +238 -0
  39. package/src/lib/components/campaign-test-send-drawer.js.map +1 -0
  40. package/src/lib/components/campaigns-card.d.ts +31 -0
  41. package/src/lib/components/campaigns-card.js +667 -0
  42. package/src/lib/components/campaigns-card.js.map +1 -0
  43. package/src/lib/components/conversion-attribution.component.d.ts +62 -0
  44. package/src/lib/components/conversion-attribution.component.js +127 -0
  45. package/src/lib/components/conversion-attribution.component.js.map +1 -0
  46. package/src/lib/components/email-compose-card.d.ts +12 -0
  47. package/src/lib/components/email-compose-card.js +157 -0
  48. package/src/lib/components/email-compose-card.js.map +1 -0
  49. package/src/lib/components/email-detail.d.ts +40 -0
  50. package/src/lib/components/email-detail.js +1180 -0
  51. package/src/lib/components/email-detail.js.map +1 -0
  52. package/src/lib/components/email-edit-drawer.d.ts +54 -0
  53. package/src/lib/components/email-edit-drawer.js +172 -0
  54. package/src/lib/components/email-edit-drawer.js.map +1 -0
  55. package/src/lib/components/email-messages-widget.d.ts +22 -0
  56. package/src/lib/components/email-messages-widget.js +49 -0
  57. package/src/lib/components/email-messages-widget.js.map +1 -0
  58. package/src/lib/components/email-recipients-card.d.ts +50 -0
  59. package/src/lib/components/email-recipients-card.js +338 -0
  60. package/src/lib/components/email-recipients-card.js.map +1 -0
  61. package/src/lib/components/emails-list-card.d.ts +36 -0
  62. package/src/lib/components/emails-list-card.js +544 -0
  63. package/src/lib/components/emails-list-card.js.map +1 -0
  64. package/src/lib/components/experiment-zones.d.ts +78 -0
  65. package/src/lib/components/experiment-zones.js +65 -0
  66. package/src/lib/components/experiment-zones.js.map +1 -0
  67. package/src/lib/components/host-experiments-card.component.d.ts +17 -0
  68. package/src/lib/components/host-experiments-card.component.js +887 -0
  69. package/src/lib/components/host-experiments-card.component.js.map +1 -0
  70. package/src/lib/components/host-marketing-summary-card.component.d.ts +15 -0
  71. package/src/lib/components/host-marketing-summary-card.component.js +161 -0
  72. package/src/lib/components/host-marketing-summary-card.component.js.map +1 -0
  73. package/src/lib/components/host-overlays-card.component.d.ts +19 -0
  74. package/src/lib/components/host-overlays-card.component.js +783 -0
  75. package/src/lib/components/host-overlays-card.component.js.map +1 -0
  76. package/src/lib/components/marketing-console-page.d.ts +28 -0
  77. package/src/lib/components/marketing-console-page.js +166 -0
  78. package/src/lib/components/marketing-console-page.js.map +1 -0
  79. package/src/lib/components/marketing-console-sections.d.ts +34 -0
  80. package/src/lib/components/marketing-console-sections.js +77 -0
  81. package/src/lib/components/marketing-console-sections.js.map +1 -0
  82. package/src/lib/components/overlay-stats-row.component.d.ts +15 -0
  83. package/src/lib/components/overlay-stats-row.component.js +78 -0
  84. package/src/lib/components/overlay-stats-row.component.js.map +1 -0
  85. package/src/lib/components/popup-card.component.d.ts +18 -0
  86. package/src/lib/components/popup-card.component.js +462 -0
  87. package/src/lib/components/popup-card.component.js.map +1 -0
  88. package/src/lib/components/record-attribution-widget.d.ts +35 -0
  89. package/src/lib/components/record-attribution-widget.js +33 -0
  90. package/src/lib/components/record-attribution-widget.js.map +1 -0
  91. package/src/lib/components/site-runtime.d.ts +9 -0
  92. package/src/lib/components/site-runtime.js +1081 -0
  93. package/src/lib/components/site-runtime.js.map +1 -0
  94. package/src/lib/components/use-campaign-send-api.d.ts +35 -0
  95. package/src/lib/components/use-campaign-send-api.js +99 -0
  96. package/src/lib/components/use-campaign-send-api.js.map +1 -0
  97. package/src/lib/components/use-campaign-topic-options.d.ts +20 -0
  98. package/src/lib/components/use-campaign-topic-options.js +44 -0
  99. package/src/lib/components/use-campaign-topic-options.js.map +1 -0
  100. package/src/lib/components/use-emails-hub-path.d.ts +39 -0
  101. package/src/lib/components/use-emails-hub-path.js +70 -0
  102. package/src/lib/components/use-emails-hub-path.js.map +1 -0
  103. package/src/lib/components/use-marketing-hub-path.d.ts +21 -0
  104. package/src/lib/components/use-marketing-hub-path.js +50 -0
  105. package/src/lib/components/use-marketing-hub-path.js.map +1 -0
  106. package/src/lib/components/use-overlay-copy-editor.d.ts +79 -0
  107. package/src/lib/components/use-overlay-copy-editor.js +110 -0
  108. package/src/lib/components/use-overlay-copy-editor.js.map +1 -0
  109. package/src/lib/constants/bundle-common.d.ts +8 -0
  110. package/src/lib/constants/bundle-common.js +9 -0
  111. package/src/lib/constants/bundle-common.js.map +1 -0
  112. package/src/lib/model/campaign-membership-figures.d.ts +109 -0
  113. package/src/lib/model/campaign-membership-figures.js +113 -0
  114. package/src/lib/model/campaign-membership-figures.js.map +1 -0
  115. package/src/lib/model/compile-client-automations.d.ts +65 -0
  116. package/src/lib/model/compile-client-automations.js +95 -0
  117. package/src/lib/model/compile-client-automations.js.map +1 -0
  118. package/src/lib/model/experiments.d.ts +130 -0
  119. package/src/lib/model/experiments.js +186 -0
  120. package/src/lib/model/experiments.js.map +1 -0
  121. package/src/lib/model/index.d.ts +26 -0
  122. package/src/lib/model/index.js +26 -0
  123. package/src/lib/model/index.js.map +1 -0
  124. package/src/lib/model/marketing-record-routes.d.ts +6 -0
  125. package/src/lib/model/marketing-record-routes.js +46 -0
  126. package/src/lib/model/marketing-record-routes.js.map +1 -0
  127. package/src/lib/model/overlay-copy.d.ts +62 -0
  128. package/src/lib/model/overlay-copy.js +78 -0
  129. package/src/lib/model/overlay-copy.js.map +1 -0
  130. package/src/lib/model/overlays.d.ts +113 -0
  131. package/src/lib/model/overlays.js +93 -0
  132. package/src/lib/model/overlays.js.map +1 -0
  133. package/src/lib/model/site-contract.d.ts +100 -0
  134. package/src/lib/model/site-contract.js +23 -0
  135. package/src/lib/model/site-contract.js.map +1 -0
  136. package/src/lib/plugin.d.ts +27 -0
  137. package/src/lib/plugin.js +168 -0
  138. package/src/lib/plugin.js.map +1 -0
  139. package/src/lib/preview-props.d.ts +31 -0
  140. package/src/lib/preview-props.js +54 -0
  141. package/src/lib/preview-props.js.map +1 -0
  142. package/src/lib/server/campaign-manage.d.ts +62 -0
  143. package/src/lib/server/campaign-manage.js +829 -0
  144. package/src/lib/server/campaign-manage.js.map +1 -0
  145. package/src/lib/server/campaign-process-scheduled.d.ts +26 -0
  146. package/src/lib/server/campaign-process-scheduled.js +198 -0
  147. package/src/lib/server/campaign-process-scheduled.js.map +1 -0
  148. package/src/lib/server/campaign-recipients.d.ts +55 -0
  149. package/src/lib/server/campaign-recipients.js +219 -0
  150. package/src/lib/server/campaign-recipients.js.map +1 -0
  151. package/src/lib/server/campaign-send.d.ts +487 -0
  152. package/src/lib/server/campaign-send.js +3157 -0
  153. package/src/lib/server/campaign-send.js.map +1 -0
  154. package/src/lib/server/email-campaign-reach.d.ts +174 -0
  155. package/src/lib/server/email-campaign-reach.js +254 -0
  156. package/src/lib/server/email-campaign-reach.js.map +1 -0
  157. package/src/lib/server/email-events.d.ts +27 -0
  158. package/src/lib/server/email-events.js +714 -0
  159. package/src/lib/server/email-events.js.map +1 -0
  160. package/src/lib/server/get-client-automations.d.ts +60 -0
  161. package/src/lib/server/get-client-automations.js +87 -0
  162. package/src/lib/server/get-client-automations.js.map +1 -0
  163. package/src/lib/server/get-overlays.d.ts +28 -0
  164. package/src/lib/server/get-overlays.js +65 -0
  165. package/src/lib/server/get-overlays.js.map +1 -0
  166. package/src/lib/server/get-screen-experiments.d.ts +42 -0
  167. package/src/lib/server/get-screen-experiments.js +82 -0
  168. package/src/lib/server/get-screen-experiments.js.map +1 -0
  169. package/src/lib/server/lists-materialize.d.ts +18 -0
  170. package/src/lib/server/lists-materialize.js +130 -0
  171. package/src/lib/server/lists-materialize.js.map +1 -0
  172. package/src/lib/server/marketing-figures.d.ts +37 -0
  173. package/src/lib/server/marketing-figures.js +248 -0
  174. package/src/lib/server/marketing-figures.js.map +1 -0
  175. package/src/lib/server/site-page-enricher.d.ts +26 -0
  176. package/src/lib/server/site-page-enricher.js +210 -0
  177. package/src/lib/server/site-page-enricher.js.map +1 -0
  178. package/src/lib/server.d.ts +20 -0
  179. package/src/lib/server.js +152 -0
  180. package/src/lib/server.js.map +1 -0
  181. package/src/lib/site.d.ts +22 -0
  182. package/src/lib/site.js +48 -0
  183. package/src/lib/site.js.map +1 -0
@@ -0,0 +1,829 @@
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
+ */ /**
18
+ * TAKING SOMETHING AWAY: a campaign container, and an abandoned draft.
19
+ *
20
+ * Both are removals, and neither may take a delivered message with it. That
21
+ * is the whole reason this file is small and the comments are long.
22
+ *
23
+ * ## Why these two are not client writes
24
+ *
25
+ * The console creates and edits a campaign CONTAINER with the client SDK —
26
+ * `hosts/{hostId}/emailCampaigns` is deliberately outside the rules'
27
+ * server-only exclusion list, because a container holds no counter, no
28
+ * consent record and no entitlement input.
29
+ *
30
+ * The SEND collection is the opposite: `hosts/{hostId}/campaigns` is excluded
31
+ * from client create, update AND delete, because each document is the record
32
+ * of what a merchant mailed and to whom they were allowed to mail it. Both
33
+ * operations here have to touch it — one to detach sends from the container
34
+ * being removed, the other to remove a draft outright — so both run on the
35
+ * Admin SDK behind the same site-role check the send route uses.
36
+ *
37
+ * ## What deleting a campaign MEANS
38
+ *
39
+ * The container goes; every email inside it stays, and reads afterwards as a
40
+ * single send.
41
+ *
42
+ * It cannot mean anything else. A send id is cited by mail already delivered
43
+ * — every unsubscribe footer carries `cid={sendId}` inside its own HMAC, and
44
+ * `/marketing/campaigns/{sendId}` is a URL merchants paste into their own
45
+ * messages — so deleting a container that destroyed its sends would break
46
+ * opt-out links that must go on resolving forever, which is a compliance
47
+ * failure rather than a broken page.
48
+ *
49
+ * Nor may the sends simply be left pointing at a container that is gone.
50
+ * `campaignListRows` groups sends by the container they name and draws one
51
+ * row per container; a send naming a container that no longer exists is in
52
+ * neither half of that — not a container row, and not an orphan the list
53
+ * adopts — so it would vanish from the campaigns table while remaining
54
+ * perfectly deliverable. Detaching is what puts it back in the list, as the
55
+ * "Single send" the product already models and the table already draws.
56
+ *
57
+ * Deleting a campaign is therefore NOT a way to stop its mail. A scheduled
58
+ * email inside it keeps its send time and still goes out, because a
59
+ * container is a grouping and cancelling somebody's mail is `cancel`'s job.
60
+ * The console says so before it asks.
61
+ *
62
+ * ## The same rule for everything else the campaign held
63
+ *
64
+ * A campaign also groups forms, screens and contacts, each of which names it
65
+ * from its OWN document. They are detached on exactly the argument the sends
66
+ * are: a form left naming a container nobody can read has not been freed, it
67
+ * has been made unreadable — its page draws a dead id where a campaign name
68
+ * belongs, and the campaigns table can no longer find it. The record itself
69
+ * is never touched. Deleting a campaign deletes a campaign.
70
+ */ /*
71
+ * The MODULE, not the barrel. `@aglyn/tenant-data-admin`'s index reaches
72
+ * `render-cache`, which imports `next/cache` and therefore the whole Next
73
+ * server pipeline — everything this file needs from the admin SDK is one
74
+ * default export, and taking it from the leaf keeps that pipeline out of the
75
+ * graph.
76
+ */ import firebaseAdmin from "@aglyn/tenant-data-admin/server/firebase-admin";
77
+ import { isDocumentId } from "@aglyn/tenant-data-admin/server/document-id";
78
+ import { logResourceDuplicated } from "@aglyn/tenant-data-admin/server/duplicate-activity";
79
+ import { DUPLICATE_BUSY_MESSAGE, duplicateDisplayName, uniqueDuplicateName } from "@aglyn/aglyn/app-utils/duplicate-resource";
80
+ import { claimAttempt, createResourceUid } from "@aglyn/aglyn/server";
81
+ /*
82
+ * The MODULES again, for the same reason: `consentGroupForSite` and
83
+ * `orgDataCollectionForHost` are what the contact pass needs, and taking them
84
+ * from `@aglyn/tenant-data-admin`'s index would pull the whole Next server
85
+ * pipeline back into this file's graph.
86
+ */ import { consentGroupForSite, orgDataCollectionForHost, resolveOrgIdForHost } from "@aglyn/tenant-data-admin/server/organizations";
87
+ import { CAMPAIGN_MEMBER_HOST_COLLECTIONS, CAMPAIGN_MEMBERSHIP_FIELD, contactCampaignFieldPath } from "@aglyn/aglyn/server";
88
+ import { CAMPAIGN_SEND_CONTAINER_FIELD } from "@aglyn/shared-ui-email-campaigns/model";
89
+ import { SCREEN_KIND_EMAIL } from "@aglyn/aglyn/app-utils/screen-route";
90
+ import { registerPluginResourceDraftWriter } from "@aglyn/aglyn/plugin-manager/plugin-resource-drafts";
91
+ /**
92
+ * Sends detached in one write.
93
+ *
94
+ * Under Firestore's 500-operation batch limit with room to spare rather than
95
+ * at it: a batch that is refused for being one over has done nothing, and the
96
+ * margin costs one extra round trip on a campaign of exactly this size.
97
+ */ const DETACH_BATCH = 400;
98
+ /**
99
+ * How many batches one request will run.
100
+ *
101
+ * A ceiling on the REQUEST, not on the campaign. The loop below terminates on
102
+ * its own — each pass clears the very field it queries on, so a detached send
103
+ * is not returned again — but a request that runs unbounded is a request that
104
+ * eventually exceeds the platform's own timeout with no idea how far it got.
105
+ *
106
+ * Stopping early is safe and is why this is a ceiling rather than a refusal
107
+ * up front: detaching is idempotent and partial progress is a consistent
108
+ * state — the sends already detached read as single sends, the rest still
109
+ * read under the campaign — so the answer to hitting it is to ask again.
110
+ */ const DETACH_PASSES = 25;
111
+ /**
112
+ * Clears {@link CAMPAIGN_SEND_CONTAINER_FIELD} from every send in a campaign.
113
+ *
114
+ * Equality on one field with a `limit` — no `orderBy` — which Firestore's
115
+ * automatic single-field index serves. Ordering would need a composite index
116
+ * for a walk whose order does not matter: every match is being written, so
117
+ * which order they come back in cannot change the outcome.
118
+ *
119
+ * @returns how many were detached, and whether any were left.
120
+ */ async function detachSends(hostRef, campaignId) {
121
+ const firestore = hostRef.firestore;
122
+ let detached = 0;
123
+ for(let pass = 0; pass < DETACH_PASSES; pass += 1){
124
+ const page = await hostRef.collection('campaigns').where(CAMPAIGN_SEND_CONTAINER_FIELD, '==', campaignId).limit(DETACH_BATCH).get();
125
+ if (page.empty) return {
126
+ detached,
127
+ remaining: false
128
+ };
129
+ const batch = firestore.batch();
130
+ for (const send of page.docs){
131
+ /*
132
+ * The FIELD is removed, not set to an empty string. `campaignListRows`
133
+ * adopts a send whose container id is falsy, so an empty string would
134
+ * work by accident today — but the field is also what the campaign
135
+ * detail page's `where` clause matches, and an equality query on `''`
136
+ * is a different query from the absence the pre-container sends have.
137
+ * One shape for "belongs to no campaign" is what keeps those two
138
+ * readers agreeing.
139
+ */ batch.update(send.ref, {
140
+ [CAMPAIGN_SEND_CONTAINER_FIELD]: firebaseAdmin.firestore.FieldValue.delete()
141
+ });
142
+ }
143
+ await batch.commit();
144
+ detached += page.size;
145
+ if (page.size < DETACH_BATCH) return {
146
+ detached,
147
+ remaining: false
148
+ };
149
+ }
150
+ return {
151
+ detached,
152
+ remaining: true
153
+ };
154
+ }
155
+ /**
156
+ * Clears one campaign id out of a membership array, wherever it is held.
157
+ *
158
+ * The same walk {@link detachSends} makes, over an ARRAY field rather than a
159
+ * scalar one: `array-contains` matches on Firestore's automatic single-field
160
+ * index, and `arrayRemove` takes out the one id without touching the other
161
+ * campaigns a form or a screen is in. That is the whole reason the membership
162
+ * is an array here and a `FieldValue.delete()` on a send — a send is in one
163
+ * campaign and a landing page is in several, so removing one must not be
164
+ * removing all of them.
165
+ *
166
+ * `query` rather than a collection ref, because the contact pass hands in a
167
+ * path this file cannot build for itself.
168
+ *
169
+ * @returns how many were detached, and whether any were left.
170
+ */ async function detachMembership(firestore, collectionRef, fieldPath, campaignId) {
171
+ let detached = 0;
172
+ for(let pass = 0; pass < DETACH_PASSES; pass += 1){
173
+ const page = await collectionRef.where(fieldPath, 'array-contains', campaignId).limit(DETACH_BATCH).get();
174
+ if (page.empty) return {
175
+ detached,
176
+ remaining: false
177
+ };
178
+ const batch = firestore.batch();
179
+ for (const member of page.docs){
180
+ batch.update(member.ref, {
181
+ [fieldPath]: firebaseAdmin.firestore.FieldValue.arrayRemove(campaignId)
182
+ });
183
+ }
184
+ await batch.commit();
185
+ detached += page.size;
186
+ if (page.size < DETACH_BATCH) return {
187
+ detached,
188
+ remaining: false
189
+ };
190
+ }
191
+ return {
192
+ detached,
193
+ remaining: true
194
+ };
195
+ }
196
+ /**
197
+ * Clears the campaign off every form, screen and contact holding it.
198
+ *
199
+ * ## Why the campaign is not simply deleted over the top of them
200
+ *
201
+ * A campaign that went away leaving members naming it is the same failure the
202
+ * send detach exists to prevent, one collection over: the campaign's own page
203
+ * finds its forms and screens with `array-contains`, and a form still naming
204
+ * a container nobody can read is in neither half of that — not a member of
205
+ * any campaign the console can draw, and not free of one either. Its own page
206
+ * would render the dead id as a chip with no name.
207
+ *
208
+ * ## The contacts are reached by a different path, on purpose
209
+ *
210
+ * A contact lives on the ORG (`orgs/{orgId}/contacts`) and is shared by every
211
+ * site in it, so the membership sits inside this site's consent-group facet
212
+ * rather than at the top of the document. The field PATH is therefore the
213
+ * scope: only rows this group filed under the campaign match it, and no other
214
+ * holder's filing is touched. The unscoped collection ref is what the walk
215
+ * runs on precisely because the path already carries the boundary — the
216
+ * `visibleTo` filter `orgDataQueryForHost` adds would spend the query's one
217
+ * array-contains slot and leave none for the membership.
218
+ *
219
+ * A group that cannot be resolved answers as the site alone, which is
220
+ * {@link consentGroupForSite}'s documented failure direction and the safe one
221
+ * here too: the pass then clears the site's own facet and no other.
222
+ */ async function detachMembers(hostId, hostRef, campaignId) {
223
+ const firestore = hostRef.firestore;
224
+ let detached = 0;
225
+ let remaining = false;
226
+ for (const collectionName of CAMPAIGN_MEMBER_HOST_COLLECTIONS){
227
+ const pass = await detachMembership(firestore, hostRef.collection(collectionName), CAMPAIGN_MEMBERSHIP_FIELD, campaignId);
228
+ detached += pass.detached;
229
+ remaining = remaining || pass.remaining;
230
+ }
231
+ /*
232
+ * NO ORG, NO ORG CONTACTS — and that is a skip rather than a failure.
233
+ *
234
+ * Contacts live at `orgs/{orgId}/contacts`, so a site whose `hostIndex`
235
+ * entry names no org holds none of them and there is nothing here to
236
+ * detach. Resolving it first is what separates that from a read that FAILED:
237
+ * `orgDataCollectionForHost` throws for both, and swallowing both would let
238
+ * a transient Firestore error delete the campaign with contacts still
239
+ * naming it. A thrown read reaches the handler's 500 and the container
240
+ * survives, which is the direction a failure here has to fall.
241
+ */ const orgId = await resolveOrgIdForHost(hostId);
242
+ if (!orgId) return {
243
+ detached,
244
+ remaining
245
+ };
246
+ const group = await consentGroupForSite(hostId);
247
+ const contacts = await orgDataCollectionForHost(hostId, 'contacts');
248
+ const contactPass = await detachMembership(firestore, contacts, contactCampaignFieldPath(group.groupId), campaignId);
249
+ return {
250
+ detached: detached + contactPass.detached,
251
+ remaining: remaining || contactPass.remaining
252
+ };
253
+ }
254
+ /**
255
+ * Removes a campaign container, leaving its emails standing.
256
+ *
257
+ * Detach first, delete second, and the order is the point: stopping between
258
+ * them leaves a container whose emails are already single sends, which the
259
+ * list draws correctly and a second run finishes. Deleting first would leave
260
+ * sends naming a container nobody can read, which is the state that loses
261
+ * them from the table.
262
+ */ async function deleteCampaign(hostId, hostRef, campaignId) {
263
+ const ref = hostRef.collection('emailCampaigns').doc(campaignId);
264
+ const snapshot = await ref.get();
265
+ /*
266
+ * A 404 rather than a silent success. The id in a campaign URL may name a
267
+ * container OR a single send — that ambiguity is what keeps every link
268
+ * minted before containers existed resolving — so "no container here" is
269
+ * very often "this is a send", and answering it as a completed deletion
270
+ * would tell the console it had removed something it never touched.
271
+ */ if (!snapshot.exists) {
272
+ return {
273
+ status: 404,
274
+ body: {
275
+ error: 'Unknown campaign'
276
+ }
277
+ };
278
+ }
279
+ const { detached, remaining } = await detachSends(hostRef, campaignId);
280
+ if (remaining) {
281
+ return {
282
+ status: 409,
283
+ body: {
284
+ detached,
285
+ error: `This campaign holds more emails than one request can detach. ` + `${detached.toLocaleString()} of them now read as single sends and ` + 'the campaign is still here — run the delete again to finish it.'
286
+ }
287
+ };
288
+ }
289
+ /*
290
+ * The MEMBERS come off before the container does, for the reason the sends
291
+ * do: a partial run leaves records that are already out of the campaign,
292
+ * which every surface draws correctly, while deleting first would leave
293
+ * forms, screens and contacts naming an id nothing can resolve.
294
+ *
295
+ * Their own 409 rather than a shared one. "More emails than one request can
296
+ * detach" is a sentence about the campaign's mail, and a merchant reading
297
+ * it about a campaign of two emails and four thousand contacts would go
298
+ * looking for emails that are not the problem.
299
+ */ const members = await detachMembers(hostId, hostRef, campaignId);
300
+ if (members.remaining) {
301
+ return {
302
+ status: 409,
303
+ body: {
304
+ detached,
305
+ detachedMembers: members.detached,
306
+ error: 'This campaign is on more records than one request can clear. ' + `${members.detached.toLocaleString()} of them are already out of ` + 'it and the campaign is still here — run the delete again to ' + 'finish it.'
307
+ }
308
+ };
309
+ }
310
+ await ref.delete();
311
+ return {
312
+ status: 200,
313
+ body: {
314
+ campaignId,
315
+ detached,
316
+ detachedMembers: members.detached,
317
+ deleted: true
318
+ }
319
+ };
320
+ }
321
+ /**
322
+ * Discards a DRAFT email, and refuses anything else.
323
+ *
324
+ * The state check is inside the transaction as well as being the reason for
325
+ * it. `sendNow` claims a draft by moving it to `sending` in a transaction of
326
+ * its own, so a check made against an earlier read could delete a record
327
+ * while the send path was mailing from it — the merchant would receive a
328
+ * report for an email that no longer exists, and its `cid` would stop
329
+ * resolving with the message already in inboxes.
330
+ *
331
+ * Only `draft`. A scheduled email is withdrawn with `cancel`, which leaves
332
+ * the record and its report standing; a sent one has reached people, and
333
+ * nothing on this surface may remove the evidence of that.
334
+ */ async function discardDraft(hostRef, emailId) {
335
+ const ref = hostRef.collection('campaigns').doc(emailId);
336
+ const firestore = hostRef.firestore;
337
+ const refusal = (state)=>{
338
+ if (state === 'sent') {
339
+ return 'This email has already been sent, so it cannot be discarded. ' + 'Its report and its unsubscribe links have to go on resolving.';
340
+ }
341
+ if (state === 'scheduled') {
342
+ return 'This email is scheduled. Cancel the send first — cancelling ' + 'keeps the email and takes it off the clock.';
343
+ }
344
+ if (state === 'sending') {
345
+ return 'This email is being sent right now.';
346
+ }
347
+ return 'Only a draft can be discarded.';
348
+ };
349
+ const outcome = await firestore.runTransaction(async (transaction)=>{
350
+ var _fresh_get;
351
+ const fresh = await transaction.get(ref);
352
+ if (!fresh.exists) {
353
+ return {
354
+ status: 404,
355
+ body: {
356
+ error: 'Unknown email'
357
+ }
358
+ };
359
+ }
360
+ const state = String((_fresh_get = fresh.get('status')) != null ? _fresh_get : '');
361
+ if (state !== 'draft') {
362
+ return {
363
+ status: 409,
364
+ body: {
365
+ error: refusal(state)
366
+ }
367
+ };
368
+ }
369
+ /*
370
+ * A plain delete, with nothing recursive under it. The one
371
+ * subcollection a message grows is `reports/links`, written by the
372
+ * delivery webhook — an email that has never been sent has no
373
+ * deliveries, so there is nothing beneath a draft to leave behind.
374
+ */ transaction.delete(ref);
375
+ return {
376
+ status: 200,
377
+ body: {
378
+ emailId,
379
+ discarded: true
380
+ }
381
+ };
382
+ });
383
+ return outcome;
384
+ }
385
+ /**
386
+ * Campaign management API: removing a campaign, and discarding a draft.
387
+ *
388
+ * Separate from `campaigns/send` because nothing here sends, reserves
389
+ * allowance or moves a meter, and because both operations are about a record
390
+ * ceasing to exist — which is the one class of change that has to be read
391
+ * against what a merchant has already mailed. Same authorization as the send
392
+ * route: a site admin or editor, proven by a Firebase ID token.
393
+ *
394
+ * Editing a campaign is deliberately NOT here. The container is client-
395
+ * writable by the same roles, its create already runs on the client SDK, and
396
+ * a second door to the same document would be a second place for the two to
397
+ * disagree about what a campaign may hold.
398
+ */ /**
399
+ * The fields a copy of a message carries (AGL-2936): what was written and
400
+ * how it is sent — never who it goes to or when. The audience, list,
401
+ * segment, addresses, schedule and experiment are the decisions a person
402
+ * makes about THIS send, and a copy that inherited them would be one click
403
+ * from mailing the same people twice.
404
+ */ const DUPLICATED_MESSAGE_FIELDS = [
405
+ 'subject',
406
+ 'body',
407
+ 'fromName',
408
+ 'replyTo',
409
+ 'senderId',
410
+ 'templateScreenId',
411
+ 'plainText',
412
+ 'plainTextVersionId',
413
+ 'topicId',
414
+ CAMPAIGN_SEND_CONTAINER_FIELD
415
+ ];
416
+ /**
417
+ * Copies a message as a new draft (AGL-2936). Any status can be copied —
418
+ * a sent email is the usual source — and the copy is always `draft`, with
419
+ * the name the person gave it made unique among the site's messages.
420
+ */ async function duplicateMessage(hostId, hostRef, emailId, requestedName, actor) {
421
+ const messages = hostRef.collection('campaigns');
422
+ const firestore = hostRef.firestore;
423
+ const id = createResourceUid();
424
+ const outcome = await firestore.runTransaction(async (transaction)=>{
425
+ var _source_data, _ref, _data_displayName;
426
+ const [source, siblings] = await Promise.all([
427
+ transaction.get(messages.doc(emailId)),
428
+ transaction.get(messages.select('displayName', 'subject'))
429
+ ]);
430
+ if (!source.exists) {
431
+ return {
432
+ status: 404,
433
+ body: {
434
+ error: 'Unknown email'
435
+ }
436
+ };
437
+ }
438
+ const data = (_source_data = source.data()) != null ? _source_data : {};
439
+ const sourceName = String((_ref = (_data_displayName = data['displayName']) != null ? _data_displayName : data['subject']) != null ? _ref : '').trim();
440
+ const name = uniqueDuplicateName(requestedName.trim() || duplicateDisplayName(sourceName), siblings.docs.map((row)=>{
441
+ var _ref, _row_get;
442
+ return String((_ref = (_row_get = row.get('displayName')) != null ? _row_get : row.get('subject')) != null ? _ref : '');
443
+ }));
444
+ const copy = {};
445
+ for (const field of DUPLICATED_MESSAGE_FIELDS){
446
+ if (data[field] !== undefined && data[field] !== null) copy[field] = data[field];
447
+ }
448
+ const nowMs = Date.now();
449
+ transaction.create(messages.doc(id), _extends({}, copy, {
450
+ displayName: name,
451
+ status: 'draft',
452
+ createdAtMs: nowMs,
453
+ draftedAt: new Date(nowMs),
454
+ draftedBy: actor.uid
455
+ }));
456
+ return {
457
+ status: 200,
458
+ body: {
459
+ emailId: id,
460
+ name
461
+ },
462
+ name,
463
+ sourceName
464
+ };
465
+ });
466
+ if (outcome.status === 200) {
467
+ var _outcome_sourceName, _outcome_name;
468
+ await logResourceDuplicated('campaign', {
469
+ uid: actor.uid,
470
+ email: actor.email
471
+ }, {
472
+ orgId: actor.orgId,
473
+ hostId,
474
+ source: {
475
+ id: emailId,
476
+ name: (_outcome_sourceName = outcome.sourceName) != null ? _outcome_sourceName : ''
477
+ },
478
+ target: {
479
+ id,
480
+ name: (_outcome_name = outcome.name) != null ? _outcome_name : ''
481
+ }
482
+ });
483
+ }
484
+ return {
485
+ status: outcome.status,
486
+ body: outcome.body
487
+ };
488
+ }
489
+ /*==========================================
490
+ * A CAMPAIGN THAT EXISTS BEFORE ANYBODY CHOSE WHO GETS IT (AGL-2912).
491
+ *
492
+ * The draft writer this plugin registers for the `campaign` resource on the
493
+ * core's resource-drafts seam. Another plugin that produces a campaign — a
494
+ * generator working from a brief, an importer bringing campaigns over from
495
+ * another tool — asks for it by name, and gets exactly two documents:
496
+ *
497
+ * - the CONTAINER at `emailCampaigns/{id}`, written as the campaigns list's
498
+ * create drawer writes one, aimed at no list;
499
+ * - ONE EMAIL inside it at `campaigns/{id}-email`, `status: 'draft'`, naming
500
+ * the email design it sends and holding its subject, preheader and the
501
+ * alternatives to them.
502
+ *
503
+ * ## What a drafted email never holds
504
+ *
505
+ * An audience, a list, a segment, addresses, a topic, a sender, a send time or
506
+ * an experiment. Those are the decisions a person makes about a send — the
507
+ * duplicate above strips the same ones for the same reason — and a draft that
508
+ * arrived holding any of them would be one click from mailing people nobody
509
+ * chose. So the writer has no field for them to arrive in.
510
+ *
511
+ * ## Why a draft cannot escape
512
+ *
513
+ * Nothing here reserves an allowance, claims a budget or calls the send path,
514
+ * and the scheduled processor queries `status == 'scheduled'`, which a draft
515
+ * never is. `performCampaignSend` is the only thing that mails a draft, behind
516
+ * the send route's own authorization, when a member asks.
517
+ *
518
+ * The id of the email is derived from the campaign's rather than minted, so a
519
+ * write asked again finds the email it made — and differs from it, because a
520
+ * campaign URL names a container OR a send, and one id naming both would read
521
+ * as either.
522
+ *=========================================*/ /** The resource name the campaign draft writer is registered under. */ export const CAMPAIGN_DRAFT_RESOURCE = 'campaign';
523
+ /** The id `plugins.config.json` registers this plugin under. */ const MARKETING_PLUGIN_ID = 'marketing';
524
+ /** A drafted campaign's name when the caller asks for none. */ export const CAMPAIGN_DRAFT_DEFAULT_NAME = 'Untitled campaign';
525
+ /** The longest subject or preheader a drafted email stores: the send route's header cap. */ export const CAMPAIGN_DRAFT_HEADER_MAX_CHARS = 200;
526
+ /** The most alternative subject lines or preheaders a drafted email keeps. */ export const CAMPAIGN_DRAFT_MAX_VARIANTS = 10;
527
+ /** The site roles that may compose this site's mail. */ export const CAMPAIGN_DRAFT_ROLES = [
528
+ 'admin',
529
+ 'editor'
530
+ ];
531
+ /** The send route's refusal for a member who may not compose this site's mail. */ export const CAMPAIGN_DRAFT_ROLE_REFUSAL = 'Not a site admin or editor';
532
+ /** The id of the one email inside a drafted campaign. */ export function campaignDraftEmailId(campaignId) {
533
+ return `${campaignId}-email`;
534
+ }
535
+ /** Every field a drafted email is written with, and so every field it can hold. */ export const CAMPAIGN_DRAFT_EMAIL_FIELDS = [
536
+ 'subject',
537
+ 'preheader',
538
+ 'templateScreenId',
539
+ 'subjectVariants',
540
+ 'preheaderVariants',
541
+ CAMPAIGN_SEND_CONTAINER_FIELD,
542
+ 'status',
543
+ 'createdAtMs',
544
+ 'draftedAt',
545
+ 'draftedBy'
546
+ ];
547
+ /** A header value on one line: controls and runs of space folded to one space. */ function draftHeaderLine(value) {
548
+ return String(value != null ? value : '').replace(RegExp("[\\u0000-\\u001F\\u007F-\\u009F\\s]+", "gu"), ' ').trim();
549
+ }
550
+ function draftVariants(value) {
551
+ if (!Array.isArray(value)) return [];
552
+ const seen = new Set();
553
+ const lines = [];
554
+ for (const raw of value){
555
+ const line = draftHeaderLine(raw);
556
+ if (!line || seen.has(line.toLowerCase())) continue;
557
+ seen.add(line.toLowerCase());
558
+ lines.push(line);
559
+ }
560
+ return lines.slice(0, CAMPAIGN_DRAFT_MAX_VARIANTS);
561
+ }
562
+ export function readCampaignDraftContent(content) {
563
+ var _content_templateScreenId;
564
+ const value = {
565
+ templateScreenId: String((_content_templateScreenId = content['templateScreenId']) != null ? _content_templateScreenId : ''),
566
+ subject: draftHeaderLine(content['subject']),
567
+ preheader: draftHeaderLine(content['preheader']),
568
+ subjectVariants: draftVariants(content['subjectVariants']),
569
+ preheaderVariants: draftVariants(content['preheaderVariants'])
570
+ };
571
+ const problems = [];
572
+ if (!isDocumentId(value.templateScreenId)) {
573
+ problems.push('A drafted campaign needs the email design it sends');
574
+ }
575
+ if (!value.subject) problems.push('A drafted campaign needs a subject line');
576
+ const long = (lines)=>lines.some((line)=>line.length > CAMPAIGN_DRAFT_HEADER_MAX_CHARS);
577
+ if (long([
578
+ value.subject,
579
+ ...value.subjectVariants
580
+ ])) {
581
+ problems.push(`A subject line is longer than ${CAMPAIGN_DRAFT_HEADER_MAX_CHARS} characters`);
582
+ }
583
+ if (long([
584
+ value.preheader,
585
+ ...value.preheaderVariants
586
+ ])) {
587
+ problems.push(`A preheader is longer than ${CAMPAIGN_DRAFT_HEADER_MAX_CHARS} characters`);
588
+ }
589
+ return problems.length ? {
590
+ ok: false,
591
+ problems
592
+ } : {
593
+ ok: true,
594
+ value
595
+ };
596
+ }
597
+ function campaignDraftRoleRefusal(host, uid) {
598
+ var _host_get;
599
+ const role = ((_host_get = host.get('memberRoles')) != null ? _host_get : {})[uid];
600
+ return CAMPAIGN_DRAFT_ROLES.includes(role) ? null : {
601
+ status: 403,
602
+ error: CAMPAIGN_DRAFT_ROLE_REFUSAL
603
+ };
604
+ }
605
+ function campaignDraftRecord(campaign) {
606
+ var _campaign_get;
607
+ return {
608
+ id: campaign.id,
609
+ name: String((_campaign_get = campaign.get('name')) != null ? _campaign_get : ''),
610
+ versionId: null,
611
+ facts: {
612
+ emailId: campaignDraftEmailId(campaign.id)
613
+ }
614
+ };
615
+ }
616
+ export function createCampaignDraftWriter(deps = {}) {
617
+ var _deps_firestore;
618
+ const firestore = (_deps_firestore = deps.firestore) != null ? _deps_firestore : ()=>firebaseAdmin.app().firestore();
619
+ return {
620
+ refusal: async ({ hostId, uid })=>{
621
+ const host = await firestore().collection('hosts').doc(hostId).get();
622
+ if (!host.exists) return {
623
+ status: 404,
624
+ error: 'Unknown site'
625
+ };
626
+ return campaignDraftRoleRefusal(host, uid);
627
+ },
628
+ check: (content)=>{
629
+ const read = readCampaignDraftContent(content);
630
+ return read.ok === false ? read : {
631
+ ok: true,
632
+ facts: {
633
+ fields: [
634
+ ...CAMPAIGN_DRAFT_EMAIL_FIELDS
635
+ ]
636
+ }
637
+ };
638
+ },
639
+ read: async ({ hostId, id })=>{
640
+ if (!isDocumentId(id)) return null;
641
+ const campaign = await firestore().collection('hosts').doc(hostId).collection('emailCampaigns').doc(id).get();
642
+ return campaign.exists ? campaignDraftRecord(campaign) : null;
643
+ },
644
+ write: async (request)=>{
645
+ const read = readCampaignDraftContent(request.content);
646
+ if (read.ok === false) return {
647
+ ok: false,
648
+ status: 400,
649
+ error: read.problems[0]
650
+ };
651
+ if (!isDocumentId(request.id)) return {
652
+ ok: false,
653
+ status: 400,
654
+ error: 'Invalid campaignId'
655
+ };
656
+ const db = firestore();
657
+ const hostRef = db.collection('hosts').doc(request.hostId);
658
+ const campaignRef = hostRef.collection('emailCampaigns').doc(request.id);
659
+ const emailId = campaignDraftEmailId(request.id);
660
+ const emailRef = hostRef.collection('campaigns').doc(emailId);
661
+ const designRef = hostRef.collection('screens').doc(read.value.templateScreenId);
662
+ return db.runTransaction(async (transaction)=>{
663
+ const [host, campaign, email, design] = await Promise.all([
664
+ transaction.get(hostRef),
665
+ transaction.get(campaignRef),
666
+ transaction.get(emailRef),
667
+ transaction.get(designRef)
668
+ ]);
669
+ if (!host.exists) return {
670
+ ok: false,
671
+ status: 404,
672
+ error: 'Unknown site'
673
+ };
674
+ if (campaign.exists) {
675
+ return _extends({
676
+ ok: true,
677
+ replayed: true
678
+ }, campaignDraftRecord(campaign));
679
+ }
680
+ const refusal = campaignDraftRoleRefusal(host, request.uid);
681
+ if (refusal) return _extends({
682
+ ok: false
683
+ }, refusal);
684
+ if (!design.exists || design.get('deletedAt') != null || design.get('kind') !== SCREEN_KIND_EMAIL) {
685
+ return {
686
+ ok: false,
687
+ status: 400,
688
+ error: 'Unknown email design'
689
+ };
690
+ }
691
+ if (email.exists) return {
692
+ ok: false,
693
+ status: 409,
694
+ error: 'That email already exists'
695
+ };
696
+ const name = draftHeaderLine(request.name) || CAMPAIGN_DRAFT_DEFAULT_NAME;
697
+ const nowMs = request.now.getTime();
698
+ // The create drawer's own document: a name, no dates, no lists.
699
+ transaction.create(campaignRef, {
700
+ name,
701
+ listIds: [],
702
+ createdAtMs: nowMs,
703
+ createdBy: request.uid
704
+ });
705
+ const { value } = read;
706
+ transaction.create(emailRef, _extends({
707
+ subject: value.subject
708
+ }, value.preheader ? {
709
+ preheader: value.preheader
710
+ } : {}, {
711
+ templateScreenId: value.templateScreenId
712
+ }, value.subjectVariants.length ? {
713
+ subjectVariants: value.subjectVariants
714
+ } : {}, value.preheaderVariants.length ? {
715
+ preheaderVariants: value.preheaderVariants
716
+ } : {}, {
717
+ [CAMPAIGN_SEND_CONTAINER_FIELD]: request.id,
718
+ status: 'draft',
719
+ createdAtMs: nowMs,
720
+ draftedAt: request.now,
721
+ draftedBy: request.uid
722
+ }));
723
+ return {
724
+ ok: true,
725
+ replayed: false,
726
+ id: request.id,
727
+ name,
728
+ versionId: null,
729
+ facts: {
730
+ emailId
731
+ }
732
+ };
733
+ });
734
+ }
735
+ };
736
+ }
737
+ export const campaignDraftWriter = createCampaignDraftWriter();
738
+ /**
739
+ * Registers the writer; the console surface calls it, since only the console
740
+ * runs AI jobs (AGL-3026). Idempotent: a second call replaces the first.
741
+ */ export function registerCampaignDraftWriter() {
742
+ registerPluginResourceDraftWriter(CAMPAIGN_DRAFT_RESOURCE, campaignDraftWriter, {
743
+ pluginId: MARKETING_PLUGIN_ID
744
+ });
745
+ }
746
+ export const campaignManageHandler = async (req, res)=>{
747
+ var _ref, _ref1, _ref2, _req_headers_authorization;
748
+ var _req_body, _req_body1, _req_body2;
749
+ if (req.method !== 'POST') {
750
+ return res.status(405).json({
751
+ error: 'Method not allowed'
752
+ });
753
+ }
754
+ const hostId = String((_ref = (_req_body = req.body) == null ? void 0 : _req_body.hostId) != null ? _ref : '');
755
+ const action = String((_ref1 = (_req_body1 = req.body) == null ? void 0 : _req_body1.action) != null ? _ref1 : '');
756
+ const targetId = String((_ref2 = (_req_body2 = req.body) == null ? void 0 : _req_body2.campaignId) != null ? _ref2 : '');
757
+ if (!hostId) return res.status(400).json({
758
+ error: 'Missing hostId'
759
+ });
760
+ if (!isDocumentId(targetId)) {
761
+ return res.status(400).json({
762
+ error: 'Invalid campaignId'
763
+ });
764
+ }
765
+ if (action !== 'deleteCampaign' && action !== 'discardEmail' && action !== 'duplicate') {
766
+ return res.status(400).json({
767
+ error: 'Unknown action'
768
+ });
769
+ }
770
+ const authorization = String((_req_headers_authorization = req.headers.authorization) != null ? _req_headers_authorization : '');
771
+ const idToken = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined;
772
+ if (!idToken) return res.status(401).json({
773
+ error: 'Unauthenticated'
774
+ });
775
+ try {
776
+ var _hostSnapshot_get;
777
+ const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
778
+ const firestore = firebaseAdmin.app().firestore();
779
+ const hostRef = firestore.collection('hosts').doc(hostId);
780
+ const hostSnapshot = await hostRef.get();
781
+ if (!hostSnapshot.exists) {
782
+ return res.status(404).json({
783
+ error: 'Unknown site'
784
+ });
785
+ }
786
+ const memberRole = ((_hostSnapshot_get = hostSnapshot.get('memberRoles')) != null ? _hostSnapshot_get : {})[decoded.uid];
787
+ if (memberRole !== 'admin' && memberRole !== 'editor') {
788
+ return res.status(403).json({
789
+ error: 'Not a site admin or editor'
790
+ });
791
+ }
792
+ if (action === 'duplicate') {
793
+ var _ref3, _ref4, _ref5, _decoded_email;
794
+ var _req_body3, _req_body4;
795
+ // One attempt key, one copy (AGL-2936): a double click on Duplicate
796
+ // replays the first answer rather than drafting the message twice.
797
+ const attemptKey = String((_ref3 = (_req_body3 = req.body) == null ? void 0 : _req_body3.attemptKey) != null ? _ref3 : '').trim().slice(0, 200);
798
+ const orgId = (_ref4 = await resolveOrgIdForHost(hostId)) != null ? _ref4 : '';
799
+ const claimed = attemptKey ? await claimAttempt(firestore, {
800
+ kind: 'duplicate-campaign',
801
+ scopeId: `${hostId}:${targetId}`,
802
+ orgId,
803
+ key: attemptKey,
804
+ busyMessage: DUPLICATE_BUSY_MESSAGE
805
+ }) : null;
806
+ if (claimed && 'replay' in claimed) {
807
+ return res.status(claimed.replay.status).json(claimed.replay.body);
808
+ }
809
+ const claim = claimed && 'claim' in claimed ? claimed.claim : null;
810
+ const copied = await duplicateMessage(hostId, hostRef, targetId, String((_ref5 = (_req_body4 = req.body) == null ? void 0 : _req_body4.name) != null ? _ref5 : ''), {
811
+ uid: decoded.uid,
812
+ email: (_decoded_email = decoded.email) != null ? _decoded_email : null,
813
+ orgId
814
+ });
815
+ if (copied.status === 200) await (claim == null ? void 0 : claim.record(copied.status, copied.body));
816
+ else await (claim == null ? void 0 : claim.release());
817
+ return res.status(copied.status).json(copied.body);
818
+ }
819
+ const result = action === 'deleteCampaign' ? await deleteCampaign(hostId, hostRef, targetId) : await discardDraft(hostRef, targetId);
820
+ return res.status(result.status).json(result.body);
821
+ } catch (error) {
822
+ console.error(error);
823
+ return res.status(500).json({
824
+ error: 'The request could not be completed'
825
+ });
826
+ }
827
+ };
828
+
829
+ //# sourceMappingURL=campaign-manage.js.map