@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.
@@ -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 === null || app.profileFields.includes(f);
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-routes.ts
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 s = (v) => typeof v === "string" ? v : void 0;
661
+ const stringValue = (value) => typeof value === "string" ? value : void 0;
649
662
  const extra = {};
650
- for (const f of chapter.application.crmFields) {
651
- if (fields[f] !== void 0) extra[f] = fields[f];
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
- { applicationId, email, firstName: s(fields.firstName), lastName: s(fields.lastName), phone: s(fields.phone), linkedin: s(fields.linkedin), extra }
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 !== "none") {
661
- try {
662
- const secret = await getVaultSecret(db, "clerk_secret_key");
663
- if (secret) {
664
- const profile = applicantProfile(chapter, fields);
665
- const publicMetadata = { applicationId, ...profile ? { profile } : {} };
666
- if (chapter.account === "create") {
667
- await createClerkUser(secret, { email, firstName: s(fields.firstName), lastName: s(fields.lastName), publicMetadata });
668
- } else {
669
- await createClerkInvitation(secret, { email, publicMetadata });
670
- }
671
- }
672
- } catch {
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 s = (v) => typeof v === "string" ? v : "";
705
+ const stringValue = (value) => typeof value === "string" ? value : "";
682
706
  await sendTemplated(
683
- { db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
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: { firstName: s(fields.firstName), lastName: s(fields.lastName), email: s(fields.email), phone: s(fields.phone), state: s(fields.state) },
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/worker-routes-schedule.ts
839
- import { computeBookableSlots, initCalendar } from "@odla-ai/calendar";
840
-
841
- // src/pipeline.ts
842
- function canTransition(from, to, p) {
843
- const fi = p.stages.indexOf(from);
844
- const ti = p.stages.indexOf(to);
845
- return fi >= 0 && ti >= 0 && ti >= fi;
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 canBook(status, p) {
848
- return p.bookableFrom.includes(status);
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 canApprove(status, p) {
851
- return p.approvableFrom.includes(status);
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 appOp = { t: "update", ns: "applications", id: applicationId, attrs: applicationBookingUpdate(status, startAt, htmlLink) };
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 || !await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
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 || !await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
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 crmDeps = (db, ctx) => ({
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(crmDeps(gate5.db, ctx));
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(crmDeps2(db, ctx), { app: { ...app, status: target }, stage: target }).catch(() => void 0);
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(crmDeps2(db, ctx), { app: { ...app, ...attrs }, stage: String(attrs.status) }).catch(() => void 0);
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);