@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.cjs
CHANGED
|
@@ -138,68 +138,6 @@ function createWorkerContext(options) {
|
|
|
138
138
|
// src/worker-routes.ts
|
|
139
139
|
var import_crm2 = require("@odla-ai/crm");
|
|
140
140
|
|
|
141
|
-
// src/clerk.ts
|
|
142
|
-
var heal = (status) => status === 422 ? { ok: true, status, existed: true } : { ok: false, status };
|
|
143
|
-
function clerkInviteRequest(input) {
|
|
144
|
-
return {
|
|
145
|
-
path: "/v1/invitations",
|
|
146
|
-
body: {
|
|
147
|
-
email_address: input.email,
|
|
148
|
-
notify: true,
|
|
149
|
-
...input.redirectUrl ? { redirect_url: input.redirectUrl } : {},
|
|
150
|
-
...input.publicMetadata ? { public_metadata: input.publicMetadata } : {}
|
|
151
|
-
}
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
async function createClerkInvitation(secretKey, input, fetchImpl = fetch) {
|
|
155
|
-
const { path, body } = clerkInviteRequest(input);
|
|
156
|
-
const res = await fetchImpl(`https://api.clerk.com${path}`, {
|
|
157
|
-
method: "POST",
|
|
158
|
-
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
159
|
-
body: JSON.stringify(body)
|
|
160
|
-
});
|
|
161
|
-
return res.ok ? { ok: true, status: res.status } : heal(res.status);
|
|
162
|
-
}
|
|
163
|
-
function clerkUserRequest(input) {
|
|
164
|
-
return {
|
|
165
|
-
path: "/v1/users",
|
|
166
|
-
body: {
|
|
167
|
-
email_address: [input.email],
|
|
168
|
-
skip_password_requirement: true,
|
|
169
|
-
...input.firstName ? { first_name: input.firstName } : {},
|
|
170
|
-
...input.lastName ? { last_name: input.lastName } : {},
|
|
171
|
-
...input.publicMetadata ? { public_metadata: input.publicMetadata } : {}
|
|
172
|
-
}
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
async function refreshUserMetadata(secretKey, email, publicMetadata, fetchImpl) {
|
|
176
|
-
const auth = { authorization: `Bearer ${secretKey}` };
|
|
177
|
-
const found = await fetchImpl(`https://api.clerk.com/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, { headers: auth });
|
|
178
|
-
if (!found.ok) return false;
|
|
179
|
-
const users = await found.json().catch(() => null);
|
|
180
|
-
const id = Array.isArray(users) && typeof users[0]?.id === "string" ? users[0].id : void 0;
|
|
181
|
-
if (!id) return false;
|
|
182
|
-
const patched = await fetchImpl(`https://api.clerk.com/v1/users/${id}/metadata`, {
|
|
183
|
-
method: "PATCH",
|
|
184
|
-
headers: { ...auth, "content-type": "application/json" },
|
|
185
|
-
body: JSON.stringify({ public_metadata: publicMetadata })
|
|
186
|
-
});
|
|
187
|
-
return patched.ok;
|
|
188
|
-
}
|
|
189
|
-
async function createClerkUser(secretKey, input, fetchImpl = fetch) {
|
|
190
|
-
const { path, body } = clerkUserRequest(input);
|
|
191
|
-
const res = await fetchImpl(`https://api.clerk.com${path}`, {
|
|
192
|
-
method: "POST",
|
|
193
|
-
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
194
|
-
body: JSON.stringify(body)
|
|
195
|
-
});
|
|
196
|
-
if (res.ok) return { ok: true, status: res.status };
|
|
197
|
-
const healed = heal(res.status);
|
|
198
|
-
if (!healed.existed || !input.publicMetadata) return healed;
|
|
199
|
-
const refreshed = await refreshUserMetadata(secretKey, input.email, input.publicMetadata, fetchImpl).catch(() => false);
|
|
200
|
-
return { ...healed, refreshed };
|
|
201
|
-
}
|
|
202
|
-
|
|
203
141
|
// src/member.ts
|
|
204
142
|
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
205
143
|
function isValidEmail(value) {
|
|
@@ -215,7 +153,7 @@ function hasDisclaimerAck(fields) {
|
|
|
215
153
|
var IDENTITY_FIELDS = /* @__PURE__ */ new Set(["email", "firstName", "lastName"]);
|
|
216
154
|
function applicantProfile(chapter, fields) {
|
|
217
155
|
const app = chapter.application;
|
|
218
|
-
const allowed = (f) => app.profileFields
|
|
156
|
+
const allowed = (f) => app.profileFields.includes(f);
|
|
219
157
|
const profile = {};
|
|
220
158
|
for (const f of [...app.required, ...app.optional]) {
|
|
221
159
|
if (IDENTITY_FIELDS.has(f) || !allowed(f)) continue;
|
|
@@ -533,6 +471,19 @@ function applicationBookingUpdate(currentStatus, startAt, htmlLink) {
|
|
|
533
471
|
};
|
|
534
472
|
}
|
|
535
473
|
|
|
474
|
+
// src/pipeline.ts
|
|
475
|
+
function canTransition(from, to, p) {
|
|
476
|
+
const fi = p.stages.indexOf(from);
|
|
477
|
+
const ti = p.stages.indexOf(to);
|
|
478
|
+
return fi >= 0 && ti >= 0 && ti >= fi;
|
|
479
|
+
}
|
|
480
|
+
function canBook(status, p) {
|
|
481
|
+
return p.bookableFrom.includes(status);
|
|
482
|
+
}
|
|
483
|
+
function canApprove(status, p) {
|
|
484
|
+
return p.approvableFrom.includes(status);
|
|
485
|
+
}
|
|
486
|
+
|
|
536
487
|
// src/session.ts
|
|
537
488
|
function applicationSummary(app) {
|
|
538
489
|
return {
|
|
@@ -565,6 +516,68 @@ function memberApplication(app, meeting, defaultTimezone) {
|
|
|
565
516
|
return { ...summary, meetingAt, meetUrl, timezone };
|
|
566
517
|
}
|
|
567
518
|
|
|
519
|
+
// src/clerk.ts
|
|
520
|
+
var heal = (status) => status === 422 ? { ok: true, status, existed: true } : { ok: false, status };
|
|
521
|
+
function clerkInviteRequest(input) {
|
|
522
|
+
return {
|
|
523
|
+
path: "/v1/invitations",
|
|
524
|
+
body: {
|
|
525
|
+
email_address: input.email,
|
|
526
|
+
notify: true,
|
|
527
|
+
...input.redirectUrl ? { redirect_url: input.redirectUrl } : {},
|
|
528
|
+
...input.publicMetadata ? { public_metadata: input.publicMetadata } : {}
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
async function createClerkInvitation(secretKey, input, fetchImpl = fetch) {
|
|
533
|
+
const { path, body } = clerkInviteRequest(input);
|
|
534
|
+
const res = await fetchImpl(`https://api.clerk.com${path}`, {
|
|
535
|
+
method: "POST",
|
|
536
|
+
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
537
|
+
body: JSON.stringify(body)
|
|
538
|
+
});
|
|
539
|
+
return res.ok ? { ok: true, status: res.status } : heal(res.status);
|
|
540
|
+
}
|
|
541
|
+
function clerkUserRequest(input) {
|
|
542
|
+
return {
|
|
543
|
+
path: "/v1/users",
|
|
544
|
+
body: {
|
|
545
|
+
email_address: [input.email],
|
|
546
|
+
skip_password_requirement: true,
|
|
547
|
+
...input.firstName ? { first_name: input.firstName } : {},
|
|
548
|
+
...input.lastName ? { last_name: input.lastName } : {},
|
|
549
|
+
...input.publicMetadata ? { public_metadata: input.publicMetadata } : {}
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
async function refreshUserMetadata(secretKey, email, publicMetadata, fetchImpl) {
|
|
554
|
+
const auth = { authorization: `Bearer ${secretKey}` };
|
|
555
|
+
const found = await fetchImpl(`https://api.clerk.com/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, { headers: auth });
|
|
556
|
+
if (!found.ok) return false;
|
|
557
|
+
const users = await found.json().catch(() => null);
|
|
558
|
+
const id = Array.isArray(users) && typeof users[0]?.id === "string" ? users[0].id : void 0;
|
|
559
|
+
if (!id) return false;
|
|
560
|
+
const patched = await fetchImpl(`https://api.clerk.com/v1/users/${id}/metadata`, {
|
|
561
|
+
method: "PATCH",
|
|
562
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
563
|
+
body: JSON.stringify({ public_metadata: publicMetadata })
|
|
564
|
+
});
|
|
565
|
+
return patched.ok;
|
|
566
|
+
}
|
|
567
|
+
async function createClerkUser(secretKey, input, fetchImpl = fetch) {
|
|
568
|
+
const { path, body } = clerkUserRequest(input);
|
|
569
|
+
const res = await fetchImpl(`https://api.clerk.com${path}`, {
|
|
570
|
+
method: "POST",
|
|
571
|
+
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
572
|
+
body: JSON.stringify(body)
|
|
573
|
+
});
|
|
574
|
+
if (res.ok) return { ok: true, status: res.status };
|
|
575
|
+
const healed = heal(res.status);
|
|
576
|
+
if (!healed.existed || !input.publicMetadata) return healed;
|
|
577
|
+
const refreshed = await refreshUserMetadata(secretKey, input.email, input.publicMetadata, fetchImpl).catch(() => false);
|
|
578
|
+
return { ...healed, refreshed };
|
|
579
|
+
}
|
|
580
|
+
|
|
568
581
|
// src/email.ts
|
|
569
582
|
function render(template, vars) {
|
|
570
583
|
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? "");
|
|
@@ -668,36 +681,47 @@ function emailGroupFrom(row) {
|
|
|
668
681
|
};
|
|
669
682
|
}
|
|
670
683
|
|
|
671
|
-
// src/worker-
|
|
684
|
+
// src/worker-provisioning.ts
|
|
672
685
|
async function provisionApplicant(db, chapter, applicationId, fields) {
|
|
673
686
|
const email = typeof fields.email === "string" ? fields.email : "";
|
|
674
687
|
if (!email) return;
|
|
675
|
-
const
|
|
688
|
+
const stringValue = (value) => typeof value === "string" ? value : void 0;
|
|
676
689
|
const extra = {};
|
|
677
|
-
for (const
|
|
678
|
-
if (fields[
|
|
690
|
+
for (const field of chapter.application.crmFields) {
|
|
691
|
+
if (fields[field] !== void 0) extra[field] = fields[field];
|
|
679
692
|
}
|
|
680
693
|
try {
|
|
681
694
|
await projectApplicant(
|
|
682
695
|
{ crm: chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID() },
|
|
683
|
-
{
|
|
696
|
+
{
|
|
697
|
+
applicationId,
|
|
698
|
+
email,
|
|
699
|
+
firstName: stringValue(fields.firstName),
|
|
700
|
+
lastName: stringValue(fields.lastName),
|
|
701
|
+
phone: stringValue(fields.phone),
|
|
702
|
+
linkedin: stringValue(fields.linkedin),
|
|
703
|
+
extra
|
|
704
|
+
}
|
|
684
705
|
);
|
|
685
706
|
} catch {
|
|
686
707
|
}
|
|
687
|
-
if (chapter.account
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
708
|
+
if (chapter.account === "none") return;
|
|
709
|
+
try {
|
|
710
|
+
const secret = await getVaultSecret(db, "clerk_secret_key");
|
|
711
|
+
if (!secret) return;
|
|
712
|
+
const profile = applicantProfile(chapter, fields);
|
|
713
|
+
const publicMetadata = { applicationId, ...profile ? { profile } : {} };
|
|
714
|
+
if (chapter.account === "create") {
|
|
715
|
+
await createClerkUser(secret, {
|
|
716
|
+
email,
|
|
717
|
+
firstName: stringValue(fields.firstName),
|
|
718
|
+
lastName: stringValue(fields.lastName),
|
|
719
|
+
publicMetadata
|
|
720
|
+
});
|
|
721
|
+
} else {
|
|
722
|
+
await createClerkInvitation(secret, { email, publicMetadata });
|
|
700
723
|
}
|
|
724
|
+
} catch {
|
|
701
725
|
}
|
|
702
726
|
}
|
|
703
727
|
async function notifyAdminOfApplication(db, env, chapterId, applicationId, fields) {
|
|
@@ -705,14 +729,27 @@ async function notifyAdminOfApplication(db, env, chapterId, applicationId, field
|
|
|
705
729
|
const groups = (await db.query({ groups: { $: { where: { id: chapterId }, limit: 1 } } })).groups;
|
|
706
730
|
const group = Array.isArray(groups) ? groups[0] : void 0;
|
|
707
731
|
if (!group || typeof group.notificationEmail !== "string" || !group.notificationEmail) return;
|
|
708
|
-
const
|
|
732
|
+
const stringValue = (value) => typeof value === "string" ? value : "";
|
|
709
733
|
await sendTemplated(
|
|
710
|
-
{
|
|
734
|
+
{
|
|
735
|
+
db,
|
|
736
|
+
envName: env.ODLA_ENV,
|
|
737
|
+
sender: env.SEND_EMAIL,
|
|
738
|
+
from: env.EMAIL_FROM,
|
|
739
|
+
now: () => Date.now(),
|
|
740
|
+
newId: () => crypto.randomUUID()
|
|
741
|
+
},
|
|
711
742
|
{
|
|
712
743
|
group: emailGroupFrom(group),
|
|
713
744
|
template: "adminNotification",
|
|
714
745
|
to: group.notificationEmail,
|
|
715
|
-
vars: {
|
|
746
|
+
vars: {
|
|
747
|
+
firstName: stringValue(fields.firstName),
|
|
748
|
+
lastName: stringValue(fields.lastName),
|
|
749
|
+
email: stringValue(fields.email),
|
|
750
|
+
phone: stringValue(fields.phone),
|
|
751
|
+
state: stringValue(fields.state)
|
|
752
|
+
},
|
|
716
753
|
dedupeKey: `apply:${applicationId}:admin`,
|
|
717
754
|
applicationId
|
|
718
755
|
}
|
|
@@ -720,6 +757,8 @@ async function notifyAdminOfApplication(db, env, chapterId, applicationId, field
|
|
|
720
757
|
} catch {
|
|
721
758
|
}
|
|
722
759
|
}
|
|
760
|
+
|
|
761
|
+
// src/worker-routes.ts
|
|
723
762
|
async function memberSessionApplication(db, chapterId, email) {
|
|
724
763
|
const apps = (await db.query({ applications: { $: { where: { email }, order: { createdAt: "desc" }, limit: 1 } } })).applications;
|
|
725
764
|
const app = Array.isArray(apps) ? apps[0] : void 0;
|
|
@@ -824,7 +863,54 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
824
863
|
if (!group) return json({ error: "not found" }, 404);
|
|
825
864
|
const stripeKey = await getVaultSecret(db, "stripe_secret_key");
|
|
826
865
|
const paymentsReady = Boolean(group.stripePublishableKey && group.stripePriceId && stripeKey);
|
|
827
|
-
return json(joinConfig(group, paymentsReady));
|
|
866
|
+
return json({ ...joinConfig(group, paymentsReady), copy: chapter.copy.join });
|
|
867
|
+
}
|
|
868
|
+
if (req.method === "GET" && url.pathname === "/api/join/resume") {
|
|
869
|
+
const applicationId = url.searchParams.get("application") ?? "";
|
|
870
|
+
if (!applicationId) return json({ error: "application is required" }, 400);
|
|
871
|
+
const db = ctx.makeDb(env);
|
|
872
|
+
const apps = (await db.query({
|
|
873
|
+
applications: { $: { where: { id: applicationId }, limit: 1 } }
|
|
874
|
+
})).applications;
|
|
875
|
+
const app = Array.isArray(apps) ? apps[0] : void 0;
|
|
876
|
+
if (!app || String(app.groupId ?? "") !== chapter.id) return json({ error: "not found" }, 404);
|
|
877
|
+
const meetings = (await db.query({
|
|
878
|
+
meetings: {
|
|
879
|
+
$: {
|
|
880
|
+
where: { applicationId, status: "scheduled" },
|
|
881
|
+
order: { createdAt: "desc" },
|
|
882
|
+
limit: 1
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
})).meetings;
|
|
886
|
+
const meeting = Array.isArray(meetings) ? meetings[0] : void 0;
|
|
887
|
+
if (meeting && typeof meeting.startAt === "number") {
|
|
888
|
+
const groups2 = (await db.query({
|
|
889
|
+
groups: { $: { where: { id: chapter.id }, limit: 1 } }
|
|
890
|
+
})).groups;
|
|
891
|
+
const group2 = Array.isArray(groups2) ? groups2[0] : void 0;
|
|
892
|
+
const timezone = resolveScheduling(group2?.schedulingJson).timezone;
|
|
893
|
+
return json({
|
|
894
|
+
state: "done",
|
|
895
|
+
applicationId,
|
|
896
|
+
booked: { startAt: meeting.startAt, timezone }
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
const groups = (await db.query({
|
|
900
|
+
groups: { $: { where: { id: chapter.id }, limit: 1 } }
|
|
901
|
+
})).groups;
|
|
902
|
+
const group = Array.isArray(groups) ? groups[0] : void 0;
|
|
903
|
+
if (!group) return json({ error: "not found" }, 404);
|
|
904
|
+
const stripeKey = await getVaultSecret(db, "stripe_secret_key");
|
|
905
|
+
const paymentsReady = Boolean(group.stripePublishableKey && group.stripePriceId && stripeKey);
|
|
906
|
+
const status = String(app.status ?? "");
|
|
907
|
+
if (!paymentsReady) return json({ state: "booking", applicationId });
|
|
908
|
+
if (status === chapter.pipeline.initial && app.stripeSubscriptionId) {
|
|
909
|
+
return json({ state: "paymentPending", applicationId });
|
|
910
|
+
}
|
|
911
|
+
if (status === chapter.pipeline.initial) return json({ state: "payment", applicationId });
|
|
912
|
+
if (canBook(status, chapter.pipeline)) return json({ state: "booking", applicationId });
|
|
913
|
+
return json({ error: `application cannot resume from status "${status}"` }, 409);
|
|
828
914
|
}
|
|
829
915
|
if (req.method === "POST" && url.pathname === "/api/applications") {
|
|
830
916
|
const raw = await req.text();
|
|
@@ -862,23 +948,95 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
862
948
|
return null;
|
|
863
949
|
};
|
|
864
950
|
|
|
865
|
-
// src/
|
|
866
|
-
var
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
951
|
+
// src/crm-sync.ts
|
|
952
|
+
var import_crm3 = require("@odla-ai/crm");
|
|
953
|
+
var str = (v) => typeof v === "string" ? v : v == null ? "" : String(v);
|
|
954
|
+
var crmDeps = (db, ctx) => ({
|
|
955
|
+
crm: ctx.chapter.crm,
|
|
956
|
+
db,
|
|
957
|
+
now: () => Date.now(),
|
|
958
|
+
newId: () => crypto.randomUUID(),
|
|
959
|
+
chapter: ctx.chapter
|
|
960
|
+
});
|
|
961
|
+
function personInputFromApp(chapter, app) {
|
|
962
|
+
const input = sharedPersonInput({
|
|
963
|
+
email: str(app.email),
|
|
964
|
+
firstName: str(app.firstName) || void 0,
|
|
965
|
+
lastName: str(app.lastName) || void 0,
|
|
966
|
+
phone: str(app.phone) || void 0,
|
|
967
|
+
linkedin: str(app.linkedin) || void 0,
|
|
968
|
+
hubRecordId: str(app.id)
|
|
969
|
+
});
|
|
970
|
+
for (const f of chapter.application.crmFields) {
|
|
971
|
+
if (app[f] !== void 0) input[f] = app[f];
|
|
972
|
+
}
|
|
973
|
+
if (app.id !== void 0) input.applicationId = str(app.id);
|
|
974
|
+
return input;
|
|
873
975
|
}
|
|
874
|
-
function
|
|
875
|
-
|
|
976
|
+
function billingColumns(app) {
|
|
977
|
+
const status = str(app.status);
|
|
978
|
+
const paid = Boolean(app.stripeSubscriptionId) && status !== "refunded";
|
|
979
|
+
const billingStatus = status === "refunded" ? "refunded" : app.canceled === true ? "canceled" : paid ? "active" : "none";
|
|
980
|
+
const cols = { billingStatus };
|
|
981
|
+
if (app.stripeCustomerId) cols.stripeCustomerId = str(app.stripeCustomerId);
|
|
982
|
+
if (app.stripeSubscriptionId) cols.subscriptionId = str(app.stripeSubscriptionId);
|
|
983
|
+
if (typeof app.renewalAt === "number") cols.renewalAt = app.renewalAt;
|
|
984
|
+
return cols;
|
|
876
985
|
}
|
|
877
|
-
function
|
|
878
|
-
|
|
986
|
+
async function syncApplicationToCrm(deps, opts) {
|
|
987
|
+
const emailKey = str(opts.app.email).toLowerCase();
|
|
988
|
+
if (!emailKey) return null;
|
|
989
|
+
const recordDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
|
|
990
|
+
const input = personInputFromApp(deps.chapter, opts.app);
|
|
991
|
+
const { crm_record } = await deps.db.query({
|
|
992
|
+
crm_record: { $: { where: { type: "person", primaryEmail: emailKey }, limit: 1 } }
|
|
993
|
+
});
|
|
994
|
+
const existing = crm_record?.[0] ?? null;
|
|
995
|
+
const stage = opts.stage || void 0;
|
|
996
|
+
let recordId;
|
|
997
|
+
if (existing && typeof existing.id === "string") {
|
|
998
|
+
recordId = existing.id;
|
|
999
|
+
await (0, import_crm3.updateRecord)(recordDeps, { id: recordId, input });
|
|
1000
|
+
} else {
|
|
1001
|
+
const created = await (0, import_crm3.createRecord)(recordDeps, { type: "person", input, ...stage ? { stage } : {} });
|
|
1002
|
+
recordId = created.id;
|
|
1003
|
+
}
|
|
1004
|
+
if (existing && stage && existing.stage !== stage) {
|
|
1005
|
+
await (0, import_crm3.setStage)(recordDeps, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${stage}` }).catch(() => void 0);
|
|
1006
|
+
}
|
|
1007
|
+
await deps.db.transact([{ t: "update", ns: "crm_record", id: recordId, attrs: billingColumns(opts.app) }]);
|
|
1008
|
+
await (0, import_crm3.linkIdentity)(recordDeps, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
|
|
1009
|
+
return recordId;
|
|
1010
|
+
}
|
|
1011
|
+
async function backfillCrm(deps) {
|
|
1012
|
+
const [appsRes, usersRes] = await Promise.all([
|
|
1013
|
+
deps.db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 1e3 } } }),
|
|
1014
|
+
deps.db.query({ $users: { $: { limit: 1e3 } } })
|
|
1015
|
+
]);
|
|
1016
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1017
|
+
let synced = 0;
|
|
1018
|
+
const errors = [];
|
|
1019
|
+
const run = async (app, stage) => {
|
|
1020
|
+
const key = str(app.email).toLowerCase();
|
|
1021
|
+
if (!key || seen.has(key)) return;
|
|
1022
|
+
seen.add(key);
|
|
1023
|
+
try {
|
|
1024
|
+
await syncApplicationToCrm(deps, { app, stage });
|
|
1025
|
+
synced += 1;
|
|
1026
|
+
} catch (err) {
|
|
1027
|
+
errors.push({ email: key, error: err instanceof Error ? err.message : String(err) });
|
|
1028
|
+
}
|
|
1029
|
+
};
|
|
1030
|
+
for (const a of appsRes.applications ?? []) await run(a, str(a.status));
|
|
1031
|
+
for (const u of usersRes.$users ?? []) {
|
|
1032
|
+
if (u.deleted === true) continue;
|
|
1033
|
+
await run({ email: u.email, firstName: str(u.name) });
|
|
1034
|
+
}
|
|
1035
|
+
return { synced, errors };
|
|
879
1036
|
}
|
|
880
1037
|
|
|
881
1038
|
// src/worker-routes-schedule.ts
|
|
1039
|
+
var import_calendar = require("@odla-ai/calendar");
|
|
882
1040
|
function errCode(err) {
|
|
883
1041
|
if (err && typeof err === "object") {
|
|
884
1042
|
const code = err.code;
|
|
@@ -980,8 +1138,11 @@ async function bookSlot(req, env, ctx) {
|
|
|
980
1138
|
if (code === "calendar_slot_unavailable") return json({ error: "slot no longer available", code }, 409);
|
|
981
1139
|
return json({ error: "booking failed", code }, 502);
|
|
982
1140
|
}
|
|
983
|
-
const
|
|
1141
|
+
const bookingAttrs = applicationBookingUpdate(status, startAt, htmlLink);
|
|
1142
|
+
const appOp = { t: "update", ns: "applications", id: applicationId, attrs: bookingAttrs };
|
|
984
1143
|
await db.transact([meetingOp, appOp]);
|
|
1144
|
+
const booked = { ...app, ...bookingAttrs };
|
|
1145
|
+
await syncApplicationToCrm(crmDeps(db, ctx), { app: booked, stage: String(booked.status ?? "") }).catch(() => void 0);
|
|
985
1146
|
if (typeof app.email === "string" && app.email) {
|
|
986
1147
|
await sendTemplated(
|
|
987
1148
|
{ db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
|
|
@@ -1331,6 +1492,8 @@ async function ingestWebhook(req, env, ctx) {
|
|
|
1331
1492
|
const patch = webhookPatch(event, String(app.status ?? ""));
|
|
1332
1493
|
if (Object.keys(patch).length) {
|
|
1333
1494
|
await db.transact([{ t: "update", ns: "applications", id: String(app.id), attrs: patch }], { mutationId: webhookMutationId(eventId) });
|
|
1495
|
+
const next = { ...app, ...patch };
|
|
1496
|
+
await syncApplicationToCrm(crmDeps(db, ctx), { app: next, stage: String(next.status ?? "") }).catch(() => void 0);
|
|
1334
1497
|
}
|
|
1335
1498
|
if (event.kind === "first_payment") {
|
|
1336
1499
|
await notifyPaymentConfirmed(db, env, eventId, app);
|
|
@@ -1338,37 +1501,10 @@ async function ingestWebhook(req, env, ctx) {
|
|
|
1338
1501
|
}
|
|
1339
1502
|
return json({ ok: true });
|
|
1340
1503
|
}
|
|
1341
|
-
async function refundApplication(req, url, env, ctx) {
|
|
1342
|
-
const rawDb = ctx.makeDb(env);
|
|
1343
|
-
const u = await ctx.verifyUser(req, env);
|
|
1344
|
-
if (!u || !await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
|
|
1345
|
-
const id = url.pathname.split("/")[4] ?? "";
|
|
1346
|
-
const db = rawDb;
|
|
1347
|
-
const app = await firstRow2(db, "applications", { where: { id }, limit: 1 });
|
|
1348
|
-
if (!app) return json({ error: "not found" }, 404);
|
|
1349
|
-
if (app.status === "refunded") return json({ error: "already refunded" }, 409);
|
|
1350
|
-
if (app.status === "approved") return json({ error: "approved memberships are non-refundable" }, 409);
|
|
1351
|
-
if (!app.stripeSubscriptionId) return json({ error: "no subscription on file" }, 409);
|
|
1352
|
-
if (!app.stripeCustomerId) return json({ error: "no customer on file" }, 409);
|
|
1353
|
-
const secretKey = await getVaultSecret(db, "stripe_secret_key");
|
|
1354
|
-
if (!secretKey) return json({ error: "payments not configured" }, 503);
|
|
1355
|
-
try {
|
|
1356
|
-
const result = await createStripeProvider({ secretKey }).refund({
|
|
1357
|
-
customerId: String(app.stripeCustomerId),
|
|
1358
|
-
subscriptionId: String(app.stripeSubscriptionId)
|
|
1359
|
-
});
|
|
1360
|
-
return json({ ok: true, refundedCents: result.refundedCents, subscriptionCanceled: result.subscriptionCanceled });
|
|
1361
|
-
} catch (err) {
|
|
1362
|
-
const code = codeOf(err);
|
|
1363
|
-
return json({ error: "refund failed", code }, code === "no_charge" ? 409 : 502);
|
|
1364
|
-
}
|
|
1365
|
-
}
|
|
1366
|
-
var REFUND_PATH = /^\/api\/admin\/applications\/[^/]+\/refund$/;
|
|
1367
1504
|
var handlePayments = async (req, url, env, ctx) => {
|
|
1368
1505
|
if (ctx.chapter.mode !== "chapter") return null;
|
|
1369
1506
|
if (req.method === "POST" && url.pathname === "/api/payments/subscription") return startSubscription(req, env, ctx);
|
|
1370
1507
|
if (req.method === "POST" && url.pathname === "/api/webhooks/stripe") return ingestWebhook(req, env, ctx);
|
|
1371
|
-
if (req.method === "POST" && REFUND_PATH.test(url.pathname)) return refundApplication(req, url, env, ctx);
|
|
1372
1508
|
return null;
|
|
1373
1509
|
};
|
|
1374
1510
|
|
|
@@ -1411,7 +1547,8 @@ function reconcileMeetings(meetings, events, now) {
|
|
|
1411
1547
|
async function adminGroup(req, env, ctx, url) {
|
|
1412
1548
|
const rawDb = ctx.makeDb(env);
|
|
1413
1549
|
const u = await ctx.verifyUser(req, env);
|
|
1414
|
-
if (!u
|
|
1550
|
+
if (!u) return json({ error: "unauthorized" }, 401);
|
|
1551
|
+
if (!await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
|
|
1415
1552
|
const db = rawDb;
|
|
1416
1553
|
const groupId = url.searchParams.get("group") ?? ctx.chapter.id;
|
|
1417
1554
|
const group = (await db.query({ groups: { $: { where: { id: groupId }, limit: 1 } } })).groups?.[0];
|
|
@@ -1456,7 +1593,8 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
|
|
|
1456
1593
|
if (req.method !== "GET" || url.pathname !== "/api/admin/meetings") return null;
|
|
1457
1594
|
const rawDb = ctx.makeDb(env);
|
|
1458
1595
|
const u = await ctx.verifyUser(req, env);
|
|
1459
|
-
if (!u
|
|
1596
|
+
if (!u) return json({ error: "unauthorized" }, 401);
|
|
1597
|
+
if (!await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
|
|
1460
1598
|
const db = rawDb;
|
|
1461
1599
|
const all = url.searchParams.get("all") === "1";
|
|
1462
1600
|
const from = Number(url.searchParams.get("from"));
|
|
@@ -1547,86 +1685,6 @@ async function clerkSetRole(secretKey, id, role, fetchImpl = fetch) {
|
|
|
1547
1685
|
return res.ok;
|
|
1548
1686
|
}
|
|
1549
1687
|
|
|
1550
|
-
// src/crm-sync.ts
|
|
1551
|
-
var import_crm3 = require("@odla-ai/crm");
|
|
1552
|
-
var str = (v) => typeof v === "string" ? v : v == null ? "" : String(v);
|
|
1553
|
-
function personInputFromApp(chapter, app) {
|
|
1554
|
-
const input = sharedPersonInput({
|
|
1555
|
-
email: str(app.email),
|
|
1556
|
-
firstName: str(app.firstName) || void 0,
|
|
1557
|
-
lastName: str(app.lastName) || void 0,
|
|
1558
|
-
phone: str(app.phone) || void 0,
|
|
1559
|
-
linkedin: str(app.linkedin) || void 0,
|
|
1560
|
-
hubRecordId: str(app.id)
|
|
1561
|
-
});
|
|
1562
|
-
for (const f of chapter.application.crmFields) {
|
|
1563
|
-
if (app[f] !== void 0) input[f] = app[f];
|
|
1564
|
-
}
|
|
1565
|
-
if (app.id !== void 0) input.applicationId = str(app.id);
|
|
1566
|
-
return input;
|
|
1567
|
-
}
|
|
1568
|
-
function billingColumns(app) {
|
|
1569
|
-
const status = str(app.status);
|
|
1570
|
-
const paid = Boolean(app.stripeSubscriptionId) && status !== "refunded";
|
|
1571
|
-
const billingStatus = status === "refunded" ? "refunded" : app.canceled === true ? "canceled" : paid ? "active" : "none";
|
|
1572
|
-
const cols = { billingStatus };
|
|
1573
|
-
if (app.stripeCustomerId) cols.stripeCustomerId = str(app.stripeCustomerId);
|
|
1574
|
-
if (app.stripeSubscriptionId) cols.subscriptionId = str(app.stripeSubscriptionId);
|
|
1575
|
-
if (typeof app.renewalAt === "number") cols.renewalAt = app.renewalAt;
|
|
1576
|
-
return cols;
|
|
1577
|
-
}
|
|
1578
|
-
async function syncApplicationToCrm(deps, opts) {
|
|
1579
|
-
const emailKey = str(opts.app.email).toLowerCase();
|
|
1580
|
-
if (!emailKey) return null;
|
|
1581
|
-
const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
|
|
1582
|
-
const input = personInputFromApp(deps.chapter, opts.app);
|
|
1583
|
-
const { crm_record } = await deps.db.query({
|
|
1584
|
-
crm_record: { $: { where: { type: "person", primaryEmail: emailKey }, limit: 1 } }
|
|
1585
|
-
});
|
|
1586
|
-
const existing = crm_record?.[0] ?? null;
|
|
1587
|
-
const stage = opts.stage || void 0;
|
|
1588
|
-
let recordId;
|
|
1589
|
-
if (existing && typeof existing.id === "string") {
|
|
1590
|
-
recordId = existing.id;
|
|
1591
|
-
await (0, import_crm3.updateRecord)(crmDeps3, { id: recordId, input });
|
|
1592
|
-
} else {
|
|
1593
|
-
const created = await (0, import_crm3.createRecord)(crmDeps3, { type: "person", input, ...stage ? { stage } : {} });
|
|
1594
|
-
recordId = created.id;
|
|
1595
|
-
}
|
|
1596
|
-
if (existing && stage && existing.stage !== stage) {
|
|
1597
|
-
await (0, import_crm3.setStage)(crmDeps3, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${stage}` }).catch(() => void 0);
|
|
1598
|
-
}
|
|
1599
|
-
await deps.db.transact([{ t: "update", ns: "crm_record", id: recordId, attrs: billingColumns(opts.app) }]);
|
|
1600
|
-
await (0, import_crm3.linkIdentity)(crmDeps3, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
|
|
1601
|
-
return recordId;
|
|
1602
|
-
}
|
|
1603
|
-
async function backfillCrm(deps) {
|
|
1604
|
-
const [appsRes, usersRes] = await Promise.all([
|
|
1605
|
-
deps.db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 1e3 } } }),
|
|
1606
|
-
deps.db.query({ $users: { $: { limit: 1e3 } } })
|
|
1607
|
-
]);
|
|
1608
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1609
|
-
let synced = 0;
|
|
1610
|
-
const errors = [];
|
|
1611
|
-
const run = async (app, stage) => {
|
|
1612
|
-
const key = str(app.email).toLowerCase();
|
|
1613
|
-
if (!key || seen.has(key)) return;
|
|
1614
|
-
seen.add(key);
|
|
1615
|
-
try {
|
|
1616
|
-
await syncApplicationToCrm(deps, { app, stage });
|
|
1617
|
-
synced += 1;
|
|
1618
|
-
} catch (err) {
|
|
1619
|
-
errors.push({ email: key, error: err instanceof Error ? err.message : String(err) });
|
|
1620
|
-
}
|
|
1621
|
-
};
|
|
1622
|
-
for (const a of appsRes.applications ?? []) await run(a, str(a.status));
|
|
1623
|
-
for (const u of usersRes.$users ?? []) {
|
|
1624
|
-
if (u.deleted === true) continue;
|
|
1625
|
-
await run({ email: u.email, firstName: str(u.name) });
|
|
1626
|
-
}
|
|
1627
|
-
return { synced, errors };
|
|
1628
|
-
}
|
|
1629
|
-
|
|
1630
1688
|
// src/worker-routes-admin-people.ts
|
|
1631
1689
|
async function adminGate(req, env, ctx) {
|
|
1632
1690
|
const rawDb = ctx.makeDb(env);
|
|
@@ -1635,7 +1693,7 @@ async function adminGate(req, env, ctx) {
|
|
|
1635
1693
|
if (!await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
|
|
1636
1694
|
return { db: rawDb, actor: { userId: u.userId, email: u.email ?? void 0 } };
|
|
1637
1695
|
}
|
|
1638
|
-
var
|
|
1696
|
+
var crmDeps2 = (db, ctx) => ({
|
|
1639
1697
|
crm: ctx.chapter.crm,
|
|
1640
1698
|
db,
|
|
1641
1699
|
now: () => Date.now(),
|
|
@@ -1646,7 +1704,7 @@ var handleAdminCrmSync = async (req, url, env, ctx) => {
|
|
|
1646
1704
|
if (req.method !== "POST" || url.pathname !== "/api/admin/crm/sync") return null;
|
|
1647
1705
|
const gate5 = await adminGate(req, env, ctx);
|
|
1648
1706
|
if (gate5 instanceof Response) return gate5;
|
|
1649
|
-
const result = await backfillCrm(
|
|
1707
|
+
const result = await backfillCrm(crmDeps2(gate5.db, ctx));
|
|
1650
1708
|
return json({ ok: true, ...result });
|
|
1651
1709
|
};
|
|
1652
1710
|
var handleAdminPeople = async (req, url, env, ctx) => {
|
|
@@ -1926,7 +1984,6 @@ async function gate2(req, env, ctx) {
|
|
|
1926
1984
|
return rawDb;
|
|
1927
1985
|
}
|
|
1928
1986
|
var calFor = (env) => (0, import_calendar3.initCalendar)({ appId: env.ODLA_APP_ID, env: env.ODLA_ENV, adminToken: env.ODLA_API_KEY, endpoint: env.ODLA_PLATFORM });
|
|
1929
|
-
var crmDeps2 = (db, ctx) => ({ crm: ctx.chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID(), chapter: ctx.chapter });
|
|
1930
1987
|
var readJson = async (req) => {
|
|
1931
1988
|
try {
|
|
1932
1989
|
return await req.json();
|
|
@@ -2012,7 +2069,7 @@ var handleAdminApprove = async (req, url, env, ctx) => {
|
|
|
2012
2069
|
if (!canApprove(String(app.status), ctx.chapter.pipeline)) return json({ error: `cannot approve from status "${String(app.status)}"` }, 409);
|
|
2013
2070
|
const target = "approved";
|
|
2014
2071
|
await db.transact([{ t: "update", ns: "applications", id, attrs: { status: target } }]);
|
|
2015
|
-
await syncApplicationToCrm(
|
|
2072
|
+
await syncApplicationToCrm(crmDeps(db, ctx), { app: { ...app, status: target }, stage: target }).catch(() => void 0);
|
|
2016
2073
|
const { promoteTo, send } = ctx.chapter.operations.onApprove;
|
|
2017
2074
|
let rolePromoted = false;
|
|
2018
2075
|
const sk = await getVaultSecret(db, "clerk_secret_key");
|
|
@@ -2093,7 +2150,7 @@ var handleAdminApplicationPatch = async (req, url, env, ctx) => {
|
|
|
2093
2150
|
if (Object.keys(attrs).length === 0) return json({ error: "nothing to update" }, 400);
|
|
2094
2151
|
await db.transact([{ t: "update", ns: "applications", id, attrs }]);
|
|
2095
2152
|
if (attrs.status !== void 0) {
|
|
2096
|
-
await syncApplicationToCrm(
|
|
2153
|
+
await syncApplicationToCrm(crmDeps(db, ctx), { app: { ...app, ...attrs }, stage: String(attrs.status) }).catch(() => void 0);
|
|
2097
2154
|
}
|
|
2098
2155
|
return json({ ok: true });
|
|
2099
2156
|
};
|
|
@@ -2368,7 +2425,11 @@ var BUILTIN_ROUTES = [
|
|
|
2368
2425
|
handleAdminComms,
|
|
2369
2426
|
// Leader → follower record delivery
|
|
2370
2427
|
handleAdminNetworkTargets,
|
|
2371
|
-
handleAdminNetworkPush
|
|
2428
|
+
handleAdminNetworkPush,
|
|
2429
|
+
// API requests must never fall through to an SPA asset response. Hosts still
|
|
2430
|
+
// get first refusal through options.routes, then this terminates unknown API
|
|
2431
|
+
// paths with an explicit machine-readable 404.
|
|
2432
|
+
async (_req, url) => url.pathname.startsWith("/api/") ? json({ error: "not found" }, 404) : null
|
|
2372
2433
|
];
|
|
2373
2434
|
function chapterWorker(options) {
|
|
2374
2435
|
const ctx = createWorkerContext(options);
|