@aglyn/plugins-marketing 1.0.0-beta.173 → 1.0.0-beta.175

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
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/server/campaign-process-scheduled.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 { writeCronBeat, type PluginApiHandler } from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport {\n CampaignSendDeferredError,\n CampaignSendError,\n performCampaignSend,\n type CampaignSendResult,\n} from './campaign-send'\n\n/**\n * Scheduled-campaign processor (AGL-272): scheduler-invoked (Cloud\n * Scheduler / cron, x-cron-secret like report-usage), it claims due\n * `status: 'scheduled'` campaigns across every host and delivers them\n * through the shared send core. A transaction flips scheduled → sending\n * so overlapping runs never double-send; failures mark the campaign\n * `failed` with the reason instead of retrying forever.\n */\nexport const campaignProcessScheduledHandler: PluginApiHandler = async (\n req,\n res,\n) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const cronSecret = process.env.CRON_SECRET\n if (!cronSecret) {\n return res\n .status(501)\n .json({ error: 'Scheduling is not configured (CRON_SECRET).' })\n }\n if (req.headers['x-cron-secret'] !== cronSecret) {\n return res.status(401).json({ error: 'Unauthenticated' })\n }\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n // AGL-1955 — the mark `/api/health/crons` reads to notice this job going\n // AWAY. This is the one route in the set that has already been sold,\n // wired and completely inert once (AGL-2134): the composer wrote\n // `status: 'scheduled'` and nothing ever POSTed here, so a customer's\n // campaign sat in the collection until somebody cancelled it. Nothing\n // downstream ages when this stops — a fortnight with no due campaign\n // produces exactly the same silence as a deleted schedule — so the\n // invocation itself is the only honest thing to watch.\n await writeCronBeat(firestore, 'campaigns-process-scheduled')\n const due = await firestore\n .collectionGroup('campaigns')\n .where('status', '==', 'scheduled')\n .where('sendAtMs', '<=', Date.now())\n .limit(10)\n .get()\n\n const results: Array<Record<string, unknown> | CampaignSendResult> = []\n for (const campaignDoc of due.docs) {\n const hostRef = campaignDoc.ref.parent.parent\n if (!hostRef) continue\n const claimed = await firestore.runTransaction(async (transaction) => {\n const fresh = await transaction.get(campaignDoc.ref)\n if (fresh.get('status') !== 'scheduled') return false\n transaction.update(campaignDoc.ref, { status: 'sending' })\n return true\n })\n if (!claimed) continue\n const data = campaignDoc.data()\n /*\n * IS THIS A CAMPAIGN, OR THE REST OF ONE?\n *\n * An audience larger than one send may carry goes out over several\n * runs. Each batch writes the email back as `scheduled` with a `resume`\n * map, so this query picks it up again — the same claim, the same\n * transaction, the same send core. The presence of a batch count is\n * what tells the two apart, and it is read off the record rather than\n * passed in because there is nobody to pass it: a merchant pressed Send\n * once, possibly hours ago.\n *\n * It matters more than a label. A continuation subtracts everyone the\n * email has already SETTLED and leaves the audience figures the first\n * batch recorded alone; a first send does neither. Getting this wrong\n * in the false direction mails people a second copy.\n */\n const resumingBatch =\n Math.max(0, Math.floor(Number(data['resume']?.batch ?? 0)) || 0) > 0\n try {\n const result = await performCampaignSend({\n hostId: hostRef.id,\n ...(resumingBatch ? { continuation: true } : {}),\n subject: String(data['subject'] ?? ''),\n body: String(data['body'] ?? ''),\n audience: String(data['audience'] ?? 'leads'),\n segmentId: String(data['segmentId'] ?? ''),\n listId: String(data['listId'] ?? ''),\n // A scheduled campaign carries the topic it was composed under, not\n // whatever the default is on the day the cron picks it up: the\n // unsubscribe links it mints have to name the stream the author\n // chose. Absent on every campaign scheduled before topics existed,\n // which `performCampaignSend` resolves to the default.\n topicId: String(data['topicId'] ?? '') || undefined,\n emails: Array.isArray(data['emails'])\n ? data['emails'].map(String)\n : undefined,\n campaignId: campaignDoc.id,\n experimentId: String(data['experimentId'] ?? ''),\n templateScreenId: String(data['templateScreenId'] ?? '') || undefined,\n /*\n * The plain-text part the author wrote, read back with the design.\n * Regenerating one from the design at cron time would mail a text\n * half the merchant never reviewed — and a different one from what\n * the composer previewed and the test send delivered.\n */\n plainText: String(data['plainText'] ?? '') || undefined,\n // The sender fields the composer chose. Read back rather than\n // resolved fresh: a scheduled campaign must go out as the message\n // that was composed, not as whatever the org's branding says an hour\n // later.\n fromName: String(data['fromName'] ?? ''),\n replyTo: String(data['replyTo'] ?? ''),\n preheader: String(data['preheader'] ?? ''),\n emailCampaignId: String(data['emailCampaignId'] ?? ''),\n /*\n * Whoever sent it, and `scheduledBy` is not that person on a\n * campaign a merchant sent by hand — an immediate send that batches\n * has no `scheduledBy` at all. `sentBy` is what the first batch\n * recorded, so the later ones are attributed to the same person\n * rather than to the cron.\n */\n senderUid: String(\n data['scheduledBy'] ?? data['sentBy'] ?? 'scheduler',\n ),\n })\n results.push(result)\n } catch (error) {\n /*\n * DEFERRED IS NOT FAILED (AGL-2409).\n *\n * The platform send-rate governor had no room for this campaign in\n * the current hour and NOTHING WAS SENT — the admission check runs\n * before the first message and before any counter moves, which is\n * what makes this safe to retry. So the claim is released by putting\n * the row back to `scheduled`, and the next 15-minute run picks it up.\n *\n * Marking it `failed` here — which is the right answer for every\n * other `CampaignSendError`, an empty audience or a stopped\n * experiment — would turn a ramp into a lost campaign that the\n * merchant has to notice in the History list and re-create by hand.\n * That is the failure mode the ceiling exists to avoid making worse.\n */\n if (error instanceof CampaignSendDeferredError) {\n await campaignDoc.ref\n .set(\n {\n status: 'scheduled',\n deferredReason: error.message,\n deferredUntilMs: error.retryAtMs,\n /*\n * Due when the window that deferred it rolls, rather than\n * immediately. Without this the row stays due and every\n * fifteen-minute run re-resolves an audience of up to five\n * thousand documents to be told the same no — which for a\n * workspace ramped to a day is ninety-six pointless\n * resolutions of somebody's whole contact list.\n */\n sendAtMs: error.retryAtMs,\n },\n { merge: true },\n )\n .catch(() => undefined)\n results.push({\n campaignId: campaignDoc.id,\n deferred: true,\n retryAtMs: error.retryAtMs,\n })\n continue\n }\n const message =\n error instanceof CampaignSendError\n ? error.message\n : 'Campaign send failed'\n if (!(error instanceof CampaignSendError)) console.error(error)\n await campaignDoc.ref\n .set(\n {\n status: 'failed',\n error: message,\n failedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n .catch(() => undefined)\n results.push({ campaignId: campaignDoc.id, error: message })\n }\n }\n return res.status(200).json({ processed: results.length, results })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Processing failed' })\n }\n}\n"],"names":["writeCronBeat","firebaseAdmin","CampaignSendDeferredError","CampaignSendError","performCampaignSend","campaignProcessScheduledHandler","req","res","method","status","json","error","cronSecret","process","env","CRON_SECRET","headers","firestore","app","due","collectionGroup","where","Date","now","limit","get","results","campaignDoc","docs","data","hostRef","ref","parent","claimed","runTransaction","transaction","fresh","update","resumingBatch","Math","max","floor","Number","batch","result","hostId","id","continuation","subject","String","body","audience","segmentId","listId","topicId","undefined","emails","Array","isArray","map","campaignId","experimentId","templateScreenId","plainText","fromName","replyTo","preheader","emailCampaignId","senderUid","push","set","deferredReason","message","deferredUntilMs","retryAtMs","sendAtMs","merge","catch","deferred","console","failedAt","FieldValue","serverTimestamp","processed","length"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/server/campaign-process-scheduled.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 { writeCronBeat, type PluginApiHandler } from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport {\n CampaignSendDeferredError,\n CampaignSendError,\n performCampaignSend,\n type CampaignSendResult,\n} from './campaign-send'\n\n/** The most sends one run delivers. */\nconst PROCESS_BATCH = 10\n\n/**\n * How many due sends one run reads to find {@link PROCESS_BATCH} it may\n * deliver.\n *\n * Wider than the batch because the collection-group query cannot be narrowed\n * to one parent without a composite index, and it also returns sends still\n * at the site path that are waiting for the migration (see\n * {@link scheduledSendSite}). Those are skipped, so without the margin ten\n * of them would crowd every organization's sends out of every run until the\n * migration reached them.\n */\nconst PROCESS_READ = 30\n\n/**\n * Which site a due send is sent as, or why it is not this run's to send.\n *\n * `collectionGroup('campaigns')` matches every collection of that name, so\n * the answer depends on where the document is:\n *\n * - `orgs/{orgId}/campaigns/{id}` — a send, whose site is its own\n * `hostId` field. One that records none cannot be delivered, because the\n * sender, the consent group and the unsubscribe signature are all the\n * site's; it is skipped and logged rather than guessed at.\n * - `hosts/{hostId}/campaigns/{id}` — a send the migration has not moved\n * yet. NOT delivered and NOT claimed: the send core records every send\n * under the org, so delivering this one would split it across two\n * documents, the counters on one and the copy on the other. It stays\n * `scheduled`, and the migration moves it with its site stamped, after\n * which the next run delivers it.\n * - anything else — some other collection named `campaigns`, never ours.\n */\nexport function scheduledSendSite(\n ref: { path: string },\n data: Record<string, unknown>,\n):\n | { kind: 'send'; hostId: string }\n | { kind: 'legacy' }\n | { kind: 'unsited' }\n | { kind: 'foreign' } {\n const segments = String(ref?.path ?? '').split('/')\n const ownerCollection = segments.length === 4 ? segments[0] : ''\n if (ownerCollection === 'orgs') {\n const hostId = data['hostId']\n return typeof hostId === 'string' && hostId && !hostId.includes('/')\n ? { kind: 'send', hostId }\n : { kind: 'unsited' }\n }\n if (ownerCollection === 'hosts') return { kind: 'legacy' }\n return { kind: 'foreign' }\n}\n\n/**\n * Scheduled-campaign processor (AGL-272): scheduler-invoked (Cloud\n * Scheduler / cron, x-cron-secret like report-usage), it claims due\n * `status: 'scheduled'` sends across every organization and delivers them\n * through the shared send core. A transaction flips scheduled → sending\n * so overlapping runs never double-send; failures mark the send\n * `failed` with the reason instead of retrying forever.\n */\nexport const campaignProcessScheduledHandler: PluginApiHandler = async (\n req,\n res,\n) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const cronSecret = process.env.CRON_SECRET\n if (!cronSecret) {\n return res\n .status(501)\n .json({ error: 'Scheduling is not configured (CRON_SECRET).' })\n }\n if (req.headers['x-cron-secret'] !== cronSecret) {\n return res.status(401).json({ error: 'Unauthenticated' })\n }\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n // AGL-1955 — the mark `/api/health/crons` reads to notice this job going\n // AWAY. This is the one route in the set that has already been sold,\n // wired and completely inert once (AGL-2134): the composer wrote\n // `status: 'scheduled'` and nothing ever POSTed here, so a customer's\n // campaign sat in the collection until somebody cancelled it. Nothing\n // downstream ages when this stops — a fortnight with no due campaign\n // produces exactly the same silence as a deleted schedule — so the\n // invocation itself is the only honest thing to watch.\n await writeCronBeat(firestore, 'campaigns-process-scheduled')\n const due = await firestore\n .collectionGroup('campaigns')\n .where('status', '==', 'scheduled')\n .where('sendAtMs', '<=', Date.now())\n .limit(PROCESS_READ)\n .get()\n\n const results: Array<Record<string, unknown> | CampaignSendResult> = []\n /** Due sends still at a site path, left for the migration. */\n let awaitingMigration = 0\n /** Due org sends that name no site, and so cannot be sent. */\n let unsited = 0\n let attempted = 0\n for (const campaignDoc of due.docs) {\n if (attempted >= PROCESS_BATCH) break\n const site = scheduledSendSite(\n campaignDoc.ref,\n (campaignDoc.data() ?? {}) as Record<string, unknown>,\n )\n if (site.kind === 'legacy') {\n awaitingMigration += 1\n continue\n }\n if (site.kind === 'unsited') {\n unsited += 1\n console.error(\n `[campaigns] scheduled send ${campaignDoc.ref.path} names no site and was not sent`,\n )\n continue\n }\n if (site.kind !== 'send') continue\n attempted += 1\n const claimed = await firestore.runTransaction(async (transaction) => {\n const fresh = await transaction.get(campaignDoc.ref)\n if (fresh.get('status') !== 'scheduled') return false\n transaction.update(campaignDoc.ref, { status: 'sending' })\n return true\n })\n if (!claimed) continue\n const data = campaignDoc.data()\n /*\n * IS THIS A CAMPAIGN, OR THE REST OF ONE?\n *\n * An audience larger than one send may carry goes out over several\n * runs. Each batch writes the email back as `scheduled` with a `resume`\n * map, so this query picks it up again — the same claim, the same\n * transaction, the same send core. The presence of a batch count is\n * what tells the two apart, and it is read off the record rather than\n * passed in because there is nobody to pass it: a merchant pressed Send\n * once, possibly hours ago.\n *\n * It matters more than a label. A continuation subtracts everyone the\n * email has already SETTLED and leaves the audience figures the first\n * batch recorded alone; a first send does neither. Getting this wrong\n * in the false direction mails people a second copy.\n */\n const resumingBatch =\n Math.max(0, Math.floor(Number(data['resume']?.batch ?? 0)) || 0) > 0\n try {\n const result = await performCampaignSend({\n hostId: site.hostId,\n ...(resumingBatch ? { continuation: true } : {}),\n subject: String(data['subject'] ?? ''),\n body: String(data['body'] ?? ''),\n audience: String(data['audience'] ?? 'leads'),\n segmentId: String(data['segmentId'] ?? ''),\n listId: String(data['listId'] ?? ''),\n // A scheduled campaign carries the topic it was composed under, not\n // whatever the default is on the day the cron picks it up: the\n // unsubscribe links it mints have to name the stream the author\n // chose. Absent on every campaign scheduled before topics existed,\n // which `performCampaignSend` resolves to the default.\n topicId: String(data['topicId'] ?? '') || undefined,\n emails: Array.isArray(data['emails'])\n ? data['emails'].map(String)\n : undefined,\n campaignId: campaignDoc.id,\n experimentId: String(data['experimentId'] ?? ''),\n templateScreenId: String(data['templateScreenId'] ?? '') || undefined,\n /*\n * The plain-text part the author wrote, read back with the design.\n * Regenerating one from the design at cron time would mail a text\n * half the merchant never reviewed — and a different one from what\n * the composer previewed and the test send delivered.\n */\n plainText: String(data['plainText'] ?? '') || undefined,\n // The sender fields the composer chose. Read back rather than\n // resolved fresh: a scheduled campaign must go out as the message\n // that was composed, not as whatever the org's branding says an hour\n // later.\n fromName: String(data['fromName'] ?? ''),\n replyTo: String(data['replyTo'] ?? ''),\n preheader: String(data['preheader'] ?? ''),\n emailCampaignId: String(data['emailCampaignId'] ?? ''),\n /*\n * Whoever sent it, and `scheduledBy` is not that person on a\n * campaign a merchant sent by hand — an immediate send that batches\n * has no `scheduledBy` at all. `sentBy` is what the first batch\n * recorded, so the later ones are attributed to the same person\n * rather than to the cron.\n */\n senderUid: String(\n data['scheduledBy'] ?? data['sentBy'] ?? 'scheduler',\n ),\n })\n results.push(result)\n } catch (error) {\n /*\n * DEFERRED IS NOT FAILED (AGL-2409).\n *\n * The platform send-rate governor had no room for this campaign in\n * the current hour and NOTHING WAS SENT — the admission check runs\n * before the first message and before any counter moves, which is\n * what makes this safe to retry. So the claim is released by putting\n * the row back to `scheduled`, and the next 15-minute run picks it up.\n *\n * Marking it `failed` here — which is the right answer for every\n * other `CampaignSendError`, an empty audience or a stopped\n * experiment — would turn a ramp into a lost campaign that the\n * merchant has to notice in the History list and re-create by hand.\n * That is the failure mode the ceiling exists to avoid making worse.\n */\n if (error instanceof CampaignSendDeferredError) {\n await campaignDoc.ref\n .set(\n {\n status: 'scheduled',\n deferredReason: error.message,\n deferredUntilMs: error.retryAtMs,\n /*\n * Due when the window that deferred it rolls, rather than\n * immediately. Without this the row stays due and every\n * fifteen-minute run re-resolves an audience of up to five\n * thousand documents to be told the same no — which for a\n * workspace ramped to a day is ninety-six pointless\n * resolutions of somebody's whole contact list.\n */\n sendAtMs: error.retryAtMs,\n },\n { merge: true },\n )\n .catch(() => undefined)\n results.push({\n campaignId: campaignDoc.id,\n deferred: true,\n retryAtMs: error.retryAtMs,\n })\n continue\n }\n const message =\n error instanceof CampaignSendError\n ? error.message\n : 'Campaign send failed'\n if (!(error instanceof CampaignSendError)) console.error(error)\n await campaignDoc.ref\n .set(\n {\n status: 'failed',\n error: message,\n failedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n .catch(() => undefined)\n results.push({ campaignId: campaignDoc.id, error: message })\n }\n }\n if (awaitingMigration) {\n console.warn(\n `[campaigns] ${awaitingMigration} due send(s) are still at a site path and wait for the migration`,\n )\n }\n return res.status(200).json({\n processed: results.length,\n results,\n ...(awaitingMigration ? { awaitingMigration } : {}),\n ...(unsited ? { unsited } : {}),\n })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Processing failed' })\n }\n}\n"],"names":["writeCronBeat","firebaseAdmin","CampaignSendDeferredError","CampaignSendError","performCampaignSend","PROCESS_BATCH","PROCESS_READ","scheduledSendSite","ref","data","segments","String","path","split","ownerCollection","length","hostId","includes","kind","campaignProcessScheduledHandler","req","res","method","status","json","error","cronSecret","process","env","CRON_SECRET","headers","firestore","app","due","collectionGroup","where","Date","now","limit","get","results","awaitingMigration","unsited","attempted","campaignDoc","docs","site","console","claimed","runTransaction","transaction","fresh","update","resumingBatch","Math","max","floor","Number","batch","result","continuation","subject","body","audience","segmentId","listId","topicId","undefined","emails","Array","isArray","map","campaignId","id","experimentId","templateScreenId","plainText","fromName","replyTo","preheader","emailCampaignId","senderUid","push","set","deferredReason","message","deferredUntilMs","retryAtMs","sendAtMs","merge","catch","deferred","failedAt","FieldValue","serverTimestamp","warn","processed"],"mappings":"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"}
@@ -13,7 +13,7 @@
13
13
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
- */ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
16
+ */ import { firebaseAdmin, resolveOrgIdForHost } from "@aglyn/tenant-data-admin";
17
17
  // Leaf imports, for the reason `email-events.ts` records: a spec that mocks
