@odla-ai/chapter 0.22.1 → 0.24.0
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/README.md +99 -26
- package/dist/{chunk-4COPR4SY.js → chunk-GMGTJIY3.js} +292 -97
- package/dist/chunk-GMGTJIY3.js.map +1 -0
- package/dist/chunk-WGQO4WDJ.js +412 -0
- package/dist/chunk-WGQO4WDJ.js.map +1 -0
- package/dist/{chunk-C4XQSRW7.js → chunk-ZPAJL7KG.js} +225 -190
- package/dist/chunk-ZPAJL7KG.js.map +1 -0
- package/dist/{types-CgWUQ2MH.d.ts → copy-context-Dp5hvj5W.d.ts} +85 -20
- package/dist/index.cjs +340 -9
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +111 -24
- package/dist/index.d.ts +111 -24
- package/dist/index.js +340 -9
- package/dist/index.js.map +1 -1
- package/dist/ui/admin/index.d.ts +5 -2
- package/dist/ui/admin/index.js +8 -3
- package/dist/ui/index.d.ts +2 -2
- package/dist/ui/index.js +11 -5
- package/dist/ui/member/index.d.ts +118 -2
- package/dist/ui/member/index.js +10 -4
- package/dist/worker/index.cjs +274 -213
- package/dist/worker/index.cjs.map +1 -1
- package/dist/worker/index.d.cts +74 -19
- package/dist/worker/index.d.ts +74 -19
- package/dist/worker/index.js +274 -213
- package/dist/worker/index.js.map +1 -1
- package/package.json +1 -1
- package/runbooks/adopt-existing.md +23 -5
- package/runbooks/greenfield.md +85 -4
- package/dist/chunk-3QKGKUTX.js +0 -91
- package/dist/chunk-3QKGKUTX.js.map +0 -1
- package/dist/chunk-4COPR4SY.js.map +0 -1
- package/dist/chunk-C4XQSRW7.js.map +0 -1
package/dist/worker/index.js
CHANGED
|
@@ -111,68 +111,6 @@ function createWorkerContext(options) {
|
|
|
111
111
|
// src/worker-routes.ts
|
|
112
112
|
import { createCrmRoutes } from "@odla-ai/crm";
|
|
113
113
|
|
|
114
|
-
// src/clerk.ts
|
|
115
|
-
var heal = (status) => status === 422 ? { ok: true, status, existed: true } : { ok: false, status };
|
|
116
|
-
function clerkInviteRequest(input) {
|
|
117
|
-
return {
|
|
118
|
-
path: "/v1/invitations",
|
|
119
|
-
body: {
|
|
120
|
-
email_address: input.email,
|
|
121
|
-
notify: true,
|
|
122
|
-
...input.redirectUrl ? { redirect_url: input.redirectUrl } : {},
|
|
123
|
-
...input.publicMetadata ? { public_metadata: input.publicMetadata } : {}
|
|
124
|
-
}
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
async function createClerkInvitation(secretKey, input, fetchImpl = fetch) {
|
|
128
|
-
const { path, body } = clerkInviteRequest(input);
|
|
129
|
-
const res = await fetchImpl(`https://api.clerk.com${path}`, {
|
|
130
|
-
method: "POST",
|
|
131
|
-
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
132
|
-
body: JSON.stringify(body)
|
|
133
|
-
});
|
|
134
|
-
return res.ok ? { ok: true, status: res.status } : heal(res.status);
|
|
135
|
-
}
|
|
136
|
-
function clerkUserRequest(input) {
|
|
137
|
-
return {
|
|
138
|
-
path: "/v1/users",
|
|
139
|
-
body: {
|
|
140
|
-
email_address: [input.email],
|
|
141
|
-
skip_password_requirement: true,
|
|
142
|
-
...input.firstName ? { first_name: input.firstName } : {},
|
|
143
|
-
...input.lastName ? { last_name: input.lastName } : {},
|
|
144
|
-
...input.publicMetadata ? { public_metadata: input.publicMetadata } : {}
|
|
145
|
-
}
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
async function refreshUserMetadata(secretKey, email, publicMetadata, fetchImpl) {
|
|
149
|
-
const auth = { authorization: `Bearer ${secretKey}` };
|
|
150
|
-
const found = await fetchImpl(`https://api.clerk.com/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, { headers: auth });
|
|
151
|
-
if (!found.ok) return false;
|
|
152
|
-
const users = await found.json().catch(() => null);
|
|
153
|
-
const id = Array.isArray(users) && typeof users[0]?.id === "string" ? users[0].id : void 0;
|
|
154
|
-
if (!id) return false;
|
|
155
|
-
const patched = await fetchImpl(`https://api.clerk.com/v1/users/${id}/metadata`, {
|
|
156
|
-
method: "PATCH",
|
|
157
|
-
headers: { ...auth, "content-type": "application/json" },
|
|
158
|
-
body: JSON.stringify({ public_metadata: publicMetadata })
|
|
159
|
-
});
|
|
160
|
-
return patched.ok;
|
|
161
|
-
}
|
|
162
|
-
async function createClerkUser(secretKey, input, fetchImpl = fetch) {
|
|
163
|
-
const { path, body } = clerkUserRequest(input);
|
|
164
|
-
const res = await fetchImpl(`https://api.clerk.com${path}`, {
|
|
165
|
-
method: "POST",
|
|
166
|
-
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
167
|
-
body: JSON.stringify(body)
|
|
168
|
-
});
|
|
169
|
-
if (res.ok) return { ok: true, status: res.status };
|
|
170
|
-
const healed = heal(res.status);
|
|
171
|
-
if (!healed.existed || !input.publicMetadata) return healed;
|
|
172
|
-
const refreshed = await refreshUserMetadata(secretKey, input.email, input.publicMetadata, fetchImpl).catch(() => false);
|
|
173
|
-
return { ...healed, refreshed };
|
|
174
|
-
}
|
|
175
|
-
|
|
176
114
|
// src/member.ts
|
|
177
115
|
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
178
116
|
function isValidEmail(value) {
|
|
@@ -188,7 +126,7 @@ function hasDisclaimerAck(fields) {
|
|
|
188
126
|
var IDENTITY_FIELDS = /* @__PURE__ */ new Set(["email", "firstName", "lastName"]);
|
|
189
127
|
function applicantProfile(chapter, fields) {
|
|
190
128
|
const app = chapter.application;
|
|
191
|
-
const allowed = (f) => app.profileFields
|
|
129
|
+
const allowed = (f) => app.profileFields.includes(f);
|
|
192
130
|
const profile = {};
|
|
193
131
|
for (const f of [...app.required, ...app.optional]) {
|
|
194
132
|
if (IDENTITY_FIELDS.has(f) || !allowed(f)) continue;
|
|
@@ -506,6 +444,19 @@ function applicationBookingUpdate(currentStatus, startAt, htmlLink) {
|
|
|
506
444
|
};
|
|
507
445
|
}
|
|
508
446
|
|
|
447
|
+
// src/pipeline.ts
|
|
448
|
+
function canTransition(from, to, p) {
|
|
449
|
+
const fi = p.stages.indexOf(from);
|
|
450
|
+
const ti = p.stages.indexOf(to);
|
|
451
|
+
return fi >= 0 && ti >= 0 && ti >= fi;
|
|
452
|
+
}
|
|
453
|
+
function canBook(status, p) {
|
|
454
|
+
return p.bookableFrom.includes(status);
|
|
455
|
+
}
|
|
456
|
+
function canApprove(status, p) {
|
|
457
|
+
return p.approvableFrom.includes(status);
|
|
458
|
+
}
|
|
459
|
+
|
|
509
460
|
// src/session.ts
|
|
510
461
|
function applicationSummary(app) {
|
|
511
462
|
return {
|
|
@@ -538,6 +489,68 @@ function memberApplication(app, meeting, defaultTimezone) {
|
|
|
538
489
|
return { ...summary, meetingAt, meetUrl, timezone };
|
|
539
490
|
}
|
|
540
491
|
|
|
492
|
+
// src/clerk.ts
|
|
493
|
+
var heal = (status) => status === 422 ? { ok: true, status, existed: true } : { ok: false, status };
|
|
494
|
+
function clerkInviteRequest(input) {
|
|
495
|
+
return {
|
|
496
|
+
path: "/v1/invitations",
|
|
497
|
+
body: {
|
|
498
|
+
email_address: input.email,
|
|
499
|
+
notify: true,
|
|
500
|
+
...input.redirectUrl ? { redirect_url: input.redirectUrl } : {},
|
|
501
|
+
...input.publicMetadata ? { public_metadata: input.publicMetadata } : {}
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
async function createClerkInvitation(secretKey, input, fetchImpl = fetch) {
|
|
506
|
+
const { path, body } = clerkInviteRequest(input);
|
|
507
|
+
const res = await fetchImpl(`https://api.clerk.com${path}`, {
|
|
508
|
+
method: "POST",
|
|
509
|
+
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
510
|
+
body: JSON.stringify(body)
|
|
511
|
+
});
|
|
512
|
+
return res.ok ? { ok: true, status: res.status } : heal(res.status);
|
|
513
|
+
}
|
|
514
|
+
function clerkUserRequest(input) {
|
|
515
|
+
return {
|
|
516
|
+
path: "/v1/users",
|
|
517
|
+
body: {
|
|
518
|
+
email_address: [input.email],
|
|
519
|
+
skip_password_requirement: true,
|
|
520
|
+
...input.firstName ? { first_name: input.firstName } : {},
|
|
521
|
+
...input.lastName ? { last_name: input.lastName } : {},
|
|
522
|
+
...input.publicMetadata ? { public_metadata: input.publicMetadata } : {}
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
async function refreshUserMetadata(secretKey, email, publicMetadata, fetchImpl) {
|
|
527
|
+
const auth = { authorization: `Bearer ${secretKey}` };
|
|
528
|
+
const found = await fetchImpl(`https://api.clerk.com/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, { headers: auth });
|
|
529
|
+
if (!found.ok) return false;
|
|
530
|
+
const users = await found.json().catch(() => null);
|
|
531
|
+
const id = Array.isArray(users) && typeof users[0]?.id === "string" ? users[0].id : void 0;
|
|
532
|
+
if (!id) return false;
|
|
533
|
+
const patched = await fetchImpl(`https://api.clerk.com/v1/users/${id}/metadata`, {
|
|
534
|
+
method: "PATCH",
|
|
535
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
536
|
+
body: JSON.stringify({ public_metadata: publicMetadata })
|
|
537
|
+
});
|
|
538
|
+
return patched.ok;
|
|
539
|
+
}
|
|
540
|
+
async function createClerkUser(secretKey, input, fetchImpl = fetch) {
|
|
541
|
+
const { path, body } = clerkUserRequest(input);
|
|
542
|
+
const res = await fetchImpl(`https://api.clerk.com${path}`, {
|
|
543
|
+
method: "POST",
|
|
544
|
+
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
545
|
+
body: JSON.stringify(body)
|
|
546
|
+
});
|
|
547
|
+
if (res.ok) return { ok: true, status: res.status };
|
|
548
|
+
const healed = heal(res.status);
|
|
549
|
+
if (!healed.existed || !input.publicMetadata) return healed;
|
|
550
|
+
const refreshed = await refreshUserMetadata(secretKey, input.email, input.publicMetadata, fetchImpl).catch(() => false);
|
|
551
|
+
return { ...healed, refreshed };
|
|
552
|
+
}
|
|
553
|
+
|
|
541
554
|
// src/email.ts
|
|
542
555
|
function render(template, vars) {
|
|
543
556
|
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? "");
|
|
@@ -641,36 +654,47 @@ function emailGroupFrom(row) {
|
|
|
641
654
|
};
|
|
642
655
|
}
|
|
643
656
|
|
|
644
|
-
// src/worker-
|
|
657
|
+
// src/worker-provisioning.ts
|
|
645
658
|
async function provisionApplicant(db, chapter, applicationId, fields) {
|
|
646
659
|
const email = typeof fields.email === "string" ? fields.email : "";
|
|
647
660
|
if (!email) return;
|
|
648
|
-
const
|
|
661
|
+
const stringValue = (value) => typeof value === "string" ? value : void 0;
|
|
649
662
|
const extra = {};
|
|
650
|
-
for (const
|
|
651
|
-
if (fields[
|
|
663
|
+
for (const field of chapter.application.crmFields) {
|
|
664
|
+
if (fields[field] !== void 0) extra[field] = fields[field];
|
|
652
665
|
}
|
|
653
666
|
try {
|
|
654
667
|
await projectApplicant(
|
|
655
668
|
{ crm: chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID() },
|
|
656
|
-
{
|
|
669
|
+
{
|
|
670
|
+
applicationId,
|
|
671
|
+
email,
|
|
672
|
+
firstName: stringValue(fields.firstName),
|
|
673
|
+
lastName: stringValue(fields.lastName),
|
|
674
|
+
phone: stringValue(fields.phone),
|
|
675
|
+
linkedin: stringValue(fields.linkedin),
|
|
676
|
+
extra
|
|
677
|
+
}
|
|
657
678
|
);
|
|
658
679
|
} catch {
|
|
659
680
|
}
|
|
660
|
-
if (chapter.account
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
681
|
+
if (chapter.account === "none") return;
|
|
682
|
+
try {
|
|
683
|
+
const secret = await getVaultSecret(db, "clerk_secret_key");
|
|
684
|
+
if (!secret) return;
|
|
685
|
+
const profile = applicantProfile(chapter, fields);
|
|
686
|
+
const publicMetadata = { applicationId, ...profile ? { profile } : {} };
|
|
687
|
+
if (chapter.account === "create") {
|
|
688
|
+
await createClerkUser(secret, {
|
|
689
|
+
email,
|
|
690
|
+
firstName: stringValue(fields.firstName),
|
|
691
|
+
lastName: stringValue(fields.lastName),
|
|
692
|
+
publicMetadata
|
|
693
|
+
});
|
|
694
|
+
} else {
|
|
695
|
+
await createClerkInvitation(secret, { email, publicMetadata });
|
|
673
696
|
}
|
|
697
|
+
} catch {
|
|
674
698
|
}
|
|
675
699
|
}
|
|
676
700
|
async function notifyAdminOfApplication(db, env, chapterId, applicationId, fields) {
|
|
@@ -678,14 +702,27 @@ async function notifyAdminOfApplication(db, env, chapterId, applicationId, field
|
|
|
678
702
|
const groups = (await db.query({ groups: { $: { where: { id: chapterId }, limit: 1 } } })).groups;
|
|
679
703
|
const group = Array.isArray(groups) ? groups[0] : void 0;
|
|
680
704
|
if (!group || typeof group.notificationEmail !== "string" || !group.notificationEmail) return;
|
|
681
|
-
const
|
|
705
|
+
const stringValue = (value) => typeof value === "string" ? value : "";
|
|
682
706
|
await sendTemplated(
|
|
683
|
-
{
|
|
707
|
+
{
|
|
708
|
+
db,
|
|
709
|
+
envName: env.ODLA_ENV,
|
|
710
|
+
sender: env.SEND_EMAIL,
|
|
711
|
+
from: env.EMAIL_FROM,
|
|
712
|
+
now: () => Date.now(),
|
|
713
|
+
newId: () => crypto.randomUUID()
|
|
714
|
+
},
|
|
684
715
|
{
|
|
685
716
|
group: emailGroupFrom(group),
|
|
686
717
|
template: "adminNotification",
|
|
687
718
|
to: group.notificationEmail,
|
|
688
|
-
vars: {
|
|
719
|
+
vars: {
|
|
720
|
+
firstName: stringValue(fields.firstName),
|
|
721
|
+
lastName: stringValue(fields.lastName),
|
|
722
|
+
email: stringValue(fields.email),
|
|
723
|
+
phone: stringValue(fields.phone),
|
|
724
|
+
state: stringValue(fields.state)
|
|
725
|
+
},
|
|
689
726
|
dedupeKey: `apply:${applicationId}:admin`,
|
|
690
727
|
applicationId
|
|
691
728
|
}
|
|
@@ -693,6 +730,8 @@ async function notifyAdminOfApplication(db, env, chapterId, applicationId, field
|
|
|
693
730
|
} catch {
|
|
694
731
|
}
|
|
695
732
|
}
|
|
733
|
+
|
|
734
|
+
// src/worker-routes.ts
|
|
696
735
|
async function memberSessionApplication(db, chapterId, email) {
|
|
697
736
|
const apps = (await db.query({ applications: { $: { where: { email }, order: { createdAt: "desc" }, limit: 1 } } })).applications;
|
|
698
737
|
const app = Array.isArray(apps) ? apps[0] : void 0;
|
|
@@ -797,7 +836,54 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
797
836
|
if (!group) return json({ error: "not found" }, 404);
|
|
798
837
|
const stripeKey = await getVaultSecret(db, "stripe_secret_key");
|
|
799
838
|
const paymentsReady = Boolean(group.stripePublishableKey && group.stripePriceId && stripeKey);
|
|
800
|
-
return json(joinConfig(group, paymentsReady));
|
|
839
|
+
return json({ ...joinConfig(group, paymentsReady), copy: chapter.copy.join });
|
|
840
|
+
}
|
|
841
|
+
if (req.method === "GET" && url.pathname === "/api/join/resume") {
|
|
842
|
+
const applicationId = url.searchParams.get("application") ?? "";
|
|
843
|
+
if (!applicationId) return json({ error: "application is required" }, 400);
|
|
844
|
+
const db = ctx.makeDb(env);
|
|
845
|
+
const apps = (await db.query({
|
|
846
|
+
applications: { $: { where: { id: applicationId }, limit: 1 } }
|
|
847
|
+
})).applications;
|
|
848
|
+
const app = Array.isArray(apps) ? apps[0] : void 0;
|
|
849
|
+
if (!app || String(app.groupId ?? "") !== chapter.id) return json({ error: "not found" }, 404);
|
|
850
|
+
const meetings = (await db.query({
|
|
851
|
+
meetings: {
|
|
852
|
+
$: {
|
|
853
|
+
where: { applicationId, status: "scheduled" },
|
|
854
|
+
order: { createdAt: "desc" },
|
|
855
|
+
limit: 1
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
})).meetings;
|
|
859
|
+
const meeting = Array.isArray(meetings) ? meetings[0] : void 0;
|
|
860
|
+
if (meeting && typeof meeting.startAt === "number") {
|
|
861
|
+
const groups2 = (await db.query({
|
|
862
|
+
groups: { $: { where: { id: chapter.id }, limit: 1 } }
|
|
863
|
+
})).groups;
|
|
864
|
+
const group2 = Array.isArray(groups2) ? groups2[0] : void 0;
|
|
865
|
+
const timezone = resolveScheduling(group2?.schedulingJson).timezone;
|
|
866
|
+
return json({
|
|
867
|
+
state: "done",
|
|
868
|
+
applicationId,
|
|
869
|
+
booked: { startAt: meeting.startAt, timezone }
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
const groups = (await db.query({
|
|
873
|
+
groups: { $: { where: { id: chapter.id }, limit: 1 } }
|
|
874
|
+
})).groups;
|
|
875
|
+
const group = Array.isArray(groups) ? groups[0] : void 0;
|
|
876
|
+
if (!group) return json({ error: "not found" }, 404);
|
|
877
|
+
const stripeKey = await getVaultSecret(db, "stripe_secret_key");
|
|
878
|
+
const paymentsReady = Boolean(group.stripePublishableKey && group.stripePriceId && stripeKey);
|
|
879
|
+
const status = String(app.status ?? "");
|
|
880
|
+
if (!paymentsReady) return json({ state: "booking", applicationId });
|
|
881
|
+
if (status === chapter.pipeline.initial && app.stripeSubscriptionId) {
|
|
882
|
+
return json({ state: "paymentPending", applicationId });
|
|
883
|
+
}
|
|
884
|
+
if (status === chapter.pipeline.initial) return json({ state: "payment", applicationId });
|
|
885
|
+
if (canBook(status, chapter.pipeline)) return json({ state: "booking", applicationId });
|
|
886
|
+
return json({ error: `application cannot resume from status "${status}"` }, 409);
|
|
801
887
|
}
|
|
802
888
|
if (req.method === "POST" && url.pathname === "/api/applications") {
|
|
803
889
|
const raw = await req.text();
|
|
@@ -835,23 +921,95 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
835
921
|
return null;
|
|
836
922
|
};
|
|
837
923
|
|
|
838
|
-
// src/
|
|
839
|
-
import {
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
924
|
+
// src/crm-sync.ts
|
|
925
|
+
import { createRecord as createRecord2, updateRecord as updateRecord2, setStage, linkIdentity } from "@odla-ai/crm";
|
|
926
|
+
var str = (v) => typeof v === "string" ? v : v == null ? "" : String(v);
|
|
927
|
+
var crmDeps = (db, ctx) => ({
|
|
928
|
+
crm: ctx.chapter.crm,
|
|
929
|
+
db,
|
|
930
|
+
now: () => Date.now(),
|
|
931
|
+
newId: () => crypto.randomUUID(),
|
|
932
|
+
chapter: ctx.chapter
|
|
933
|
+
});
|
|
934
|
+
function personInputFromApp(chapter, app) {
|
|
935
|
+
const input = sharedPersonInput({
|
|
936
|
+
email: str(app.email),
|
|
937
|
+
firstName: str(app.firstName) || void 0,
|
|
938
|
+
lastName: str(app.lastName) || void 0,
|
|
939
|
+
phone: str(app.phone) || void 0,
|
|
940
|
+
linkedin: str(app.linkedin) || void 0,
|
|
941
|
+
hubRecordId: str(app.id)
|
|
942
|
+
});
|
|
943
|
+
for (const f of chapter.application.crmFields) {
|
|
944
|
+
if (app[f] !== void 0) input[f] = app[f];
|
|
945
|
+
}
|
|
946
|
+
if (app.id !== void 0) input.applicationId = str(app.id);
|
|
947
|
+
return input;
|
|
846
948
|
}
|
|
847
|
-
function
|
|
848
|
-
|
|
949
|
+
function billingColumns(app) {
|
|
950
|
+
const status = str(app.status);
|
|
951
|
+
const paid = Boolean(app.stripeSubscriptionId) && status !== "refunded";
|
|
952
|
+
const billingStatus = status === "refunded" ? "refunded" : app.canceled === true ? "canceled" : paid ? "active" : "none";
|
|
953
|
+
const cols = { billingStatus };
|
|
954
|
+
if (app.stripeCustomerId) cols.stripeCustomerId = str(app.stripeCustomerId);
|
|
955
|
+
if (app.stripeSubscriptionId) cols.subscriptionId = str(app.stripeSubscriptionId);
|
|
956
|
+
if (typeof app.renewalAt === "number") cols.renewalAt = app.renewalAt;
|
|
957
|
+
return cols;
|
|
849
958
|
}
|
|
850
|
-
function
|
|
851
|
-
|
|
959
|
+
async function syncApplicationToCrm(deps, opts) {
|
|
960
|
+
const emailKey = str(opts.app.email).toLowerCase();
|
|
961
|
+
if (!emailKey) return null;
|
|
962
|
+
const recordDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
|
|
963
|
+
const input = personInputFromApp(deps.chapter, opts.app);
|
|
964
|
+
const { crm_record } = await deps.db.query({
|
|
965
|
+
crm_record: { $: { where: { type: "person", primaryEmail: emailKey }, limit: 1 } }
|
|
966
|
+
});
|
|
967
|
+
const existing = crm_record?.[0] ?? null;
|
|
968
|
+
const stage = opts.stage || void 0;
|
|
969
|
+
let recordId;
|
|
970
|
+
if (existing && typeof existing.id === "string") {
|
|
971
|
+
recordId = existing.id;
|
|
972
|
+
await updateRecord2(recordDeps, { id: recordId, input });
|
|
973
|
+
} else {
|
|
974
|
+
const created = await createRecord2(recordDeps, { type: "person", input, ...stage ? { stage } : {} });
|
|
975
|
+
recordId = created.id;
|
|
976
|
+
}
|
|
977
|
+
if (existing && stage && existing.stage !== stage) {
|
|
978
|
+
await setStage(recordDeps, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${stage}` }).catch(() => void 0);
|
|
979
|
+
}
|
|
980
|
+
await deps.db.transact([{ t: "update", ns: "crm_record", id: recordId, attrs: billingColumns(opts.app) }]);
|
|
981
|
+
await linkIdentity(recordDeps, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
|
|
982
|
+
return recordId;
|
|
983
|
+
}
|
|
984
|
+
async function backfillCrm(deps) {
|
|
985
|
+
const [appsRes, usersRes] = await Promise.all([
|
|
986
|
+
deps.db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 1e3 } } }),
|
|
987
|
+
deps.db.query({ $users: { $: { limit: 1e3 } } })
|
|
988
|
+
]);
|
|
989
|
+
const seen = /* @__PURE__ */ new Set();
|
|
990
|
+
let synced = 0;
|
|
991
|
+
const errors = [];
|
|
992
|
+
const run = async (app, stage) => {
|
|
993
|
+
const key = str(app.email).toLowerCase();
|
|
994
|
+
if (!key || seen.has(key)) return;
|
|
995
|
+
seen.add(key);
|
|
996
|
+
try {
|
|
997
|
+
await syncApplicationToCrm(deps, { app, stage });
|
|
998
|
+
synced += 1;
|
|
999
|
+
} catch (err) {
|
|
1000
|
+
errors.push({ email: key, error: err instanceof Error ? err.message : String(err) });
|
|
1001
|
+
}
|
|
1002
|
+
};
|
|
1003
|
+
for (const a of appsRes.applications ?? []) await run(a, str(a.status));
|
|
1004
|
+
for (const u of usersRes.$users ?? []) {
|
|
1005
|
+
if (u.deleted === true) continue;
|
|
1006
|
+
await run({ email: u.email, firstName: str(u.name) });
|
|
1007
|
+
}
|
|
1008
|
+
return { synced, errors };
|
|
852
1009
|
}
|
|
853
1010
|
|
|
854
1011
|
// src/worker-routes-schedule.ts
|
|
1012
|
+
import { computeBookableSlots, initCalendar } from "@odla-ai/calendar";
|
|
855
1013
|
function errCode(err) {
|
|
856
1014
|
if (err && typeof err === "object") {
|
|
857
1015
|
const code = err.code;
|
|
@@ -953,8 +1111,11 @@ async function bookSlot(req, env, ctx) {
|
|
|
953
1111
|
if (code === "calendar_slot_unavailable") return json({ error: "slot no longer available", code }, 409);
|
|
954
1112
|
return json({ error: "booking failed", code }, 502);
|
|
955
1113
|
}
|
|
956
|
-
const
|
|
1114
|
+
const bookingAttrs = applicationBookingUpdate(status, startAt, htmlLink);
|
|
1115
|
+
const appOp = { t: "update", ns: "applications", id: applicationId, attrs: bookingAttrs };
|
|
957
1116
|
await db.transact([meetingOp, appOp]);
|
|
1117
|
+
const booked = { ...app, ...bookingAttrs };
|
|
1118
|
+
await syncApplicationToCrm(crmDeps(db, ctx), { app: booked, stage: String(booked.status ?? "") }).catch(() => void 0);
|
|
958
1119
|
if (typeof app.email === "string" && app.email) {
|
|
959
1120
|
await sendTemplated(
|
|
960
1121
|
{ db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
|
|
@@ -1304,6 +1465,8 @@ async function ingestWebhook(req, env, ctx) {
|
|
|
1304
1465
|
const patch = webhookPatch(event, String(app.status ?? ""));
|
|
1305
1466
|
if (Object.keys(patch).length) {
|
|
1306
1467
|
await db.transact([{ t: "update", ns: "applications", id: String(app.id), attrs: patch }], { mutationId: webhookMutationId(eventId) });
|
|
1468
|
+
const next = { ...app, ...patch };
|
|
1469
|
+
await syncApplicationToCrm(crmDeps(db, ctx), { app: next, stage: String(next.status ?? "") }).catch(() => void 0);
|
|
1307
1470
|
}
|
|
1308
1471
|
if (event.kind === "first_payment") {
|
|
1309
1472
|
await notifyPaymentConfirmed(db, env, eventId, app);
|
|
@@ -1311,37 +1474,10 @@ async function ingestWebhook(req, env, ctx) {
|
|
|
1311
1474
|
}
|
|
1312
1475
|
return json({ ok: true });
|
|
1313
1476
|
}
|
|
1314
|
-
async function refundApplication(req, url, env, ctx) {
|
|
1315
|
-
const rawDb = ctx.makeDb(env);
|
|
1316
|
-
const u = await ctx.verifyUser(req, env);
|
|
1317
|
-
if (!u || !await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
|
|
1318
|
-
const id = url.pathname.split("/")[4] ?? "";
|
|
1319
|
-
const db = rawDb;
|
|
1320
|
-
const app = await firstRow2(db, "applications", { where: { id }, limit: 1 });
|
|
1321
|
-
if (!app) return json({ error: "not found" }, 404);
|
|
1322
|
-
if (app.status === "refunded") return json({ error: "already refunded" }, 409);
|
|
1323
|
-
if (app.status === "approved") return json({ error: "approved memberships are non-refundable" }, 409);
|
|
1324
|
-
if (!app.stripeSubscriptionId) return json({ error: "no subscription on file" }, 409);
|
|
1325
|
-
if (!app.stripeCustomerId) return json({ error: "no customer on file" }, 409);
|
|
1326
|
-
const secretKey = await getVaultSecret(db, "stripe_secret_key");
|
|
1327
|
-
if (!secretKey) return json({ error: "payments not configured" }, 503);
|
|
1328
|
-
try {
|
|
1329
|
-
const result = await createStripeProvider({ secretKey }).refund({
|
|
1330
|
-
customerId: String(app.stripeCustomerId),
|
|
1331
|
-
subscriptionId: String(app.stripeSubscriptionId)
|
|
1332
|
-
});
|
|
1333
|
-
return json({ ok: true, refundedCents: result.refundedCents, subscriptionCanceled: result.subscriptionCanceled });
|
|
1334
|
-
} catch (err) {
|
|
1335
|
-
const code = codeOf(err);
|
|
1336
|
-
return json({ error: "refund failed", code }, code === "no_charge" ? 409 : 502);
|
|
1337
|
-
}
|
|
1338
|
-
}
|
|
1339
|
-
var REFUND_PATH = /^\/api\/admin\/applications\/[^/]+\/refund$/;
|
|
1340
1477
|
var handlePayments = async (req, url, env, ctx) => {
|
|
1341
1478
|
if (ctx.chapter.mode !== "chapter") return null;
|
|
1342
1479
|
if (req.method === "POST" && url.pathname === "/api/payments/subscription") return startSubscription(req, env, ctx);
|
|
1343
1480
|
if (req.method === "POST" && url.pathname === "/api/webhooks/stripe") return ingestWebhook(req, env, ctx);
|
|
1344
|
-
if (req.method === "POST" && REFUND_PATH.test(url.pathname)) return refundApplication(req, url, env, ctx);
|
|
1345
1481
|
return null;
|
|
1346
1482
|
};
|
|
1347
1483
|
|
|
@@ -1384,7 +1520,8 @@ function reconcileMeetings(meetings, events, now) {
|
|
|
1384
1520
|
async function adminGroup(req, env, ctx, url) {
|
|
1385
1521
|
const rawDb = ctx.makeDb(env);
|
|
1386
1522
|
const u = await ctx.verifyUser(req, env);
|
|
1387
|
-
if (!u
|
|
1523
|
+
if (!u) return json({ error: "unauthorized" }, 401);
|
|
1524
|
+
if (!await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
|
|
1388
1525
|
const db = rawDb;
|
|
1389
1526
|
const groupId = url.searchParams.get("group") ?? ctx.chapter.id;
|
|
1390
1527
|
const group = (await db.query({ groups: { $: { where: { id: groupId }, limit: 1 } } })).groups?.[0];
|
|
@@ -1429,7 +1566,8 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
|
|
|
1429
1566
|
if (req.method !== "GET" || url.pathname !== "/api/admin/meetings") return null;
|
|
1430
1567
|
const rawDb = ctx.makeDb(env);
|
|
1431
1568
|
const u = await ctx.verifyUser(req, env);
|
|
1432
|
-
if (!u
|
|
1569
|
+
if (!u) return json({ error: "unauthorized" }, 401);
|
|
1570
|
+
if (!await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
|
|
1433
1571
|
const db = rawDb;
|
|
1434
1572
|
const all = url.searchParams.get("all") === "1";
|
|
1435
1573
|
const from = Number(url.searchParams.get("from"));
|
|
@@ -1520,86 +1658,6 @@ async function clerkSetRole(secretKey, id, role, fetchImpl = fetch) {
|
|
|
1520
1658
|
return res.ok;
|
|
1521
1659
|
}
|
|
1522
1660
|
|
|
1523
|
-
// src/crm-sync.ts
|
|
1524
|
-
import { createRecord as createRecord2, updateRecord as updateRecord2, setStage, linkIdentity } from "@odla-ai/crm";
|
|
1525
|
-
var str = (v) => typeof v === "string" ? v : v == null ? "" : String(v);
|
|
1526
|
-
function personInputFromApp(chapter, app) {
|
|
1527
|
-
const input = sharedPersonInput({
|
|
1528
|
-
email: str(app.email),
|
|
1529
|
-
firstName: str(app.firstName) || void 0,
|
|
1530
|
-
lastName: str(app.lastName) || void 0,
|
|
1531
|
-
phone: str(app.phone) || void 0,
|
|
1532
|
-
linkedin: str(app.linkedin) || void 0,
|
|
1533
|
-
hubRecordId: str(app.id)
|
|
1534
|
-
});
|
|
1535
|
-
for (const f of chapter.application.crmFields) {
|
|
1536
|
-
if (app[f] !== void 0) input[f] = app[f];
|
|
1537
|
-
}
|
|
1538
|
-
if (app.id !== void 0) input.applicationId = str(app.id);
|
|
1539
|
-
return input;
|
|
1540
|
-
}
|
|
1541
|
-
function billingColumns(app) {
|
|
1542
|
-
const status = str(app.status);
|
|
1543
|
-
const paid = Boolean(app.stripeSubscriptionId) && status !== "refunded";
|
|
1544
|
-
const billingStatus = status === "refunded" ? "refunded" : app.canceled === true ? "canceled" : paid ? "active" : "none";
|
|
1545
|
-
const cols = { billingStatus };
|
|
1546
|
-
if (app.stripeCustomerId) cols.stripeCustomerId = str(app.stripeCustomerId);
|
|
1547
|
-
if (app.stripeSubscriptionId) cols.subscriptionId = str(app.stripeSubscriptionId);
|
|
1548
|
-
if (typeof app.renewalAt === "number") cols.renewalAt = app.renewalAt;
|
|
1549
|
-
return cols;
|
|
1550
|
-
}
|
|
1551
|
-
async function syncApplicationToCrm(deps, opts) {
|
|
1552
|
-
const emailKey = str(opts.app.email).toLowerCase();
|
|
1553
|
-
if (!emailKey) return null;
|
|
1554
|
-
const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
|
|
1555
|
-
const input = personInputFromApp(deps.chapter, opts.app);
|
|
1556
|
-
const { crm_record } = await deps.db.query({
|
|
1557
|
-
crm_record: { $: { where: { type: "person", primaryEmail: emailKey }, limit: 1 } }
|
|
1558
|
-
});
|
|
1559
|
-
const existing = crm_record?.[0] ?? null;
|
|
1560
|
-
const stage = opts.stage || void 0;
|
|
1561
|
-
let recordId;
|
|
1562
|
-
if (existing && typeof existing.id === "string") {
|
|
1563
|
-
recordId = existing.id;
|
|
1564
|
-
await updateRecord2(crmDeps3, { id: recordId, input });
|
|
1565
|
-
} else {
|
|
1566
|
-
const created = await createRecord2(crmDeps3, { type: "person", input, ...stage ? { stage } : {} });
|
|
1567
|
-
recordId = created.id;
|
|
1568
|
-
}
|
|
1569
|
-
if (existing && stage && existing.stage !== stage) {
|
|
1570
|
-
await setStage(crmDeps3, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${stage}` }).catch(() => void 0);
|
|
1571
|
-
}
|
|
1572
|
-
await deps.db.transact([{ t: "update", ns: "crm_record", id: recordId, attrs: billingColumns(opts.app) }]);
|
|
1573
|
-
await linkIdentity(crmDeps3, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
|
|
1574
|
-
return recordId;
|
|
1575
|
-
}
|
|
1576
|
-
async function backfillCrm(deps) {
|
|
1577
|
-
const [appsRes, usersRes] = await Promise.all([
|
|
1578
|
-
deps.db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 1e3 } } }),
|
|
1579
|
-
deps.db.query({ $users: { $: { limit: 1e3 } } })
|
|
1580
|
-
]);
|
|
1581
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1582
|
-
let synced = 0;
|
|
1583
|
-
const errors = [];
|
|
1584
|
-
const run = async (app, stage) => {
|
|
1585
|
-
const key = str(app.email).toLowerCase();
|
|
1586
|
-
if (!key || seen.has(key)) return;
|
|
1587
|
-
seen.add(key);
|
|
1588
|
-
try {
|
|
1589
|
-
await syncApplicationToCrm(deps, { app, stage });
|
|
1590
|
-
synced += 1;
|
|
1591
|
-
} catch (err) {
|
|
1592
|
-
errors.push({ email: key, error: err instanceof Error ? err.message : String(err) });
|
|
1593
|
-
}
|
|
1594
|
-
};
|
|
1595
|
-
for (const a of appsRes.applications ?? []) await run(a, str(a.status));
|
|
1596
|
-
for (const u of usersRes.$users ?? []) {
|
|
1597
|
-
if (u.deleted === true) continue;
|
|
1598
|
-
await run({ email: u.email, firstName: str(u.name) });
|
|
1599
|
-
}
|
|
1600
|
-
return { synced, errors };
|
|
1601
|
-
}
|
|
1602
|
-
|
|
1603
1661
|
// src/worker-routes-admin-people.ts
|
|
1604
1662
|
async function adminGate(req, env, ctx) {
|
|
1605
1663
|
const rawDb = ctx.makeDb(env);
|
|
@@ -1608,7 +1666,7 @@ async function adminGate(req, env, ctx) {
|
|
|
1608
1666
|
if (!await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
|
|
1609
1667
|
return { db: rawDb, actor: { userId: u.userId, email: u.email ?? void 0 } };
|
|
1610
1668
|
}
|
|
1611
|
-
var
|
|
1669
|
+
var crmDeps2 = (db, ctx) => ({
|
|
1612
1670
|
crm: ctx.chapter.crm,
|
|
1613
1671
|
db,
|
|
1614
1672
|
now: () => Date.now(),
|
|
@@ -1619,7 +1677,7 @@ var handleAdminCrmSync = async (req, url, env, ctx) => {
|
|
|
1619
1677
|
if (req.method !== "POST" || url.pathname !== "/api/admin/crm/sync") return null;
|
|
1620
1678
|
const gate5 = await adminGate(req, env, ctx);
|
|
1621
1679
|
if (gate5 instanceof Response) return gate5;
|
|
1622
|
-
const result = await backfillCrm(
|
|
1680
|
+
const result = await backfillCrm(crmDeps2(gate5.db, ctx));
|
|
1623
1681
|
return json({ ok: true, ...result });
|
|
1624
1682
|
};
|
|
1625
1683
|
var handleAdminPeople = async (req, url, env, ctx) => {
|
|
@@ -1899,7 +1957,6 @@ async function gate2(req, env, ctx) {
|
|
|
1899
1957
|
return rawDb;
|
|
1900
1958
|
}
|
|
1901
1959
|
var calFor = (env) => initCalendar3({ appId: env.ODLA_APP_ID, env: env.ODLA_ENV, adminToken: env.ODLA_API_KEY, endpoint: env.ODLA_PLATFORM });
|
|
1902
|
-
var crmDeps2 = (db, ctx) => ({ crm: ctx.chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID(), chapter: ctx.chapter });
|
|
1903
1960
|
var readJson = async (req) => {
|
|
1904
1961
|
try {
|
|
1905
1962
|
return await req.json();
|
|
@@ -1985,7 +2042,7 @@ var handleAdminApprove = async (req, url, env, ctx) => {
|
|
|
1985
2042
|
if (!canApprove(String(app.status), ctx.chapter.pipeline)) return json({ error: `cannot approve from status "${String(app.status)}"` }, 409);
|
|
1986
2043
|
const target = "approved";
|
|
1987
2044
|
await db.transact([{ t: "update", ns: "applications", id, attrs: { status: target } }]);
|
|
1988
|
-
await syncApplicationToCrm(
|
|
2045
|
+
await syncApplicationToCrm(crmDeps(db, ctx), { app: { ...app, status: target }, stage: target }).catch(() => void 0);
|
|
1989
2046
|
const { promoteTo, send } = ctx.chapter.operations.onApprove;
|
|
1990
2047
|
let rolePromoted = false;
|
|
1991
2048
|
const sk = await getVaultSecret(db, "clerk_secret_key");
|
|
@@ -2066,7 +2123,7 @@ var handleAdminApplicationPatch = async (req, url, env, ctx) => {
|
|
|
2066
2123
|
if (Object.keys(attrs).length === 0) return json({ error: "nothing to update" }, 400);
|
|
2067
2124
|
await db.transact([{ t: "update", ns: "applications", id, attrs }]);
|
|
2068
2125
|
if (attrs.status !== void 0) {
|
|
2069
|
-
await syncApplicationToCrm(
|
|
2126
|
+
await syncApplicationToCrm(crmDeps(db, ctx), { app: { ...app, ...attrs }, stage: String(attrs.status) }).catch(() => void 0);
|
|
2070
2127
|
}
|
|
2071
2128
|
return json({ ok: true });
|
|
2072
2129
|
};
|
|
@@ -2341,7 +2398,11 @@ var BUILTIN_ROUTES = [
|
|
|
2341
2398
|
handleAdminComms,
|
|
2342
2399
|
// Leader → follower record delivery
|
|
2343
2400
|
handleAdminNetworkTargets,
|
|
2344
|
-
handleAdminNetworkPush
|
|
2401
|
+
handleAdminNetworkPush,
|
|
2402
|
+
// API requests must never fall through to an SPA asset response. Hosts still
|
|
2403
|
+
// get first refusal through options.routes, then this terminates unknown API
|
|
2404
|
+
// paths with an explicit machine-readable 404.
|
|
2405
|
+
async (_req, url) => url.pathname.startsWith("/api/") ? json({ error: "not found" }, 404) : null
|
|
2345
2406
|
];
|
|
2346
2407
|
function chapterWorker(options) {
|
|
2347
2408
|
const ctx = createWorkerContext(options);
|