@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.
- package/LICENSE +201 -0
- package/README.md +93 -0
- package/package.json +52 -0
- package/src/index.d.ts +22 -0
- package/src/index.js +29 -0
- package/src/index.js.map +1 -0
- package/src/lib/components/compliance-section.d.ts +20 -0
- package/src/lib/components/compliance-section.js +291 -0
- package/src/lib/components/compliance-section.js.map +1 -0
- package/src/lib/components/enroll-dialog.d.ts +36 -0
- package/src/lib/components/enroll-dialog.js +656 -0
- package/src/lib/components/enroll-dialog.js.map +1 -0
- package/src/lib/components/enrollments-table.d.ts +28 -0
- package/src/lib/components/enrollments-table.js +375 -0
- package/src/lib/components/enrollments-table.js.map +1 -0
- package/src/lib/components/mailbox-card.d.ts +46 -0
- package/src/lib/components/mailbox-card.js +517 -0
- package/src/lib/components/mailbox-card.js.map +1 -0
- package/src/lib/components/mailboxes-section.d.ts +24 -0
- package/src/lib/components/mailboxes-section.js +258 -0
- package/src/lib/components/mailboxes-section.js.map +1 -0
- package/src/lib/components/outreach-console-page.d.ts +17 -0
- package/src/lib/components/outreach-console-page.js +81 -0
- package/src/lib/components/outreach-console-page.js.map +1 -0
- package/src/lib/components/outreach-console-sections.d.ts +43 -0
- package/src/lib/components/outreach-console-sections.js +55 -0
- package/src/lib/components/outreach-console-sections.js.map +1 -0
- package/src/lib/components/outreach-ui.d.ts +51 -0
- package/src/lib/components/outreach-ui.js +171 -0
- package/src/lib/components/outreach-ui.js.map +1 -0
- package/src/lib/components/send-window-fields.d.ts +25 -0
- package/src/lib/components/send-window-fields.js +178 -0
- package/src/lib/components/send-window-fields.js.map +1 -0
- package/src/lib/components/sequence-detail.d.ts +54 -0
- package/src/lib/components/sequence-detail.js +435 -0
- package/src/lib/components/sequence-detail.js.map +1 -0
- package/src/lib/components/sequence-editor.d.ts +37 -0
- package/src/lib/components/sequence-editor.js +485 -0
- package/src/lib/components/sequence-editor.js.map +1 -0
- package/src/lib/components/sequence-mailbox-picker.d.ts +44 -0
- package/src/lib/components/sequence-mailbox-picker.js +137 -0
- package/src/lib/components/sequence-mailbox-picker.js.map +1 -0
- package/src/lib/components/sequence-preview.d.ts +27 -0
- package/src/lib/components/sequence-preview.js +146 -0
- package/src/lib/components/sequence-preview.js.map +1 -0
- package/src/lib/components/sequence-step-card.d.ts +31 -0
- package/src/lib/components/sequence-step-card.js +326 -0
- package/src/lib/components/sequence-step-card.js.map +1 -0
- package/src/lib/components/sequences-section.d.ts +40 -0
- package/src/lib/components/sequences-section.js +310 -0
- package/src/lib/components/sequences-section.js.map +1 -0
- package/src/lib/components/use-outreach-api.d.ts +38 -0
- package/src/lib/components/use-outreach-api.js +139 -0
- package/src/lib/components/use-outreach-api.js.map +1 -0
- package/src/lib/components/use-outreach-crm.d.ts +50 -0
- package/src/lib/components/use-outreach-crm.js +200 -0
- package/src/lib/components/use-outreach-crm.js.map +1 -0
- package/src/lib/components/use-outreach-data.d.ts +55 -0
- package/src/lib/components/use-outreach-data.js +199 -0
- package/src/lib/components/use-outreach-data.js.map +1 -0
- package/src/lib/components/use-outreach-mailbox-api.d.ts +30 -0
- package/src/lib/components/use-outreach-mailbox-api.js +101 -0
- package/src/lib/components/use-outreach-mailbox-api.js.map +1 -0
- package/src/lib/components/use-outreach-mailboxes.d.ts +22 -0
- package/src/lib/components/use-outreach-mailboxes.js +68 -0
- package/src/lib/components/use-outreach-mailboxes.js.map +1 -0
- package/src/lib/components/use-outreach-settings.d.ts +17 -0
- package/src/lib/components/use-outreach-settings.js +64 -0
- package/src/lib/components/use-outreach-settings.js.map +1 -0
- package/src/lib/constants/api-routes.d.ts +68 -0
- package/src/lib/constants/api-routes.js +57 -0
- package/src/lib/constants/api-routes.js.map +1 -0
- package/src/lib/constants/bundle-common.d.ts +46 -0
- package/src/lib/constants/bundle-common.js +55 -0
- package/src/lib/constants/bundle-common.js.map +1 -0
- package/src/lib/constants/runtime-jobs.d.ts +24 -0
- package/src/lib/constants/runtime-jobs.js +24 -0
- package/src/lib/constants/runtime-jobs.js.map +1 -0
- package/src/lib/declarations.console-server.d.ts +38 -0
- package/src/lib/declarations.console-server.js +99 -0
- package/src/lib/declarations.console-server.js.map +1 -0
- package/src/lib/engine/compose.d.ts +86 -0
- package/src/lib/engine/compose.js +212 -0
- package/src/lib/engine/compose.js.map +1 -0
- package/src/lib/engine/delivery-status.d.ts +53 -0
- package/src/lib/engine/delivery-status.js +184 -0
- package/src/lib/engine/delivery-status.js.map +1 -0
- package/src/lib/engine/do-not-contact.d.ts +18 -0
- package/src/lib/engine/do-not-contact.js +34 -0
- package/src/lib/engine/do-not-contact.js.map +1 -0
- package/src/lib/engine/enrollment-state.d.ts +156 -0
- package/src/lib/engine/enrollment-state.js +303 -0
- package/src/lib/engine/enrollment-state.js.map +1 -0
- package/src/lib/engine/gates.d.ts +125 -0
- package/src/lib/engine/gates.js +232 -0
- package/src/lib/engine/gates.js.map +1 -0
- package/src/lib/engine/index.d.ts +28 -0
- package/src/lib/engine/index.js +33 -0
- package/src/lib/engine/index.js.map +1 -0
- package/src/lib/engine/mailbox-health.d.ts +49 -0
- package/src/lib/engine/mailbox-health.js +83 -0
- package/src/lib/engine/mailbox-health.js.map +1 -0
- package/src/lib/engine/opt-out-intent.d.ts +41 -0
- package/src/lib/engine/opt-out-intent.js +220 -0
- package/src/lib/engine/opt-out-intent.js.map +1 -0
- package/src/lib/engine/recipient-country.d.ts +41 -0
- package/src/lib/engine/recipient-country.js +171 -0
- package/src/lib/engine/recipient-country.js.map +1 -0
- package/src/lib/engine/schedule.d.ts +70 -0
- package/src/lib/engine/schedule.js +115 -0
- package/src/lib/engine/schedule.js.map +1 -0
- package/src/lib/engine/sending-capacity.d.ts +96 -0
- package/src/lib/engine/sending-capacity.js +172 -0
- package/src/lib/engine/sending-capacity.js.map +1 -0
- package/src/lib/engine/sequence-validation.d.ts +90 -0
- package/src/lib/engine/sequence-validation.js +291 -0
- package/src/lib/engine/sequence-validation.js.map +1 -0
- package/src/lib/engine/thread-classification.d.ts +78 -0
- package/src/lib/engine/thread-classification.js +224 -0
- package/src/lib/engine/thread-classification.js.map +1 -0
- package/src/lib/engine/thread-message.d.ts +80 -0
- package/src/lib/engine/thread-message.js +143 -0
- package/src/lib/engine/thread-message.js.map +1 -0
- package/src/lib/enrollment/enroll-people.d.ts +81 -0
- package/src/lib/enrollment/enroll-people.js +250 -0
- package/src/lib/enrollment/enroll-people.js.map +1 -0
- package/src/lib/enrollment/gate-lookups.d.ts +32 -0
- package/src/lib/enrollment/gate-lookups.js +154 -0
- package/src/lib/enrollment/gate-lookups.js.map +1 -0
- package/src/lib/mailboxes/mailbox-api.d.ts +126 -0
- package/src/lib/mailboxes/mailbox-api.js +66 -0
- package/src/lib/mailboxes/mailbox-api.js.map +1 -0
- package/src/lib/mailboxes/mailbox-credentials.d.ts +95 -0
- package/src/lib/mailboxes/mailbox-credentials.js +102 -0
- package/src/lib/mailboxes/mailbox-credentials.js.map +1 -0
- package/src/lib/mailboxes/mailbox-erasure.d.ts +69 -0
- package/src/lib/mailboxes/mailbox-erasure.js +148 -0
- package/src/lib/mailboxes/mailbox-erasure.js.map +1 -0
- package/src/lib/mailboxes/mailbox-gate.d.ts +75 -0
- package/src/lib/mailboxes/mailbox-gate.js +98 -0
- package/src/lib/mailboxes/mailbox-gate.js.map +1 -0
- package/src/lib/mailboxes/mailbox-revoke.d.ts +59 -0
- package/src/lib/mailboxes/mailbox-revoke.js +41 -0
- package/src/lib/mailboxes/mailbox-revoke.js.map +1 -0
- package/src/lib/mailboxes/mailbox-routes.d.ts +119 -0
- package/src/lib/mailboxes/mailbox-routes.js +605 -0
- package/src/lib/mailboxes/mailbox-routes.js.map +1 -0
- package/src/lib/mailboxes/mailbox-settings.d.ts +110 -0
- package/src/lib/mailboxes/mailbox-settings.js +212 -0
- package/src/lib/mailboxes/mailbox-settings.js.map +1 -0
- package/src/lib/mailboxes/mailbox-transport.d.ts +64 -0
- package/src/lib/mailboxes/mailbox-transport.js +94 -0
- package/src/lib/mailboxes/mailbox-transport.js.map +1 -0
- package/src/lib/mailboxes/oauth-redirect.d.ts +41 -0
- package/src/lib/mailboxes/oauth-redirect.js +63 -0
- package/src/lib/mailboxes/oauth-redirect.js.map +1 -0
- package/src/lib/mailboxes/oauth-state.d.ts +152 -0
- package/src/lib/mailboxes/oauth-state.js +212 -0
- package/src/lib/mailboxes/oauth-state.js.map +1 -0
- package/src/lib/mailboxes/outreach-config.d.ts +64 -0
- package/src/lib/mailboxes/outreach-config.js +77 -0
- package/src/lib/mailboxes/outreach-config.js.map +1 -0
- package/src/lib/mailboxes/register-mailbox-routes.d.ts +47 -0
- package/src/lib/mailboxes/register-mailbox-routes.js +135 -0
- package/src/lib/mailboxes/register-mailbox-routes.js.map +1 -0
- package/src/lib/model/compliance-settings.d.ts +78 -0
- package/src/lib/model/compliance-settings.js +155 -0
- package/src/lib/model/compliance-settings.js.map +1 -0
- package/src/lib/model/outreach-api.d.ts +210 -0
- package/src/lib/model/outreach-api.js +28 -0
- package/src/lib/model/outreach-api.js.map +1 -0
- package/src/lib/model/outreach.types.d.ts +563 -0
- package/src/lib/model/outreach.types.js +225 -0
- package/src/lib/model/outreach.types.js.map +1 -0
- package/src/lib/model/sequence-draft.d.ts +84 -0
- package/src/lib/model/sequence-draft.js +239 -0
- package/src/lib/model/sequence-draft.js.map +1 -0
- package/src/lib/model/step-preview.d.ts +45 -0
- package/src/lib/model/step-preview.js +98 -0
- package/src/lib/model/step-preview.js.map +1 -0
- package/src/lib/model/stored-records.d.ts +23 -0
- package/src/lib/model/stored-records.js +84 -0
- package/src/lib/model/stored-records.js.map +1 -0
- package/src/lib/plugin.d.ts +46 -0
- package/src/lib/plugin.js +86 -0
- package/src/lib/plugin.js.map +1 -0
- package/src/lib/routes/enroll-routes.d.ts +64 -0
- package/src/lib/routes/enroll-routes.js +350 -0
- package/src/lib/routes/enroll-routes.js.map +1 -0
- package/src/lib/routes/enrollment-routes.d.ts +19 -0
- package/src/lib/routes/enrollment-routes.js +171 -0
- package/src/lib/routes/enrollment-routes.js.map +1 -0
- package/src/lib/routes/platform-deps.d.ts +51 -0
- package/src/lib/routes/platform-deps.js +66 -0
- package/src/lib/routes/platform-deps.js.map +1 -0
- package/src/lib/routes/preview-routes.d.ts +33 -0
- package/src/lib/routes/preview-routes.js +117 -0
- package/src/lib/routes/preview-routes.js.map +1 -0
- package/src/lib/routes/register-routes.d.ts +22 -0
- package/src/lib/routes/register-routes.js +92 -0
- package/src/lib/routes/register-routes.js.map +1 -0
- package/src/lib/routes/route-deps.d.ts +49 -0
- package/src/lib/routes/route-deps.js +27 -0
- package/src/lib/routes/route-deps.js.map +1 -0
- package/src/lib/routes/route-gate.d.ts +94 -0
- package/src/lib/routes/route-gate.js +87 -0
- package/src/lib/routes/route-gate.js.map +1 -0
- package/src/lib/routes/route-http.d.ts +32 -0
- package/src/lib/routes/route-http.js +62 -0
- package/src/lib/routes/route-http.js.map +1 -0
- package/src/lib/routes/sequence-routes.d.ts +32 -0
- package/src/lib/routes/sequence-routes.js +359 -0
- package/src/lib/routes/sequence-routes.js.map +1 -0
- package/src/lib/routes/settings-routes.d.ts +30 -0
- package/src/lib/routes/settings-routes.js +73 -0
- package/src/lib/routes/settings-routes.js.map +1 -0
- package/src/lib/runtime/enrollment-events.d.ts +86 -0
- package/src/lib/runtime/enrollment-events.js +141 -0
- package/src/lib/runtime/enrollment-events.js.map +1 -0
- package/src/lib/runtime/fixtures/fake-gmail.d.ts +121 -0
- package/src/lib/runtime/fixtures/fake-gmail.js +312 -0
- package/src/lib/runtime/fixtures/fake-gmail.js.map +1 -0
- package/src/lib/runtime/mailbox-health-store.d.ts +47 -0
- package/src/lib/runtime/mailbox-health-store.js +168 -0
- package/src/lib/runtime/mailbox-health-store.js.map +1 -0
- package/src/lib/runtime/person-erasure.d.ts +39 -0
- package/src/lib/runtime/person-erasure.js +57 -0
- package/src/lib/runtime/person-erasure.js.map +1 -0
- package/src/lib/runtime/platform-runtime-deps.d.ts +29 -0
- package/src/lib/runtime/platform-runtime-deps.js +98 -0
- package/src/lib/runtime/platform-runtime-deps.js.map +1 -0
- package/src/lib/runtime/runtime-deps.d.ts +77 -0
- package/src/lib/runtime/runtime-deps.js +18 -0
- package/src/lib/runtime/runtime-deps.js.map +1 -0
- package/src/lib/runtime/send-job.d.ts +42 -0
- package/src/lib/runtime/send-job.js +780 -0
- package/src/lib/runtime/send-job.js.map +1 -0
- package/src/lib/runtime/sync-job.d.ts +41 -0
- package/src/lib/runtime/sync-job.js +480 -0
- package/src/lib/runtime/sync-job.js.map +1 -0
- package/src/lib/runtime/timeline.d.ts +67 -0
- package/src/lib/runtime/timeline.js +93 -0
- package/src/lib/runtime/timeline.js.map +1 -0
- package/src/lib/runtime/unsubscribe-link.d.ts +75 -0
- package/src/lib/runtime/unsubscribe-link.js +121 -0
- package/src/lib/runtime/unsubscribe-link.js.map +1 -0
- package/src/lib/runtime/unsubscribe-route.d.ts +24 -0
- package/src/lib/runtime/unsubscribe-route.js +103 -0
- package/src/lib/runtime/unsubscribe-route.js.map +1 -0
- package/src/lib/server.d.ts +53 -0
- package/src/lib/server.js +94 -0
- package/src/lib/server.js.map +1 -0
- package/src/lib/storage/compliance-settings-store.d.ts +44 -0
- package/src/lib/storage/compliance-settings-store.js +78 -0
- package/src/lib/storage/compliance-settings-store.js.map +1 -0
- package/src/lib/storage/do-not-contact-store.d.ts +60 -0
- package/src/lib/storage/do-not-contact-store.js +168 -0
- package/src/lib/storage/do-not-contact-store.js.map +1 -0
- package/src/lib/storage/outreach-records.d.ts +33 -0
- package/src/lib/storage/outreach-records.js +35 -0
- package/src/lib/storage/outreach-records.js.map +1 -0
- package/src/lib/subprocessors.d.ts +48 -0
- package/src/lib/subprocessors.js +70 -0
- package/src/lib/subprocessors.js.map +1 -0
- package/src/lib/transport/gmail-client.d.ts +145 -0
- package/src/lib/transport/gmail-client.js +229 -0
- package/src/lib/transport/gmail-client.js.map +1 -0
- package/src/lib/transport/gmail-errors.d.ts +84 -0
- package/src/lib/transport/gmail-errors.js +138 -0
- package/src/lib/transport/gmail-errors.js.map +1 -0
- package/src/lib/transport/google-oauth.d.ts +138 -0
- package/src/lib/transport/google-oauth.js +247 -0
- package/src/lib/transport/google-oauth.js.map +1 -0
- package/src/lib/transport/http.d.ts +53 -0
- package/src/lib/transport/http.js +79 -0
- package/src/lib/transport/http.js.map +1 -0
- package/src/lib/transport/rfc5322.d.ts +116 -0
- package/src/lib/transport/rfc5322.js +330 -0
- package/src/lib/transport/rfc5322.js.map +1 -0
- package/src/lib/transport/send-message.d.ts +69 -0
- package/src/lib/transport/send-message.js +77 -0
- 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
|