18
18
  // the `@aglyn/tenant-data-admin` barrel would otherwise replace the real
19
19
  // delivery-log reader with whatever the factory listed, and this handler's
@@ -21,6 +21,8 @@
21
21
  import { readCampaignEngagement, EMAIL_CAMPAIGN_ENGAGEMENT_MAX_CAMPAIGNS } from "@aglyn/tenant-data-admin/server/email-delivery-log";
22
22
  import { isDocumentId } from "@aglyn/tenant-data-admin/server/document-id";
23
23
  import { FieldPath } from "firebase-admin/firestore";
24
+ import { CAMPAIGN_SEND_HOST_FIELD } from "@aglyn/shared-ui-email-campaigns/model";
25
+ import { orgCampaignSends, sendIsOnHost } from "./campaign-org-refs.js";
24
26
  /**
25
27
  * When a message went out, or is due to.
26
28
  *
@@ -137,6 +139,17 @@ import { FieldPath } from "firebase-admin/firestore";
137
139
  });
138
140
  }
139
141
  /*
142
+ * The messages are the organization's, one collection for every site, so
143
+ * each read below is narrowed to the ones sent as THIS site — by equality
144
+ * on the template query, and by the stored site on a single message.
145
+ */ const orgId = await resolveOrgIdForHost(hostId);
146
+ if (!orgId) {
147
+ return res.status(404).json({
148
+ error: emailId ? 'Unknown email' : 'Unknown design'
149
+ });
150
+ }
151
+ const sends = orgCampaignSends(firestore, orgId);
152
+ /*
140
153
  * The messages this read covers.
141
154
  *
142
155
  * A single message is its own id and costs no query. A TEMPLATE resolves
@@ -150,7 +163,8 @@ import { FieldPath } from "firebase-admin/firestore";
150
163
  * `sendAtMs`; `orderBy` DROPS every document missing the ordered field,
151
164
  * so ordering on either would silently hide half the template's messages.
152
165
  * The document id is the one ordering every message satisfies, it needs
153
- * no composite index beside the equality filter, and the messages are
166
+ * no composite index beside the two equality filters — Firestore merges
167
+ * their single-field indexes in document-id order — and the messages are
154
168
  * then sorted by send time here — over a window already read, so the
155
169
  * "most recent" the caller is told about really is the most recent.
156
170
  *
@@ -159,8 +173,8 @@ import { FieldPath } from "firebase-admin/firestore";
159
173
  * all of them.
160
174
  */ const ceiling = EMAIL_CAMPAIGN_ENGAGEMENT_MAX_CAMPAIGNS + 1;
