@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.
Files changed (79) hide show
  1. package/README.md +2 -1
  2. package/package.json +15 -15
  3. package/src/lib/components/campaign-detail-card.d.ts +12 -1
  4. package/src/lib/components/campaign-detail-card.js +146 -35
  5. package/src/lib/components/campaign-detail-card.js.map +1 -1
  6. package/src/lib/components/campaign-edit-drawer.d.ts +13 -0
  7. package/src/lib/components/campaign-edit-drawer.js +56 -4
  8. package/src/lib/components/campaign-edit-drawer.js.map +1 -1
  9. package/src/lib/components/campaign-glance-card.component.js +10 -2
  10. package/src/lib/components/campaign-glance-card.component.js.map +1 -1
  11. package/src/lib/components/campaign-members-section.d.ts +6 -0
  12. package/src/lib/components/campaign-members-section.js +2 -2
  13. package/src/lib/components/campaign-members-section.js.map +1 -1
  14. package/src/lib/components/campaign-queries.d.ts +58 -0
  15. package/src/lib/components/campaign-queries.js +72 -0
  16. package/src/lib/components/campaign-queries.js.map +1 -0
  17. package/src/lib/components/campaign-reach-sections.d.ts +21 -7
  18. package/src/lib/components/campaign-reach-sections.js +20 -17
  19. package/src/lib/components/campaign-reach-sections.js.map +1 -1
  20. package/src/lib/components/campaign-report-card.d.ts +8 -1
  21. package/src/lib/components/campaign-report-card.js +33 -16
  22. package/src/lib/components/campaign-report-card.js.map +1 -1
  23. package/src/lib/components/campaigns-card.d.ts +10 -1
  24. package/src/lib/components/campaigns-card.js +102 -19
  25. package/src/lib/components/campaigns-card.js.map +1 -1
  26. package/src/lib/components/email-compose-card.d.ts +3 -2
  27. package/src/lib/components/email-compose-card.js +18 -7
  28. package/src/lib/components/email-compose-card.js.map +1 -1
  29. package/src/lib/components/email-detail.d.ts +11 -2
  30. package/src/lib/components/email-detail.js +50 -23
  31. package/src/lib/components/email-detail.js.map +1 -1
  32. package/src/lib/components/emails-list-card.d.ts +10 -1
  33. package/src/lib/components/emails-list-card.js +129 -23
  34. package/src/lib/components/emails-list-card.js.map +1 -1
  35. package/src/lib/components/host-marketing-summary-card.component.js +9 -1
  36. package/src/lib/components/host-marketing-summary-card.component.js.map +1 -1
  37. package/src/lib/components/marketing-console-page.js +81 -4
  38. package/src/lib/components/marketing-console-page.js.map +1 -1
  39. package/src/lib/components/marketing-console-sections.d.ts +16 -1
  40. package/src/lib/components/marketing-console-sections.js +25 -2
  41. package/src/lib/components/marketing-console-sections.js.map +1 -1
  42. package/src/lib/components/marketing-org-mount.d.ts +71 -0
  43. package/src/lib/components/marketing-org-mount.js +84 -0
  44. package/src/lib/components/marketing-org-mount.js.map +1 -0
  45. package/src/lib/components/use-campaign-send-api.d.ts +2 -2
  46. package/src/lib/components/use-campaign-send-api.js +22 -6
  47. package/src/lib/components/use-campaign-send-api.js.map +1 -1
  48. package/src/lib/components/use-emails-hub-path.d.ts +13 -3
  49. package/src/lib/components/use-emails-hub-path.js +25 -4
  50. package/src/lib/components/use-emails-hub-path.js.map +1 -1
  51. package/src/lib/components/use-marketing-hub-path.d.ts +19 -16
  52. package/src/lib/components/use-marketing-hub-path.js +6 -32
  53. package/src/lib/components/use-marketing-hub-path.js.map +1 -1
  54. package/src/lib/model/marketing-record-routes.js +11 -4
  55. package/src/lib/model/marketing-record-routes.js.map +1 -1
  56. package/src/lib/plugin.js +31 -1
  57. package/src/lib/plugin.js.map +1 -1
  58. package/src/lib/server/campaign-manage.d.ts +31 -2
  59. package/src/lib/server/campaign-manage.js +365 -137
  60. package/src/lib/server/campaign-manage.js.map +1 -1
  61. package/src/lib/server/campaign-org-refs.d.ts +45 -0
  62. package/src/lib/server/campaign-org-refs.js +83 -0
  63. package/src/lib/server/campaign-org-refs.js.map +1 -0
  64. package/src/lib/server/campaign-process-scheduled.d.ts +32 -2
  65. package/src/lib/server/campaign-process-scheduled.js +79 -9
  66. package/src/lib/server/campaign-process-scheduled.js.map +1 -1
  67. package/src/lib/server/campaign-recipients.js +18 -4
  68. package/src/lib/server/campaign-recipients.js.map +1 -1
  69. package/src/lib/server/campaign-send.js +139 -55
  70. package/src/lib/server/campaign-send.js.map +1 -1
  71. package/src/lib/server/email-campaign-reach.d.ts +6 -5
  72. package/src/lib/server/email-campaign-reach.js +14 -14
  73. package/src/lib/server/email-campaign-reach.js.map +1 -1
  74. package/src/lib/server/email-events.js +48 -17
  75. package/src/lib/server/email-events.js.map +1 -1
  76. package/src/lib/server/marketing-figures.js +12 -1
  77. package/src/lib/server/marketing-figures.js.map +1 -1
  78. package/src/lib/server.js +7 -3
  79. 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
