@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 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/server/campaign-manage.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\n/**\n * TAKING SOMETHING AWAY: a campaign container, and an abandoned draft.\n *\n * Both are removals, and neither may take a delivered message with it. That\n * is the whole reason this file is small and the comments are long.\n *\n * ## Why these two are not client writes\n *\n * The console creates and edits a campaign CONTAINER with the client SDK —\n * `hosts/{hostId}/emailCampaigns` is deliberately outside the rules'\n * server-only exclusion list, because a container holds no counter, no\n * consent record and no entitlement input.\n *\n * The SEND collection is the opposite: `hosts/{hostId}/campaigns` is excluded\n * from client create, update AND delete, because each document is the record\n * of what a merchant mailed and to whom they were allowed to mail it. Both\n * operations here have to touch it — one to detach sends from the container\n * being removed, the other to remove a draft outright — so both run on the\n * Admin SDK behind the same site-role check the send route uses.\n *\n * ## What deleting a campaign MEANS\n *\n * The container goes; every email inside it stays, and reads afterwards as a\n * single send.\n *\n * It cannot mean anything else. A send id is cited by mail already delivered\n * — every unsubscribe footer carries `cid={sendId}` inside its own HMAC, and\n * `/marketing/campaigns/{sendId}` is a URL merchants paste into their own\n * messages — so deleting a container that destroyed its sends would break\n * opt-out links that must go on resolving forever, which is a compliance\n * failure rather than a broken page.\n *\n * Nor may the sends simply be left pointing at a container that is gone.\n * `campaignListRows` groups sends by the container they name and draws one\n * row per container; a send naming a container that no longer exists is in\n * neither half of that — not a container row, and not an orphan the list\n * adopts — so it would vanish from the campaigns table while remaining\n * perfectly deliverable. Detaching is what puts it back in the list, as the\n * \"Single send\" the product already models and the table already draws.\n *\n * Deleting a campaign is therefore NOT a way to stop its mail. A scheduled\n * email inside it keeps its send time and still goes out, because a\n * container is a grouping and cancelling somebody's mail is `cancel`'s job.\n * The console says so before it asks.\n *\n * ## The same rule for everything else the campaign held\n *\n * A campaign also groups forms, screens and contacts, each of which names it\n * from its OWN document. They are detached on exactly the argument the sends\n * are: a form left naming a container nobody can read has not been freed, it\n * has been made unreadable — its page draws a dead id where a campaign name\n * belongs, and the campaigns table can no longer find it. The record itself\n * is never touched. Deleting a campaign deletes a campaign.\n */\n\n/*\n * The MODULE, not the barrel. `@aglyn/tenant-data-admin`'s index reaches\n * `render-cache`, which imports `next/cache` and therefore the whole Next\n * server pipeline — everything this file needs from the admin SDK is one\n * default export, and taking it from the leaf keeps that pipeline out of the\n * graph.\n */\nimport firebaseAdmin from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { isDocumentId } from '@aglyn/tenant-data-admin/server/document-id'\nimport { logResourceDuplicated } from '@aglyn/tenant-data-admin/server/duplicate-activity'\nimport {\n DUPLICATE_BUSY_MESSAGE,\n duplicateDisplayName,\n uniqueDuplicateName,\n} from '@aglyn/aglyn/app-utils/duplicate-resource'\nimport { claimAttempt, createResourceUid } from '@aglyn/aglyn/server'\n/*\n * The MODULES again, for the same reason: `consentGroupForSite` and\n * `orgDataCollectionForHost` are what the contact pass needs, and taking them\n * from `@aglyn/tenant-data-admin`'s index would pull the whole Next server\n * pipeline back into this file's graph.\n */\nimport {\n consentGroupForSite,\n orgDataCollectionForHost,\n resolveOrgIdForHost,\n} from '@aglyn/tenant-data-admin/server/organizations'\nimport {\n CAMPAIGN_MEMBER_HOST_COLLECTIONS,\n CAMPAIGN_MEMBERSHIP_FIELD,\n contactCampaignFieldPath,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n CAMPAIGN_SEND_CONTAINER_FIELD,\n} from '@aglyn/shared-ui-email-campaigns/model'\nimport { SCREEN_KIND_EMAIL } from '@aglyn/aglyn/app-utils/screen-route'\nimport {\n registerPluginResourceDraftWriter,\n type PluginDraftRecord,\n type PluginDraftRefusal,\n type PluginDraftWrite,\n type PluginResourceDraftWriter,\n} from '@aglyn/aglyn/plugin-manager/plugin-resource-drafts'\n\n/**\n * Sends detached in one write.\n *\n * Under Firestore's 500-operation batch limit with room to spare rather than\n * at it: a batch that is refused for being one over has done nothing, and the\n * margin costs one extra round trip on a campaign of exactly this size.\n */\nconst DETACH_BATCH = 400\n\n/**\n * How many batches one request will run.\n *\n * A ceiling on the REQUEST, not on the campaign. The loop below terminates on\n * its own — each pass clears the very field it queries on, so a detached send\n * is not returned again — but a request that runs unbounded is a request that\n * eventually exceeds the platform's own timeout with no idea how far it got.\n *\n * Stopping early is safe and is why this is a ceiling rather than a refusal\n * up front: detaching is idempotent and partial progress is a consistent\n * state — the sends already detached read as single sends, the rest still\n * read under the campaign — so the answer to hitting it is to ask again.\n */\nconst DETACH_PASSES = 25\n\n/** What one `campaigns/manage` call answered with. */\ninterface ManageResult {\n status: number\n body: Record<string, unknown>\n}\n\n/**\n * Clears {@link CAMPAIGN_SEND_CONTAINER_FIELD} from every send in a campaign.\n *\n * Equality on one field with a `limit` — no `orderBy` — which Firestore's\n * automatic single-field index serves. Ordering would need a composite index\n * for a walk whose order does not matter: every match is being written, so\n * which order they come back in cannot change the outcome.\n *\n * @returns how many were detached, and whether any were left.\n */\nasync function detachSends(\n hostRef: FirebaseFirestore.DocumentReference,\n campaignId: string,\n): Promise<{ detached: number; remaining: boolean }> {\n const firestore = hostRef.firestore\n let detached = 0\n for (let pass = 0; pass < DETACH_PASSES; pass += 1) {\n const page = await hostRef\n .collection('campaigns')\n .where(CAMPAIGN_SEND_CONTAINER_FIELD, '==', campaignId)\n .limit(DETACH_BATCH)\n .get()\n if (page.empty) return { detached, remaining: false }\n const batch = firestore.batch()\n for (const send of page.docs) {\n /*\n * The FIELD is removed, not set to an empty string. `campaignListRows`\n * adopts a send whose container id is falsy, so an empty string would\n * work by accident today — but the field is also what the campaign\n * detail page's `where` clause matches, and an equality query on `''`\n * is a different query from the absence the pre-container sends have.\n * One shape for \"belongs to no campaign\" is what keeps those two\n * readers agreeing.\n */\n batch.update(send.ref, {\n [CAMPAIGN_SEND_CONTAINER_FIELD]:\n firebaseAdmin.firestore.FieldValue.delete(),\n })\n }\n await batch.commit()\n detached += page.size\n if (page.size < DETACH_BATCH) return { detached, remaining: false }\n }\n return { detached, remaining: true }\n}\n\n/**\n * Clears one campaign id out of a membership array, wherever it is held.\n *\n * The same walk {@link detachSends} makes, over an ARRAY field rather than a\n * scalar one: `array-contains` matches on Firestore's automatic single-field\n * index, and `arrayRemove` takes out the one id without touching the other\n * campaigns a form or a screen is in. That is the whole reason the membership\n * is an array here and a `FieldValue.delete()` on a send — a send is in one\n * campaign and a landing page is in several, so removing one must not be\n * removing all of them.\n *\n * `query` rather than a collection ref, because the contact pass hands in a\n * path this file cannot build for itself.\n *\n * @returns how many were detached, and whether any were left.\n */\nasync function detachMembership(\n firestore: FirebaseFirestore.Firestore,\n collectionRef: FirebaseFirestore.CollectionReference,\n fieldPath: string,\n campaignId: string,\n): Promise<{ detached: number; remaining: boolean }> {\n let detached = 0\n for (let pass = 0; pass < DETACH_PASSES; pass += 1) {\n const page = await collectionRef\n .where(fieldPath, 'array-contains', campaignId)\n .limit(DETACH_BATCH)\n .get()\n if (page.empty) return { detached, remaining: false }\n const batch = firestore.batch()\n for (const member of page.docs) {\n batch.update(member.ref, {\n [fieldPath]: firebaseAdmin.firestore.FieldValue.arrayRemove(campaignId),\n })\n }\n await batch.commit()\n detached += page.size\n if (page.size < DETACH_BATCH) return { detached, remaining: false }\n }\n return { detached, remaining: true }\n}\n\n/**\n * Clears the campaign off every form, screen and contact holding it.\n *\n * ## Why the campaign is not simply deleted over the top of them\n *\n * A campaign that went away leaving members naming it is the same failure the\n * send detach exists to prevent, one collection over: the campaign's own page\n * finds its forms and screens with `array-contains`, and a form still naming\n * a container nobody can read is in neither half of that — not a member of\n * any campaign the console can draw, and not free of one either. Its own page\n * would render the dead id as a chip with no name.\n *\n * ## The contacts are reached by a different path, on purpose\n *\n * A contact lives on the ORG (`orgs/{orgId}/contacts`) and is shared by every\n * site in it, so the membership sits inside this site's consent-group facet\n * rather than at the top of the document. The field PATH is therefore the\n * scope: only rows this group filed under the campaign match it, and no other\n * holder's filing is touched. The unscoped collection ref is what the walk\n * runs on precisely because the path already carries the boundary — the\n * `visibleTo` filter `orgDataQueryForHost` adds would spend the query's one\n * array-contains slot and leave none for the membership.\n *\n * A group that cannot be resolved answers as the site alone, which is\n * {@link consentGroupForSite}'s documented failure direction and the safe one\n * here too: the pass then clears the site's own facet and no other.\n */\nasync function detachMembers(\n hostId: string,\n hostRef: FirebaseFirestore.DocumentReference,\n campaignId: string,\n): Promise<{ detached: number; remaining: boolean }> {\n const firestore = hostRef.firestore\n let detached = 0\n let remaining = false\n for (const collectionName of CAMPAIGN_MEMBER_HOST_COLLECTIONS) {\n const pass = await detachMembership(\n firestore,\n hostRef.collection(collectionName),\n CAMPAIGN_MEMBERSHIP_FIELD,\n campaignId,\n )\n detached += pass.detached\n remaining = remaining || pass.remaining\n }\n /*\n * NO ORG, NO ORG CONTACTS — and that is a skip rather than a failure.\n *\n * Contacts live at `orgs/{orgId}/contacts`, so a site whose `hostIndex`\n * entry names no org holds none of them and there is nothing here to\n * detach. Resolving it first is what separates that from a read that FAILED:\n * `orgDataCollectionForHost` throws for both, and swallowing both would let\n * a transient Firestore error delete the campaign with contacts still\n * naming it. A thrown read reaches the handler's 500 and the container\n * survives, which is the direction a failure here has to fall.\n */\n const orgId = await resolveOrgIdForHost(hostId)\n if (!orgId) return { detached, remaining }\n const group = await consentGroupForSite(hostId)\n const contacts = await orgDataCollectionForHost(hostId, 'contacts')\n const contactPass = await detachMembership(\n firestore,\n contacts,\n contactCampaignFieldPath(group.groupId),\n campaignId,\n )\n return {\n detached: detached + contactPass.detached,\n remaining: remaining || contactPass.remaining,\n }\n}\n\n/**\n * Removes a campaign container, leaving its emails standing.\n *\n * Detach first, delete second, and the order is the point: stopping between\n * them leaves a container whose emails are already single sends, which the\n * list draws correctly and a second run finishes. Deleting first would leave\n * sends naming a container nobody can read, which is the state that loses\n * them from the table.\n */\nasync function deleteCampaign(\n hostId: string,\n hostRef: FirebaseFirestore.DocumentReference,\n campaignId: string,\n): Promise<ManageResult> {\n const ref = hostRef.collection('emailCampaigns').doc(campaignId)\n const snapshot = await ref.get()\n /*\n * A 404 rather than a silent success. The id in a campaign URL may name a\n * container OR a single send — that ambiguity is what keeps every link\n * minted before containers existed resolving — so \"no container here\" is\n * very often \"this is a send\", and answering it as a completed deletion\n * would tell the console it had removed something it never touched.\n */\n if (!snapshot.exists) {\n return { status: 404, body: { error: 'Unknown campaign' } }\n }\n const { detached, remaining } = await detachSends(hostRef, campaignId)\n if (remaining) {\n return {\n status: 409,\n body: {\n detached,\n error:\n `This campaign holds more emails than one request can detach. ` +\n `${detached.toLocaleString()} of them now read as single sends and ` +\n 'the campaign is still here — run the delete again to finish it.',\n },\n }\n }\n /*\n * The MEMBERS come off before the container does, for the reason the sends\n * do: a partial run leaves records that are already out of the campaign,\n * which every surface draws correctly, while deleting first would leave\n * forms, screens and contacts naming an id nothing can resolve.\n *\n * Their own 409 rather than a shared one. \"More emails than one request can\n * detach\" is a sentence about the campaign's mail, and a merchant reading\n * it about a campaign of two emails and four thousand contacts would go\n * looking for emails that are not the problem.\n */\n const members = await detachMembers(hostId, hostRef, campaignId)\n if (members.remaining) {\n return {\n status: 409,\n body: {\n detached,\n detachedMembers: members.detached,\n error:\n 'This campaign is on more records than one request can clear. ' +\n `${members.detached.toLocaleString()} of them are already out of ` +\n 'it and the campaign is still here — run the delete again to ' +\n 'finish it.',\n },\n }\n }\n await ref.delete()\n return {\n status: 200,\n body: {\n campaignId,\n detached,\n detachedMembers: members.detached,\n deleted: true,\n },\n }\n}\n\n/**\n * Discards a DRAFT email, and refuses anything else.\n *\n * The state check is inside the transaction as well as being the reason for\n * it. `sendNow` claims a draft by moving it to `sending` in a transaction of\n * its own, so a check made against an earlier read could delete a record\n * while the send path was mailing from it — the merchant would receive a\n * report for an email that no longer exists, and its `cid` would stop\n * resolving with the message already in inboxes.\n *\n * Only `draft`. A scheduled email is withdrawn with `cancel`, which leaves\n * the record and its report standing; a sent one has reached people, and\n * nothing on this surface may remove the evidence of that.\n */\nasync function discardDraft(\n hostRef: FirebaseFirestore.DocumentReference,\n emailId: string,\n): Promise<ManageResult> {\n const ref = hostRef.collection('campaigns').doc(emailId)\n const firestore = hostRef.firestore\n const refusal = (state: string): string => {\n if (state === 'sent') {\n return 'This email has already been sent, so it cannot be discarded. ' +\n 'Its report and its unsubscribe links have to go on resolving.'\n }\n if (state === 'scheduled') {\n return 'This email is scheduled. Cancel the send first — cancelling ' +\n 'keeps the email and takes it off the clock.'\n }\n if (state === 'sending') {\n return 'This email is being sent right now.'\n }\n return 'Only a draft can be discarded.'\n }\n const outcome = await firestore.runTransaction(\n async (transaction): Promise<ManageResult> => {\n const fresh = await transaction.get(ref)\n if (!fresh.exists) {\n return { status: 404, body: { error: 'Unknown email' } }\n }\n const state = String(fresh.get('status') ?? '')\n if (state !== 'draft') {\n return { status: 409, body: { error: refusal(state) } }\n }\n /*\n * A plain delete, with nothing recursive under it. The one\n * subcollection a message grows is `reports/links`, written by the\n * delivery webhook — an email that has never been sent has no\n * deliveries, so there is nothing beneath a draft to leave behind.\n */\n transaction.delete(ref)\n return { status: 200, body: { emailId, discarded: true } }\n },\n )\n return outcome\n}\n\n/**\n * Campaign management API: removing a campaign, and discarding a draft.\n *\n * Separate from `campaigns/send` because nothing here sends, reserves\n * allowance or moves a meter, and because both operations are about a record\n * ceasing to exist — which is the one class of change that has to be read\n * against what a merchant has already mailed. Same authorization as the send\n * route: a site admin or editor, proven by a Firebase ID token.\n *\n * Editing a campaign is deliberately NOT here. The container is client-\n * writable by the same roles, its create already runs on the client SDK, and\n * a second door to the same document would be a second place for the two to\n * disagree about what a campaign may hold.\n */\n/**\n * The fields a copy of a message carries (AGL-2936): what was written and\n * how it is sent — never who it goes to or when. The audience, list,\n * segment, addresses, schedule and experiment are the decisions a person\n * makes about THIS send, and a copy that inherited them would be one click\n * from mailing the same people twice.\n */\nconst DUPLICATED_MESSAGE_FIELDS = [\n 'subject',\n 'body',\n 'fromName',\n 'replyTo',\n 'senderId',\n 'templateScreenId',\n 'plainText',\n 'plainTextVersionId',\n 'topicId',\n CAMPAIGN_SEND_CONTAINER_FIELD,\n] as const\n\n/**\n * Copies a message as a new draft (AGL-2936). Any status can be copied —\n * a sent email is the usual source — and the copy is always `draft`, with\n * the name the person gave it made unique among the site's messages.\n */\nasync function duplicateMessage(\n hostId: string,\n hostRef: FirebaseFirestore.DocumentReference,\n emailId: string,\n requestedName: string,\n actor: { uid: string; email: string | null; orgId: string },\n): Promise<ManageResult> {\n const messages = hostRef.collection('campaigns')\n const firestore = hostRef.firestore\n const id = createResourceUid()\n const outcome = await firestore.runTransaction(\n async (transaction): Promise<ManageResult & { name?: string; sourceName?: string }> => {\n const [source, siblings] = await Promise.all([\n transaction.get(messages.doc(emailId)),\n transaction.get(messages.select('displayName', 'subject')),\n ])\n if (!source.exists) {\n return { status: 404, body: { error: 'Unknown email' } }\n }\n const data = (source.data() ?? {}) as Record<string, unknown>\n const sourceName = String(data['displayName'] ?? data['subject'] ?? '').trim()\n const name = uniqueDuplicateName(\n requestedName.trim() || duplicateDisplayName(sourceName),\n siblings.docs.map((row) => String(row.get('displayName') ?? row.get('subject') ?? '')),\n )\n const copy: Record<string, unknown> = {}\n for (const field of DUPLICATED_MESSAGE_FIELDS) {\n if (data[field] !== undefined && data[field] !== null) copy[field] = data[field]\n }\n const nowMs = Date.now()\n transaction.create(messages.doc(id), {\n ...copy,\n displayName: name,\n status: 'draft',\n createdAtMs: nowMs,\n draftedAt: new Date(nowMs),\n draftedBy: actor.uid,\n })\n return { status: 200, body: { emailId: id, name }, name, sourceName }\n },\n )\n if (outcome.status === 200) {\n await logResourceDuplicated(\n 'campaign',\n { uid: actor.uid, email: actor.email },\n {\n orgId: actor.orgId,\n hostId,\n source: { id: emailId, name: outcome.sourceName ?? '' },\n target: { id, name: outcome.name ?? '' },\n },\n )\n }\n return { status: outcome.status, body: outcome.body }\n}\n\n/*==========================================\n * A CAMPAIGN THAT EXISTS BEFORE ANYBODY CHOSE WHO GETS IT (AGL-2912).\n *\n * The draft writer this plugin registers for the `campaign` resource on the\n * core's resource-drafts seam. Another plugin that produces a campaign — a\n * generator working from a brief, an importer bringing campaigns over from\n * another tool — asks for it by name, and gets exactly two documents:\n *\n * - the CONTAINER at `emailCampaigns/{id}`, written as the campaigns list's\n * create drawer writes one, aimed at no list;\n * - ONE EMAIL inside it at `campaigns/{id}-email`, `status: 'draft'`, naming\n * the email design it sends and holding its subject, preheader and the\n * alternatives to them.\n *\n * ## What a drafted email never holds\n *\n * An audience, a list, a segment, addresses, a topic, a sender, a send time or\n * an experiment. Those are the decisions a person makes about a send — the\n * duplicate above strips the same ones for the same reason — and a draft that\n * arrived holding any of them would be one click from mailing people nobody\n * chose. So the writer has no field for them to arrive in.\n *\n * ## Why a draft cannot escape\n *\n * Nothing here reserves an allowance, claims a budget or calls the send path,\n * and the scheduled processor queries `status == 'scheduled'`, which a draft\n * never is. `performCampaignSend` is the only thing that mails a draft, behind\n * the send route's own authorization, when a member asks.\n *\n * The id of the email is derived from the campaign's rather than minted, so a\n * write asked again finds the email it made — and differs from it, because a\n * campaign URL names a container OR a send, and one id naming both would read\n * as either.\n *=========================================*/\n\n/** The resource name the campaign draft writer is registered under. */\nexport const CAMPAIGN_DRAFT_RESOURCE = 'campaign'\n\n/** The id `plugins.config.json` registers this plugin under. */\nconst MARKETING_PLUGIN_ID = 'marketing'\n\n/** A drafted campaign's name when the caller asks for none. */\nexport const CAMPAIGN_DRAFT_DEFAULT_NAME = 'Untitled campaign'\n\n/** The longest subject or preheader a drafted email stores: the send route's header cap. */\nexport const CAMPAIGN_DRAFT_HEADER_MAX_CHARS = 200\n\n/** The most alternative subject lines or preheaders a drafted email keeps. */\nexport const CAMPAIGN_DRAFT_MAX_VARIANTS = 10\n\n/** The site roles that may compose this site's mail. */\nexport const CAMPAIGN_DRAFT_ROLES = ['admin', 'editor'] as const\n/** The send route's refusal for a member who may not compose this site's mail. */\nexport const CAMPAIGN_DRAFT_ROLE_REFUSAL = 'Not a site admin or editor'\n\n/** The id of the one email inside a drafted campaign. */\nexport function campaignDraftEmailId(campaignId: string): string {\n return `${campaignId}-email`\n}\n\n/** Every field a drafted email is written with, and so every field it can hold. */\nexport const CAMPAIGN_DRAFT_EMAIL_FIELDS = [\n 'subject',\n 'preheader',\n 'templateScreenId',\n 'subjectVariants',\n 'preheaderVariants',\n CAMPAIGN_SEND_CONTAINER_FIELD,\n 'status',\n 'createdAtMs',\n 'draftedAt',\n 'draftedBy',\n] as const\n\n/** What a caller sends as `content`. */\nexport interface CampaignDraftContent {\n /** The email design the email sends: a `kind: 'email'` screen on the site. */\n templateScreenId: string\n subject: string\n preheader: string\n subjectVariants: string[]\n preheaderVariants: string[]\n}\n\n/** A header value on one line: controls and runs of space folded to one space. */\nfunction draftHeaderLine(value: unknown): string {\n return String(value ?? '').replace(/[\\p{Cc}\\s]+/gu, ' ').trim()\n}\n\nfunction draftVariants(value: unknown): string[] {\n if (!Array.isArray(value)) return []\n const seen = new Set<string>()\n const lines: string[] = []\n for (const raw of value) {\n const line = draftHeaderLine(raw)\n if (!line || seen.has(line.toLowerCase())) continue\n seen.add(line.toLowerCase())\n lines.push(line)\n }\n return lines.slice(0, CAMPAIGN_DRAFT_MAX_VARIANTS)\n}\n\nexport function readCampaignDraftContent(\n content: Readonly<Record<string, unknown>>,\n): { ok: true; value: CampaignDraftContent } | { ok: false; problems: string[] } {\n const value: CampaignDraftContent = {\n templateScreenId: String(content['templateScreenId'] ?? ''),\n subject: draftHeaderLine(content['subject']),\n preheader: draftHeaderLine(content['preheader']),\n subjectVariants: draftVariants(content['subjectVariants']),\n preheaderVariants: draftVariants(content['preheaderVariants']),\n }\n const problems: string[] = []\n if (!isDocumentId(value.templateScreenId)) {\n problems.push('A drafted campaign needs the email design it sends')\n }\n if (!value.subject) problems.push('A drafted campaign needs a subject line')\n const long = (lines: string[]) =>\n lines.some((line) => line.length > CAMPAIGN_DRAFT_HEADER_MAX_CHARS)\n if (long([value.subject, ...value.subjectVariants])) {\n problems.push(`A subject line is longer than ${CAMPAIGN_DRAFT_HEADER_MAX_CHARS} characters`)\n }\n if (long([value.preheader, ...value.preheaderVariants])) {\n problems.push(`A preheader is longer than ${CAMPAIGN_DRAFT_HEADER_MAX_CHARS} characters`)\n }\n return problems.length ? { ok: false, problems } : { ok: true, value }\n}\n\nfunction campaignDraftRoleRefusal(\n host: FirebaseFirestore.DocumentSnapshot,\n uid: string,\n): PluginDraftRefusal | null {\n const role = (host.get('memberRoles') ?? {})[uid]\n return CAMPAIGN_DRAFT_ROLES.includes(role)\n ? null\n : { status: 403, error: CAMPAIGN_DRAFT_ROLE_REFUSAL }\n}\n\nfunction campaignDraftRecord(campaign: FirebaseFirestore.DocumentSnapshot): PluginDraftRecord {\n return {\n id: campaign.id,\n name: String(campaign.get('name') ?? ''),\n versionId: null,\n facts: { emailId: campaignDraftEmailId(campaign.id) },\n }\n}\n\nexport interface CampaignDraftWriterDeps {\n /** The Admin SDK handle; specs hand in a double. */\n firestore?: () => FirebaseFirestore.Firestore\n}\n\nexport function createCampaignDraftWriter(\n deps: CampaignDraftWriterDeps = {},\n): PluginResourceDraftWriter {\n const firestore =\n deps.firestore ??\n (() => firebaseAdmin.app().firestore() as unknown as FirebaseFirestore.Firestore)\n return {\n refusal: async ({ hostId, uid }) => {\n const host = await firestore().collection('hosts').doc(hostId).get()\n if (!host.exists) return { status: 404, error: 'Unknown site' }\n return campaignDraftRoleRefusal(host, uid)\n },\n\n check: (content) => {\n const read = readCampaignDraftContent(content)\n return read.ok === false\n ? read\n : { ok: true, facts: { fields: [...CAMPAIGN_DRAFT_EMAIL_FIELDS] } }\n },\n\n read: async ({ hostId, id }) => {\n if (!isDocumentId(id)) return null\n const campaign = await firestore()\n .collection('hosts')\n .doc(hostId)\n .collection('emailCampaigns')\n .doc(id)\n .get()\n return campaign.exists ? campaignDraftRecord(campaign) : null\n },\n\n write: async (request): Promise<PluginDraftWrite> => {\n const read = readCampaignDraftContent(request.content)\n if (read.ok === false) return { ok: false, status: 400, error: read.problems[0] }\n if (!isDocumentId(request.id)) return { ok: false, status: 400, error: 'Invalid campaignId' }\n const db = firestore()\n const hostRef = db.collection('hosts').doc(request.hostId)\n const campaignRef = hostRef.collection('emailCampaigns').doc(request.id)\n const emailId = campaignDraftEmailId(request.id)\n const emailRef = hostRef.collection('campaigns').doc(emailId)\n const designRef = hostRef.collection('screens').doc(read.value.templateScreenId)\n return db.runTransaction(async (transaction): Promise<PluginDraftWrite> => {\n const [host, campaign, email, design] = await Promise.all([\n transaction.get(hostRef),\n transaction.get(campaignRef),\n transaction.get(emailRef),\n transaction.get(designRef),\n ])\n if (!host.exists) return { ok: false, status: 404, error: 'Unknown site' }\n if (campaign.exists) {\n return { ok: true, replayed: true, ...campaignDraftRecord(campaign) }\n }\n const refusal = campaignDraftRoleRefusal(host, request.uid)\n if (refusal) return { ok: false, ...refusal }\n if (\n !design.exists ||\n design.get('deletedAt') != null ||\n design.get('kind') !== SCREEN_KIND_EMAIL\n ) {\n return { ok: false, status: 400, error: 'Unknown email design' }\n }\n if (email.exists) return { ok: false, status: 409, error: 'That email already exists' }\n const name = draftHeaderLine(request.name) || CAMPAIGN_DRAFT_DEFAULT_NAME\n const nowMs = request.now.getTime()\n // The create drawer's own document: a name, no dates, no lists.\n transaction.create(campaignRef, {\n name,\n listIds: [],\n createdAtMs: nowMs,\n createdBy: request.uid,\n })\n const { value } = read\n transaction.create(emailRef, {\n subject: value.subject,\n ...(value.preheader ? { preheader: value.preheader } : {}),\n templateScreenId: value.templateScreenId,\n ...(value.subjectVariants.length ? { subjectVariants: value.subjectVariants } : {}),\n ...(value.preheaderVariants.length ? { preheaderVariants: value.preheaderVariants } : {}),\n [CAMPAIGN_SEND_CONTAINER_FIELD]: request.id,\n status: 'draft',\n createdAtMs: nowMs,\n draftedAt: request.now,\n draftedBy: request.uid,\n })\n return {\n ok: true,\n replayed: false,\n id: request.id,\n name,\n versionId: null,\n facts: { emailId },\n }\n })\n },\n }\n}\n\nexport const campaignDraftWriter = createCampaignDraftWriter()\n\n/**\n * Registers the writer; the console surface calls it, since only the console\n * runs AI jobs (AGL-3026). Idempotent: a second call replaces the first.\n */\nexport function registerCampaignDraftWriter(): void {\n registerPluginResourceDraftWriter(CAMPAIGN_DRAFT_RESOURCE, campaignDraftWriter, {\n pluginId: MARKETING_PLUGIN_ID,\n })\n}\n\nexport const campaignManageHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const hostId = String(req.body?.hostId ?? '')\n const action = String(req.body?.action ?? '')\n const targetId = String(req.body?.campaignId ?? '')\n if (!hostId) return res.status(400).json({ error: 'Missing hostId' })\n if (!isDocumentId(targetId)) {\n return res.status(400).json({ error: 'Invalid campaignId' })\n }\n if (\n action !== 'deleteCampaign' &&\n action !== 'discardEmail' &&\n action !== 'duplicate'\n ) {\n return res.status(400).json({ error: 'Unknown action' })\n }\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 if (action === 'duplicate') {\n // One attempt key, one copy (AGL-2936): a double click on Duplicate\n // replays the first answer rather than drafting the message twice.\n const attemptKey = String(req.body?.attemptKey ?? '').trim().slice(0, 200)\n const orgId = (await resolveOrgIdForHost(hostId)) ?? ''\n const claimed = attemptKey\n ? await claimAttempt(firestore as never, {\n kind: 'duplicate-campaign',\n scopeId: `${hostId}:${targetId}`,\n orgId,\n key: attemptKey,\n busyMessage: DUPLICATE_BUSY_MESSAGE,\n })\n : null\n if (claimed && 'replay' in claimed) {\n return res.status(claimed.replay.status).json(claimed.replay.body)\n }\n const claim = claimed && 'claim' in claimed ? claimed.claim : null\n const copied = await duplicateMessage(\n hostId,\n hostRef,\n targetId,\n String(req.body?.name ?? ''),\n { uid: decoded.uid, email: decoded.email ?? null, orgId },\n )\n if (copied.status === 200) await claim?.record(copied.status, copied.body)\n else await claim?.release()\n return res.status(copied.status).json(copied.body)\n }\n const result =\n action === 'deleteCampaign'\n ? await deleteCampaign(hostId, hostRef, targetId)\n : await discardDraft(hostRef, targetId)\n return res.status(result.status).json(result.body)\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'The request could not be completed' })\n }\n}\n"],"names":["firebaseAdmin","isDocumentId","logResourceDuplicated","DUPLICATE_BUSY_MESSAGE","duplicateDisplayName","uniqueDuplicateName","claimAttempt","createResourceUid","consentGroupForSite","orgDataCollectionForHost","resolveOrgIdForHost","CAMPAIGN_MEMBER_HOST_COLLECTIONS","CAMPAIGN_MEMBERSHIP_FIELD","contactCampaignFieldPath","CAMPAIGN_SEND_CONTAINER_FIELD","SCREEN_KIND_EMAIL","registerPluginResourceDraftWriter","DETACH_BATCH","DETACH_PASSES","detachSends","hostRef","campaignId","firestore","detached","pass","page","collection","where","limit","get","empty","remaining","batch","send","docs","update","ref","FieldValue","delete","commit","size","detachMembership","collectionRef","fieldPath","member","arrayRemove","detachMembers","hostId","collectionName","orgId","group","contacts","contactPass","groupId","deleteCampaign","doc","snapshot","exists","status","body","error","toLocaleString","members","detachedMembers","deleted","discardDraft","emailId","refusal","state","outcome","runTransaction","transaction","fresh","String","discarded","DUPLICATED_MESSAGE_FIELDS","duplicateMessage","requestedName","actor","messages","id","source","data","siblings","Promise","all","select","sourceName","trim","name","map","row","copy","field","undefined","nowMs","Date","now","create","displayName","createdAtMs","draftedAt","draftedBy","uid","email","target","CAMPAIGN_DRAFT_RESOURCE","MARKETING_PLUGIN_ID","CAMPAIGN_DRAFT_DEFAULT_NAME","CAMPAIGN_DRAFT_HEADER_MAX_CHARS","CAMPAIGN_DRAFT_MAX_VARIANTS","CAMPAIGN_DRAFT_ROLES","CAMPAIGN_DRAFT_ROLE_REFUSAL","campaignDraftEmailId","CAMPAIGN_DRAFT_EMAIL_FIELDS","draftHeaderLine","value","replace","draftVariants","Array","isArray","seen","Set","lines","raw","line","has","toLowerCase","add","push","slice","readCampaignDraftContent","content","templateScreenId","subject","preheader","subjectVariants","preheaderVariants","problems","long","some","length","ok","campaignDraftRoleRefusal","host","role","includes","campaignDraftRecord","campaign","versionId","facts","createCampaignDraftWriter","deps","app","check","read","fields","write","request","db","campaignRef","emailRef","designRef","design","replayed","getTime","listIds","createdBy","campaignDraftWriter","registerCampaignDraftWriter","pluginId","campaignManageHandler","req","res","method","json","action","targetId","authorization","headers","idToken","startsWith","hostSnapshot","decoded","auth","verifyIdToken","memberRole","attemptKey","claimed","kind","scopeId","key","busyMessage","replay","claim","copied","record","release","result","console"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAqDC,GAED;;;;;;CAMC,GACD,OAAOA,mBAAmB,iDAAgD;AAC1E,SAASC,YAAY,QAAQ,8CAA6C;AAC1E,SAASC,qBAAqB,QAAQ,qDAAoD;AAC1F,SACEC,sBAAsB,EACtBC,oBAAoB,EACpBC,mBAAmB,QACd,4CAA2C;AAClD,SAASC,YAAY,EAAEC,iBAAiB,QAAQ,sBAAqB;AACrE;;;;;CAKC,GACD,SACEC,mBAAmB,EACnBC,wBAAwB,EACxBC,mBAAmB,QACd,gDAA+C;AACtD,SACEC,gCAAgC,EAChCC,yBAAyB,EACzBC,wBAAwB,QAEnB,sBAAqB;AAC5B,SACEC,6BAA6B,QACxB,yCAAwC;AAC/C,SAASC,iBAAiB,QAAQ,sCAAqC;AACvE,SACEC,iCAAiC,QAK5B,qDAAoD;AAE3D;;;;;;CAMC,GACD,MAAMC,eAAe;AAErB;;;;;;;;;;;;CAYC,GACD,MAAMC,gBAAgB;AAQtB;;;;;;;;;CASC,GACD,eAAeC,YACbC,OAA4C,EAC5CC,UAAkB;IAElB,MAAMC,YAAYF,QAAQE,SAAS;IACnC,IAAIC,WAAW;IACf,IAAK,IAAIC,OAAO,GAAGA,OAAON,eAAeM,QAAQ,EAAG;QAClD,MAAMC,OAAO,MAAML,QAChBM,UAAU,CAAC,aACXC,KAAK,CAACb,+BAA+B,MAAMO,YAC3CO,KAAK,CAACX,cACNY,GAAG;QACN,IAAIJ,KAAKK,KAAK,EAAE,OAAO;YAAEP;YAAUQ,WAAW;QAAM;QACpD,MAAMC,QAAQV,UAAUU,KAAK;QAC7B,KAAK,MAAMC,QAAQR,KAAKS,IAAI,CAAE;YAC5B;;;;;;;;OAQC,GACDF,MAAMG,MAAM,CAACF,KAAKG,GAAG,EAAE;gBACrB,CAACtB,8BAA8B,EAC7Bd,cAAcsB,SAAS,CAACe,UAAU,CAACC,MAAM;YAC7C;QACF;QACA,MAAMN,MAAMO,MAAM;QAClBhB,YAAYE,KAAKe,IAAI;QACrB,IAAIf,KAAKe,IAAI,GAAGvB,cAAc,OAAO;YAAEM;YAAUQ,WAAW;QAAM;IACpE;IACA,OAAO;QAAER;QAAUQ,WAAW;IAAK;AACrC;AAEA;;;;;;;;;;;;;;;CAeC,GACD,eAAeU,iBACbnB,SAAsC,EACtCoB,aAAoD,EACpDC,SAAiB,EACjBtB,UAAkB;IAElB,IAAIE,WAAW;IACf,IAAK,IAAIC,OAAO,GAAGA,OAAON,eAAeM,QAAQ,EAAG;QAClD,MAAMC,OAAO,MAAMiB,cAChBf,KAAK,CAACgB,WAAW,kBAAkBtB,YACnCO,KAAK,CAACX,cACNY,GAAG;QACN,IAAIJ,KAAKK,KAAK,EAAE,OAAO;YAAEP;YAAUQ,WAAW;QAAM;QACpD,MAAMC,QAAQV,UAAUU,KAAK;QAC7B,KAAK,MAAMY,UAAUnB,KAAKS,IAAI,CAAE;YAC9BF,MAAMG,MAAM,CAACS,OAAOR,GAAG,EAAE;gBACvB,CAACO,UAAU,EAAE3C,cAAcsB,SAAS,CAACe,UAAU,CAACQ,WAAW,CAACxB;YAC9D;QACF;QACA,MAAMW,MAAMO,MAAM;QAClBhB,YAAYE,KAAKe,IAAI;QACrB,IAAIf,KAAKe,IAAI,GAAGvB,cAAc,OAAO;YAAEM;YAAUQ,WAAW;QAAM;IACpE;IACA,OAAO;QAAER;QAAUQ,WAAW;IAAK;AACrC;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GACD,eAAee,cACbC,MAAc,EACd3B,OAA4C,EAC5CC,UAAkB;IAElB,MAAMC,YAAYF,QAAQE,SAAS;IACnC,IAAIC,WAAW;IACf,IAAIQ,YAAY;IAChB,KAAK,MAAMiB,kBAAkBrC,iCAAkC;QAC7D,MAAMa,OAAO,MAAMiB,iBACjBnB,WACAF,QAAQM,UAAU,CAACsB,iBACnBpC,2BACAS;QAEFE,YAAYC,KAAKD,QAAQ;QACzBQ,YAAYA,aAAaP,KAAKO,SAAS;IACzC;IACA;;;;;;;;;;GAUC,GACD,MAAMkB,QAAQ,MAAMvC,oBAAoBqC;IACxC,IAAI,CAACE,OAAO,OAAO;QAAE1B;QAAUQ;IAAU;IACzC,MAAMmB,QAAQ,MAAM1C,oBAAoBuC;IACxC,MAAMI,WAAW,MAAM1C,yBAAyBsC,QAAQ;IACxD,MAAMK,cAAc,MAAMX,iBACxBnB,WACA6B,UACAtC,yBAAyBqC,MAAMG,OAAO,GACtChC;IAEF,OAAO;QACLE,UAAUA,WAAW6B,YAAY7B,QAAQ;QACzCQ,WAAWA,aAAaqB,YAAYrB,SAAS;IAC/C;AACF;AAEA;;;;;;;;CAQC,GACD,eAAeuB,eACbP,MAAc,EACd3B,OAA4C,EAC5CC,UAAkB;IAElB,MAAMe,MAAMhB,QAAQM,UAAU,CAAC,kBAAkB6B,GAAG,CAAClC;IACrD,MAAMmC,WAAW,MAAMpB,IAAIP,GAAG;IAC9B;;;;;;GAMC,GACD,IAAI,CAAC2B,SAASC,MAAM,EAAE;QACpB,OAAO;YAAEC,QAAQ;YAAKC,MAAM;gBAAEC,OAAO;YAAmB;QAAE;IAC5D;IACA,MAAM,EAAErC,QAAQ,EAAEQ,SAAS,EAAE,GAAG,MAAMZ,YAAYC,SAASC;IAC3D,IAAIU,WAAW;QACb,OAAO;YACL2B,QAAQ;YACRC,MAAM;gBACJpC;gBACAqC,OACE,CAAC,6DAA6D,CAAC,GAC/D,GAAGrC,SAASsC,cAAc,GAAG,sCAAsC,CAAC,GACpE;YACJ;QACF;IACF;IACA;;;;;;;;;;GAUC,GACD,MAAMC,UAAU,MAAMhB,cAAcC,QAAQ3B,SAASC;IACrD,IAAIyC,QAAQ/B,SAAS,EAAE;QACrB,OAAO;YACL2B,QAAQ;YACRC,MAAM;gBACJpC;gBACAwC,iBAAiBD,QAAQvC,QAAQ;gBACjCqC,OACE,kEACA,GAAGE,QAAQvC,QAAQ,CAACsC,cAAc,GAAG,4BAA4B,CAAC,GAClE,iEACA;YACJ;QACF;IACF;IACA,MAAMzB,IAAIE,MAAM;IAChB,OAAO;QACLoB,QAAQ;QACRC,MAAM;YACJtC;YACAE;YACAwC,iBAAiBD,QAAQvC,QAAQ;YACjCyC,SAAS;QACX;IACF;AACF;AAEA;;;;;;;;;;;;;CAaC,GACD,eAAeC,aACb7C,OAA4C,EAC5C8C,OAAe;IAEf,MAAM9B,MAAMhB,QAAQM,UAAU,CAAC,aAAa6B,GAAG,CAACW;IAChD,MAAM5C,YAAYF,QAAQE,SAAS;IACnC,MAAM6C,UAAU,CAACC;QACf,IAAIA,UAAU,QAAQ;YACpB,OAAO,kEACL;QACJ;QACA,IAAIA,UAAU,aAAa;YACzB,OAAO,iEACL;QACJ;QACA,IAAIA,UAAU,WAAW;YACvB,OAAO;QACT;QACA,OAAO;IACT;IACA,MAAMC,UAAU,MAAM/C,UAAUgD,cAAc,CAC5C,OAAOC;YAKgBC;QAJrB,MAAMA,QAAQ,MAAMD,YAAY1C,GAAG,CAACO;QACpC,IAAI,CAACoC,MAAMf,MAAM,EAAE;YACjB,OAAO;gBAAEC,QAAQ;gBAAKC,MAAM;oBAAEC,OAAO;gBAAgB;YAAE;QACzD;QACA,MAAMQ,QAAQK,QAAOD,aAAAA,MAAM3C,GAAG,CAAC,qBAAV2C,aAAuB;QAC5C,IAAIJ,UAAU,SAAS;YACrB,OAAO;gBAAEV,QAAQ;gBAAKC,MAAM;oBAAEC,OAAOO,QAAQC;gBAAO;YAAE;QACxD;QACA;;;;;OAKC,GACDG,YAAYjC,MAAM,CAACF;QACnB,OAAO;YAAEsB,QAAQ;YAAKC,MAAM;gBAAEO;gBAASQ,WAAW;YAAK;QAAE;IAC3D;IAEF,OAAOL;AACT;AAEA;;;;;;;;;;;;;CAaC,GACD;;;;;;CAMC,GACD,MAAMM,4BAA4B;IAChC;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA7D;CACD;AAED;;;;CAIC,GACD,eAAe8D,iBACb7B,MAAc,EACd3B,OAA4C,EAC5C8C,OAAe,EACfW,aAAqB,EACrBC,KAA2D;IAE3D,MAAMC,WAAW3D,QAAQM,UAAU,CAAC;IACpC,MAAMJ,YAAYF,QAAQE,SAAS;IACnC,MAAM0D,KAAKzE;IACX,MAAM8D,UAAU,MAAM/C,UAAUgD,cAAc,CAC5C,OAAOC;YAQSU,cACYC,MAAAA;QAR1B,MAAM,CAACD,QAAQE,SAAS,GAAG,MAAMC,QAAQC,GAAG,CAAC;YAC3Cd,YAAY1C,GAAG,CAACkD,SAASxB,GAAG,CAACW;YAC7BK,YAAY1C,GAAG,CAACkD,SAASO,MAAM,CAAC,eAAe;SAChD;QACD,IAAI,CAACL,OAAOxB,MAAM,EAAE;YAClB,OAAO;gBAAEC,QAAQ;gBAAKC,MAAM;oBAAEC,OAAO;gBAAgB;YAAE;QACzD;QACA,MAAMsB,QAAQD,eAAAA,OAAOC,IAAI,cAAXD,eAAiB,CAAC;QAChC,MAAMM,aAAad,QAAOS,QAAAA,oBAAAA,IAAI,CAAC,cAAc,YAAnBA,oBAAuBA,IAAI,CAAC,UAAU,YAAtCA,OAA0C,IAAIM,IAAI;QAC5E,MAAMC,OAAOpF,oBACXwE,cAAcW,IAAI,MAAMpF,qBAAqBmF,aAC7CJ,SAASjD,IAAI,CAACwD,GAAG,CAAC,CAACC;gBAAeA,MAAAA;mBAAPlB,QAAOkB,QAAAA,WAAAA,IAAI9D,GAAG,CAAC,0BAAR8D,WAA0BA,IAAI9D,GAAG,CAAC,sBAAlC8D,OAAgD;;QAEpF,MAAMC,OAAgC,CAAC;QACvC,KAAK,MAAMC,SAASlB,0BAA2B;YAC7C,IAAIO,IAAI,CAACW,MAAM,KAAKC,aAAaZ,IAAI,CAACW,MAAM,KAAK,MAAMD,IAAI,CAACC,MAAM,GAAGX,IAAI,CAACW,MAAM;QAClF;QACA,MAAME,QAAQC,KAAKC,GAAG;QACtB1B,YAAY2B,MAAM,CAACnB,SAASxB,GAAG,CAACyB,KAAK,aAChCY;YACHO,aAAaV;YACb/B,QAAQ;YACR0C,aAAaL;YACbM,WAAW,IAAIL,KAAKD;YACpBO,WAAWxB,MAAMyB,GAAG;;QAEtB,OAAO;YAAE7C,QAAQ;YAAKC,MAAM;gBAAEO,SAASc;gBAAIS;YAAK;YAAGA;YAAMF;QAAW;IACtE;IAEF,IAAIlB,QAAQX,MAAM,KAAK,KAAK;YAOOW,qBACTA;QAPxB,MAAMnE,sBACJ,YACA;YAAEqG,KAAKzB,MAAMyB,GAAG;YAAEC,OAAO1B,MAAM0B,KAAK;QAAC,GACrC;YACEvD,OAAO6B,MAAM7B,KAAK;YAClBF;YACAkC,QAAQ;gBAAED,IAAId;gBAASuB,IAAI,GAAEpB,sBAAAA,QAAQkB,UAAU,YAAlBlB,sBAAsB;YAAG;YACtDoC,QAAQ;gBAAEzB;gBAAIS,IAAI,GAAEpB,gBAAAA,QAAQoB,IAAI,YAAZpB,gBAAgB;YAAG;QACzC;IAEJ;IACA,OAAO;QAAEX,QAAQW,QAAQX,MAAM;QAAEC,MAAMU,QAAQV,IAAI;IAAC;AACtD;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;2CAiC2C,GAE3C,qEAAqE,GACrE,OAAO,MAAM+C,0BAA0B,WAAU;AAEjD,8DAA8D,GAC9D,MAAMC,sBAAsB;AAE5B,6DAA6D,GAC7D,OAAO,MAAMC,8BAA8B,oBAAmB;AAE9D,0FAA0F,GAC1F,OAAO,MAAMC,kCAAkC,IAAG;AAElD,4EAA4E,GAC5E,OAAO,MAAMC,8BAA8B,GAAE;AAE7C,sDAAsD,GACtD,OAAO,MAAMC,uBAAuB;IAAC;IAAS;CAAS,CAAS;AAChE,gFAAgF,GAChF,OAAO,MAAMC,8BAA8B,6BAA4B;AAEvE,uDAAuD,GACvD,OAAO,SAASC,qBAAqB5F,UAAkB;IACrD,OAAO,GAAGA,WAAW,MAAM,CAAC;AAC9B;AAEA,iFAAiF,GACjF,OAAO,MAAM6F,8BAA8B;IACzC;IACA;IACA;IACA;IACA;IACApG;IACA;IACA;IACA;IACA;CACD,CAAS;AAYV,gFAAgF,GAChF,SAASqG,gBAAgBC,KAAc;IACrC,OAAO3C,OAAO2C,gBAAAA,QAAS,IAAIC,OAAO,CAAC,sDAAiB,KAAK7B,IAAI;AAC/D;AAEA,SAAS8B,cAAcF,KAAc;IACnC,IAAI,CAACG,MAAMC,OAAO,CAACJ,QAAQ,OAAO,EAAE;IACpC,MAAMK,OAAO,IAAIC;IACjB,MAAMC,QAAkB,EAAE;IAC1B,KAAK,MAAMC,OAAOR,MAAO;QACvB,MAAMS,OAAOV,gBAAgBS;QAC7B,IAAI,CAACC,QAAQJ,KAAKK,GAAG,CAACD,KAAKE,WAAW,KAAK;QAC3CN,KAAKO,GAAG,CAACH,KAAKE,WAAW;QACzBJ,MAAMM,IAAI,CAACJ;IACb;IACA,OAAOF,MAAMO,KAAK,CAAC,GAAGpB;AACxB;AAEA,OAAO,SAASqB,yBACdC,OAA0C;QAGfA;IAD3B,MAAMhB,QAA8B;QAClCiB,kBAAkB5D,QAAO2D,4BAAAA,OAAO,CAAC,mBAAmB,YAA3BA,4BAA+B;QACxDE,SAASnB,gBAAgBiB,OAAO,CAAC,UAAU;QAC3CG,WAAWpB,gBAAgBiB,OAAO,CAAC,YAAY;QAC/CI,iBAAiBlB,cAAcc,OAAO,CAAC,kBAAkB;QACzDK,mBAAmBnB,cAAcc,OAAO,CAAC,oBAAoB;IAC/D;IACA,MAAMM,WAAqB,EAAE;IAC7B,IAAI,CAACzI,aAAamH,MAAMiB,gBAAgB,GAAG;QACzCK,SAAST,IAAI,CAAC;IAChB;IACA,IAAI,CAACb,MAAMkB,OAAO,EAAEI,SAAST,IAAI,CAAC;IAClC,MAAMU,OAAO,CAAChB,QACZA,MAAMiB,IAAI,CAAC,CAACf,OAASA,KAAKgB,MAAM,GAAGhC;IACrC,IAAI8B,KAAK;QAACvB,MAAMkB,OAAO;WAAKlB,MAAMoB,eAAe;KAAC,GAAG;QACnDE,SAAST,IAAI,CAAC,CAAC,8BAA8B,EAAEpB,gCAAgC,WAAW,CAAC;IAC7F;IACA,IAAI8B,KAAK;QAACvB,MAAMmB,SAAS;WAAKnB,MAAMqB,iBAAiB;KAAC,GAAG;QACvDC,SAAST,IAAI,CAAC,CAAC,2BAA2B,EAAEpB,gCAAgC,WAAW,CAAC;IAC1F;IACA,OAAO6B,SAASG,MAAM,GAAG;QAAEC,IAAI;QAAOJ;IAAS,IAAI;QAAEI,IAAI;QAAM1B;IAAM;AACvE;AAEA,SAAS2B,yBACPC,IAAwC,EACxCzC,GAAW;QAEGyC;IAAd,MAAMC,OAAO,EAACD,YAAAA,KAAKnH,GAAG,CAAC,0BAATmH,YAA2B,CAAC,EAAE,CAACzC,IAAI;IACjD,OAAOQ,qBAAqBmC,QAAQ,CAACD,QACjC,OACA;QAAEvF,QAAQ;QAAKE,OAAOoD;IAA4B;AACxD;AAEA,SAASmC,oBAAoBC,QAA4C;QAGxDA;IAFf,OAAO;QACLpE,IAAIoE,SAASpE,EAAE;QACfS,MAAMhB,QAAO2E,gBAAAA,SAASvH,GAAG,CAAC,mBAAbuH,gBAAwB;QACrCC,WAAW;QACXC,OAAO;YAAEpF,SAAS+C,qBAAqBmC,SAASpE,EAAE;QAAE;IACtD;AACF;AAOA,OAAO,SAASuE,0BACdC,OAAgC,CAAC,CAAC;QAGhCA;IADF,MAAMlI,aACJkI,kBAAAA,KAAKlI,SAAS,YAAdkI,kBACC,IAAMxJ,cAAcyJ,GAAG,GAAGnI,SAAS;IACtC,OAAO;QACL6C,SAAS,OAAO,EAAEpB,MAAM,EAAEwD,GAAG,EAAE;YAC7B,MAAMyC,OAAO,MAAM1H,YAAYI,UAAU,CAAC,SAAS6B,GAAG,CAACR,QAAQlB,GAAG;YAClE,IAAI,CAACmH,KAAKvF,MAAM,EAAE,OAAO;gBAAEC,QAAQ;gBAAKE,OAAO;YAAe;YAC9D,OAAOmF,yBAAyBC,MAAMzC;QACxC;QAEAmD,OAAO,CAACtB;YACN,MAAMuB,OAAOxB,yBAAyBC;YACtC,OAAOuB,KAAKb,EAAE,KAAK,QACfa,OACA;gBAAEb,IAAI;gBAAMQ,OAAO;oBAAEM,QAAQ;2BAAI1C;qBAA4B;gBAAC;YAAE;QACtE;QAEAyC,MAAM,OAAO,EAAE5G,MAAM,EAAEiC,EAAE,EAAE;YACzB,IAAI,CAAC/E,aAAa+E,KAAK,OAAO;YAC9B,MAAMoE,WAAW,MAAM9H,YACpBI,UAAU,CAAC,SACX6B,GAAG,CAACR,QACJrB,UAAU,CAAC,kBACX6B,GAAG,CAACyB,IACJnD,GAAG;YACN,OAAOuH,SAAS3F,MAAM,GAAG0F,oBAAoBC,YAAY;QAC3D;QAEAS,OAAO,OAAOC;YACZ,MAAMH,OAAOxB,yBAAyB2B,QAAQ1B,OAAO;YACrD,IAAIuB,KAAKb,EAAE,KAAK,OAAO,OAAO;gBAAEA,IAAI;gBAAOpF,QAAQ;gBAAKE,OAAO+F,KAAKjB,QAAQ,CAAC,EAAE;YAAC;YAChF,IAAI,CAACzI,aAAa6J,QAAQ9E,EAAE,GAAG,OAAO;gBAAE8D,IAAI;gBAAOpF,QAAQ;gBAAKE,OAAO;YAAqB;YAC5F,MAAMmG,KAAKzI;YACX,MAAMF,UAAU2I,GAAGrI,UAAU,CAAC,SAAS6B,GAAG,CAACuG,QAAQ/G,MAAM;YACzD,MAAMiH,cAAc5I,QAAQM,UAAU,CAAC,kBAAkB6B,GAAG,CAACuG,QAAQ9E,EAAE;YACvE,MAAMd,UAAU+C,qBAAqB6C,QAAQ9E,EAAE;YAC/C,MAAMiF,WAAW7I,QAAQM,UAAU,CAAC,aAAa6B,GAAG,CAACW;YACrD,MAAMgG,YAAY9I,QAAQM,UAAU,CAAC,WAAW6B,GAAG,CAACoG,KAAKvC,KAAK,CAACiB,gBAAgB;YAC/E,OAAO0B,GAAGzF,cAAc,CAAC,OAAOC;gBAC9B,MAAM,CAACyE,MAAMI,UAAU5C,OAAO2D,OAAO,GAAG,MAAM/E,QAAQC,GAAG,CAAC;oBACxDd,YAAY1C,GAAG,CAACT;oBAChBmD,YAAY1C,GAAG,CAACmI;oBAChBzF,YAAY1C,GAAG,CAACoI;oBAChB1F,YAAY1C,GAAG,CAACqI;iBACjB;gBACD,IAAI,CAAClB,KAAKvF,MAAM,EAAE,OAAO;oBAAEqF,IAAI;oBAAOpF,QAAQ;oBAAKE,OAAO;gBAAe;gBACzE,IAAIwF,SAAS3F,MAAM,EAAE;oBACnB,OAAO;wBAAEqF,IAAI;wBAAMsB,UAAU;uBAASjB,oBAAoBC;gBAC5D;gBACA,MAAMjF,UAAU4E,yBAAyBC,MAAMc,QAAQvD,GAAG;gBAC1D,IAAIpC,SAAS,OAAO;oBAAE2E,IAAI;mBAAU3E;gBACpC,IACE,CAACgG,OAAO1G,MAAM,IACd0G,OAAOtI,GAAG,CAAC,gBAAgB,QAC3BsI,OAAOtI,GAAG,CAAC,YAAYd,mBACvB;oBACA,OAAO;wBAAE+H,IAAI;wBAAOpF,QAAQ;wBAAKE,OAAO;oBAAuB;gBACjE;gBACA,IAAI4C,MAAM/C,MAAM,EAAE,OAAO;oBAAEqF,IAAI;oBAAOpF,QAAQ;oBAAKE,OAAO;gBAA4B;gBACtF,MAAM6B,OAAO0B,gBAAgB2C,QAAQrE,IAAI,KAAKmB;gBAC9C,MAAMb,QAAQ+D,QAAQ7D,GAAG,CAACoE,OAAO;gBACjC,gEAAgE;gBAChE9F,YAAY2B,MAAM,CAAC8D,aAAa;oBAC9BvE;oBACA6E,SAAS,EAAE;oBACXlE,aAAaL;oBACbwE,WAAWT,QAAQvD,GAAG;gBACxB;gBACA,MAAM,EAAEa,KAAK,EAAE,GAAGuC;gBAClBpF,YAAY2B,MAAM,CAAC+D,UAAU;oBAC3B3B,SAASlB,MAAMkB,OAAO;mBAClBlB,MAAMmB,SAAS,GAAG;oBAAEA,WAAWnB,MAAMmB,SAAS;gBAAC,IAAI,CAAC;oBACxDF,kBAAkBjB,MAAMiB,gBAAgB;mBACpCjB,MAAMoB,eAAe,CAACK,MAAM,GAAG;oBAAEL,iBAAiBpB,MAAMoB,eAAe;gBAAC,IAAI,CAAC,GAC7EpB,MAAMqB,iBAAiB,CAACI,MAAM,GAAG;oBAAEJ,mBAAmBrB,MAAMqB,iBAAiB;gBAAC,IAAI,CAAC;oBACvF,CAAC3H,8BAA8B,EAAEgJ,QAAQ9E,EAAE;oBAC3CtB,QAAQ;oBACR0C,aAAaL;oBACbM,WAAWyD,QAAQ7D,GAAG;oBACtBK,WAAWwD,QAAQvD,GAAG;;gBAExB,OAAO;oBACLuC,IAAI;oBACJsB,UAAU;oBACVpF,IAAI8E,QAAQ9E,EAAE;oBACdS;oBACA4D,WAAW;oBACXC,OAAO;wBAAEpF;oBAAQ;gBACnB;YACF;QACF;IACF;AACF;AAEA,OAAO,MAAMsG,sBAAsBjB,4BAA2B;AAE9D;;;CAGC,GACD,OAAO,SAASkB;IACdzJ,kCAAkC0F,yBAAyB8D,qBAAqB;QAC9EE,UAAU/D;IACZ;AACF;AAEA,OAAO,MAAMgE,wBAA0C,OAAOC,KAAKC;4BAmBpCD;QAfPA,WACAA,YACEA;IALxB,IAAIA,IAAIE,MAAM,KAAK,QAAQ;QACzB,OAAOD,IAAInH,MAAM,CAAC,KAAKqH,IAAI,CAAC;YAAEnH,OAAO;QAAqB;IAC5D;IACA,MAAMb,SAAS0B,gBAAOmG,YAAAA,IAAIjH,IAAI,qBAARiH,UAAU7H,MAAM,mBAAI;IAC1C,MAAMiI,SAASvG,iBAAOmG,aAAAA,IAAIjH,IAAI,qBAARiH,WAAUI,MAAM,oBAAI;IAC1C,MAAMC,WAAWxG,iBAAOmG,aAAAA,IAAIjH,IAAI,qBAARiH,WAAUvJ,UAAU,oBAAI;IAChD,IAAI,CAAC0B,QAAQ,OAAO8H,IAAInH,MAAM,CAAC,KAAKqH,IAAI,CAAC;QAAEnH,OAAO;IAAiB;IACnE,IAAI,CAAC3D,aAAagL,WAAW;QAC3B,OAAOJ,IAAInH,MAAM,CAAC,KAAKqH,IAAI,CAAC;YAAEnH,OAAO;QAAqB;IAC5D;IACA,IACEoH,WAAW,oBACXA,WAAW,kBACXA,WAAW,aACX;QACA,OAAOH,IAAInH,MAAM,CAAC,KAAKqH,IAAI,CAAC;YAAEnH,OAAO;QAAiB;IACxD;IAEA,MAAMsH,gBAAgBzG,QAAOmG,6BAAAA,IAAIO,OAAO,CAACD,aAAa,YAAzBN,6BAA6B;IAC1D,MAAMQ,UAAUF,cAAcG,UAAU,CAAC,aACrCH,cAAchD,KAAK,CAAC,UAAUW,MAAM,IACpC/C;IACJ,IAAI,CAACsF,SAAS,OAAOP,IAAInH,MAAM,CAAC,KAAKqH,IAAI,CAAC;QAAEnH,OAAO;IAAkB;IAErE,IAAI;YAQkB0H;QAPpB,MAAMC,UAAU,MAAMvL,cAAcyJ,GAAG,GAAG+B,IAAI,GAAGC,aAAa,CAACL;QAC/D,MAAM9J,YAAYtB,cAAcyJ,GAAG,GAAGnI,SAAS;QAC/C,MAAMF,UAAUE,UAAUI,UAAU,CAAC,SAAS6B,GAAG,CAACR;QAClD,MAAMuI,eAAe,MAAMlK,QAAQS,GAAG;QACtC,IAAI,CAACyJ,aAAa7H,MAAM,EAAE;YACxB,OAAOoH,IAAInH,MAAM,CAAC,KAAKqH,IAAI,CAAC;gBAAEnH,OAAO;YAAe;QACtD;QACA,MAAM8H,aAAa,EAACJ,oBAAAA,aAAazJ,GAAG,CAAC,0BAAjByJ,oBAAmC,CAAC,EAAE,CAACC,QAAQhF,GAAG,CAAC;QACvE,IAAImF,eAAe,WAAWA,eAAe,UAAU;YACrD,OAAOb,IAAInH,MAAM,CAAC,KAAKqH,IAAI,CAAC;gBAAEnH,OAAO;YAA6B;QACpE;QAEA,IAAIoH,WAAW,aAAa;uBAIX,cAmBcO;gBApBHX,YAmBjBA;YArBT,oEAAoE;YACpE,mEAAmE;YACnE,MAAMe,aAAalH,iBAAOmG,aAAAA,IAAIjH,IAAI,qBAARiH,WAAUe,UAAU,oBAAI,IAAInG,IAAI,GAAG0C,KAAK,CAAC,GAAG;YACtE,MAAMjF,SAAS,QAAA,MAAMvC,oBAAoBqC,mBAA1B,QAAsC;YACrD,MAAM6I,UAAUD,aACZ,MAAMrL,aAAagB,WAAoB;gBACrCuK,MAAM;gBACNC,SAAS,GAAG/I,OAAO,CAAC,EAAEkI,UAAU;gBAChChI;gBACA8I,KAAKJ;gBACLK,aAAa7L;YACf,KACA;YACJ,IAAIyL,WAAW,YAAYA,SAAS;gBAClC,OAAOf,IAAInH,MAAM,CAACkI,QAAQK,MAAM,CAACvI,MAAM,EAAEqH,IAAI,CAACa,QAAQK,MAAM,CAACtI,IAAI;YACnE;YACA,MAAMuI,QAAQN,WAAW,WAAWA,UAAUA,QAAQM,KAAK,GAAG;YAC9D,MAAMC,SAAS,MAAMvH,iBACnB7B,QACA3B,SACA6J,UACAxG,iBAAOmG,aAAAA,IAAIjH,IAAI,qBAARiH,WAAUnF,IAAI,oBAAI,KACzB;gBAAEc,KAAKgF,QAAQhF,GAAG;gBAAEC,KAAK,GAAE+E,iBAAAA,QAAQ/E,KAAK,YAAb+E,iBAAiB;gBAAMtI;YAAM;YAE1D,IAAIkJ,OAAOzI,MAAM,KAAK,KAAK,OAAMwI,yBAAAA,MAAOE,MAAM,CAACD,OAAOzI,MAAM,EAAEyI,OAAOxI,IAAI;iBACpE,OAAMuI,yBAAAA,MAAOG,OAAO;YACzB,OAAOxB,IAAInH,MAAM,CAACyI,OAAOzI,MAAM,EAAEqH,IAAI,CAACoB,OAAOxI,IAAI;QACnD;QACA,MAAM2I,SACJtB,WAAW,mBACP,MAAM1H,eAAeP,QAAQ3B,SAAS6J,YACtC,MAAMhH,aAAa7C,SAAS6J;QAClC,OAAOJ,IAAInH,MAAM,CAAC4I,OAAO5I,MAAM,EAAEqH,IAAI,CAACuB,OAAO3I,IAAI;IACnD,EAAE,OAAOC,OAAO;QACd2I,QAAQ3I,KAAK,CAACA;QACd,OAAOiH,IAAInH,MAAM,CAAC,KAAKqH,IAAI,CAAC;YAAEnH,OAAO;QAAqC;IAC5E;AACF,EAAC"}
@@ -0,0 +1,26 @@
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
+ */
17
+ import { type PluginApiHandler } from '@aglyn/aglyn/server';
18
+ /**
19
+ * Scheduled-campaign processor (AGL-272): scheduler-invoked (Cloud
20
+ * Scheduler / cron, x-cron-secret like report-usage), it claims due
21
+ * `status: 'scheduled'` campaigns across every host and delivers them
22
+ * through the shared send core. A transaction flips scheduled → sending
23
+ * so overlapping runs never double-send; failures mark the campaign
24
+ * `failed` with the reason instead of retrying forever.
25
+ */
26
+ export declare const campaignProcessScheduledHandler: PluginApiHandler;
@@ -0,0 +1,198 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { writeCronBeat } from "@aglyn/aglyn/server";
18
+ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
19
+ import { CampaignSendDeferredError, CampaignSendError, performCampaignSend } from "./campaign-send.js";
20
+ /**
21
+ * Scheduled-campaign processor (AGL-272): scheduler-invoked (Cloud
22
+ * Scheduler / cron, x-cron-secret like report-usage), it claims due
23
+ * `status: 'scheduled'` campaigns across every host and delivers them
24
+ * through the shared send core. A transaction flips scheduled → sending
25
+ * so overlapping runs never double-send; failures mark the campaign
26
+ * `failed` with the reason instead of retrying forever.
27
+ */ export const campaignProcessScheduledHandler = async (req, res)=>{
28
+ if (req.method !== 'POST') {
29
+ return res.status(405).json({
30
+ error: 'Method not allowed'
31
+ });
32
+ }
33
+ const cronSecret = process.env.CRON_SECRET;
34
+ if (!cronSecret) {
35
+ return res.status(501).json({
36
+ error: 'Scheduling is not configured (CRON_SECRET).'
37
+ });
38
+ }
39
+ if (req.headers['x-cron-secret'] !== cronSecret) {
40
+ return res.status(401).json({
41
+ error: 'Unauthenticated'
42
+ });
43
+ }
44
+ try {
45
+ const firestore = firebaseAdmin.app().firestore();
46
+ // AGL-1955 — the mark `/api/health/crons` reads to notice this job going
47
+ // AWAY. This is the one route in the set that has already been sold,
48
+ // wired and completely inert once (AGL-2134): the composer wrote
49
+ // `status: 'scheduled'` and nothing ever POSTed here, so a customer's
50
+ // campaign sat in the collection until somebody cancelled it. Nothing
51
+ // downstream ages when this stops — a fortnight with no due campaign
52
+ // produces exactly the same silence as a deleted schedule — so the
53
+ // invocation itself is the only honest thing to watch.
54
+ await writeCronBeat(firestore, 'campaigns-process-scheduled');
55
+ const due = await firestore.collectionGroup('campaigns').where('status', '==', 'scheduled').where('sendAtMs', '<=', Date.now()).limit(10).get();
56
+ const results = [];
57
+ for (const campaignDoc of due.docs){
58
+ var _ref;
59
+ var _data_resume;
60
+ const hostRef = campaignDoc.ref.parent.parent;
61
+ if (!hostRef) continue;
62
+ const claimed = await firestore.runTransaction(async (transaction)=>{
63
+ const fresh = await transaction.get(campaignDoc.ref);
64
+ if (fresh.get('status') !== 'scheduled') return false;
65
+ transaction.update(campaignDoc.ref, {
66
+ status: 'sending'
67
+ });
68
+ return true;
69
+ });
70
+ if (!claimed) continue;
71
+ const data = campaignDoc.data();
72
+ /*
73
+ * IS THIS A CAMPAIGN, OR THE REST OF ONE?
74
+ *
75
+ * An audience larger than one send may carry goes out over several
76
+ * runs. Each batch writes the email back as `scheduled` with a `resume`
77
+ * map, so this query picks it up again — the same claim, the same
78
+ * transaction, the same send core. The presence of a batch count is
79
+ * what tells the two apart, and it is read off the record rather than
80
+ * passed in because there is nobody to pass it: a merchant pressed Send
81
+ * once, possibly hours ago.
82
+ *
83
+ * It matters more than a label. A continuation subtracts everyone the
84
+ * email has already SETTLED and leaves the audience figures the first
85
+ * batch recorded alone; a first send does neither. Getting this wrong
86
+ * in the false direction mails people a second copy.
87
+ */ const resumingBatch = Math.max(0, Math.floor(Number((_ref = (_data_resume = data['resume']) == null ? void 0 : _data_resume.batch) != null ? _ref : 0)) || 0) > 0;
88
+ try {
89
+ var _data_subject, _data_body, _data_audience, _data_segmentId, _data_listId, _data_topicId, _data_experimentId, _data_templateScreenId, _data_plainText, _data_fromName, _data_replyTo, _data_preheader, _data_emailCampaignId, _ref1, _data_scheduledBy;
90
+ const result = await performCampaignSend(_extends({
91
+ hostId: hostRef.id
92
+ }, resumingBatch ? {
93
+ continuation: true
94
+ } : {}, {
95
+ subject: String((_data_subject = data['subject']) != null ? _data_subject : ''),
96
+ body: String((_data_body = data['body']) != null ? _data_body : ''),
97
+ audience: String((_data_audience = data['audience']) != null ? _data_audience : 'leads'),
98
+ segmentId: String((_data_segmentId = data['segmentId']) != null ? _data_segmentId : ''),
99
+ listId: String((_data_listId = data['listId']) != null ? _data_listId : ''),
100
+ // A scheduled campaign carries the topic it was composed under, not
101
+ // whatever the default is on the day the cron picks it up: the
102
+ // unsubscribe links it mints have to name the stream the author
103
+ // chose. Absent on every campaign scheduled before topics existed,
104
+ // which `performCampaignSend` resolves to the default.
105
+ topicId: String((_data_topicId = data['topicId']) != null ? _data_topicId : '') || undefined,
106
+ emails: Array.isArray(data['emails']) ? data['emails'].map(String) : undefined,
107
+ campaignId: campaignDoc.id,
108
+ experimentId: String((_data_experimentId = data['experimentId']) != null ? _data_experimentId : ''),
109
+ templateScreenId: String((_data_templateScreenId = data['templateScreenId']) != null ? _data_templateScreenId : '') || undefined,
110
+ /*
111
+ * The plain-text part the author wrote, read back with the design.
112
+ * Regenerating one from the design at cron time would mail a text
113
+ * half the merchant never reviewed — and a different one from what
114
+ * the composer previewed and the test send delivered.
115
+ */ plainText: String((_data_plainText = data['plainText']) != null ? _data_plainText : '') || undefined,
116
+ // The sender fields the composer chose. Read back rather than
117
+ // resolved fresh: a scheduled campaign must go out as the message
118
+ // that was composed, not as whatever the org's branding says an hour
119
+ // later.
120
+ fromName: String((_data_fromName = data['fromName']) != null ? _data_fromName : ''),
121
+ replyTo: String((_data_replyTo = data['replyTo']) != null ? _data_replyTo : ''),
122
+ preheader: String((_data_preheader = data['preheader']) != null ? _data_preheader : ''),
123
+ emailCampaignId: String((_data_emailCampaignId = data['emailCampaignId']) != null ? _data_emailCampaignId : ''),
124
+ /*
125
+ * Whoever sent it, and `scheduledBy` is not that person on a
126
+ * campaign a merchant sent by hand — an immediate send that batches
127
+ * has no `scheduledBy` at all. `sentBy` is what the first batch
128
+ * recorded, so the later ones are attributed to the same person
129
+ * rather than to the cron.
130
+ */ senderUid: String((_ref1 = (_data_scheduledBy = data['scheduledBy']) != null ? _data_scheduledBy : data['sentBy']) != null ? _ref1 : 'scheduler')
131
+ }));
132
+ results.push(result);
133
+ } catch (error) {
134
+ /*
135
+ * DEFERRED IS NOT FAILED (AGL-2409).
136
+ *
137
+ * The platform send-rate governor had no room for this campaign in
138
+ * the current hour and NOTHING WAS SENT — the admission check runs
139
+ * before the first message and before any counter moves, which is
140
+ * what makes this safe to retry. So the claim is released by putting
141
+ * the row back to `scheduled`, and the next 15-minute run picks it up.
142
+ *
143
+ * Marking it `failed` here — which is the right answer for every
144
+ * other `CampaignSendError`, an empty audience or a stopped
145
+ * experiment — would turn a ramp into a lost campaign that the
146
+ * merchant has to notice in the History list and re-create by hand.
147
+ * That is the failure mode the ceiling exists to avoid making worse.
148
+ */ if (error instanceof CampaignSendDeferredError) {
149
+ await campaignDoc.ref.set({
150
+ status: 'scheduled',
151
+ deferredReason: error.message,
152
+ deferredUntilMs: error.retryAtMs,
153
+ /*
154
+ * Due when the window that deferred it rolls, rather than
155
+ * immediately. Without this the row stays due and every
156
+ * fifteen-minute run re-resolves an audience of up to five
157
+ * thousand documents to be told the same no — which for a
158
+ * workspace ramped to a day is ninety-six pointless
159
+ * resolutions of somebody's whole contact list.
160
+ */ sendAtMs: error.retryAtMs
161
+ }, {
162
+ merge: true
163
+ }).catch(()=>undefined);
164
+ results.push({
165
+ campaignId: campaignDoc.id,
166
+ deferred: true,
167
+ retryAtMs: error.retryAtMs
168
+ });
169
+ continue;
170
+ }
171
+ const message = error instanceof CampaignSendError ? error.message : 'Campaign send failed';
172
+ if (!(error instanceof CampaignSendError)) console.error(error);
173
+ await campaignDoc.ref.set({
174
+ status: 'failed',
175
+ error: message,
176
+ failedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp()
177
+ }, {
178
+ merge: true
179
+ }).catch(()=>undefined);
180
+ results.push({
181
+ campaignId: campaignDoc.id,
182
+ error: message
183
+ });
184
+ }
185
+ }
186
+ return res.status(200).json({
187
+ processed: results.length,
188
+ results
189
+ });
190
+ } catch (error) {
191
+ console.error(error);
192
+ return res.status(500).json({
193
+ error: 'Processing failed'
194
+ });
195
+ }
196
+ };
197
+
198
+ //# sourceMappingURL=campaign-process-scheduled.js.map
@@ -0,0 +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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"}
@@ -0,0 +1,55 @@
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
+ */
17
+ import type { PluginApiHandler } from '@aglyn/aglyn/server';
18
+ /**
19
+ * WHO OPENED THIS, ONE PAGE AT A TIME.
20
+ *
21
+ * ## Why a server route rather than a client listen
22
+ *
23
+ * The per-recipient delivery log lives at `emailDeliveries/{sha256(address)}`
24
+ * — a platform-level collection keyed by a hash of an address, holding the
25
+ * mail of every site on the install and every transactional message besides.
26
+ * No security rule can express "this site's editors may read the rows tagged
27
+ * with this site" over a collection-group query, because the predicate that
28
+ * narrows it is the query's own `where`, and a rule cannot require one. So
29
+ * the narrowing happens here, behind a role check, on a server that holds the
30
+ * admin credential.
31
+ *
32
+ * ## What the role check is
33
+ *
34
+ * Admin or editor on the site — deliberately the SAME role the send path
35
+ * requires, and checked the same way. Whoever may address these people may
36
+ * see which of them opened; a page that were readable by anyone the campaign
37
+ * report is readable by would hand the address list to a role that cannot
38
+ * mail it.
39
+ *
40
+ * ## What comes back, and what does not
41
+ *
42
+ * The rows carry the recipient address, because that is the question. They do
43
+ * not carry the sha256 key, the suppression state, or anything about mail
44
+ * this site did not send: the query is filtered on `hostId` as well as on the
45
+ * message ids, so a row belonging to another site cannot be returned even if
46
+ * a message id were wrong.
47
+ *
48
+ * ## Two scopes, one route
49
+ *
50
+ * `emailId` reads one message; `screenId` reads every message built from one
51
+ * template. They are one route because they are one question asked at two
52
+ * altitudes, and splitting them would mean two role checks, two cursors and
53
+ * two chances for one of them to drift out of agreement with the other.
54
+ */
55
+ export declare const campaignRecipientsHandler: PluginApiHandler;