161
175
  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;
176
+ await sends.doc(emailId).get()
177
+ ].filter((snapshot)=>snapshot.exists && sendIsOnHost(snapshot, hostId)) : (await sends.where(CAMPAIGN_SEND_HOST_FIELD, '==', hostId).where('templateScreenId', '==', screenId).orderBy(FieldPath.documentId()).limit(ceiling).get()).docs;
164
178
  if (emailId && !messageDocs.length) {
165
179
  return res.status(404).json({
166
180
  error: 'Unknown email'
@@ -1 +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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"}
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, resolveOrgIdForHost } 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'\nimport { CAMPAIGN_SEND_HOST_FIELD } from '@aglyn/shared-ui-email-campaigns/model'\nimport { orgCampaignSends, sendIsOnHost } from './campaign-org-refs'\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 * The messages are the organization's, one collection for every site, so\n * each read below is narrowed to the ones sent as THIS site — by equality\n * on the template query, and by the stored site on a single message.\n */\n const orgId = await resolveOrgIdForHost(hostId)\n if (!orgId) {\n return res.status(404).json({ error: emailId ? 'Unknown email' : 'Unknown design' })\n }\n const sends = orgCampaignSends(firestore, orgId)\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 two equality filters — Firestore merges\n * their single-field indexes in document-id order — 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 sends.doc(emailId).get()].filter(\n (snapshot: any) => snapshot.exists && sendIsOnHost(snapshot, hostId),\n )\n : (\n await sends\n .where(CAMPAIGN_SEND_HOST_FIELD, '==', hostId)\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","resolveOrgIdForHost","readCampaignEngagement","EMAIL_CAMPAIGN_ENGAGEMENT_MAX_CAMPAIGNS","isDocumentId","FieldPath","CAMPAIGN_SEND_HOST_FIELD","orgCampaignSends","sendIsOnHost","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","orgId","sends","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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"}