@aglyn/plugins-outreach 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 (282) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +93 -0
  3. package/package.json +52 -0
  4. package/src/index.d.ts +22 -0
  5. package/src/index.js +29 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/compliance-section.d.ts +20 -0
  8. package/src/lib/components/compliance-section.js +291 -0
  9. package/src/lib/components/compliance-section.js.map +1 -0
  10. package/src/lib/components/enroll-dialog.d.ts +36 -0
  11. package/src/lib/components/enroll-dialog.js +656 -0
  12. package/src/lib/components/enroll-dialog.js.map +1 -0
  13. package/src/lib/components/enrollments-table.d.ts +28 -0
  14. package/src/lib/components/enrollments-table.js +375 -0
  15. package/src/lib/components/enrollments-table.js.map +1 -0
  16. package/src/lib/components/mailbox-card.d.ts +46 -0
  17. package/src/lib/components/mailbox-card.js +517 -0
  18. package/src/lib/components/mailbox-card.js.map +1 -0
  19. package/src/lib/components/mailboxes-section.d.ts +24 -0
  20. package/src/lib/components/mailboxes-section.js +258 -0
  21. package/src/lib/components/mailboxes-section.js.map +1 -0
  22. package/src/lib/components/outreach-console-page.d.ts +17 -0
  23. package/src/lib/components/outreach-console-page.js +81 -0
  24. package/src/lib/components/outreach-console-page.js.map +1 -0
  25. package/src/lib/components/outreach-console-sections.d.ts +43 -0
  26. package/src/lib/components/outreach-console-sections.js +55 -0
  27. package/src/lib/components/outreach-console-sections.js.map +1 -0
  28. package/src/lib/components/outreach-ui.d.ts +51 -0
  29. package/src/lib/components/outreach-ui.js +171 -0
  30. package/src/lib/components/outreach-ui.js.map +1 -0
  31. package/src/lib/components/send-window-fields.d.ts +25 -0
  32. package/src/lib/components/send-window-fields.js +178 -0
  33. package/src/lib/components/send-window-fields.js.map +1 -0
  34. package/src/lib/components/sequence-detail.d.ts +54 -0
  35. package/src/lib/components/sequence-detail.js +435 -0
  36. package/src/lib/components/sequence-detail.js.map +1 -0
  37. package/src/lib/components/sequence-editor.d.ts +37 -0
  38. package/src/lib/components/sequence-editor.js +485 -0
  39. package/src/lib/components/sequence-editor.js.map +1 -0
  40. package/src/lib/components/sequence-mailbox-picker.d.ts +44 -0
  41. package/src/lib/components/sequence-mailbox-picker.js +137 -0
  42. package/src/lib/components/sequence-mailbox-picker.js.map +1 -0
  43. package/src/lib/components/sequence-preview.d.ts +27 -0
  44. package/src/lib/components/sequence-preview.js +146 -0
  45. package/src/lib/components/sequence-preview.js.map +1 -0
  46. package/src/lib/components/sequence-step-card.d.ts +31 -0
  47. package/src/lib/components/sequence-step-card.js +326 -0
  48. package/src/lib/components/sequence-step-card.js.map +1 -0
  49. package/src/lib/components/sequences-section.d.ts +40 -0
  50. package/src/lib/components/sequences-section.js +310 -0
  51. package/src/lib/components/sequences-section.js.map +1 -0
  52. package/src/lib/components/use-outreach-api.d.ts +38 -0
  53. package/src/lib/components/use-outreach-api.js +139 -0
  54. package/src/lib/components/use-outreach-api.js.map +1 -0
  55. package/src/lib/components/use-outreach-crm.d.ts +50 -0
  56. package/src/lib/components/use-outreach-crm.js +200 -0
  57. package/src/lib/components/use-outreach-crm.js.map +1 -0
  58. package/src/lib/components/use-outreach-data.d.ts +55 -0
  59. package/src/lib/components/use-outreach-data.js +199 -0
  60. package/src/lib/components/use-outreach-data.js.map +1 -0
  61. package/src/lib/components/use-outreach-mailbox-api.d.ts +30 -0
  62. package/src/lib/components/use-outreach-mailbox-api.js +101 -0
  63. package/src/lib/components/use-outreach-mailbox-api.js.map +1 -0
  64. package/src/lib/components/use-outreach-mailboxes.d.ts +22 -0
  65. package/src/lib/components/use-outreach-mailboxes.js +68 -0
  66. package/src/lib/components/use-outreach-mailboxes.js.map +1 -0
  67. package/src/lib/components/use-outreach-settings.d.ts +17 -0
  68. package/src/lib/components/use-outreach-settings.js +64 -0
  69. package/src/lib/components/use-outreach-settings.js.map +1 -0
  70. package/src/lib/constants/api-routes.d.ts +68 -0
  71. package/src/lib/constants/api-routes.js +57 -0
  72. package/src/lib/constants/api-routes.js.map +1 -0
  73. package/src/lib/constants/bundle-common.d.ts +46 -0
  74. package/src/lib/constants/bundle-common.js +55 -0
  75. package/src/lib/constants/bundle-common.js.map +1 -0
  76. package/src/lib/constants/runtime-jobs.d.ts +24 -0
  77. package/src/lib/constants/runtime-jobs.js +24 -0
  78. package/src/lib/constants/runtime-jobs.js.map +1 -0
  79. package/src/lib/declarations.console-server.d.ts +38 -0
  80. package/src/lib/declarations.console-server.js +99 -0
  81. package/src/lib/declarations.console-server.js.map +1 -0
  82. package/src/lib/engine/compose.d.ts +86 -0
  83. package/src/lib/engine/compose.js +212 -0
  84. package/src/lib/engine/compose.js.map +1 -0
  85. package/src/lib/engine/delivery-status.d.ts +53 -0
  86. package/src/lib/engine/delivery-status.js +184 -0
  87. package/src/lib/engine/delivery-status.js.map +1 -0
  88. package/src/lib/engine/do-not-contact.d.ts +18 -0
  89. package/src/lib/engine/do-not-contact.js +34 -0
  90. package/src/lib/engine/do-not-contact.js.map +1 -0
  91. package/src/lib/engine/enrollment-state.d.ts +156 -0
  92. package/src/lib/engine/enrollment-state.js +303 -0
  93. package/src/lib/engine/enrollment-state.js.map +1 -0
  94. package/src/lib/engine/gates.d.ts +125 -0
  95. package/src/lib/engine/gates.js +232 -0
  96. package/src/lib/engine/gates.js.map +1 -0
  97. package/src/lib/engine/index.d.ts +28 -0
  98. package/src/lib/engine/index.js +33 -0
  99. package/src/lib/engine/index.js.map +1 -0
  100. package/src/lib/engine/mailbox-health.d.ts +49 -0
  101. package/src/lib/engine/mailbox-health.js +83 -0
  102. package/src/lib/engine/mailbox-health.js.map +1 -0
  103. package/src/lib/engine/opt-out-intent.d.ts +41 -0
  104. package/src/lib/engine/opt-out-intent.js +220 -0
  105. package/src/lib/engine/opt-out-intent.js.map +1 -0
  106. package/src/lib/engine/recipient-country.d.ts +41 -0
  107. package/src/lib/engine/recipient-country.js +171 -0
  108. package/src/lib/engine/recipient-country.js.map +1 -0
  109. package/src/lib/engine/schedule.d.ts +70 -0
  110. package/src/lib/engine/schedule.js +115 -0
  111. package/src/lib/engine/schedule.js.map +1 -0
  112. package/src/lib/engine/sending-capacity.d.ts +96 -0
  113. package/src/lib/engine/sending-capacity.js +172 -0
  114. package/src/lib/engine/sending-capacity.js.map +1 -0
  115. package/src/lib/engine/sequence-validation.d.ts +90 -0
  116. package/src/lib/engine/sequence-validation.js +291 -0
  117. package/src/lib/engine/sequence-validation.js.map +1 -0
  118. package/src/lib/engine/thread-classification.d.ts +78 -0
  119. package/src/lib/engine/thread-classification.js +224 -0
  120. package/src/lib/engine/thread-classification.js.map +1 -0
  121. package/src/lib/engine/thread-message.d.ts +80 -0
  122. package/src/lib/engine/thread-message.js +143 -0
  123. package/src/lib/engine/thread-message.js.map +1 -0
  124. package/src/lib/enrollment/enroll-people.d.ts +81 -0
  125. package/src/lib/enrollment/enroll-people.js +250 -0
  126. package/src/lib/enrollment/enroll-people.js.map +1 -0
  127. package/src/lib/enrollment/gate-lookups.d.ts +32 -0
  128. package/src/lib/enrollment/gate-lookups.js +154 -0
  129. package/src/lib/enrollment/gate-lookups.js.map +1 -0
  130. package/src/lib/mailboxes/mailbox-api.d.ts +126 -0
  131. package/src/lib/mailboxes/mailbox-api.js +66 -0
  132. package/src/lib/mailboxes/mailbox-api.js.map +1 -0
  133. package/src/lib/mailboxes/mailbox-credentials.d.ts +95 -0
  134. package/src/lib/mailboxes/mailbox-credentials.js +102 -0
  135. package/src/lib/mailboxes/mailbox-credentials.js.map +1 -0
  136. package/src/lib/mailboxes/mailbox-erasure.d.ts +69 -0
  137. package/src/lib/mailboxes/mailbox-erasure.js +148 -0
  138. package/src/lib/mailboxes/mailbox-erasure.js.map +1 -0
  139. package/src/lib/mailboxes/mailbox-gate.d.ts +75 -0
  140. package/src/lib/mailboxes/mailbox-gate.js +98 -0
  141. package/src/lib/mailboxes/mailbox-gate.js.map +1 -0
  142. package/src/lib/mailboxes/mailbox-revoke.d.ts +59 -0
  143. package/src/lib/mailboxes/mailbox-revoke.js +41 -0
  144. package/src/lib/mailboxes/mailbox-revoke.js.map +1 -0
  145. package/src/lib/mailboxes/mailbox-routes.d.ts +119 -0
  146. package/src/lib/mailboxes/mailbox-routes.js +605 -0
  147. package/src/lib/mailboxes/mailbox-routes.js.map +1 -0
  148. package/src/lib/mailboxes/mailbox-settings.d.ts +110 -0
  149. package/src/lib/mailboxes/mailbox-settings.js +212 -0
  150. package/src/lib/mailboxes/mailbox-settings.js.map +1 -0
  151. package/src/lib/mailboxes/mailbox-transport.d.ts +64 -0
  152. package/src/lib/mailboxes/mailbox-transport.js +94 -0
  153. package/src/lib/mailboxes/mailbox-transport.js.map +1 -0
  154. package/src/lib/mailboxes/oauth-redirect.d.ts +41 -0
  155. package/src/lib/mailboxes/oauth-redirect.js +63 -0
  156. package/src/lib/mailboxes/oauth-redirect.js.map +1 -0
  157. package/src/lib/mailboxes/oauth-state.d.ts +152 -0
  158. package/src/lib/mailboxes/oauth-state.js +212 -0
  159. package/src/lib/mailboxes/oauth-state.js.map +1 -0
  160. package/src/lib/mailboxes/outreach-config.d.ts +64 -0
  161. package/src/lib/mailboxes/outreach-config.js +77 -0
  162. package/src/lib/mailboxes/outreach-config.js.map +1 -0
  163. package/src/lib/mailboxes/register-mailbox-routes.d.ts +47 -0
  164. package/src/lib/mailboxes/register-mailbox-routes.js +135 -0
  165. package/src/lib/mailboxes/register-mailbox-routes.js.map +1 -0
  166. package/src/lib/model/compliance-settings.d.ts +78 -0
  167. package/src/lib/model/compliance-settings.js +155 -0
  168. package/src/lib/model/compliance-settings.js.map +1 -0
  169. package/src/lib/model/outreach-api.d.ts +210 -0
  170. package/src/lib/model/outreach-api.js +28 -0
  171. package/src/lib/model/outreach-api.js.map +1 -0
  172. package/src/lib/model/outreach.types.d.ts +563 -0
  173. package/src/lib/model/outreach.types.js +225 -0
  174. package/src/lib/model/outreach.types.js.map +1 -0
  175. package/src/lib/model/sequence-draft.d.ts +84 -0
  176. package/src/lib/model/sequence-draft.js +239 -0
  177. package/src/lib/model/sequence-draft.js.map +1 -0
  178. package/src/lib/model/step-preview.d.ts +45 -0
  179. package/src/lib/model/step-preview.js +98 -0
  180. package/src/lib/model/step-preview.js.map +1 -0
  181. package/src/lib/model/stored-records.d.ts +23 -0
  182. package/src/lib/model/stored-records.js +84 -0
  183. package/src/lib/model/stored-records.js.map +1 -0
  184. package/src/lib/plugin.d.ts +46 -0
  185. package/src/lib/plugin.js +86 -0
  186. package/src/lib/plugin.js.map +1 -0
  187. package/src/lib/routes/enroll-routes.d.ts +64 -0
  188. package/src/lib/routes/enroll-routes.js +350 -0
  189. package/src/lib/routes/enroll-routes.js.map +1 -0
  190. package/src/lib/routes/enrollment-routes.d.ts +19 -0
  191. package/src/lib/routes/enrollment-routes.js +171 -0
  192. package/src/lib/routes/enrollment-routes.js.map +1 -0
  193. package/src/lib/routes/platform-deps.d.ts +51 -0
  194. package/src/lib/routes/platform-deps.js +66 -0
  195. package/src/lib/routes/platform-deps.js.map +1 -0
  196. package/src/lib/routes/preview-routes.d.ts +33 -0
  197. package/src/lib/routes/preview-routes.js +117 -0
  198. package/src/lib/routes/preview-routes.js.map +1 -0
  199. package/src/lib/routes/register-routes.d.ts +22 -0
  200. package/src/lib/routes/register-routes.js +92 -0
  201. package/src/lib/routes/register-routes.js.map +1 -0
  202. package/src/lib/routes/route-deps.d.ts +49 -0
  203. package/src/lib/routes/route-deps.js +27 -0
  204. package/src/lib/routes/route-deps.js.map +1 -0
  205. package/src/lib/routes/route-gate.d.ts +94 -0
  206. package/src/lib/routes/route-gate.js +87 -0
  207. package/src/lib/routes/route-gate.js.map +1 -0
  208. package/src/lib/routes/route-http.d.ts +32 -0
  209. package/src/lib/routes/route-http.js +62 -0
  210. package/src/lib/routes/route-http.js.map +1 -0
  211. package/src/lib/routes/sequence-routes.d.ts +32 -0
  212. package/src/lib/routes/sequence-routes.js +359 -0
  213. package/src/lib/routes/sequence-routes.js.map +1 -0
  214. package/src/lib/routes/settings-routes.d.ts +30 -0
  215. package/src/lib/routes/settings-routes.js +73 -0
  216. package/src/lib/routes/settings-routes.js.map +1 -0
  217. package/src/lib/runtime/enrollment-events.d.ts +86 -0
  218. package/src/lib/runtime/enrollment-events.js +141 -0
  219. package/src/lib/runtime/enrollment-events.js.map +1 -0
  220. package/src/lib/runtime/fixtures/fake-gmail.d.ts +121 -0
  221. package/src/lib/runtime/fixtures/fake-gmail.js +312 -0
  222. package/src/lib/runtime/fixtures/fake-gmail.js.map +1 -0
  223. package/src/lib/runtime/mailbox-health-store.d.ts +47 -0
  224. package/src/lib/runtime/mailbox-health-store.js +168 -0
  225. package/src/lib/runtime/mailbox-health-store.js.map +1 -0
  226. package/src/lib/runtime/person-erasure.d.ts +39 -0
  227. package/src/lib/runtime/person-erasure.js +57 -0
  228. package/src/lib/runtime/person-erasure.js.map +1 -0
  229. package/src/lib/runtime/platform-runtime-deps.d.ts +29 -0
  230. package/src/lib/runtime/platform-runtime-deps.js +98 -0
  231. package/src/lib/runtime/platform-runtime-deps.js.map +1 -0
  232. package/src/lib/runtime/runtime-deps.d.ts +77 -0
  233. package/src/lib/runtime/runtime-deps.js +18 -0
  234. package/src/lib/runtime/runtime-deps.js.map +1 -0
  235. package/src/lib/runtime/send-job.d.ts +42 -0
  236. package/src/lib/runtime/send-job.js +780 -0
  237. package/src/lib/runtime/send-job.js.map +1 -0
  238. package/src/lib/runtime/sync-job.d.ts +41 -0
  239. package/src/lib/runtime/sync-job.js +480 -0
  240. package/src/lib/runtime/sync-job.js.map +1 -0
  241. package/src/lib/runtime/timeline.d.ts +67 -0
  242. package/src/lib/runtime/timeline.js +93 -0
  243. package/src/lib/runtime/timeline.js.map +1 -0
  244. package/src/lib/runtime/unsubscribe-link.d.ts +75 -0
  245. package/src/lib/runtime/unsubscribe-link.js +121 -0
  246. package/src/lib/runtime/unsubscribe-link.js.map +1 -0
  247. package/src/lib/runtime/unsubscribe-route.d.ts +24 -0
  248. package/src/lib/runtime/unsubscribe-route.js +103 -0
  249. package/src/lib/runtime/unsubscribe-route.js.map +1 -0
  250. package/src/lib/server.d.ts +53 -0
  251. package/src/lib/server.js +94 -0
  252. package/src/lib/server.js.map +1 -0
  253. package/src/lib/storage/compliance-settings-store.d.ts +44 -0
  254. package/src/lib/storage/compliance-settings-store.js +78 -0
  255. package/src/lib/storage/compliance-settings-store.js.map +1 -0
  256. package/src/lib/storage/do-not-contact-store.d.ts +60 -0
  257. package/src/lib/storage/do-not-contact-store.js +168 -0
  258. package/src/lib/storage/do-not-contact-store.js.map +1 -0
  259. package/src/lib/storage/outreach-records.d.ts +33 -0
  260. package/src/lib/storage/outreach-records.js +35 -0
  261. package/src/lib/storage/outreach-records.js.map +1 -0
  262. package/src/lib/subprocessors.d.ts +48 -0
  263. package/src/lib/subprocessors.js +70 -0
  264. package/src/lib/subprocessors.js.map +1 -0
  265. package/src/lib/transport/gmail-client.d.ts +145 -0
  266. package/src/lib/transport/gmail-client.js +229 -0
  267. package/src/lib/transport/gmail-client.js.map +1 -0
  268. package/src/lib/transport/gmail-errors.d.ts +84 -0
  269. package/src/lib/transport/gmail-errors.js +138 -0
  270. package/src/lib/transport/gmail-errors.js.map +1 -0
  271. package/src/lib/transport/google-oauth.d.ts +138 -0
  272. package/src/lib/transport/google-oauth.js +247 -0
  273. package/src/lib/transport/google-oauth.js.map +1 -0
  274. package/src/lib/transport/http.d.ts +53 -0
  275. package/src/lib/transport/http.js +79 -0
  276. package/src/lib/transport/http.js.map +1 -0
  277. package/src/lib/transport/rfc5322.d.ts +116 -0
  278. package/src/lib/transport/rfc5322.js +330 -0
  279. package/src/lib/transport/rfc5322.js.map +1 -0
  280. package/src/lib/transport/send-message.d.ts +69 -0
  281. package/src/lib/transport/send-message.js +77 -0
  282. package/src/lib/transport/send-message.js.map +1 -0
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { checkEntitlement } from "@aglyn/aglyn/server";
17
+ import { isEmailVerified, isImpersonationSession } from "@aglyn/tenant-data-admin/server/firebase-admin";
18
+ import { invalidIdTokenResponse } from "@aglyn/tenant-data-admin/server/id-token-refusal";
19
+ import { OUTREACH_USE_PERMISSION } from "../constants/bundle-common.js";
20
+ import { outreachRefusal, readOutreachDocumentId } from "./route-http.js";
21
+ function bearer(request) {
22
+ var _request_headers_get;
23
+ const authorization = (_request_headers_get = request.headers.get('authorization')) != null ? _request_headers_get : '';
24
+ return authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : null;
25
+ }
26
+ export async function outreachRouteGate(request, rawOrgId, deps, extra = []) {
27
+ var _ref;
28
+ const token = bearer(request);
29
+ if (!token) return outreachRefusal(401, 'unauthenticated', 'Sign in to use Sequences.');
30
+ let decoded;
31
+ try {
32
+ decoded = await deps.verifyIdToken(token);
33
+ } catch (error) {
34
+ // A refused credential is the caller's; an Auth outage is ours and
35
+ // propagates, so it answers 500 instead of "sign in again".
36
+ if (invalidIdTokenResponse(error)) {
37
+ return outreachRefusal(401, 'unauthenticated', 'Your sign-in could not be confirmed. Sign in again.');
38
+ }
39
+ throw error;
40
+ }
41
+ if (!isEmailVerified(decoded) && !isImpersonationSession(decoded)) {
42
+ return outreachRefusal(403, 'email-unverified', 'Verify your email address before using Sequences.');
43
+ }
44
+ const orgId = readOutreachDocumentId(rawOrgId);
45
+ if (!orgId) return outreachRefusal(400, 'org-required', 'Open an organization before using Sequences.');
46
+ const membership = await deps.resolveOrgPermissions(decoded.uid, {
47
+ orgId
48
+ });
49
+ // A targeted org the account is not on answers with that org's id and no
50
+ // role — as does a lookup that failed closed — so the role is the test.
51
+ if (!membership.role || membership.orgId !== orgId) {
52
+ return outreachRefusal(403, 'not-a-member', 'You are not a member of that organization.');
53
+ }
54
+ if (!membership.orgWide) {
55
+ return outreachRefusal(403, 'not-org-wide', 'Sequences covers the whole organization, and your access is to particular sites.');
56
+ }
57
+ if (membership.permissions[OUTREACH_USE_PERMISSION] !== true) {
58
+ return outreachRefusal(403, 'permission', 'Your role does not include Use Sequences.');
59
+ }
60
+ for (const permission of extra){
61
+ if (!await deps.holdsOrgCatalogPermission(decoded.uid, orgId, permission.key)) {
62
+ return outreachRefusal(403, 'permission', permission.refusal);
63
+ }
64
+ }
65
+ const org = (_ref = await deps.readOrg(orgId)) != null ? _ref : {};
66
+ if (!checkEntitlement(org, 'outreach')) {
67
+ return outreachRefusal(403, 'entitlement', "Sequences isn't available to this workspace yet.");
68
+ }
69
+ const staff = decoded['staff'] === true;
70
+ const locked = await deps.lockdownRefusal({
71
+ request,
72
+ staff,
73
+ uid: decoded.uid,
74
+ org
75
+ });
76
+ if (locked) return locked;
77
+ return {
78
+ uid: decoded.uid,
79
+ email: typeof decoded.email === 'string' ? decoded.email.trim().toLowerCase() : null,
80
+ staff,
81
+ orgId,
82
+ org,
83
+ isOrgAdmin: membership.isOwner
84
+ };
85
+ }
86
+
87
+ //# sourceMappingURL=route-gate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/route-gate.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 { checkEntitlement } from '@aglyn/aglyn/server'\nimport {\n isEmailVerified,\n isImpersonationSession,\n} from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { invalidIdTokenResponse } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport type { DecodedIdToken } from 'firebase-admin/auth'\nimport { OUTREACH_USE_PERMISSION } from '../constants/bundle-common'\nimport { outreachRefusal, readOutreachDocumentId } from './route-http'\n\n/**\n * THE GATE EVERY SETTINGS, SEQUENCE AND ENROLLMENT ROUTE CLIMBS (AGL-2980).\n *\n * The console dispatcher has already refused a request while\n * `release_outreach` is off for it, an unverified account's token, a\n * lockdown with no org in scope, and a caller over the console rate limit —\n * every route here is registered under the `outreach/` prefix, which is what\n * puts it behind those. What the dispatcher cannot know is anything about\n * the member, so each rung below is this plugin's:\n *\n * 401 no bearer token, or one the verifier refused\n * 403 the account's address is unverified (an impersonation session is exempt)\n * 400 the request did not name an organization\n * 403 not a member of it, a site collaborator without org-wide reach, or\n * without `outreach.use` — or without a permission the route adds\n * 403 the organization does not hold the `outreach` entitlement\n * 423 lockdown, now with the organization and the account in scope\n *\n * The reach is org-wide for the reason the rules give: a sequence enrolls\n * people from every site of the organization, so none of it is a fact\n * about the one site a collaborator was invited to.\n *\n * `resolveOrgPermissions` is always given the concrete org id. Called\n * without one it resolves a caller with no org at all as that future org's\n * owner, which is right for a fresh signup and wrong for every question\n * asked here.\n */\n\nexport interface OutreachRouteGateDeps {\n verifyIdToken(idToken: string): Promise<DecodedIdToken>\n resolveOrgPermissions(\n uid: string,\n context: { orgId: string },\n ): Promise<{\n orgId: string | null\n /** `null` when the account is not on the organization's roster. */\n role: string | null\n isOwner: boolean\n orgWide: boolean\n permissions: Partial<Record<string, boolean | undefined>>\n }>\n /**\n * Whether the member holds a CATALOG permission (`data.manage`) as the\n * org's roles resolve it. Asked separately because the map\n * `resolveOrgPermissions` answers carries the legacy keys and the plugins'\n * own, never a catalog key: read there, a catalog key refuses everyone.\n */\n holdsOrgCatalogPermission(uid: string, orgId: string, key: string): Promise<boolean>\n readOrg(orgId: string): Promise<Record<string, unknown> | null>\n lockdownRefusal(options: {\n request: Request\n staff: boolean\n uid: string\n org: Record<string, unknown>\n }): Promise<Response | null>\n}\n\n/** Who is asking, once the gate let them through. */\nexport interface OutreachRouteCaller {\n uid: string\n /** The account's sign-in address, lower-cased, when the token carries one. */\n email: string | null\n staff: boolean\n orgId: string\n /** The organization document the entitlement was read from. */\n org: Record<string, unknown>\n /** An owner or admin of the organization, who may send from any member's mailbox. */\n isOrgAdmin: boolean\n}\n\n/**\n * A catalog permission a route asks for beyond `outreach.use`, with its\n * refusal — `data.manage` for a route that reads the CRM's people, which\n * the contacts' own rules gate on.\n */\nexport interface OutreachRouteExtraPermission {\n key: string\n /** Why the route needs it, as the refusal says it. */\n refusal: string\n}\n\nfunction bearer(request: Request): string | null {\n const authorization = request.headers.get('authorization') ?? ''\n return authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : null\n}\n\nexport async function outreachRouteGate(\n request: Request,\n rawOrgId: unknown,\n deps: OutreachRouteGateDeps,\n extra: readonly OutreachRouteExtraPermission[] = [],\n): Promise<Response | OutreachRouteCaller> {\n const token = bearer(request)\n if (!token) return outreachRefusal(401, 'unauthenticated', 'Sign in to use Sequences.')\n let decoded: DecodedIdToken\n try {\n decoded = await deps.verifyIdToken(token)\n } catch (error) {\n // A refused credential is the caller's; an Auth outage is ours and\n // propagates, so it answers 500 instead of \"sign in again\".\n if (invalidIdTokenResponse(error)) {\n return outreachRefusal(401, 'unauthenticated', 'Your sign-in could not be confirmed. Sign in again.')\n }\n throw error\n }\n if (!isEmailVerified(decoded) && !isImpersonationSession(decoded)) {\n return outreachRefusal(403, 'email-unverified', 'Verify your email address before using Sequences.')\n }\n\n const orgId = readOutreachDocumentId(rawOrgId)\n if (!orgId) return outreachRefusal(400, 'org-required', 'Open an organization before using Sequences.')\n\n const membership = await deps.resolveOrgPermissions(decoded.uid, { orgId })\n // A targeted org the account is not on answers with that org's id and no\n // role — as does a lookup that failed closed — so the role is the test.\n if (!membership.role || membership.orgId !== orgId) {\n return outreachRefusal(403, 'not-a-member', 'You are not a member of that organization.')\n }\n if (!membership.orgWide) {\n return outreachRefusal(\n 403,\n 'not-org-wide',\n 'Sequences covers the whole organization, and your access is to particular sites.',\n )\n }\n if (membership.permissions[OUTREACH_USE_PERMISSION] !== true) {\n return outreachRefusal(403, 'permission', 'Your role does not include Use Sequences.')\n }\n for (const permission of extra) {\n if (!(await deps.holdsOrgCatalogPermission(decoded.uid, orgId, permission.key))) {\n return outreachRefusal(403, 'permission', permission.refusal)\n }\n }\n\n const org = (await deps.readOrg(orgId)) ?? {}\n if (!checkEntitlement(org, 'outreach')) {\n return outreachRefusal(403, 'entitlement', \"Sequences isn't available to this workspace yet.\")\n }\n const staff = decoded['staff'] === true\n const locked = await deps.lockdownRefusal({ request, staff, uid: decoded.uid, org })\n if (locked) return locked\n\n return {\n uid: decoded.uid,\n email: typeof decoded.email === 'string' ? decoded.email.trim().toLowerCase() : null,\n staff,\n orgId,\n org,\n isOrgAdmin: membership.isOwner,\n }\n}\n"],"names":["checkEntitlement","isEmailVerified","isImpersonationSession","invalidIdTokenResponse","OUTREACH_USE_PERMISSION","outreachRefusal","readOutreachDocumentId","bearer","request","authorization","headers","get","startsWith","slice","length","outreachRouteGate","rawOrgId","deps","extra","token","decoded","verifyIdToken","error","orgId","membership","resolveOrgPermissions","uid","role","orgWide","permissions","permission","holdsOrgCatalogPermission","key","refusal","org","readOrg","staff","locked","lockdownRefusal","email","trim","toLowerCase","isOrgAdmin","isOwner"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,gBAAgB,QAAQ,sBAAqB;AACtD,SACEC,eAAe,EACfC,sBAAsB,QACjB,iDAAgD;AACvD,SAASC,sBAAsB,QAAQ,mDAAkD;AAEzF,SAASC,uBAAuB,QAAQ,gCAA4B;AACpE,SAASC,eAAe,EAAEC,sBAAsB,QAAQ,kBAAc;AAmFtE,SAASC,OAAOC,OAAgB;QACRA;IAAtB,MAAMC,iBAAgBD,uBAAAA,QAAQE,OAAO,CAACC,GAAG,CAAC,4BAApBH,uBAAwC;IAC9D,OAAOC,cAAcG,UAAU,CAAC,aAAaH,cAAcI,KAAK,CAAC,UAAUC,MAAM,IAAI;AACvF;AAEA,OAAO,eAAeC,kBACpBP,OAAgB,EAChBQ,QAAiB,EACjBC,IAA2B,EAC3BC,QAAiD,EAAE;QA4CtC;IA1Cb,MAAMC,QAAQZ,OAAOC;IACrB,IAAI,CAACW,OAAO,OAAOd,gBAAgB,KAAK,mBAAmB;IAC3D,IAAIe;IACJ,IAAI;QACFA,UAAU,MAAMH,KAAKI,aAAa,CAACF;IACrC,EAAE,OAAOG,OAAO;QACd,mEAAmE;QACnE,4DAA4D;QAC5D,IAAInB,uBAAuBmB,QAAQ;YACjC,OAAOjB,gBAAgB,KAAK,mBAAmB;QACjD;QACA,MAAMiB;IACR;IACA,IAAI,CAACrB,gBAAgBmB,YAAY,CAAClB,uBAAuBkB,UAAU;QACjE,OAAOf,gBAAgB,KAAK,oBAAoB;IAClD;IAEA,MAAMkB,QAAQjB,uBAAuBU;IACrC,IAAI,CAACO,OAAO,OAAOlB,gBAAgB,KAAK,gBAAgB;IAExD,MAAMmB,aAAa,MAAMP,KAAKQ,qBAAqB,CAACL,QAAQM,GAAG,EAAE;QAAEH;IAAM;IACzE,yEAAyE;IACzE,wEAAwE;IACxE,IAAI,CAACC,WAAWG,IAAI,IAAIH,WAAWD,KAAK,KAAKA,OAAO;QAClD,OAAOlB,gBAAgB,KAAK,gBAAgB;IAC9C;IACA,IAAI,CAACmB,WAAWI,OAAO,EAAE;QACvB,OAAOvB,gBACL,KACA,gBACA;IAEJ;IACA,IAAImB,WAAWK,WAAW,CAACzB,wBAAwB,KAAK,MAAM;QAC5D,OAAOC,gBAAgB,KAAK,cAAc;IAC5C;IACA,KAAK,MAAMyB,cAAcZ,MAAO;QAC9B,IAAI,CAAE,MAAMD,KAAKc,yBAAyB,CAACX,QAAQM,GAAG,EAAEH,OAAOO,WAAWE,GAAG,GAAI;YAC/E,OAAO3B,gBAAgB,KAAK,cAAcyB,WAAWG,OAAO;QAC9D;IACF;IAEA,MAAMC,OAAO,OAAA,MAAMjB,KAAKkB,OAAO,CAACZ,kBAAnB,OAA8B,CAAC;IAC5C,IAAI,CAACvB,iBAAiBkC,KAAK,aAAa;QACtC,OAAO7B,gBAAgB,KAAK,eAAe;IAC7C;IACA,MAAM+B,QAAQhB,OAAO,CAAC,QAAQ,KAAK;IACnC,MAAMiB,SAAS,MAAMpB,KAAKqB,eAAe,CAAC;QAAE9B;QAAS4B;QAAOV,KAAKN,QAAQM,GAAG;QAAEQ;IAAI;IAClF,IAAIG,QAAQ,OAAOA;IAEnB,OAAO;QACLX,KAAKN,QAAQM,GAAG;QAChBa,OAAO,OAAOnB,QAAQmB,KAAK,KAAK,WAAWnB,QAAQmB,KAAK,CAACC,IAAI,GAAGC,WAAW,KAAK;QAChFL;QACAb;QACAW;QACAQ,YAAYlB,WAAWmB,OAAO;IAChC;AACF"}
@@ -0,0 +1,32 @@
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
+ /**
18
+ * The few shapes every settings, sequence and enrollment route answers in
19
+ * (AGL-2980): a `200` with a body, a refusal with a sentence and a stable
20
+ * reason, and a JSON body read without trusting it. Nothing is cached — an
21
+ * answer about a workspace's sending is stale the moment it is written.
22
+ */
23
+ import type { OutreachRouteRefusal, OutreachRouteRefusalReason } from '../model/outreach-api';
24
+ /** A `200` carrying `body`. */
25
+ export declare function outreachOk(body: unknown): Response;
26
+ /** A refusal in the one shape the routes answer with. */
27
+ export declare function outreachRefusal(status: number, reason: OutreachRouteRefusalReason, error: string, extra?: Omit<OutreachRouteRefusal, 'error' | 'reason'>): Response;
28
+ export declare function outreachMethodNotAllowed(allow: string): Response;
29
+ /** The request's JSON object, or `{}` for a body that is not one. */
30
+ export declare function readOutreachJsonBody(request: Request): Promise<Record<string, unknown>>;
31
+ /** `value` when it is a plain document id, else `null`. */
32
+ export declare function readOutreachDocumentId(value: unknown): string | null;
@@ -0,0 +1,62 @@
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
+ * The few shapes every settings, sequence and enrollment route answers in
18
+ * (AGL-2980): a `200` with a body, a refusal with a sentence and a stable
19
+ * reason, and a JSON body read without trusting it. Nothing is cached — an
20
+ * answer about a workspace's sending is stale the moment it is written.
21
+ */ import { _ as _extends } from "@swc/helpers/_/_extends";
22
+ const NO_STORE = {
23
+ 'Cache-Control': 'no-store'
24
+ };
25
+ /** A `200` carrying `body`. */ export function outreachOk(body) {
26
+ return Response.json(body, {
27
+ status: 200,
28
+ headers: NO_STORE
29
+ });
30
+ }
31
+ /** A refusal in the one shape the routes answer with. */ export function outreachRefusal(status, reason, error, extra) {
32
+ const body = _extends({
33
+ error,
34
+ reason
35
+ }, extra != null ? extra : {});
36
+ return Response.json(body, {
37
+ status,
38
+ headers: NO_STORE
39
+ });
40
+ }
41
+ export function outreachMethodNotAllowed(allow) {
42
+ return Response.json({
43
+ error: 'Method not allowed',
44
+ reason: 'method-not-allowed'
45
+ }, {
46
+ status: 405,
47
+ headers: _extends({}, NO_STORE, {
48
+ Allow: allow
49
+ })
50
+ });
51
+ }
52
+ /** The request's JSON object, or `{}` for a body that is not one. */ export async function readOutreachJsonBody(request) {
53
+ const body = await request.json().catch(()=>null);
54
+ return body && typeof body === 'object' && !Array.isArray(body) ? body : {};
55
+ }
56
+ /** A document id as a request may name one: a plain path segment. */ const DOCUMENT_ID = /^[A-Za-z0-9_-]{1,128}$/;
57
+ /** `value` when it is a plain document id, else `null`. */ export function readOutreachDocumentId(value) {
58
+ const id = typeof value === 'string' ? value.trim() : '';
59
+ return DOCUMENT_ID.test(id) ? id : null;
60
+ }
61
+
62
+ //# sourceMappingURL=route-http.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/route-http.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 * The few shapes every settings, sequence and enrollment route answers in\n * (AGL-2980): a `200` with a body, a refusal with a sentence and a stable\n * reason, and a JSON body read without trusting it. Nothing is cached — an\n * answer about a workspace's sending is stale the moment it is written.\n */\n\nimport type {\n OutreachRouteRefusal,\n OutreachRouteRefusalReason,\n} from '../model/outreach-api'\n\nconst NO_STORE = { 'Cache-Control': 'no-store' }\n\n/** A `200` carrying `body`. */\nexport function outreachOk(body: unknown): Response {\n return Response.json(body, { status: 200, headers: NO_STORE })\n}\n\n/** A refusal in the one shape the routes answer with. */\nexport function outreachRefusal(\n status: number,\n reason: OutreachRouteRefusalReason,\n error: string,\n extra?: Omit<OutreachRouteRefusal, 'error' | 'reason'>,\n): Response {\n const body: OutreachRouteRefusal = { error, reason, ...(extra ?? {}) }\n return Response.json(body, { status, headers: NO_STORE })\n}\n\nexport function outreachMethodNotAllowed(allow: string): Response {\n return Response.json(\n { error: 'Method not allowed', reason: 'method-not-allowed' } satisfies OutreachRouteRefusal,\n { status: 405, headers: { ...NO_STORE, Allow: allow } },\n )\n}\n\n/** The request's JSON object, or `{}` for a body that is not one. */\nexport async function readOutreachJsonBody(request: Request): Promise<Record<string, unknown>> {\n const body = await request.json().catch(() => null)\n return body && typeof body === 'object' && !Array.isArray(body)\n ? (body as Record<string, unknown>)\n : {}\n}\n\n/** A document id as a request may name one: a plain path segment. */\nconst DOCUMENT_ID = /^[A-Za-z0-9_-]{1,128}$/\n\n/** `value` when it is a plain document id, else `null`. */\nexport function readOutreachDocumentId(value: unknown): string | null {\n const id = typeof value === 'string' ? value.trim() : ''\n return DOCUMENT_ID.test(id) ? id : null\n}\n"],"names":["NO_STORE","outreachOk","body","Response","json","status","headers","outreachRefusal","reason","error","extra","outreachMethodNotAllowed","allow","Allow","readOutreachJsonBody","request","catch","Array","isArray","DOCUMENT_ID","readOutreachDocumentId","value","id","trim","test"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;CAKC;AAOD,MAAMA,WAAW;IAAE,iBAAiB;AAAW;AAE/C,6BAA6B,GAC7B,OAAO,SAASC,WAAWC,IAAa;IACtC,OAAOC,SAASC,IAAI,CAACF,MAAM;QAAEG,QAAQ;QAAKC,SAASN;IAAS;AAC9D;AAEA,uDAAuD,GACvD,OAAO,SAASO,gBACdF,MAAc,EACdG,MAAkC,EAClCC,KAAa,EACbC,KAAsD;IAEtD,MAAMR,OAA6B;QAAEO;QAAOD;OAAYE,gBAAAA,QAAS,CAAC;IAClE,OAAOP,SAASC,IAAI,CAACF,MAAM;QAAEG;QAAQC,SAASN;IAAS;AACzD;AAEA,OAAO,SAASW,yBAAyBC,KAAa;IACpD,OAAOT,SAASC,IAAI,CAClB;QAAEK,OAAO;QAAsBD,QAAQ;IAAqB,GAC5D;QAAEH,QAAQ;QAAKC,SAAS,aAAKN;YAAUa,OAAOD;;IAAQ;AAE1D;AAEA,mEAAmE,GACnE,OAAO,eAAeE,qBAAqBC,OAAgB;IACzD,MAAMb,OAAO,MAAMa,QAAQX,IAAI,GAAGY,KAAK,CAAC,IAAM;IAC9C,OAAOd,QAAQ,OAAOA,SAAS,YAAY,CAACe,MAAMC,OAAO,CAAChB,QACrDA,OACD,CAAC;AACP;AAEA,mEAAmE,GACnE,MAAMiB,cAAc;AAEpB,yDAAyD,GACzD,OAAO,SAASC,uBAAuBC,KAAc;IACnD,MAAMC,KAAK,OAAOD,UAAU,WAAWA,MAAME,IAAI,KAAK;IACtD,OAAOJ,YAAYK,IAAI,CAACF,MAAMA,KAAK;AACrC"}
@@ -0,0 +1,32 @@
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 { PluginWebApiHandler } from '@aglyn/aglyn/server';
18
+ import { type OutreachRouteDeps } from './route-deps';
19
+ /** The activity line each lifecycle act writes. */
20
+ export declare const OUTREACH_SEQUENCE_ACTIVITY: {
21
+ readonly create: "Created a sequence";
22
+ readonly activate: "Activated a sequence";
23
+ readonly pause: "Paused a sequence";
24
+ readonly archive: "Archived a sequence";
25
+ readonly delete: "Deleted a sequence";
26
+ };
27
+ export interface OutreachSequenceRoutes {
28
+ save: PluginWebApiHandler;
29
+ status: PluginWebApiHandler;
30
+ remove: PluginWebApiHandler;
31
+ }
32
+ export declare function createOutreachSequenceRoutes(deps: OutreachRouteDeps): OutreachSequenceRoutes;
@@ -0,0 +1,359 @@
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 { createResourceUid } from "@aglyn/aglyn/app-utils/create-resource-uid";
18
+ import { applyOutreachEnrollmentEvent } from "../engine/enrollment-state.js";
19
+ import { validateOutreachSequence, validateOutreachSequenceActivation } from "../engine/sequence-validation.js";
20
+ import { OUTREACH_OPEN_ENROLLMENT_STATUSES } from "../engine/gates.js";
21
+ import { outreachEnrolledSequenceIssues, outreachMailboxActivationIssue, readOutreachSequenceDraft } from "../model/sequence-draft.js";
22
+ import { readOutreachComplianceSettingsDoc } from "../storage/compliance-settings-store.js";
23
+ import { outreachOrgCollection, readStoredOutreachEnrollment, readStoredOutreachMailbox, readStoredOutreachSequence } from "../storage/outreach-records.js";
24
+ import { OUTREACH_SEQUENCE_ACTIVITY_TARGET } from "./route-deps.js";
25
+ import { outreachRouteGate } from "./route-gate.js";
26
+ import { outreachMethodNotAllowed, outreachOk, outreachRefusal, readOutreachDocumentId, readOutreachJsonBody } from "./route-http.js";
27
+ /**
28
+ * THE SEQUENCE ROUTES (AGL-2980): save, activate, pause, archive, delete.
29
+ *
30
+ * ## Who may change a sequence
31
+ *
32
+ * A sequence sends as the member whose mailbox it names, so it is theirs to
33
+ * change: the member who connected the mailbox, or an organization owner or
34
+ * admin, may save it, activate it and send from it — the same two the
35
+ * mailbox's own routes let manage the mailbox. Anyone with `outreach.use`
36
+ * may pause or archive any sequence, because stopping mail is never the act
37
+ * that needs guarding.
38
+ *
39
+ * ## Saving
40
+ *
41
+ * The draft is read with the editor's reader and judged by the engine's
42
+ * validator, so the save refuses exactly what the editor showed beside a
43
+ * field. On top of that, the site must be one of the organization's, the
44
+ * mailbox one of its connected mailboxes, the countries ones the
45
+ * organization allows, and — once anyone is enrolled — the steps, site and
46
+ * mailbox fixed (`outreachEnrolledSequenceIssues`). A new sequence is a
47
+ * draft; an edit keeps the status it had, and an active sequence stays
48
+ * active only because the edit passed the same validator activation does.
49
+ *
50
+ * ## Activating
51
+ *
52
+ * `validateOutreachSequenceActivation`: everything a save refuses on, and an
53
+ * organization whose footer cannot be written — no legal name, or no postal
54
+ * address. And its mailbox must be sending (`outreachMailboxActivationIssue`):
55
+ * chosen, still connected, not paused, and not waiting to be reconnected —
56
+ * the same words the sequence page shows beside its disabled Activate.
57
+ *
58
+ * ## Archiving
59
+ *
60
+ * Final. Every person still active or paused in the sequence is stopped
61
+ * with `sequence_archived`, in batches, so no archived sequence goes on
62
+ * sending.
63
+ */ /** A batch holds 500 writes; a page of enrollments stops this many. */ const ARCHIVE_PAGE = 400;
64
+ /** The activity line each lifecycle act writes. */ export const OUTREACH_SEQUENCE_ACTIVITY = {
65
+ create: 'Created a sequence',
66
+ activate: 'Activated a sequence',
67
+ pause: 'Paused a sequence',
68
+ archive: 'Archived a sequence',
69
+ delete: 'Deleted a sequence'
70
+ };
71
+ /** Where each action may start, and where it ends. */ const TRANSITIONS = {
72
+ activate: {
73
+ from: [
74
+ 'draft',
75
+ 'paused'
76
+ ],
77
+ to: 'active'
78
+ },
79
+ pause: {
80
+ from: [
81
+ 'active'
82
+ ],
83
+ to: 'paused'
84
+ },
85
+ archive: {
86
+ from: [
87
+ 'draft',
88
+ 'active',
89
+ 'paused'
90
+ ],
91
+ to: 'archived'
92
+ }
93
+ };
94
+ const ACTION_REFUSALS = {
95
+ activate: 'Only a draft or paused sequence can be activated.',
96
+ pause: 'Only an active sequence can be paused.',
97
+ archive: 'This sequence is already archived.'
98
+ };
99
+ async function loadSequence(firestore, orgId, sequenceId) {
100
+ const snapshot = await outreachOrgCollection(firestore, orgId, 'sequences').doc(sequenceId).get();
101
+ return readStoredOutreachSequence(sequenceId, snapshot.exists ? snapshot.data() : undefined);
102
+ }
103
+ /** Whether anyone was ever enrolled in the sequence. */ async function hasEnrollments(firestore, orgId, sequenceId) {
104
+ const found = await outreachOrgCollection(firestore, orgId, 'enrollments').where('sequenceId', '==', sequenceId).limit(1).get();
105
+ return !found.empty;
106
+ }
107
+ const issue = (path, code, message)=>({
108
+ path,
109
+ code,
110
+ message,
111
+ severity: 'error'
112
+ });
113
+ /**
114
+ * The organization-level facts a draft has to agree with: its site is the
115
+ * organization's, its mailbox is connected and the caller may send from it,
116
+ * and its countries are ones the organization allows. `activating` adds
117
+ * that the mailbox is sending now — chosen, and neither paused nor waiting
118
+ * to be reconnected.
119
+ */ async function draftPlacementIssues(firestore, caller, draft, options = {}) {
120
+ const issues = [];
121
+ if (draft.hostId) {
122
+ const host = readOutreachDocumentId(draft.hostId) ? await firestore.collection('hosts').doc(draft.hostId).get() : null;
123
+ if (!(host == null ? void 0 : host.exists) || host.get('orgId') !== caller.orgId) {
124
+ issues.push(issue('hostId', 'host_unknown', "Choose one of this organization's sites."));
125
+ }
126
+ } else {
127
+ issues.push(issue('hostId', 'host_unknown', "Choose the site whose contacts this sequence emails."));
128
+ }
129
+ const mailbox = draft.mailboxId ? await loadMailbox(firestore, caller.orgId, draft.mailboxId) : null;
130
+ const activation = options.activating ? outreachMailboxActivationIssue(draft.mailboxId, mailbox) : null;
131
+ if (draft.mailboxId) {
132
+ const placement = mailboxIssues(caller, mailbox);
133
+ // A mailbox that is gone is refused once, in activation's words when
134
+ // activating: they say what to do next.
135
+ issues.push(...(activation == null ? void 0 : activation.code) === 'mailbox_unknown' ? placement.filter((entry)=>entry.code !== 'mailbox_unknown') : placement);
136
+ }
137
+ if (activation && !issues.some((entry)=>entry.code === 'mailbox_not_yours')) issues.push(activation);
138
+ const settings = await readOutreachComplianceSettingsDoc(firestore, caller.orgId);
139
+ const outside = draft.settings.allowedCountries.filter((code)=>!settings.allowedCountries.includes(code));
140
+ if (outside.length) {
141
+ issues.push(issue('settings.allowedCountries', 'country_not_in_org', `${outside.join(', ')} ${outside.length === 1 ? "isn't" : "aren't"} among the countries your organization allows. Add ${outside.length === 1 ? 'it' : 'them'} in Sequences compliance settings first.`));
142
+ }
143
+ return issues;
144
+ }
145
+ /** A mailbox of this organization's, as stored; `null` when there is none by that id. */ async function loadMailbox(firestore, orgId, mailboxId) {
146
+ const snapshot = readOutreachDocumentId(mailboxId) ? await outreachOrgCollection(firestore, orgId, 'mailboxes').doc(mailboxId).get() : null;
147
+ return readStoredOutreachMailbox(mailboxId, (snapshot == null ? void 0 : snapshot.exists) ? snapshot.data() : undefined);
148
+ }
149
+ /** Whether the mailbox is connected here, and the caller may send from it. */ function mailboxIssues(caller, mailbox) {
150
+ if (!mailbox || mailbox.status === 'disconnected') {
151
+ return [
152
+ issue('mailboxId', 'mailbox_unknown', 'That mailbox is not connected to this organization.')
153
+ ];
154
+ }
155
+ if (mailbox.connectedByUid !== caller.uid && !caller.isOrgAdmin) {
156
+ return [
157
+ issue('mailboxId', 'mailbox_not_yours', 'Only the member who connected this mailbox, or an organization owner or admin, can send from it.')
158
+ ];
159
+ }
160
+ return [];
161
+ }
162
+ /** One issue per field and code: activation and the engine can both ask for a mailbox. */ function distinctIssues(issues) {
163
+ const seen = new Set();
164
+ return issues.filter((entry)=>{
165
+ const key = `${entry.path}|${entry.code}`;
166
+ if (seen.has(key)) return false;
167
+ seen.add(key);
168
+ return true;
169
+ });
170
+ }
171
+ export function createOutreachSequenceRoutes(deps) {
172
+ const activity = (caller, action, sequence)=>deps.logOrgActivity(caller.orgId, {
173
+ uid: caller.uid,
174
+ email: caller.email
175
+ }, action, {
176
+ type: OUTREACH_SEQUENCE_ACTIVITY_TARGET,
177
+ id: sequence.id,
178
+ name: sequence.name
179
+ });
180
+ const save = async (request)=>{
181
+ var _ref;
182
+ if (request.method !== 'POST') return outreachMethodNotAllowed('POST');
183
+ const body = await readOutreachJsonBody(request);
184
+ const caller = await outreachRouteGate(request, body['orgId'], deps.gate);
185
+ if (caller instanceof Response) return caller;
186
+ const firestore = deps.firestore();
187
+ const rawId = body['sequenceId'];
188
+ const sequenceId = rawId === undefined || rawId === null || rawId === '' ? null : readOutreachDocumentId(rawId);
189
+ if (rawId && !sequenceId) return outreachRefusal(400, 'invalid-request', 'Name the sequence to save.');
190
+ const existing = sequenceId ? await loadSequence(firestore, caller.orgId, sequenceId) : null;
191
+ if (sequenceId && !existing) {
192
+ return outreachRefusal(404, 'sequence-not-found', 'That sequence no longer exists.');
193
+ }
194
+ if ((existing == null ? void 0 : existing.status) === 'archived') {
195
+ return outreachRefusal(409, 'sequence-archived', "An archived sequence can't be edited.");
196
+ }
197
+ const draft = readOutreachSequenceDraft(body['sequence']);
198
+ // A sequence sends as its mailbox's member, so it is theirs (or an
199
+ // admin's) to change — the stored mailbox as well as a new one: moving
200
+ // a colleague's sequence onto your own mailbox is still changing theirs.
201
+ if ((existing == null ? void 0 : existing.mailboxId) && existing.mailboxId !== draft.mailboxId) {
202
+ const stored = mailboxIssues(caller, await loadMailbox(firestore, caller.orgId, existing.mailboxId));
203
+ const refused = stored.find((entry)=>entry.code === 'mailbox_not_yours');
204
+ if (refused) return outreachRefusal(403, 'permission', refused.message);
205
+ }
206
+ const judged = [
207
+ ...validateOutreachSequence(draft),
208
+ ...await draftPlacementIssues(firestore, caller, draft)
209
+ ];
210
+ const ownership = judged.find((entry)=>entry.code === 'mailbox_not_yours');
211
+ if (ownership) return outreachRefusal(403, 'permission', ownership.message);
212
+ if (existing && await hasEnrollments(firestore, caller.orgId, existing.id)) {
213
+ judged.push(...outreachEnrolledSequenceIssues(existing, draft));
214
+ }
215
+ const errors = judged.filter((entry)=>entry.severity === 'error');
216
+ if (errors.length) {
217
+ return outreachRefusal(400, 'invalid-sequence', errors[0].message, {
218
+ issues: judged
219
+ });
220
+ }
221
+ const nowMs = deps.now();
222
+ const ref = existing ? outreachOrgCollection(firestore, caller.orgId, 'sequences').doc(existing.id) : outreachOrgCollection(firestore, caller.orgId, 'sequences').doc(createResourceUid());
223
+ const sequence = _extends({
224
+ id: ref.id
225
+ }, draft, {
226
+ status: (_ref = existing == null ? void 0 : existing.status) != null ? _ref : 'draft',
227
+ createdAtMs: (existing == null ? void 0 : existing.createdAtMs) || nowMs,
228
+ updatedAtMs: nowMs
229
+ });
230
+ await ref.set(sequence);
231
+ if (!existing) await activity(caller, OUTREACH_SEQUENCE_ACTIVITY.create, sequence);
232
+ return outreachOk({
233
+ ok: true,
234
+ sequence,
235
+ created: !existing,
236
+ warnings: judged.filter((entry)=>entry.severity === 'warning')
237
+ });
238
+ };
239
+ const status = async (request)=>{
240
+ if (request.method !== 'POST') return outreachMethodNotAllowed('POST');
241
+ const body = await readOutreachJsonBody(request);
242
+ const caller = await outreachRouteGate(request, body['orgId'], deps.gate);
243
+ if (caller instanceof Response) return caller;
244
+ const action = body['action'];
245
+ const sequenceId = readOutreachDocumentId(body['sequenceId']);
246
+ if (!sequenceId || !Object.prototype.hasOwnProperty.call(TRANSITIONS, action)) {
247
+ return outreachRefusal(400, 'invalid-request', 'Name the sequence, and activate, pause or archive.');
248
+ }
249
+ const firestore = deps.firestore();
250
+ const sequence = await loadSequence(firestore, caller.orgId, sequenceId);
251
+ if (!sequence) return outreachRefusal(404, 'sequence-not-found', 'That sequence no longer exists.');
252
+ const transition = TRANSITIONS[action];
253
+ if (sequence.status === transition.to) {
254
+ return outreachOk({
255
+ ok: true,
256
+ sequence,
257
+ stoppedEnrollments: 0
258
+ });
259
+ }
260
+ if (!transition.from.includes(sequence.status)) {
261
+ return outreachRefusal(409, 'transition-refused', ACTION_REFUSALS[action]);
262
+ }
263
+ if (action === 'activate') {
264
+ const orgSettings = await readOutreachComplianceSettingsDoc(firestore, caller.orgId);
265
+ const judged = distinctIssues([
266
+ ...validateOutreachSequenceActivation(sequence, orgSettings),
267
+ ...await draftPlacementIssues(firestore, caller, sequence, {
268
+ activating: true
269
+ })
270
+ ]).filter((entry)=>entry.severity === 'error');
271
+ if (judged.length) {
272
+ const ownership = judged.find((entry)=>entry.code === 'mailbox_not_yours');
273
+ return ownership ? outreachRefusal(403, 'permission', ownership.message) : outreachRefusal(409, 'activation-refused', judged[0].message, {
274
+ issues: judged
275
+ });
276
+ }
277
+ }
278
+ const nowMs = deps.now();
279
+ const next = _extends({}, sequence, {
280
+ status: transition.to,
281
+ updatedAtMs: nowMs
282
+ });
283
+ await outreachOrgCollection(firestore, caller.orgId, 'sequences').doc(sequence.id).update({
284
+ status: next.status,
285
+ updatedAtMs: nowMs
286
+ });
287
+ const stoppedEnrollments = action === 'archive' ? await stopOpenEnrollments(firestore, caller, sequence.id, nowMs) : 0;
288
+ await activity(caller, OUTREACH_SEQUENCE_ACTIVITY[action], sequence);
289
+ return outreachOk({
290
+ ok: true,
291
+ sequence: next,
292
+ stoppedEnrollments
293
+ });
294
+ };
295
+ const remove = async (request)=>{
296
+ if (request.method !== 'POST') return outreachMethodNotAllowed('POST');
297
+ const body = await readOutreachJsonBody(request);
298
+ const caller = await outreachRouteGate(request, body['orgId'], deps.gate);
299
+ if (caller instanceof Response) return caller;
300
+ const sequenceId = readOutreachDocumentId(body['sequenceId']);
301
+ if (!sequenceId) return outreachRefusal(400, 'invalid-request', 'Name the sequence to delete.');
302
+ const firestore = deps.firestore();
303
+ const sequence = await loadSequence(firestore, caller.orgId, sequenceId);
304
+ if (!sequence) return outreachRefusal(404, 'sequence-not-found', 'That sequence no longer exists.');
305
+ if (sequence.status !== 'draft' || await hasEnrollments(firestore, caller.orgId, sequence.id)) {
306
+ // What people were sent, and why they stopped, is the record of what
307
+ // this organization did; only a draft nobody was enrolled in has none.
308
+ return outreachRefusal(409, 'delete-refused', 'Only a draft nobody was enrolled in can be deleted. Archive this sequence instead.');
309
+ }
310
+ await outreachOrgCollection(firestore, caller.orgId, 'sequences').doc(sequence.id).delete();
311
+ await activity(caller, OUTREACH_SEQUENCE_ACTIVITY.delete, sequence);
312
+ return outreachOk({
313
+ ok: true
314
+ });
315
+ };
316
+ return {
317
+ save,
318
+ status,
319
+ remove
320
+ };
321
+ }
322
+ /**
323
+ * Stops everyone still active or paused in an archived sequence, a page at
324
+ * a time, through the engine's own transition. Returns how many it stopped.
325
+ */ async function stopOpenEnrollments(firestore, caller, sequenceId, nowMs) {
326
+ const enrollments = outreachOrgCollection(firestore, caller.orgId, 'enrollments');
327
+ let stopped = 0;
328
+ let after = null;
329
+ for(;;){
330
+ let page = enrollments.where('sequenceId', '==', sequenceId).orderBy('__name__').limit(ARCHIVE_PAGE);
331
+ if (after) page = page.startAfter(after);
332
+ const snapshot = await page.get();
333
+ if (snapshot.empty) break;
334
+ const batch = firestore.batch();
335
+ let writes = 0;
336
+ for (const doc of snapshot.docs){
337
+ const enrollment = readStoredOutreachEnrollment(doc.id, doc.data());
338
+ if (!enrollment || !OUTREACH_OPEN_ENROLLMENT_STATUSES.includes(enrollment.status)) continue;
339
+ const { patch } = applyOutreachEnrollmentEvent(enrollment, {
340
+ type: 'stop',
341
+ reason: 'sequence_archived',
342
+ atMs: nowMs,
343
+ byUid: caller.uid
344
+ });
345
+ if (!patch) continue;
346
+ batch.update(doc.ref, _extends({}, patch, {
347
+ updatedAtMs: nowMs
348
+ }));
349
+ writes += 1;
350
+ }
351
+ if (writes) await batch.commit();
352
+ stopped += writes;
353
+ after = snapshot.docs[snapshot.docs.length - 1];
354
+ if (snapshot.size < ARCHIVE_PAGE) break;
355
+ }
356
+ return stopped;
357
+ }
358
+
359
+ //# sourceMappingURL=sequence-routes.js.map