@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.
@@ -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 === null || app.profileFields.includes(f);
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-routes.ts
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 s = (v) => typeof v === "string" ? v : void 0;
688
+ const stringValue = (value) => typeof value === "string" ? value : void 0;
676
689
  const extra = {};
677
- for (const f of chapter.application.crmFields) {
678
- if (fields[f] !== void 0) extra[f] = fields[f];
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
- { applicationId, email, firstName: s(fields.firstName), lastName: s(fields.lastName), phone: s(fields.phone), linkedin: s(fields.linkedin), extra }
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 !== "none") {
688
- try {
689
- const secret = await getVaultSecret(db, "clerk_secret_key");
690
- if (secret) {
691
- const profile = applicantProfile(chapter, fields);
692
- const publicMetadata = { applicationId, ...profile ? { profile } : {} };
693
- if (chapter.account === "create") {
694
- await createClerkUser(secret, { email, firstName: s(fields.firstName), lastName: s(fields.lastName), publicMetadata });
695
- } else {
696
- await createClerkInvitation(secret, { email, publicMetadata });
697
- }
698
- }
699
- } catch {
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 s = (v) => typeof v === "string" ? v : "";
732
+ const stringValue = (value) => typeof value === "string" ? value : "";
709
733
  await sendTemplated(
710
- { db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
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: { firstName: s(fields.firstName), lastName: s(fields.lastName), email: s(fields.email), phone: s(fields.phone), state: s(fields.state) },
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/worker-routes-schedule.ts
866
- var import_calendar = require("@odla-ai/calendar");
867
-
868
- // src/pipeline.ts
869
- function canTransition(from, to, p) {
870
- const fi = p.stages.indexOf(from);
871
- const ti = p.stages.indexOf(to);
872
- return fi >= 0 && ti >= 0 && ti >= fi;
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 canBook(status, p) {
875
- return p.bookableFrom.includes(status);
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 canApprove(status, p) {
878
- return p.approvableFrom.includes(status);
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 appOp = { t: "update", ns: "applications", id: applicationId, attrs: applicationBookingUpdate(status, startAt, htmlLink) };
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 || !await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
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 || !await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
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 crmDeps = (db, ctx) => ({
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(crmDeps(gate5.db, ctx));
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(crmDeps2(db, ctx), { app: { ...app, status: target }, stage: target }).catch(() => void 0);
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(crmDeps2(db, ctx), { app: { ...app, ...attrs }, stage: String(attrs.status) }).catch(() => void 0);
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);