@aglyn/plugins-marketing 1.0.0-beta.172 → 1.0.0-beta.174
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/README.md +2 -1
- package/package.json +15 -15
- package/src/lib/components/campaign-detail-card.d.ts +12 -1
- package/src/lib/components/campaign-detail-card.js +146 -35
- package/src/lib/components/campaign-detail-card.js.map +1 -1
- package/src/lib/components/campaign-edit-drawer.d.ts +13 -0
- package/src/lib/components/campaign-edit-drawer.js +56 -4
- package/src/lib/components/campaign-edit-drawer.js.map +1 -1
- package/src/lib/components/campaign-glance-card.component.js +10 -2
- package/src/lib/components/campaign-glance-card.component.js.map +1 -1
- package/src/lib/components/campaign-members-section.d.ts +6 -0
- package/src/lib/components/campaign-members-section.js +2 -2
- package/src/lib/components/campaign-members-section.js.map +1 -1
- package/src/lib/components/campaign-queries.d.ts +58 -0
- package/src/lib/components/campaign-queries.js +72 -0
- package/src/lib/components/campaign-queries.js.map +1 -0
- package/src/lib/components/campaign-reach-sections.d.ts +21 -7
- package/src/lib/components/campaign-reach-sections.js +20 -17
- package/src/lib/components/campaign-reach-sections.js.map +1 -1
- package/src/lib/components/campaign-report-card.d.ts +8 -1
- package/src/lib/components/campaign-report-card.js +33 -16
- package/src/lib/components/campaign-report-card.js.map +1 -1
- package/src/lib/components/campaigns-card.d.ts +10 -1
- package/src/lib/components/campaigns-card.js +102 -19
- package/src/lib/components/campaigns-card.js.map +1 -1
- package/src/lib/components/email-compose-card.d.ts +3 -2
- package/src/lib/components/email-compose-card.js +18 -7
- package/src/lib/components/email-compose-card.js.map +1 -1
- package/src/lib/components/email-detail.d.ts +11 -2
- package/src/lib/components/email-detail.js +50 -23
- package/src/lib/components/email-detail.js.map +1 -1
- package/src/lib/components/emails-list-card.d.ts +10 -1
- package/src/lib/components/emails-list-card.js +129 -23
- package/src/lib/components/emails-list-card.js.map +1 -1
- package/src/lib/components/host-marketing-summary-card.component.js +9 -1
- package/src/lib/components/host-marketing-summary-card.component.js.map +1 -1
- package/src/lib/components/marketing-console-page.js +81 -4
- package/src/lib/components/marketing-console-page.js.map +1 -1
- package/src/lib/components/marketing-console-sections.d.ts +16 -1
- package/src/lib/components/marketing-console-sections.js +25 -2
- package/src/lib/components/marketing-console-sections.js.map +1 -1
- package/src/lib/components/marketing-org-mount.d.ts +71 -0
- package/src/lib/components/marketing-org-mount.js +84 -0
- package/src/lib/components/marketing-org-mount.js.map +1 -0
- package/src/lib/components/use-campaign-send-api.d.ts +2 -2
- package/src/lib/components/use-campaign-send-api.js +22 -6
- package/src/lib/components/use-campaign-send-api.js.map +1 -1
- package/src/lib/components/use-emails-hub-path.d.ts +13 -3
- package/src/lib/components/use-emails-hub-path.js +25 -4
- package/src/lib/components/use-emails-hub-path.js.map +1 -1
- package/src/lib/components/use-marketing-hub-path.d.ts +19 -16
- package/src/lib/components/use-marketing-hub-path.js +6 -32
- package/src/lib/components/use-marketing-hub-path.js.map +1 -1
- package/src/lib/model/marketing-record-routes.js +11 -4
- package/src/lib/model/marketing-record-routes.js.map +1 -1
- package/src/lib/plugin.js +31 -1
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/server/campaign-manage.d.ts +31 -2
- package/src/lib/server/campaign-manage.js +365 -137
- package/src/lib/server/campaign-manage.js.map +1 -1
- package/src/lib/server/campaign-org-refs.d.ts +45 -0
- package/src/lib/server/campaign-org-refs.js +83 -0
- package/src/lib/server/campaign-org-refs.js.map +1 -0
- package/src/lib/server/campaign-process-scheduled.d.ts +32 -2
- package/src/lib/server/campaign-process-scheduled.js +79 -9
- package/src/lib/server/campaign-process-scheduled.js.map +1 -1
- package/src/lib/server/campaign-recipients.js +18 -4
- package/src/lib/server/campaign-recipients.js.map +1 -1
- package/src/lib/server/campaign-send.js +139 -55
- package/src/lib/server/campaign-send.js.map +1 -1
- package/src/lib/server/email-campaign-reach.d.ts +6 -5
- package/src/lib/server/email-campaign-reach.js +14 -14
- package/src/lib/server/email-campaign-reach.js.map +1 -1
- package/src/lib/server/email-events.js +48 -17
- package/src/lib/server/email-events.js.map +1 -1
- package/src/lib/server/marketing-figures.js +12 -1
- package/src/lib/server/marketing-figures.js.map +1 -1
- package/src/lib/server.js +7 -3
- package/src/lib/server.js.map +1 -1
|
@@ -23,16 +23,28 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
23
23
|
* ## Why these two are not client writes
|
|
24
24
|
*
|
|
25
25
|
* The console creates and edits a campaign CONTAINER with the client SDK —
|
|
26
|
-
* `
|
|
27
|
-
*
|
|
28
|
-
*
|
|
26
|
+
* `orgs/{orgId}/emailCampaigns` is client-writable, because a container holds
|
|
27
|
+
* no counter, no consent record and no entitlement input. It is not
|
|
28
|
+
* client-DELETABLE: removing one has to detach everything that names it
|
|
29
|
+
* first, across every site in the organization, which no rule can express.
|
|
29
30
|
*
|
|
30
|
-
* The SEND collection is the opposite: `
|
|
31
|
+
* The SEND collection is the opposite: `orgs/{orgId}/campaigns` is excluded
|
|
31
32
|
* from client create, update AND delete, because each document is the record
|
|
32
33
|
* of what a merchant mailed and to whom they were allowed to mail it. Both
|
|
33
34
|
* operations here have to touch it — one to detach sends from the container
|
|
34
35
|
* being removed, the other to remove a draft outright — so both run on the
|
|
35
|
-
* Admin SDK behind
|
|
36
|
+
* Admin SDK behind a role check.
|
|
37
|
+
*
|
|
38
|
+
* ## Two doors: a site, and the organization
|
|
39
|
+
*
|
|
40
|
+
* A campaign belongs to the organization and may be placed on several of its
|
|
41
|
+
* sites. A request from a SITE hub names the site (`hostId`) and is
|
|
42
|
+
* authorized by the caller's role there, as the send route is — and it acts
|
|
43
|
+
* only on what that site holds: a send sent as another site, or a campaign
|
|
44
|
+
* not placed on this one, answers as if it did not exist. A request from the
|
|
45
|
+
* ORGANIZATION hub names the org (`orgId`) and no site, and is authorized by
|
|
46
|
+
* the caller's org-wide membership; for an email it acts in the name of the
|
|
47
|
+
* site the email is sent as.
|
|
36
48
|
*
|
|
37
49
|
* ## What deleting a campaign MEANS
|
|
38
50
|
*
|
|
@@ -79,16 +91,18 @@ import { logResourceDuplicated } from "@aglyn/tenant-data-admin/server/duplicate
|
|
|
79
91
|
import { DUPLICATE_BUSY_MESSAGE, duplicateDisplayName, uniqueDuplicateName } from "@aglyn/aglyn/app-utils/duplicate-resource";
|
|
80
92
|
import { claimAttempt, createResourceUid } from "@aglyn/aglyn/server";
|
|
81
93
|
/*
|
|
82
|
-
* The MODULES again, for the same reason:
|
|
83
|
-
*
|
|
94
|
+
* The MODULES again, for the same reason: the org and membership reads here
|
|
95
|
+
* are what the contact pass and the organization door need, and taking them
|
|
84
96
|
* from `@aglyn/tenant-data-admin`'s index would pull the whole Next server
|
|
85
97
|
* pipeline back into this file's graph.
|
|
86
|
-
*/ import { consentGroupForSite,
|
|
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";
|
|
98
|
+
*/ import { consentGroupForSite, getOrgDoc, resolveOrgIdForHost, resolveOrgMembership } from "@aglyn/tenant-data-admin/server/organizations";
|
|
99
|
+
import { CAMPAIGN_MEMBER_HOST_COLLECTIONS, CAMPAIGN_MEMBERSHIP_FIELD, contactCampaignFieldPath, isOrgWideMember } from "@aglyn/aglyn/server";
|
|
100
|
+
import { CAMPAIGN_SEND_CONTAINER_FIELD, CAMPAIGN_SEND_HOST_FIELD, campaignPlacedOnHost, campaignSiteIds, campaignVisibleTo } from "@aglyn/shared-ui-email-campaigns/model";
|
|
101
|
+
import { campaignSendSiteStamp, orgCampaignSends, orgEmailCampaigns, sendHostId, sendIsOnHost } from "./campaign-org-refs.js";
|
|
89
102
|
import { SCREEN_KIND_EMAIL } from "@aglyn/aglyn/app-utils/screen-route";
|
|
90
103
|
import { registerPluginResourceDraftWriter } from "@aglyn/aglyn/plugin-manager/plugin-resource-drafts";
|
|
91
104
|
import { runPluginMembershipDetachers } from "@aglyn/aglyn/plugin-manager/plugin-membership-detach";
|
|
105
|
+
import { isPluginEnabled } from "@aglyn/aglyn/plugin-manager/enabled-plugins";
|
|
92
106
|
/**
|
|
93
107
|
* Sends detached in one write.
|
|
94
108
|
*
|
|
@@ -109,6 +123,14 @@ import { runPluginMembershipDetachers } from "@aglyn/aglyn/plugin-manager/plugin
|
|
|
109
123
|
* state — the sends already detached read as single sends, the rest still
|
|
110
124
|
* read under the campaign — so the answer to hitting it is to ask again.
|
|
111
125
|
*/ const DETACH_PASSES = 25;
|
|
126
|
+
/**
|
|
127
|
+
* The most sites one deletion walks for members.
|
|
128
|
+
*
|
|
129
|
+
* A campaign is the organization's, so its forms, screens and consent-group
|
|
130
|
+
* facets may be on any of its sites. An organization past this has more
|
|
131
|
+
* sites than one request should sweep, and the deletion refuses rather than
|
|
132
|
+
* removing a container some sites' records would still name.
|
|
133
|
+
*/ const ORG_SITES_MAX = 200;
|
|
112
134
|
/**
|
|
113
135
|
* Clears {@link CAMPAIGN_SEND_CONTAINER_FIELD} from every send in a campaign.
|
|
114
136
|
*
|
|
@@ -118,11 +140,10 @@ import { runPluginMembershipDetachers } from "@aglyn/aglyn/plugin-manager/plugin
|
|
|
118
140
|
* which order they come back in cannot change the outcome.
|
|
119
141
|
*
|
|
120
142
|
* @returns how many were detached, and whether any were left.
|
|
121
|
-
*/ async function detachSends(
|
|
122
|
-
const firestore = hostRef.firestore;
|
|
143
|
+
*/ async function detachSends(firestore, orgId, campaignId) {
|
|
123
144
|
let detached = 0;
|
|
124
145
|
for(let pass = 0; pass < DETACH_PASSES; pass += 1){
|
|
125
|
-
const page = await
|
|
146
|
+
const page = await orgCampaignSends(firestore, orgId).where(CAMPAIGN_SEND_CONTAINER_FIELD, '==', campaignId).limit(DETACH_BATCH).get();
|
|
126
147
|
if (page.empty) return {
|
|
127
148
|
detached,
|
|
128
149
|
remaining: false
|
|
@@ -194,9 +215,24 @@ import { runPluginMembershipDetachers } from "@aglyn/aglyn/plugin-manager/plugin
|
|
|
194
215
|
remaining: true
|
|
195
216
|
};
|
|
196
217
|
}
|
|
218
|
+
/**
|
|
219
|
+
* Every site of the organization, by id — the sites a campaign's members may
|
|
220
|
+
* be on. One equality on the `hostIndex` mirror, the same document
|
|
221
|
+
* `resolveOrgIdForHost` reads to put a site in an org.
|
|
222
|
+
*
|
|
223
|
+
* @returns the ids, and whether the org has more than {@link ORG_SITES_MAX}.
|
|
224
|
+
*/ async function orgSiteIds(firestore, orgId) {
|
|
225
|
+
const page = await firestore.collection('hostIndex').where('orgId', '==', orgId).limit(ORG_SITES_MAX + 1).get();
|
|
226
|
+
const hostIds = page.docs.map((doc)=>String(doc.id));
|
|
227
|
+
return {
|
|
228
|
+
hostIds: hostIds.slice(0, ORG_SITES_MAX),
|
|
229
|
+
truncated: hostIds.length > ORG_SITES_MAX
|
|
230
|
+
};
|
|
231
|
+
}
|
|
197
232
|
/**
|
|
198
233
|
* Clears the campaign off every form, screen, lead and contact holding it,
|
|
199
|
-
* and then off every record a plugin
|
|
234
|
+
* on every site of the organization, and then off every record a plugin
|
|
235
|
+
* keeps about it (AGL-3254).
|
|
200
236
|
*
|
|
201
237
|
* ## Why the campaign is not simply deleted over the top of them
|
|
202
238
|
*
|
|
@@ -207,74 +243,95 @@ import { runPluginMembershipDetachers } from "@aglyn/aglyn/plugin-manager/plugin
|
|
|
207
243
|
* any campaign the console can draw, and not free of one either. Its own page
|
|
208
244
|
* would render the dead id as a chip with no name.
|
|
209
245
|
*
|
|
210
|
-
* ##
|
|
246
|
+
* ## Every site, not the one that asked
|
|
247
|
+
*
|
|
248
|
+
* The campaign is the organization's, and a site it has since been taken off
|
|
249
|
+
* may still hold forms and screens filed under it. So the walk covers every
|
|
250
|
+
* site in the org regardless of which door the deletion came through.
|
|
251
|
+
*
|
|
252
|
+
* ## Leads and contacts are reached on the organization
|
|
211
253
|
*
|
|
212
|
-
* A
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
* holder's filing is touched. The unscoped collection ref is what the walk
|
|
217
|
-
* runs on precisely because the path already carries the boundary — the
|
|
218
|
-
* `visibleTo` filter `orgDataQueryForHost` adds would spend the query's one
|
|
219
|
-
* array-contains slot and leave none for the membership.
|
|
254
|
+
* A lead lives at `orgs/{orgId}/leads` and carries the field at the top of
|
|
255
|
+
* its document, like a form. The site `leads` collection is walked as well,
|
|
256
|
+
* as part of each site's own collections: records captured before leads
|
|
257
|
+
* moved to the org are still read there.
|
|
220
258
|
*
|
|
221
|
-
* A
|
|
222
|
-
*
|
|
223
|
-
*
|
|
259
|
+
* A contact lives at `orgs/{orgId}/contacts` and is shared by every site, so
|
|
260
|
+
* the membership sits inside a consent group's facet rather than at the top
|
|
261
|
+
* of the document. The field PATH is therefore the scope, and the walk runs
|
|
262
|
+
* once per distinct group of the org's sites — two sites declared as one
|
|
263
|
+
* sender share a facet, and clearing it twice would find nothing the second
|
|
264
|
+
* time. The unscoped collection ref is what the walk runs on precisely
|
|
265
|
+
* because the path already carries the boundary — a `visibleTo` filter would
|
|
266
|
+
* spend the query's one array-contains slot and leave none for the
|
|
267
|
+
* membership.
|
|
224
268
|
*
|
|
225
269
|
* ## A plugin's members are the plugin's to clear
|
|
226
270
|
*
|
|
227
271
|
* A sequence and its enrollments name the campaign from documents under the
|
|
228
272
|
* org, in collections this plugin does not know (AGL-3254). Every plugin
|
|
229
|
-
* with a membership detacher registered on the core's seam is asked,
|
|
230
|
-
* the campaign's id and the field it is held in,
|
|
231
|
-
* records
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*/ async function detachMembers(
|
|
237
|
-
|
|
273
|
+
* with a membership detacher registered on the core's seam is asked, once
|
|
274
|
+
* per site, with the campaign's id and the field it is held in, and a
|
|
275
|
+
* detacher that reports records remaining — or threw, which is reported as
|
|
276
|
+
* `null` — holds the container exactly as this pass's own `remaining` does.
|
|
277
|
+
* A plugin that failed is a plugin whose records may still name the
|
|
278
|
+
* campaign, and the deletion has no way to tell that apart from one that has
|
|
279
|
+
* more than a request clears.
|
|
280
|
+
*/ async function detachMembers(firestore, orgId, campaignId) {
|
|
281
|
+
var _ref;
|
|
238
282
|
let detached = 0;
|
|
239
283
|
let remaining = false;
|
|
240
|
-
|
|
241
|
-
const pass = await detachMembership(firestore, hostRef.collection(collectionName), CAMPAIGN_MEMBERSHIP_FIELD, campaignId);
|
|
284
|
+
const take = (pass)=>{
|
|
242
285
|
detached += pass.detached;
|
|
243
286
|
remaining = remaining || pass.remaining;
|
|
287
|
+
};
|
|
288
|
+
const { hostIds, truncated } = await orgSiteIds(firestore, orgId);
|
|
289
|
+
if (truncated) remaining = true;
|
|
290
|
+
for (const hostId of hostIds){
|
|
291
|
+
const hostRef = firestore.collection('hosts').doc(hostId);
|
|
292
|
+
for (const collectionName of CAMPAIGN_MEMBER_HOST_COLLECTIONS){
|
|
293
|
+
take(await detachMembership(firestore, hostRef.collection(collectionName), CAMPAIGN_MEMBERSHIP_FIELD, campaignId));
|
|
294
|
+
}
|
|
244
295
|
}
|
|
296
|
+
const orgRef = firestore.collection('orgs').doc(orgId);
|
|
297
|
+
take(await detachMembership(firestore, orgRef.collection('leads'), CAMPAIGN_MEMBERSHIP_FIELD, campaignId));
|
|
245
298
|
/*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
299
|
+
* An org with no sites still has plugins that may hold the campaign, so
|
|
300
|
+
* the seam is asked once with no site rather than not at all.
|
|
301
|
+
*/ for (const hostId of hostIds.length ? hostIds : [
|
|
302
|
+
''
|
|
303
|
+
]){
|
|
304
|
+
const plugins = await runPluginMembershipDetachers({
|
|
305
|
+
hostId,
|
|
306
|
+
orgId,
|
|
307
|
+
field: CAMPAIGN_MEMBERSHIP_FIELD,
|
|
308
|
+
id: campaignId
|
|
309
|
+
});
|
|
310
|
+
for (const report of Object.values(plugins)){
|
|
311
|
+
var _ref1;
|
|
312
|
+
detached += (_ref1 = report == null ? void 0 : report.detached) != null ? _ref1 : 0;
|
|
313
|
+
remaining = remaining || report === null || report.remaining;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/*
|
|
317
|
+
* One read of the org document serves every site's group: with it in
|
|
318
|
+
* hand, `consentGroupForSite` resolves from the declaration without a
|
|
319
|
+
* lookup per site. A missing document answers every site as a group of
|
|
320
|
+
* one, which is `consentGroupForSite`'s documented failure direction and
|
|
321
|
+
* the safe one here — each site's own facet is cleared, and no other.
|
|
322
|
+
*/ const org = (_ref = await getOrgDoc(orgId)) != null ? _ref : {};
|
|
323
|
+
const groupIds = new Set();
|
|
324
|
+
for (const hostId of hostIds){
|
|
325
|
+
groupIds.add((await consentGroupForSite(hostId, org)).groupId);
|
|
266
326
|
}
|
|
267
|
-
|
|
327
|
+
const contacts = orgRef.collection('contacts');
|
|
328
|
+
for (const groupId of groupIds){
|
|
329
|
+
take(await detachMembership(firestore, contacts, contactCampaignFieldPath(groupId), campaignId));
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
268
332
|
detached,
|
|
269
333
|
remaining
|
|
270
334
|
};
|
|
271
|
-
const group = await consentGroupForSite(hostId);
|
|
272
|
-
const contacts = await orgDataCollectionForHost(hostId, 'contacts');
|
|
273
|
-
const contactPass = await detachMembership(firestore, contacts, contactCampaignFieldPath(group.groupId), campaignId);
|
|
274
|
-
return {
|
|
275
|
-
detached: detached + contactPass.detached,
|
|
276
|
-
remaining: remaining || contactPass.remaining
|
|
277
|
-
};
|
|
278
335
|
}
|
|
279
336
|
/**
|
|
280
337
|
* Removes a campaign container, leaving its emails standing.
|
|
@@ -284,8 +341,17 @@ import { runPluginMembershipDetachers } from "@aglyn/aglyn/plugin-manager/plugin
|
|
|
284
341
|
* list draws correctly and a second run finishes. Deleting first would leave
|
|
285
342
|
* sends naming a container nobody can read, which is the state that loses
|
|
286
343
|
* them from the table.
|
|
287
|
-
|
|
288
|
-
|
|
344
|
+
*
|
|
345
|
+
* ## What a site hub may delete
|
|
346
|
+
*
|
|
347
|
+
* A campaign placed on this site and on no other — the one a site hub
|
|
348
|
+
* creates. A campaign that is not placed here is not this site's to see, so
|
|
349
|
+
* it answers 404 as a missing one would. A campaign also placed on other
|
|
350
|
+
* sites is refused unless the caller is an org-wide member: deleting it
|
|
351
|
+
* takes it off those sites too, which is the same reach change the rules
|
|
352
|
+
* reserve for org-wide members when a campaign is edited.
|
|
353
|
+
*/ async function deleteCampaign(firestore, orgId, campaignId, access) {
|
|
354
|
+
const ref = orgEmailCampaigns(firestore, orgId).doc(campaignId);
|
|
289
355
|
const snapshot = await ref.get();
|
|
290
356
|
/*
|
|
291
357
|
* A 404 rather than a silent success. The id in a campaign URL may name a
|
|
@@ -293,7 +359,10 @@ import { runPluginMembershipDetachers } from "@aglyn/aglyn/plugin-manager/plugin
|
|
|
293
359
|
* minted before containers existed resolving — so "no container here" is
|
|
294
360
|
* very often "this is a send", and answering it as a completed deletion
|
|
295
361
|
* would tell the console it had removed something it never touched.
|
|
296
|
-
*/
|
|
362
|
+
*/ const placement = {
|
|
363
|
+
visibleTo: snapshot.exists ? snapshot.get('visibleTo') : undefined
|
|
364
|
+
};
|
|
365
|
+
if (!snapshot.exists || access.hostId && !campaignPlacedOnHost(placement, access.hostId)) {
|
|
297
366
|
return {
|
|
298
367
|
status: 404,
|
|
299
368
|
body: {
|
|
@@ -301,7 +370,22 @@ import { runPluginMembershipDetachers } from "@aglyn/aglyn/plugin-manager/plugin
|
|
|
301
370
|
}
|
|
302
371
|
};
|
|
303
372
|
}
|
|
304
|
-
|
|
373
|
+
if (access.hostId) {
|
|
374
|
+
const sites = campaignSiteIds(placement);
|
|
375
|
+
const onlyHere = sites !== null && sites.every((id)=>id === access.hostId);
|
|
376
|
+
if (!onlyHere) {
|
|
377
|
+
const membership = await resolveOrgMembership(access.uid, orgId);
|
|
378
|
+
if (!isOrgWideMember(membership == null ? void 0 : membership.member)) {
|
|
379
|
+
return {
|
|
380
|
+
status: 403,
|
|
381
|
+
body: {
|
|
382
|
+
error: 'This campaign is on other sites too, so deleting it would take ' + 'it off them as well. An organization admin can delete it from ' + 'the organization’s campaigns.'
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
const { detached, remaining } = await detachSends(firestore, orgId, campaignId);
|
|
305
389
|
if (remaining) {
|
|
306
390
|
return {
|
|
307
391
|
status: 409,
|
|
@@ -321,7 +405,7 @@ import { runPluginMembershipDetachers } from "@aglyn/aglyn/plugin-manager/plugin
|
|
|
321
405
|
* detach" is a sentence about the campaign's mail, and a merchant reading
|
|
322
406
|
* it about a campaign of two emails and four thousand contacts would go
|
|
323
407
|
* looking for emails that are not the problem.
|
|
324
|
-
*/ const members = await detachMembers(
|
|
408
|
+
*/ const members = await detachMembers(firestore, orgId, campaignId);
|
|
325
409
|
if (members.remaining) {
|
|
326
410
|
return {
|
|
327
411
|
status: 409,
|
|
@@ -356,9 +440,12 @@ import { runPluginMembershipDetachers } from "@aglyn/aglyn/plugin-manager/plugin
|
|
|
356
440
|
* Only `draft`. A scheduled email is withdrawn with `cancel`, which leaves
|
|
357
441
|
* the record and its report standing; a sent one has reached people, and
|
|
358
442
|
* nothing on this surface may remove the evidence of that.
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
443
|
+
*
|
|
444
|
+
* `hostId` is the site hub the request came from, and a draft sent as a
|
|
445
|
+
* different site is refused as unknown; from the organization hub it is
|
|
446
|
+
* `''` and any site's draft may be discarded.
|
|
447
|
+
*/ async function discardDraft(firestore, orgId, emailId, hostId) {
|
|
448
|
+
const ref = orgCampaignSends(firestore, orgId).doc(emailId);
|
|
362
449
|
const refusal = (state)=>{
|
|
363
450
|
if (state === 'sent') {
|
|
364
451
|
return 'This email has already been sent, so it cannot be discarded. ' + 'Its report and its unsubscribe links have to go on resolving.';
|
|
@@ -374,7 +461,7 @@ import { runPluginMembershipDetachers } from "@aglyn/aglyn/plugin-manager/plugin
|
|
|
374
461
|
const outcome = await firestore.runTransaction(async (transaction)=>{
|
|
375
462
|
var _fresh_get;
|
|
376
463
|
const fresh = await transaction.get(ref);
|
|
377
|
-
if (!fresh.exists) {
|
|
464
|
+
if (!fresh.exists || hostId && !sendIsOnHost(fresh, hostId)) {
|
|
378
465
|
return {
|
|
379
466
|
status: 404,
|
|
380
467
|
body: {
|
|
@@ -408,19 +495,6 @@ import { runPluginMembershipDetachers } from "@aglyn/aglyn/plugin-manager/plugin
|
|
|
408
495
|
return outcome;
|
|
409
496
|
}
|
|
410
497
|
/**
|
|
411
|
-
* Campaign management API: removing a campaign, and discarding a draft.
|
|
412
|
-
*
|
|
413
|
-
* Separate from `campaigns/send` because nothing here sends, reserves
|
|
414
|
-
* allowance or moves a meter, and because both operations are about a record
|
|
415
|
-
* ceasing to exist — which is the one class of change that has to be read
|
|
416
|
-
* against what a merchant has already mailed. Same authorization as the send
|
|
417
|
-
* route: a site admin or editor, proven by a Firebase ID token.
|
|
418
|
-
*
|
|
419
|
-
* Editing a campaign is deliberately NOT here. The container is client-
|
|
420
|
-
* writable by the same roles, its create already runs on the client SDK, and
|
|
421
|
-
* a second door to the same document would be a second place for the two to
|
|
422
|
-
* disagree about what a campaign may hold.
|
|
423
|
-
*/ /**
|
|
424
498
|
* The fields a copy of a message carries (AGL-2936): what was written and
|
|
425
499
|
* how it is sent — never who it goes to or when. The audience, list,
|
|
426
500
|
* segment, addresses, schedule and experiment are the decisions a person
|
|
@@ -441,16 +515,44 @@ import { runPluginMembershipDetachers } from "@aglyn/aglyn/plugin-manager/plugin
|
|
|
441
515
|
/**
|
|
442
516
|
* Copies a message as a new draft (AGL-2936). Any status can be copied —
|
|
443
517
|
* a sent email is the usual source — and the copy is always `draft`, with
|
|
444
|
-
* the name the person gave it made unique among
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
518
|
+
* the name the person gave it made unique among its site's messages.
|
|
519
|
+
*
|
|
520
|
+
* The copy is sent as the same site as its source: the sender, the design
|
|
521
|
+
* and the consent it would mail under are all that site's. From a site hub
|
|
522
|
+
* that is the requesting site, and a source sent as another site is refused
|
|
523
|
+
* as unknown; from the organization hub (`hostId` `''`) it is whichever site
|
|
524
|
+
* the source records, and a source that records none cannot be copied,
|
|
525
|
+
* because the copy would be an email that can never be sent.
|
|
526
|
+
*/ async function duplicateMessage(firestore, orgId, hostId, emailId, requestedName, actor) {
|
|
527
|
+
const messages = orgCampaignSends(firestore, orgId);
|
|
448
528
|
const id = createResourceUid();
|
|
529
|
+
/*
|
|
530
|
+
* The source is read once outside the transaction to learn its site, which
|
|
531
|
+
* decides which siblings the name must be unique among; the transaction
|
|
532
|
+
* reads it again, so a source changed in between is copied as it stands.
|
|
533
|
+
*/ const peek = await messages.doc(emailId).get();
|
|
534
|
+
if (!peek.exists || hostId && !sendIsOnHost(peek, hostId)) {
|
|
535
|
+
return {
|
|
536
|
+
status: 404,
|
|
537
|
+
body: {
|
|
538
|
+
error: 'Unknown email'
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
const siteId = hostId || sendHostId(peek);
|
|
543
|
+
if (!siteId) {
|
|
544
|
+
return {
|
|
545
|
+
status: 409,
|
|
546
|
+
body: {
|
|
547
|
+
error: 'This email does not record which site it is sent from, so it ' + 'cannot be copied from here. Open it from its site instead.'
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
}
|
|
449
551
|
const outcome = await firestore.runTransaction(async (transaction)=>{
|
|
450
552
|
var _source_data, _ref, _data_displayName;
|
|
451
553
|
const [source, siblings] = await Promise.all([
|
|
452
554
|
transaction.get(messages.doc(emailId)),
|
|
453
|
-
transaction.get(messages.select('displayName', 'subject'))
|
|
555
|
+
transaction.get(messages.select('displayName', 'subject').where(CAMPAIGN_SEND_HOST_FIELD, '==', siteId))
|
|
454
556
|
]);
|
|
455
557
|
if (!source.exists) {
|
|
456
558
|
return {
|
|
@@ -471,7 +573,7 @@ import { runPluginMembershipDetachers } from "@aglyn/aglyn/plugin-manager/plugin
|
|
|
471
573
|
if (data[field] !== undefined && data[field] !== null) copy[field] = data[field];
|
|
472
574
|
}
|
|
473
575
|
const nowMs = Date.now();
|
|
474
|
-
transaction.create(messages.doc(id), _extends({}, copy, {
|
|
576
|
+
transaction.create(messages.doc(id), _extends({}, copy, campaignSendSiteStamp(siteId), {
|
|
475
577
|
displayName: name,
|
|
476
578
|
status: 'draft',
|
|
477
579
|
createdAtMs: nowMs,
|
|
@@ -494,8 +596,8 @@ import { runPluginMembershipDetachers } from "@aglyn/aglyn/plugin-manager/plugin
|
|
|
494
596
|
uid: actor.uid,
|
|
495
597
|
email: actor.email
|
|
496
598
|
}, {
|
|
497
|
-
orgId
|
|
498
|
-
hostId,
|
|
599
|
+
orgId,
|
|
600
|
+
hostId: siteId,
|
|
499
601
|
source: {
|
|
500
602
|
id: emailId,
|
|
501
603
|
name: (_outcome_sourceName = outcome.sourceName) != null ? _outcome_sourceName : ''
|
|
@@ -519,11 +621,12 @@ import { runPluginMembershipDetachers } from "@aglyn/aglyn/plugin-manager/plugin
|
|
|
519
621
|
* generator working from a brief, an importer bringing campaigns over from
|
|
520
622
|
* another tool — asks for it by name, and gets exactly two documents:
|
|
521
623
|
*
|
|
522
|
-
* - the CONTAINER at `emailCampaigns/{id}`, written as
|
|
523
|
-
* create drawer writes one
|
|
524
|
-
*
|
|
525
|
-
*
|
|
526
|
-
*
|
|
624
|
+
* - the CONTAINER at `orgs/{orgId}/emailCampaigns/{id}`, written as a site
|
|
625
|
+
* hub's create drawer writes one: aimed at no list, placed on the site
|
|
626
|
+
* the draft was asked for;
|
|
627
|
+
* - ONE EMAIL inside it at `orgs/{orgId}/campaigns/{id}-email`,
|
|
628
|
+
* `status: 'draft'`, sent as that site, naming the email design it sends
|
|
629
|
+
* and holding its subject, preheader and the alternatives to them.
|
|
527
630
|
*
|
|
528
631
|
* ## What a drafted email never holds
|
|
529
632
|
*
|
|
@@ -564,6 +667,8 @@ import { runPluginMembershipDetachers } from "@aglyn/aglyn/plugin-manager/plugin
|
|
|
564
667
|
'subjectVariants',
|
|
565
668
|
'preheaderVariants',
|
|
566
669
|
CAMPAIGN_SEND_CONTAINER_FIELD,
|
|
670
|
+
CAMPAIGN_SEND_HOST_FIELD,
|
|
671
|
+
'visibleTo',
|
|
567
672
|
'status',
|
|
568
673
|
'createdAtMs',
|
|
569
674
|
'draftedAt',
|
|
@@ -639,8 +744,13 @@ function campaignDraftRecord(campaign) {
|
|
|
639
744
|
};
|
|
640
745
|
}
|
|
641
746
|
export function createCampaignDraftWriter(deps = {}) {
|
|
642
|
-
var _deps_firestore;
|
|
747
|
+
var _deps_firestore, _deps_resolveOrgId;
|
|
643
748
|
const firestore = (_deps_firestore = deps.firestore) != null ? _deps_firestore : ()=>firebaseAdmin.app().firestore();
|
|
749
|
+
/*
|
|
750
|
+
* The org is resolved from the SITE rather than taken from the request's
|
|
751
|
+
* context: the site is what the role check reads, so the campaign lands in
|
|
752
|
+
* the organization that site belongs to and no other.
|
|
753
|
+
*/ const resolveOrgId = (_deps_resolveOrgId = deps.resolveOrgId) != null ? _deps_resolveOrgId : resolveOrgIdForHost;
|
|
644
754
|
return {
|
|
645
755
|
refusal: async ({ hostId, uid })=>{
|
|
646
756
|
const host = await firestore().collection('hosts').doc(hostId).get();
|
|
@@ -663,8 +773,12 @@ export function createCampaignDraftWriter(deps = {}) {
|
|
|
663
773
|
},
|
|
664
774
|
read: async ({ hostId, id })=>{
|
|
665
775
|
if (!isDocumentId(id)) return null;
|
|
666
|
-
const
|
|
667
|
-
|
|
776
|
+
const orgId = await resolveOrgId(hostId);
|
|
777
|
+
if (!orgId) return null;
|
|
778
|
+
const campaign = await orgEmailCampaigns(firestore(), orgId).doc(id).get();
|
|
779
|
+
return campaign.exists && campaignPlacedOnHost({
|
|
780
|
+
visibleTo: campaign.get('visibleTo')
|
|
781
|
+
}, hostId) ? campaignDraftRecord(campaign) : null;
|
|
668
782
|
},
|
|
669
783
|
write: async (request)=>{
|
|
670
784
|
const read = readCampaignDraftContent(request.content);
|
|
@@ -679,10 +793,18 @@ export function createCampaignDraftWriter(deps = {}) {
|
|
|
679
793
|
error: 'Invalid campaignId'
|
|
680
794
|
};
|
|
681
795
|
const db = firestore();
|
|
796
|
+
const orgId = await resolveOrgId(request.hostId);
|
|
797
|
+
if (!orgId) {
|
|
798
|
+
return {
|
|
799
|
+
ok: false,
|
|
800
|
+
status: 409,
|
|
801
|
+
error: 'This site is not part of an organization'
|
|
802
|
+
};
|
|
803
|
+
}
|
|
682
804
|
const hostRef = db.collection('hosts').doc(request.hostId);
|
|
683
|
-
const campaignRef =
|
|
805
|
+
const campaignRef = orgEmailCampaigns(db, orgId).doc(request.id);
|
|
684
806
|
const emailId = campaignDraftEmailId(request.id);
|
|
685
|
-
const emailRef =
|
|
807
|
+
const emailRef = orgCampaignSends(db, orgId).doc(emailId);
|
|
686
808
|
const designRef = hostRef.collection('screens').doc(read.value.templateScreenId);
|
|
687
809
|
return db.runTransaction(async (transaction)=>{
|
|
688
810
|
const [host, campaign, email, design] = await Promise.all([
|
|
@@ -697,6 +819,19 @@ export function createCampaignDraftWriter(deps = {}) {
|
|
|
697
819
|
error: 'Unknown site'
|
|
698
820
|
};
|
|
699
821
|
if (campaign.exists) {
|
|
822
|
+
/*
|
|
823
|
+
* A replay answers only for a campaign this site can see: the id
|
|
824
|
+
* space is the organization's, and one placed on a sibling site is
|
|
825
|
+
* not this request's to report or to write over.
|
|
826
|
+
*/ if (!campaignPlacedOnHost({
|
|
827
|
+
visibleTo: campaign.get('visibleTo')
|
|
828
|
+
}, request.hostId)) {
|
|
829
|
+
return {
|
|
830
|
+
ok: false,
|
|
831
|
+
status: 409,
|
|
832
|
+
error: 'That campaign already exists'
|
|
833
|
+
};
|
|
834
|
+
}
|
|
700
835
|
return _extends({
|
|
701
836
|
ok: true,
|
|
702
837
|
replayed: true
|
|
@@ -720,10 +855,14 @@ export function createCampaignDraftWriter(deps = {}) {
|
|
|
720
855
|
};
|
|
721
856
|
const name = draftHeaderLine(request.name) || CAMPAIGN_DRAFT_DEFAULT_NAME;
|
|
722
857
|
const nowMs = request.now.getTime();
|
|
723
|
-
//
|
|
858
|
+
// A site hub's create drawer's own document: a name, no dates, no
|
|
859
|
+
// lists, placed on the site it was drafted for.
|
|
724
860
|
transaction.create(campaignRef, {
|
|
725
861
|
name,
|
|
726
862
|
listIds: [],
|
|
863
|
+
visibleTo: campaignVisibleTo([
|
|
864
|
+
request.hostId
|
|
865
|
+
]),
|
|
727
866
|
createdAtMs: nowMs,
|
|
728
867
|
createdBy: request.uid
|
|
729
868
|
});
|
|
@@ -739,7 +878,8 @@ export function createCampaignDraftWriter(deps = {}) {
|
|
|
739
878
|
} : {}, value.preheaderVariants.length ? {
|
|
740
879
|
preheaderVariants: value.preheaderVariants
|
|
741
880
|
} : {}, {
|
|
742
|
-
[CAMPAIGN_SEND_CONTAINER_FIELD]: request.id
|
|
881
|
+
[CAMPAIGN_SEND_CONTAINER_FIELD]: request.id
|
|
882
|
+
}, campaignSendSiteStamp(request.hostId), {
|
|
743
883
|
status: 'draft',
|
|
744
884
|
createdAtMs: nowMs,
|
|
745
885
|
draftedAt: request.now,
|
|
@@ -768,20 +908,70 @@ export const campaignDraftWriter = createCampaignDraftWriter();
|
|
|
768
908
|
pluginId: MARKETING_PLUGIN_ID
|
|
769
909
|
});
|
|
770
910
|
}
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
911
|
+
/** The org roles that may manage campaigns from the organization hub. */ const ORG_MANAGE_ROLES = [
|
|
912
|
+
'owner',
|
|
913
|
+
'admin',
|
|
914
|
+
'editor'
|
|
915
|
+
];
|
|
916
|
+
/**
|
|
917
|
+
* The organization a `campaigns/manage` request from the organization hub is
|
|
918
|
+
* for, read off its JSON body, so the dispatcher's release gate asks about the
|
|
919
|
+
* right organization. Only consulted for a request that names no site.
|
|
920
|
+
* Unverified, exactly as a `hostId` is: the handler refuses anyone who is not
|
|
921
|
+
* an org-wide member of it.
|
|
922
|
+
*/ export async function campaignManageSubject(request) {
|
|
923
|
+
if (request.method !== 'POST') return null;
|
|
924
|
+
const body = await request.json().catch(()=>null);
|
|
925
|
+
const orgId = typeof (body == null ? void 0 : body.orgId) === 'string' ? body.orgId : '';
|
|
926
|
+
return isDocumentId(orgId) ? {
|
|
927
|
+
orgId
|
|
928
|
+
} : null;
|
|
929
|
+
}
|
|
930
|
+
/**
|
|
931
|
+
* Campaign management API: removing a campaign, discarding a draft, and
|
|
932
|
+
* copying an email.
|
|
933
|
+
*
|
|
934
|
+
* Separate from `campaigns/send` because nothing here sends, reserves
|
|
935
|
+
* allowance or moves a meter, and because the removals are about a record
|
|
936
|
+
* ceasing to exist — which is the one class of change that has to be read
|
|
937
|
+
* against what a merchant has already mailed.
|
|
938
|
+
*
|
|
939
|
+
* Reached through two doors — see the header. A site request carries
|
|
940
|
+
* `hostId` and is authorized by the caller's admin or editor role on that
|
|
941
|
+
* site; an organization request carries `orgId` and no `hostId`, and is
|
|
942
|
+
* authorized by an org-wide membership as owner, admin or editor.
|
|
943
|
+
*
|
|
944
|
+
* Editing a campaign is deliberately NOT here. The container is client-
|
|
945
|
+
* writable, its create already runs on the client SDK, and a second door to
|
|
946
|
+
* the same document would be a second place for the two to disagree about
|
|
947
|
+
* what a campaign may hold.
|
|
948
|
+
*/ export const campaignManageHandler = async (req, res)=>{
|
|
949
|
+
var _ref, _ref1, _ref2, _ref3, _req_headers_authorization;
|
|
950
|
+
var _req_body, _req_body1, _req_body2, _req_body3;
|
|
774
951
|
if (req.method !== 'POST') {
|
|
775
952
|
return res.status(405).json({
|
|
776
953
|
error: 'Method not allowed'
|
|
777
954
|
});
|
|
778
955
|
}
|
|
779
956
|
const hostId = String((_ref = (_req_body = req.body) == null ? void 0 : _req_body.hostId) != null ? _ref : '');
|
|
780
|
-
const
|
|
781
|
-
const
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
957
|
+
const requestedOrgId = String((_ref1 = (_req_body1 = req.body) == null ? void 0 : _req_body1.orgId) != null ? _ref1 : '');
|
|
958
|
+
const action = String((_ref2 = (_req_body2 = req.body) == null ? void 0 : _req_body2.action) != null ? _ref2 : '');
|
|
959
|
+
const targetId = String((_ref3 = (_req_body3 = req.body) == null ? void 0 : _req_body3.campaignId) != null ? _ref3 : '');
|
|
960
|
+
if (!hostId && !requestedOrgId) {
|
|
961
|
+
return res.status(400).json({
|
|
962
|
+
error: 'Missing hostId'
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
if (hostId && !isDocumentId(hostId)) {
|
|
966
|
+
return res.status(400).json({
|
|
967
|
+
error: 'Invalid hostId'
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
if (requestedOrgId && !isDocumentId(requestedOrgId)) {
|
|
971
|
+
return res.status(400).json({
|
|
972
|
+
error: 'Invalid orgId'
|
|
973
|
+
});
|
|
974
|
+
}
|
|
785
975
|
if (!isDocumentId(targetId)) {
|
|
786
976
|
return res.status(400).json({
|
|
787
977
|
error: 'Invalid campaignId'
|
|
@@ -798,32 +988,68 @@ export const campaignManageHandler = async (req, res)=>{
|
|
|
798
988
|
error: 'Unauthenticated'
|
|
799
989
|
});
|
|
800
990
|
try {
|
|
801
|
-
var _hostSnapshot_get;
|
|
802
991
|
const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
|
|
803
992
|
const firestore = firebaseAdmin.app().firestore();
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
993
|
+
let orgId;
|
|
994
|
+
if (hostId) {
|
|
995
|
+
var _hostSnapshot_get, _ref4;
|
|
996
|
+
const hostRef = firestore.collection('hosts').doc(hostId);
|
|
997
|
+
const hostSnapshot = await hostRef.get();
|
|
998
|
+
if (!hostSnapshot.exists) {
|
|
999
|
+
return res.status(404).json({
|
|
1000
|
+
error: 'Unknown site'
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
const memberRole = ((_hostSnapshot_get = hostSnapshot.get('memberRoles')) != null ? _hostSnapshot_get : {})[decoded.uid];
|
|
1004
|
+
if (memberRole !== 'admin' && memberRole !== 'editor') {
|
|
1005
|
+
return res.status(403).json({
|
|
1006
|
+
error: 'Not a site admin or editor'
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
orgId = (_ref4 = await resolveOrgIdForHost(hostId)) != null ? _ref4 : '';
|
|
1010
|
+
if (!orgId) {
|
|
1011
|
+
return res.status(409).json({
|
|
1012
|
+
error: 'This site is not part of an organization, so it holds no campaigns.'
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
/*
|
|
1016
|
+
* A request naming both is answered for the site, and only when the
|
|
1017
|
+
* two agree: an org id the site does not belong to is a confused
|
|
1018
|
+
* caller, and acting on the site's own org instead would be a guess.
|
|
1019
|
+
*/ if (requestedOrgId && requestedOrgId !== orgId) {
|
|
1020
|
+
return res.status(400).json({
|
|
1021
|
+
error: 'That site is not in this organization'
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
} else {
|
|
1025
|
+
var _member_role;
|
|
1026
|
+
const membership = await resolveOrgMembership(decoded.uid, requestedOrgId);
|
|
1027
|
+
const member = membership == null ? void 0 : membership.member;
|
|
1028
|
+
if (!member || !isOrgWideMember(member) || !ORG_MANAGE_ROLES.includes(String((_member_role = member.role) != null ? _member_role : ''))) {
|
|
1029
|
+
return res.status(403).json({
|
|
1030
|
+
error: 'Not an organization owner, admin or editor'
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
/*
|
|
1034
|
+
* The site door is refused by the dispatcher when Marketing is off for
|
|
1035
|
+
* the site; a request naming no site reaches here past only the release
|
|
1036
|
+
* gate, so the organization's own switch is asked here.
|
|
1037
|
+
*/ if (!isPluginEnabled(await getOrgDoc(requestedOrgId), MARKETING_PLUGIN_ID)) {
|
|
1038
|
+
return res.status(404).json({
|
|
1039
|
+
error: 'Not found'
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
orgId = requestedOrgId;
|
|
816
1043
|
}
|
|
817
1044
|
if (action === 'duplicate') {
|
|
818
|
-
var
|
|
819
|
-
var
|
|
1045
|
+
var _ref5, _ref6, _decoded_email;
|
|
1046
|
+
var _req_body4, _req_body5;
|
|
820
1047
|
// One attempt key, one copy (AGL-2936): a double click on Duplicate
|
|
821
1048
|
// replays the first answer rather than drafting the message twice.
|
|
822
|
-
const attemptKey = String((
|
|
823
|
-
const orgId = (_ref4 = await resolveOrgIdForHost(hostId)) != null ? _ref4 : '';
|
|
1049
|
+
const attemptKey = String((_ref5 = (_req_body4 = req.body) == null ? void 0 : _req_body4.attemptKey) != null ? _ref5 : '').trim().slice(0, 200);
|
|
824
1050
|
const claimed = attemptKey ? await claimAttempt(firestore, {
|
|
825
1051
|
kind: 'duplicate-campaign',
|
|
826
|
-
scopeId: `${hostId}:${targetId}`,
|
|
1052
|
+
scopeId: `${hostId || `org:${orgId}`}:${targetId}`,
|
|
827
1053
|
orgId,
|
|
828
1054
|
key: attemptKey,
|
|
829
1055
|
busyMessage: DUPLICATE_BUSY_MESSAGE
|
|
@@ -832,16 +1058,18 @@ export const campaignManageHandler = async (req, res)=>{
|
|
|
832
1058
|
return res.status(claimed.replay.status).json(claimed.replay.body);
|
|
833
1059
|
}
|
|
834
1060
|
const claim = claimed && 'claim' in claimed ? claimed.claim : null;
|
|
835
|
-
const copied = await duplicateMessage(
|
|
1061
|
+
const copied = await duplicateMessage(firestore, orgId, hostId, targetId, String((_ref6 = (_req_body5 = req.body) == null ? void 0 : _req_body5.name) != null ? _ref6 : ''), {
|
|
836
1062
|
uid: decoded.uid,
|
|
837
|
-
email: (_decoded_email = decoded.email) != null ? _decoded_email : null
|
|
838
|
-
orgId
|
|
1063
|
+
email: (_decoded_email = decoded.email) != null ? _decoded_email : null
|
|
839
1064
|
});
|
|
840
1065
|
if (copied.status === 200) await (claim == null ? void 0 : claim.record(copied.status, copied.body));
|
|
841
1066
|
else await (claim == null ? void 0 : claim.release());
|
|
842
1067
|
return res.status(copied.status).json(copied.body);
|
|
843
1068
|
}
|
|
844
|
-
const result = action === 'deleteCampaign' ? await deleteCampaign(
|
|
1069
|
+
const result = action === 'deleteCampaign' ? await deleteCampaign(firestore, orgId, targetId, {
|
|
1070
|
+
hostId,
|
|
1071
|
+
uid: decoded.uid
|
|
1072
|
+
}) : await discardDraft(firestore, orgId, targetId, hostId);
|
|
845
1073
|
return res.status(result.status).json(result.body);
|
|
846
1074
|
} catch (error) {
|
|
847
1075
|
console.error(error);
|