- * `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.
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: `hosts/{hostId}/campaigns` is excluded
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 the same site-role check the send route uses.
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: `consentGroupForSite` and
83
- * `orgDataCollectionForHost` are what the contact pass needs, and taking them
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, 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";
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(hostRef, campaignId) {
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 hostRef.collection('campaigns').where(CAMPAIGN_SEND_CONTAINER_FIELD, '==', campaignId).limit(DETACH_BATCH).get();
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 keeps about it (AGL-3254).
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
- * ## The contacts are reached by a different path, on purpose
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 contact lives on the ORG (`orgs/{orgId}/contacts`) and is shared by every
213
- * site in it, so the membership sits inside this site's consent-group facet
214
- * rather than at the top of the document. The field PATH is therefore the
215
- * scope: only rows this group filed under the campaign match it, and no other
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 group that cannot be resolved answers as the site alone, which is
222
- * {@link consentGroupForSite}'s documented failure direction and the safe one
223
- * here too: the pass then clears the site's own facet and no other.
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, with
230
- * the campaign's id and the field it is held in, after the site's own
231
- * records, and a detacher that reports records
232
- * remaining — or threw, which is reported as `null` — holds the container
233
- * exactly as this pass's own `remaining` does. A plugin that failed is a
234
- * plugin whose records may still name the campaign, and the deletion has
235
- * no way to tell that apart from one that has more than a request clears.
236
- */ async function detachMembers(hostId, hostRef, campaignId) {
237
- const firestore = hostRef.firestore;
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
- for (const collectionName of CAMPAIGN_MEMBER_HOST_COLLECTIONS){
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
- * NO ORG, NO ORG CONTACTS — and that is a skip rather than a failure.
247
- *
248
- * Contacts live at `orgs/{orgId}/contacts`, so a site whose `hostIndex`
249
- * entry names no org holds none of them and there is nothing here to
250
- * detach. Resolving it first is what separates that from a read that FAILED:
251
- * `orgDataCollectionForHost` throws for both, and swallowing both would let
252
- * a transient Firestore error delete the campaign with contacts still
253
- * naming it. A thrown read reaches the handler's 500 and the container
254
- * survives, which is the direction a failure here has to fall.
255
- */ const orgId = await resolveOrgIdForHost(hostId);
256
- const plugins = await runPluginMembershipDetachers({
257
- hostId,
258
- orgId: orgId != null ? orgId : '',
259
- field: CAMPAIGN_MEMBERSHIP_FIELD,
260
- id: campaignId
261
- });
262
- for (const report of Object.values(plugins)){
263
- var _ref;
264
- detached += (_ref = report == null ? void 0 : report.detached) != null ? _ref : 0;
265
- remaining = remaining || report === null || report.remaining;
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
- if (!orgId) return {
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
- */ async function deleteCampaign(hostId, hostRef, campaignId) {
288
- const ref = hostRef.collection('emailCampaigns').doc(campaignId);
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
- */ if (!snapshot.exists) {
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
- const { detached, remaining } = await detachSends(hostRef, campaignId);
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(hostId, hostRef, campaignId);
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
- */ async function discardDraft(hostRef, emailId) {
360
- const ref = hostRef.collection('campaigns').doc(emailId);
361
- const firestore = hostRef.firestore;
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 the site's messages.
445
- */ async function duplicateMessage(hostId, hostRef, emailId, requestedName, actor) {
446
- const messages = hostRef.collection('campaigns');
447
- const firestore = hostRef.firestore;
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: actor.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 the campaigns list's
523
- * create drawer writes one, aimed at no list;
524
- * - ONE EMAIL inside it at `campaigns/{id}-email`, `status: 'draft'`, naming
525
- * the email design it sends and holding its subject, preheader and the
526
- * alternatives to them.
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 campaign = await firestore().collection('hosts').doc(hostId).collection('emailCampaigns').doc(id).get();
667
- return campaign.exists ? campaignDraftRecord(campaign) : null;
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 = hostRef.collection('emailCampaigns').doc(request.id);
805
+ const campaignRef = orgEmailCampaigns(db, orgId).doc(request.id);
684
806
  const emailId = campaignDraftEmailId(request.id);
685
- const emailRef = hostRef.collection('campaigns').doc(emailId);
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
- // The create drawer's own document: a name, no dates, no lists.
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
- export const campaignManageHandler = async (req, res)=>{
772
- var _ref, _ref1, _ref2, _req_headers_authorization;
773
- var _req_body, _req_body1, _req_body2;
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 action = String((_ref1 = (_req_body1 = req.body) == null ? void 0 : _req_body1.action) != null ? _ref1 : '');
781
- const targetId = String((_ref2 = (_req_body2 = req.body) == null ? void 0 : _req_body2.campaignId) != null ? _ref2 : '');
782
- if (!hostId) return res.status(400).json({
783
- error: 'Missing hostId'
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
- const hostRef = firestore.collection('hosts').doc(hostId);
805
- const hostSnapshot = await hostRef.get();
806
- if (!hostSnapshot.exists) {
807
- return res.status(404).json({
808
- error: 'Unknown site'
809
- });
810
- }
811
- const memberRole = ((_hostSnapshot_get = hostSnapshot.get('memberRoles')) != null ? _hostSnapshot_get : {})[decoded.uid];
812
- if (memberRole !== 'admin' && memberRole !== 'editor') {
813
- return res.status(403).json({
814
- error: 'Not a site admin or editor'
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 _ref3, _ref4, _ref5, _decoded_email;
819
- var _req_body3, _req_body4;
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((_ref3 = (_req_body3 = req.body) == null ? void 0 : _req_body3.attemptKey) != null ? _ref3 : '').trim().slice(0, 200);
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(hostId, hostRef, targetId, String((_ref5 = (_req_body4 = req.body) == null ? void 0 : _req_body4.name) != null ? _ref5 : ''), {
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(hostId, hostRef, targetId) : await discardDraft(hostRef, targetId);
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);