@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.
@@ -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();
@@ -837,21 +923,6 @@ var handleMember = async (req, url, env, ctx) => {
837
923
 
838
924
  // src/worker-routes-schedule.ts
839
925
  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;
846
- }
847
- function canBook(status, p) {
848
- return p.bookableFrom.includes(status);
849
- }
850
- function canApprove(status, p) {
851
- return p.approvableFrom.includes(status);
852
- }
853
-
854
- // src/worker-routes-schedule.ts
855
926
  function errCode(err) {
856
927
  if (err && typeof err === "object") {
857
928
  const code = err.code;
@@ -1311,37 +1382,10 @@ async function ingestWebhook(req, env, ctx) {
1311
1382
  }
1312
1383
  return json({ ok: true });
1313
1384
  }
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
1385
  var handlePayments = async (req, url, env, ctx) => {
1341
1386
  if (ctx.chapter.mode !== "chapter") return null;
1342
1387
  if (req.method === "POST" && url.pathname === "/api/payments/subscription") return startSubscription(req, env, ctx);
1343
1388
  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
1389
  return null;
1346
1390
  };
1347
1391
 
@@ -1384,7 +1428,8 @@ function reconcileMeetings(meetings, events, now) {
1384
1428
  async function adminGroup(req, env, ctx, url) {
1385
1429
  const rawDb = ctx.makeDb(env);
1386
1430
  const u = await ctx.verifyUser(req, env);
1387
- if (!u || !await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
1431
+ if (!u) return json({ error: "unauthorized" }, 401);
1432
+ if (!await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
1388
1433
  const db = rawDb;
1389
1434
  const groupId = url.searchParams.get("group") ?? ctx.chapter.id;
1390
1435
  const group = (await db.query({ groups: { $: { where: { id: groupId }, limit: 1 } } })).groups?.[0];
@@ -1429,7 +1474,8 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
1429
1474
  if (req.method !== "GET" || url.pathname !== "/api/admin/meetings") return null;
1430
1475
  const rawDb = ctx.makeDb(env);
1431
1476
  const u = await ctx.verifyUser(req, env);
1432
- if (!u || !await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
1477
+ if (!u) return json({ error: "unauthorized" }, 401);
1478
+ if (!await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
1433
1479
  const db = rawDb;
1434
1480
  const all = url.searchParams.get("all") === "1";
1435
1481
  const from = Number(url.searchParams.get("from"));
@@ -2341,7 +2387,11 @@ var BUILTIN_ROUTES = [
2341
2387
  handleAdminComms,
2342
2388
  // Leader → follower record delivery
2343
2389
  handleAdminNetworkTargets,
2344
- handleAdminNetworkPush
2390
+ handleAdminNetworkPush,
2391
+ // API requests must never fall through to an SPA asset response. Hosts still
2392
+ // get first refusal through options.routes, then this terminates unknown API
2393
+ // paths with an explicit machine-readable 404.
2394
+ async (_req, url) => url.pathname.startsWith("/api/") ? json({ error: "not found" }, 404) : null
2345
2395
  ];
2346
2396
  function chapterWorker(options) {
2347
2397
  const ctx = createWorkerContext(options);