@odla-ai/chapter 0.22.0 → 0.23.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();
@@ -864,21 +950,6 @@ var handleMember = async (req, url, env, ctx) => {
864
950
 
865
951
  // src/worker-routes-schedule.ts
866
952
  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;
873
- }
874
- function canBook(status, p) {
875
- return p.bookableFrom.includes(status);
876
- }
877
- function canApprove(status, p) {
878
- return p.approvableFrom.includes(status);
879
- }
880
-
881
- // src/worker-routes-schedule.ts
882
953
  function errCode(err) {
883
954
  if (err && typeof err === "object") {
884
955
  const code = err.code;
@@ -1338,37 +1409,10 @@ async function ingestWebhook(req, env, ctx) {
1338
1409
  }
1339
1410
  return json({ ok: true });
1340
1411
  }
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
1412
  var handlePayments = async (req, url, env, ctx) => {
1368
1413
  if (ctx.chapter.mode !== "chapter") return null;
1369
1414
  if (req.method === "POST" && url.pathname === "/api/payments/subscription") return startSubscription(req, env, ctx);
1370
1415
  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
1416
  return null;
1373
1417
  };
1374
1418
 
@@ -1411,7 +1455,8 @@ function reconcileMeetings(meetings, events, now) {
1411
1455
  async function adminGroup(req, env, ctx, url) {
1412
1456
  const rawDb = ctx.makeDb(env);
1413
1457
  const u = await ctx.verifyUser(req, env);
1414
- if (!u || !await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
1458
+ if (!u) return json({ error: "unauthorized" }, 401);
1459
+ if (!await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
1415
1460
  const db = rawDb;
1416
1461
  const groupId = url.searchParams.get("group") ?? ctx.chapter.id;
1417
1462
  const group = (await db.query({ groups: { $: { where: { id: groupId }, limit: 1 } } })).groups?.[0];
@@ -1456,7 +1501,8 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
1456
1501
  if (req.method !== "GET" || url.pathname !== "/api/admin/meetings") return null;
1457
1502
  const rawDb = ctx.makeDb(env);
1458
1503
  const u = await ctx.verifyUser(req, env);
1459
- if (!u || !await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
1504
+ if (!u) return json({ error: "unauthorized" }, 401);
1505
+ if (!await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
1460
1506
  const db = rawDb;
1461
1507
  const all = url.searchParams.get("all") === "1";
1462
1508
  const from = Number(url.searchParams.get("from"));
@@ -2368,7 +2414,11 @@ var BUILTIN_ROUTES = [
2368
2414
  handleAdminComms,
2369
2415
  // Leader → follower record delivery
2370
2416
  handleAdminNetworkTargets,
2371
- handleAdminNetworkPush
2417
+ handleAdminNetworkPush,
2418
+ // API requests must never fall through to an SPA asset response. Hosts still
2419
+ // get first refusal through options.routes, then this terminates unknown API
2420
+ // paths with an explicit machine-readable 404.
2421
+ async (_req, url) => url.pathname.startsWith("/api/") ? json({ error: "not found" }, 404) : null
2372
2422
  ];
2373
2423
  function chapterWorker(options) {
2374
2424
  const ctx = createWorkerContext(options);