@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,219 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
17
+ // Leaf imports, for the reason `email-events.ts` records: a spec that mocks
18
+ // the `@aglyn/tenant-data-admin` barrel would otherwise replace the real
19
+ // delivery-log reader with whatever the factory listed, and this handler's
20
+ // whole behaviour is what that reader returns.
21
+ import { readCampaignEngagement, EMAIL_CAMPAIGN_ENGAGEMENT_MAX_CAMPAIGNS } from "@aglyn/tenant-data-admin/server/email-delivery-log";
22
+ import { isDocumentId } from "@aglyn/tenant-data-admin/server/document-id";
23
+ import { FieldPath } from "firebase-admin/firestore";
24
+ /**
25
+ * When a message went out, or is due to.
26
+ *
27
+ * There is no one date field: the send path writes `sentAt` from one branch
28
+ * and `sendAtMs` from the other, and nothing stamps a `createdAt`. Both are
29
+ * read so a scheduled message sorts beside a sent one instead of falling to
30
+ * the bottom of every list that touches it.
31
+ */ function messageTimeMs(snapshot) {
32
+ var _snapshot_get;
33
+ const sentAt = snapshot.get('sentAt');
34
+ if (typeof (sentAt == null ? void 0 : sentAt.toMillis) === 'function') return sentAt.toMillis();
35
+ const scheduled = Number((_snapshot_get = snapshot.get('sendAtMs')) != null ? _snapshot_get : 0);
36
+ return Number.isFinite(scheduled) ? scheduled : 0;
37
+ }
38
+ /**
39
+ * WHO OPENED THIS, ONE PAGE AT A TIME.
40
+ *
41
+ * ## Why a server route rather than a client listen
42
+ *
43
+ * The per-recipient delivery log lives at `emailDeliveries/{sha256(address)}`
44
+ * — a platform-level collection keyed by a hash of an address, holding the
45
+ * mail of every site on the install and every transactional message besides.
46
+ * No security rule can express "this site's editors may read the rows tagged
47
+ * with this site" over a collection-group query, because the predicate that
48
+ * narrows it is the query's own `where`, and a rule cannot require one. So
49
+ * the narrowing happens here, behind a role check, on a server that holds the
50
+ * admin credential.
51
+ *
52
+ * ## What the role check is
53
+ *
54
+ * Admin or editor on the site — deliberately the SAME role the send path
55
+ * requires, and checked the same way. Whoever may address these people may
56
+ * see which of them opened; a page that were readable by anyone the campaign
57
+ * report is readable by would hand the address list to a role that cannot
58
+ * mail it.
59
+ *
60
+ * ## What comes back, and what does not
61
+ *
62
+ * The rows carry the recipient address, because that is the question. They do
63
+ * not carry the sha256 key, the suppression state, or anything about mail
64
+ * this site did not send: the query is filtered on `hostId` as well as on the
65
+ * message ids, so a row belonging to another site cannot be returned even if
66
+ * a message id were wrong.
67
+ *
68
+ * ## Two scopes, one route
69
+ *
70
+ * `emailId` reads one message; `screenId` reads every message built from one
71
+ * template. They are one route because they are one question asked at two
72
+ * altitudes, and splitting them would mean two role checks, two cursors and
73
+ * two chances for one of them to drift out of agreement with the other.
74
+ */ export const campaignRecipientsHandler = async (req, res)=>{
75
+ var _ref, _ref1, _ref2, _ref3, _ref4, _req_headers_authorization;
76
+ var _req_body, _req_body1, _req_body2, _req_body3, _req_body4;
77
+ if (req.method !== 'POST') {
78
+ return res.status(405).json({
79
+ error: 'Method not allowed'
80
+ });
81
+ }
82
+ const hostId = String((_ref = (_req_body = req.body) == null ? void 0 : _req_body.hostId) != null ? _ref : '');
83
+ const screenId = String((_ref1 = (_req_body1 = req.body) == null ? void 0 : _req_body1.screenId) != null ? _ref1 : '');
84
+ const emailId = String((_ref2 = (_req_body2 = req.body) == null ? void 0 : _req_body2.emailId) != null ? _ref2 : '');
85
+ const rawFilter = String((_ref3 = (_req_body3 = req.body) == null ? void 0 : _req_body3.filter) != null ? _ref3 : 'all');
86
+ const cursor = String((_ref4 = (_req_body4 = req.body) == null ? void 0 : _req_body4.cursor) != null ? _ref4 : '');
87
+ if (!isDocumentId(hostId)) {
88
+ return res.status(400).json({
89
+ error: 'Missing hostId'
90
+ });
91
+ }
92
+ /*
93
+ * ONE scope, named, and validated as a document id.
94
+ *
95
+ * Both are path components on the reads below, so "non-empty" was never the
96
+ * whole question: a value carrying a slash names the nesting as well as the
97
+ * document. Refusing both-or-neither rather than preferring one keeps the
98
+ * answer a function of what was asked, so a caller who sends both because a
99
+ * component forgot to clear a prop is told rather than silently served the
100
+ * wrong scope.
101
+ */ if (Boolean(screenId) === Boolean(emailId)) {
102
+ return res.status(400).json({
103
+ error: 'Name one of screenId or emailId'
104
+ });
105
+ }
106
+ if (screenId && !isDocumentId(screenId)) {
107
+ return res.status(400).json({
108
+ error: 'Invalid screenId'
109
+ });
110
+ }
111
+ if (emailId && !isDocumentId(emailId)) {
112
+ return res.status(400).json({
113
+ error: 'Invalid emailId'
114
+ });
115
+ }
116
+ const filter = rawFilter === 'opened' || rawFilter === 'clicked' ? rawFilter : 'all';
117
+ const authorization = String((_req_headers_authorization = req.headers.authorization) != null ? _req_headers_authorization : '');
118
+ const idToken = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined;
119
+ if (!idToken) return res.status(401).json({
120
+ error: 'Unauthenticated'
121
+ });
122
+ try {
123
+ var _hostSnapshot_get;
124
+ const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
125
+ const firestore = firebaseAdmin.app().firestore();
126
+ const hostRef = firestore.collection('hosts').doc(hostId);
127
+ const hostSnapshot = await hostRef.get();
128
+ if (!hostSnapshot.exists) {
129
+ return res.status(404).json({
130
+ error: 'Unknown site'
131
+ });
132
+ }
133
+ const memberRole = ((_hostSnapshot_get = hostSnapshot.get('memberRoles')) != null ? _hostSnapshot_get : {})[decoded.uid];
134
+ if (memberRole !== 'admin' && memberRole !== 'editor') {
135
+ return res.status(403).json({
136
+ error: 'Not a site admin or editor'
137
+ });
138
+ }
139
+ /*
140
+ * The messages this read covers.
141
+ *
142
+ * A single message is its own id and costs no query. A TEMPLATE resolves
143
+ * to its messages here rather than taking a list of ids from the request:
144
+ * a caller who could name message ids could name another site's, and
145
+ * while the `hostId` filter below would still refuse them, the narrower
146
+ * input is the one that cannot be got wrong later.
147
+ *
148
+ * ORDERED BY DOCUMENT ID, not by date, and that is forced rather than
149
+ * chosen. A sent message carries `sentAt` and a scheduled one carries
150
+ * `sendAtMs`; `orderBy` DROPS every document missing the ordered field,
151
+ * so ordering on either would silently hide half the template's messages.
152
+ * The document id is the one ordering every message satisfies, it needs
153
+ * no composite index beside the equality filter, and the messages are
154
+ * then sorted by send time here — over a window already read, so the
155
+ * "most recent" the caller is told about really is the most recent.
156
+ *
157
+ * One MORE than the reader can span, so the overflow is a fact rather
158
+ * than the surface silently describing thirty messages as if they were
159
+ * all of them.
160
+ */ const ceiling = EMAIL_CAMPAIGN_ENGAGEMENT_MAX_CAMPAIGNS + 1;
161
+ const messageDocs = emailId ? [
162
+ await hostRef.collection('campaigns').doc(emailId).get()
163
+ ].filter((snapshot)=>snapshot.exists) : (await hostRef.collection('campaigns').where('templateScreenId', '==', screenId).orderBy(FieldPath.documentId()).limit(ceiling).get()).docs;
164
+ if (emailId && !messageDocs.length) {
165
+ return res.status(404).json({
166
+ error: 'Unknown email'
167
+ });
168
+ }
169
+ const ordered = [
170
+ ...messageDocs
171
+ ].sort((a, b)=>messageTimeMs(b) - messageTimeMs(a));
172
+ const campaignIds = ordered.map((doc)=>doc.id);
173
+ const subjects = {};
174
+ for (const doc of ordered){
175
+ var _doc_get;
176
+ subjects[doc.id] = String((_doc_get = doc.get('subject')) != null ? _doc_get : '');
177
+ }
178
+ const page = await readCampaignEngagement({
179
+ hostId,
180
+ campaignIds,
181
+ filter,
182
+ cursor: cursor || null,
183
+ firestore
184
+ });
185
+ return res.status(200).json({
186
+ /*
187
+ * The rows, reduced to what the table renders. Not the stored document:
188
+ * `provider`, `bounceType` and `detail` are operational fields a
189
+ * merchant has no reading for, and shipping every field of an internal
190
+ * record to a browser is how one becomes an accidental contract.
191
+ */ rows: page.rows.map((row)=>{
192
+ var _ref, _row_subject, _row_campaignId, _ref1, _ref2, _row_timestamps_clicked;
193
+ return {
194
+ messageId: row.messageId,
195
+ to: row.to,
196
+ subject: (_ref = (_row_subject = row.subject) != null ? _row_subject : subjects[(_row_campaignId = row.campaignId) != null ? _row_campaignId : '']) != null ? _ref : null,
197
+ campaignId: row.campaignId,
198
+ status: row.status,
199
+ openCount: row.openCount,
200
+ clickCount: row.clickCount,
201
+ clickedLinks: row.clickedLinks,
202
+ firstSeenAtMs: row.firstSeenAtMs,
203
+ lastEventAtMs: (_ref1 = (_ref2 = (_row_timestamps_clicked = row.timestamps.clicked) != null ? _row_timestamps_clicked : row.timestamps.opened) != null ? _ref2 : row.timestamps.delivered) != null ? _ref1 : row.firstSeenAtMs
204
+ };
205
+ }),
206
+ cursor: page.cursor,
207
+ lookupFailed: page.lookupFailed,
208
+ campaignsRead: campaignIds.length - page.campaignsOmitted,
209
+ campaignsOmitted: page.campaignsOmitted
210
+ });
211
+ } catch (error) {
212
+ console.error(error);
213
+ return res.status(500).json({
214
+ error: 'Could not read recipients'
215
+ });
216
+ }
217
+ };
218
+
219
+ //# sourceMappingURL=campaign-recipients.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/server/campaign-recipients.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\n// Leaf imports, for the reason `email-events.ts` records: a spec that mocks\n// the `@aglyn/tenant-data-admin` barrel would otherwise replace the real\n// delivery-log reader with whatever the factory listed, and this handler's\n// whole behaviour is what that reader returns.\nimport {\n readCampaignEngagement,\n EMAIL_CAMPAIGN_ENGAGEMENT_MAX_CAMPAIGNS,\n type EmailEngagementFilter,\n} from '@aglyn/tenant-data-admin/server/email-delivery-log'\nimport { isDocumentId } from '@aglyn/tenant-data-admin/server/document-id'\nimport { FieldPath } from 'firebase-admin/firestore'\n\n/**\n * When a message went out, or is due to.\n *\n * There is no one date field: the send path writes `sentAt` from one branch\n * and `sendAtMs` from the other, and nothing stamps a `createdAt`. Both are\n * read so a scheduled message sorts beside a sent one instead of falling to\n * the bottom of every list that touches it.\n */\nfunction messageTimeMs(snapshot: any): number {\n const sentAt = snapshot.get('sentAt')\n if (typeof sentAt?.toMillis === 'function') return sentAt.toMillis()\n const scheduled = Number(snapshot.get('sendAtMs') ?? 0)\n return Number.isFinite(scheduled) ? scheduled : 0\n}\n\n/**\n * WHO OPENED THIS, ONE PAGE AT A TIME.\n *\n * ## Why a server route rather than a client listen\n *\n * The per-recipient delivery log lives at `emailDeliveries/{sha256(address)}`\n * — a platform-level collection keyed by a hash of an address, holding the\n * mail of every site on the install and every transactional message besides.\n * No security rule can express \"this site's editors may read the rows tagged\n * with this site\" over a collection-group query, because the predicate that\n * narrows it is the query's own `where`, and a rule cannot require one. So\n * the narrowing happens here, behind a role check, on a server that holds the\n * admin credential.\n *\n * ## What the role check is\n *\n * Admin or editor on the site — deliberately the SAME role the send path\n * requires, and checked the same way. Whoever may address these people may\n * see which of them opened; a page that were readable by anyone the campaign\n * report is readable by would hand the address list to a role that cannot\n * mail it.\n *\n * ## What comes back, and what does not\n *\n * The rows carry the recipient address, because that is the question. They do\n * not carry the sha256 key, the suppression state, or anything about mail\n * this site did not send: the query is filtered on `hostId` as well as on the\n * message ids, so a row belonging to another site cannot be returned even if\n * a message id were wrong.\n *\n * ## Two scopes, one route\n *\n * `emailId` reads one message; `screenId` reads every message built from one\n * template. They are one route because they are one question asked at two\n * altitudes, and splitting them would mean two role checks, two cursors and\n * two chances for one of them to drift out of agreement with the other.\n */\nexport const campaignRecipientsHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n\n const hostId = String(req.body?.hostId ?? '')\n const screenId = String(req.body?.screenId ?? '')\n const emailId = String(req.body?.emailId ?? '')\n const rawFilter = String(req.body?.filter ?? 'all')\n const cursor = String(req.body?.cursor ?? '')\n if (!isDocumentId(hostId)) {\n return res.status(400).json({ error: 'Missing hostId' })\n }\n /*\n * ONE scope, named, and validated as a document id.\n *\n * Both are path components on the reads below, so \"non-empty\" was never the\n * whole question: a value carrying a slash names the nesting as well as the\n * document. Refusing both-or-neither rather than preferring one keeps the\n * answer a function of what was asked, so a caller who sends both because a\n * component forgot to clear a prop is told rather than silently served the\n * wrong scope.\n */\n if (Boolean(screenId) === Boolean(emailId)) {\n return res.status(400).json({ error: 'Name one of screenId or emailId' })\n }\n if (screenId && !isDocumentId(screenId)) {\n return res.status(400).json({ error: 'Invalid screenId' })\n }\n if (emailId && !isDocumentId(emailId)) {\n return res.status(400).json({ error: 'Invalid emailId' })\n }\n const filter: EmailEngagementFilter =\n rawFilter === 'opened' || rawFilter === 'clicked' ? rawFilter : 'all'\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown site' })\n }\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return res.status(403).json({ error: 'Not a site admin or editor' })\n }\n\n /*\n * The messages this read covers.\n *\n * A single message is its own id and costs no query. A TEMPLATE resolves\n * to its messages here rather than taking a list of ids from the request:\n * a caller who could name message ids could name another site's, and\n * while the `hostId` filter below would still refuse them, the narrower\n * input is the one that cannot be got wrong later.\n *\n * ORDERED BY DOCUMENT ID, not by date, and that is forced rather than\n * chosen. A sent message carries `sentAt` and a scheduled one carries\n * `sendAtMs`; `orderBy` DROPS every document missing the ordered field,\n * so ordering on either would silently hide half the template's messages.\n * The document id is the one ordering every message satisfies, it needs\n * no composite index beside the equality filter, and the messages are\n * then sorted by send time here — over a window already read, so the\n * \"most recent\" the caller is told about really is the most recent.\n *\n * One MORE than the reader can span, so the overflow is a fact rather\n * than the surface silently describing thirty messages as if they were\n * all of them.\n */\n const ceiling = EMAIL_CAMPAIGN_ENGAGEMENT_MAX_CAMPAIGNS + 1\n const messageDocs = emailId\n ? [await hostRef.collection('campaigns').doc(emailId).get()].filter(\n (snapshot: any) => snapshot.exists,\n )\n : (\n await hostRef\n .collection('campaigns')\n .where('templateScreenId', '==', screenId)\n .orderBy(FieldPath.documentId())\n .limit(ceiling)\n .get()\n ).docs\n if (emailId && !messageDocs.length) {\n return res.status(404).json({ error: 'Unknown email' })\n }\n const ordered = [...messageDocs].sort(\n (a: any, b: any) => messageTimeMs(b) - messageTimeMs(a),\n )\n const campaignIds = ordered.map((doc: any) => doc.id)\n const subjects: Record<string, string> = {}\n for (const doc of ordered) {\n subjects[doc.id] = String(doc.get('subject') ?? '')\n }\n\n const page = await readCampaignEngagement({\n hostId,\n campaignIds,\n filter,\n cursor: cursor || null,\n firestore,\n })\n\n return res.status(200).json({\n /*\n * The rows, reduced to what the table renders. Not the stored document:\n * `provider`, `bounceType` and `detail` are operational fields a\n * merchant has no reading for, and shipping every field of an internal\n * record to a browser is how one becomes an accidental contract.\n */\n rows: page.rows.map((row) => ({\n messageId: row.messageId,\n to: row.to,\n subject: row.subject ?? subjects[row.campaignId ?? ''] ?? null,\n campaignId: row.campaignId,\n status: row.status,\n openCount: row.openCount,\n clickCount: row.clickCount,\n clickedLinks: row.clickedLinks,\n firstSeenAtMs: row.firstSeenAtMs,\n lastEventAtMs:\n row.timestamps.clicked ??\n row.timestamps.opened ??\n row.timestamps.delivered ??\n row.firstSeenAtMs,\n })),\n cursor: page.cursor,\n lookupFailed: page.lookupFailed,\n campaignsRead: campaignIds.length - page.campaignsOmitted,\n campaignsOmitted: page.campaignsOmitted,\n })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Could not read recipients' })\n }\n}\n"],"names":["firebaseAdmin","readCampaignEngagement","EMAIL_CAMPAIGN_ENGAGEMENT_MAX_CAMPAIGNS","isDocumentId","FieldPath","messageTimeMs","snapshot","sentAt","get","toMillis","scheduled","Number","isFinite","campaignRecipientsHandler","req","res","method","status","json","error","hostId","String","body","screenId","emailId","rawFilter","filter","cursor","Boolean","authorization","headers","idToken","startsWith","slice","length","undefined","hostSnapshot","decoded","app","auth","verifyIdToken","firestore","hostRef","collection","doc","exists","memberRole","uid","ceiling","messageDocs","where","orderBy","documentId","limit","docs","ordered","sort","a","b","campaignIds","map","id","subjects","page","rows","row","messageId","to","subject","campaignId","openCount","clickCount","clickedLinks","firstSeenAtMs","lastEventAtMs","timestamps","clicked","opened","delivered","lookupFailed","campaignsRead","campaignsOmitted","console"],"mappings":"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"}