@odla-ai/chapter 0.3.0 → 0.4.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.
@@ -1,6 +1,5 @@
1
- // src/worker.ts
1
+ // src/worker-context.ts
2
2
  import { initAdmin } from "@odla-ai/db";
3
- import { createCrmRoutes } from "@odla-ai/crm";
4
3
  import { createRemoteJWKSet, jwtVerify } from "jose";
5
4
 
6
5
  // src/auth.ts
@@ -20,6 +19,81 @@ async function getVaultSecret(db, name) {
20
19
  }
21
20
  }
22
21
 
22
+ // src/worker-context.ts
23
+ var json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
24
+ function createWorkerContext(options) {
25
+ const { chapter } = options;
26
+ const auth = chapter.auth;
27
+ const crmBase = options.crmBasePath ?? "/api/crm";
28
+ let publicConfigCache = null;
29
+ const jwksByIssuer = /* @__PURE__ */ new Map();
30
+ async function getPublicConfig(env) {
31
+ if (publicConfigCache && Date.now() - publicConfigCache.at < 5 * 6e4) return publicConfigCache.value;
32
+ const res = await fetch(`${env.ODLA_PLATFORM}/registry/apps/${env.ODLA_APP_ID}/public-config?env=${env.ODLA_ENV}`);
33
+ if (!res.ok) throw new Error(`public-config fetch failed: ${res.status}`);
34
+ const value = await res.json();
35
+ publicConfigCache = { value, at: Date.now() };
36
+ return value;
37
+ }
38
+ async function verifyUser(req, env) {
39
+ const header = req.headers.get("authorization") ?? "";
40
+ if (!header.startsWith("Bearer ")) return null;
41
+ const token = header.slice(7);
42
+ const { issuer } = await getPublicConfig(env);
43
+ if (!issuer) return null;
44
+ let jwks = jwksByIssuer.get(issuer);
45
+ if (!jwks) {
46
+ jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`));
47
+ jwksByIssuer.set(issuer, jwks);
48
+ }
49
+ try {
50
+ const { payload } = await jwtVerify(token, jwks, { issuer });
51
+ if (!payload.sub) return null;
52
+ return {
53
+ userId: payload.sub,
54
+ email: typeof payload.email === "string" ? payload.email : void 0,
55
+ payload
56
+ };
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+ function makeDb(env) {
62
+ return initAdmin({ appId: env.ODLA_TENANT, adminToken: env.ODLA_API_KEY, endpoint: env.ODLA_ENDPOINT });
63
+ }
64
+ async function isAdminEmail(db, email) {
65
+ if (!email) return false;
66
+ const { admins } = await db.query({ admins: { $: { where: { email: email.toLowerCase() }, limit: 1 } } });
67
+ return Array.isArray(admins) && admins.length > 0;
68
+ }
69
+ async function isSuperAdminEmail(db, email) {
70
+ if (!auth.superAdmins || !email) return false;
71
+ const { superAdmins } = await db.query({ superAdmins: { $: { where: { email: email.toLowerCase() }, limit: 1 } } });
72
+ return Array.isArray(superAdmins) && superAdmins.length > 0;
73
+ }
74
+ async function roleFor(db, u) {
75
+ if (auth.source === "claim") return roleFromClaim(u.payload, auth);
76
+ return await isAdminEmail(db, u.email) ? auth.adminRole : auth.ladder[0];
77
+ }
78
+ async function isAdmin(db, u) {
79
+ if (auth.source === "claim") return isAdminRole(roleFromClaim(u.payload, auth), auth);
80
+ return isAdminEmail(db, u.email);
81
+ }
82
+ function crmSender(env) {
83
+ if (!env.SEND_EMAIL || !env.EMAIL_FROM) return void 0;
84
+ const binding = env.SEND_EMAIL;
85
+ return {
86
+ async send(payload) {
87
+ return binding.send(payload);
88
+ }
89
+ };
90
+ }
91
+ return { chapter, auth, crmBase, getPublicConfig, verifyUser, makeDb, isAdminEmail, isSuperAdminEmail, roleFor, isAdmin, crmSender };
92
+ }
93
+
94
+ // src/worker-routes.ts
95
+ import { createCrmRoutes } from "@odla-ai/crm";
96
+
23
97
  // src/member.ts
24
98
  async function submitApplication(db, chapter, fields, opts) {
25
99
  const app = chapter.application;
@@ -89,171 +163,708 @@ async function projectSharedRecord(deps, person) {
89
163
  return { recordId: created.id };
90
164
  }
91
165
 
92
- // src/worker.ts
93
- var json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
94
- function chapterWorker(options) {
95
- const { chapter } = options;
96
- const auth = chapter.auth;
97
- const crmBase = options.crmBasePath ?? "/api/crm";
98
- let publicConfigCache = null;
99
- const jwksByIssuer = /* @__PURE__ */ new Map();
100
- async function getPublicConfig(env) {
101
- if (publicConfigCache && Date.now() - publicConfigCache.at < 5 * 6e4) return publicConfigCache.value;
102
- const res = await fetch(
103
- `${env.ODLA_PLATFORM}/registry/apps/${env.ODLA_APP_ID}/public-config?env=${env.ODLA_ENV}`
104
- );
105
- if (!res.ok) throw new Error(`public-config fetch failed: ${res.status}`);
106
- const value = await res.json();
107
- publicConfigCache = { value, at: Date.now() };
108
- return value;
166
+ // src/scheduling.ts
167
+ var SCHEDULING_DEFAULTS = {
168
+ slotMinutes: 45,
169
+ days: [1, 2, 3, 4, 5],
170
+ startHour: 9,
171
+ endHour: 17,
172
+ timezone: "America/Los_Angeles",
173
+ minNoticeHours: 24,
174
+ windowDays: 14,
175
+ summaryTemplate: "Introduction call with {{firstName}} {{lastName}}"
176
+ };
177
+ function isValidTimeZone(tz) {
178
+ try {
179
+ new Intl.DateTimeFormat(void 0, { timeZone: tz });
180
+ return true;
181
+ } catch {
182
+ return false;
109
183
  }
110
- async function verifyUser(req, env) {
111
- const header = req.headers.get("authorization") ?? "";
112
- if (!header.startsWith("Bearer ")) return null;
113
- const token = header.slice(7);
114
- const { issuer } = await getPublicConfig(env);
115
- if (!issuer) return null;
116
- let jwks = jwksByIssuer.get(issuer);
117
- if (!jwks) {
118
- jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`));
119
- jwksByIssuer.set(issuer, jwks);
184
+ }
185
+ function resolveScheduling(config) {
186
+ const d = config ?? {};
187
+ const c = {
188
+ slotMinutes: d.slotMinutes ?? SCHEDULING_DEFAULTS.slotMinutes,
189
+ days: d.days ?? SCHEDULING_DEFAULTS.days,
190
+ startHour: d.startHour ?? SCHEDULING_DEFAULTS.startHour,
191
+ endHour: d.endHour ?? SCHEDULING_DEFAULTS.endHour,
192
+ timezone: d.timezone ?? SCHEDULING_DEFAULTS.timezone,
193
+ minNoticeHours: d.minNoticeHours ?? SCHEDULING_DEFAULTS.minNoticeHours,
194
+ windowDays: d.windowDays ?? SCHEDULING_DEFAULTS.windowDays,
195
+ summaryTemplate: d.summaryTemplate ?? SCHEDULING_DEFAULTS.summaryTemplate
196
+ };
197
+ const fail2 = (msg) => {
198
+ throw new Error(`scheduling: ${msg}`);
199
+ };
200
+ if (!(c.slotMinutes >= 15 && c.slotMinutes <= 240)) fail2("slotMinutes must be 15\u2013240");
201
+ if (!(c.windowDays >= 1 && c.windowDays <= 62)) fail2("windowDays must be 1\u201362 (FreeBusy caps at 62)");
202
+ if (!(c.minNoticeHours >= 0 && c.minNoticeHours <= 336)) fail2("minNoticeHours must be 0\u2013336");
203
+ if (!(c.startHour >= 0 && c.startHour < c.endHour && c.endHour <= 24)) fail2("require 0 \u2264 startHour < endHour \u2264 24");
204
+ const days = [...c.days];
205
+ if (!days.length || !days.every((n) => Number.isInteger(n) && n >= 0 && n <= 6)) {
206
+ fail2("days must be a non-empty list of weekday integers 0\u20136");
207
+ }
208
+ if (typeof c.timezone !== "string" || !isValidTimeZone(c.timezone)) fail2(`invalid IANA timezone "${c.timezone}"`);
209
+ if (typeof c.summaryTemplate !== "string") fail2("summaryTemplate must be a string");
210
+ return { ...c, days };
211
+ }
212
+ var BOOKABLE_STATUSES = ["submitted", "paid_pending_vetting", "call_scheduled"];
213
+ function canBookFrom(status) {
214
+ return BOOKABLE_STATUSES.includes(status);
215
+ }
216
+ function slotWindow(now, windowDays) {
217
+ return { from: now, to: now + windowDays * 864e5 };
218
+ }
219
+ function endForSlot(startAt, slotMinutes) {
220
+ return startAt + slotMinutes * 6e4;
221
+ }
222
+ function isSlotAvailable(slots, startAt) {
223
+ return slots.some((s) => s.startAt === startAt);
224
+ }
225
+ function renderSummary(template, app) {
226
+ return template.replace("{{firstName}}", app.firstName ?? "").replace("{{lastName}}", app.lastName ?? "");
227
+ }
228
+ function bookingDecision(existing) {
229
+ const eventId = existing?.googleEventId ?? null;
230
+ return { reschedule: Boolean(eventId), eventId };
231
+ }
232
+ function introIdempotencyKey(applicationId) {
233
+ return `application:${applicationId}:intro`;
234
+ }
235
+ function meetingCreateRow(i) {
236
+ return {
237
+ id: i.meetingId,
238
+ applicationId: i.applicationId,
239
+ groupId: i.groupId,
240
+ startAt: i.startAt,
241
+ endAt: i.endAt,
242
+ timezone: i.timezone,
243
+ status: "scheduled",
244
+ googleEventId: i.googleEventId,
245
+ ...i.meetUrl ? { meetUrl: i.meetUrl } : {},
246
+ ...i.htmlLink ? { htmlLink: i.htmlLink } : {},
247
+ drift: "none",
248
+ createdAt: i.createdAt
249
+ };
250
+ }
251
+ function meetingRescheduleUpdate(startAt, endAt) {
252
+ return { startAt, endAt, drift: "none" };
253
+ }
254
+ function applicationBookingUpdate(currentStatus, startAt, htmlLink) {
255
+ return {
256
+ meetingAt: startAt,
257
+ ...htmlLink ? { meetingLink: htmlLink } : {},
258
+ ...currentStatus !== "call_scheduled" ? { status: "call_scheduled" } : {}
259
+ };
260
+ }
261
+
262
+ // src/session.ts
263
+ function applicationSummary(app) {
264
+ return {
265
+ id: app.id,
266
+ firstName: app.firstName ?? null,
267
+ lastName: app.lastName ?? null,
268
+ email: app.email ?? null,
269
+ status: app.status,
270
+ createdAt: app.createdAt ?? null,
271
+ meetingLink: app.meetingLink ?? null,
272
+ paid: Boolean(app.stripeSubscriptionId) && app.status !== "refunded",
273
+ renewalAt: app.renewalAt ?? null,
274
+ canceled: app.canceled === true
275
+ };
276
+ }
277
+ function memberApplication(app, meeting, defaultTimezone) {
278
+ const summary = applicationSummary(app);
279
+ let meetingAt = app.meetingAt ?? null;
280
+ let meetUrl = null;
281
+ let timezone = defaultTimezone;
282
+ if (meeting) {
283
+ timezone = meeting.timezone ?? timezone;
284
+ if (meeting.status === "scheduled") {
285
+ meetingAt = meeting.startAt ?? null;
286
+ meetUrl = meeting.meetUrl ?? null;
287
+ } else {
288
+ meetingAt = null;
120
289
  }
290
+ }
291
+ return { ...summary, meetingAt, meetUrl, timezone };
292
+ }
293
+
294
+ // src/worker-routes.ts
295
+ async function memberSessionApplication(db, chapterId, email) {
296
+ const apps = (await db.query({ applications: { $: { where: { email }, order: { createdAt: "desc" }, limit: 1 } } })).applications;
297
+ const app = Array.isArray(apps) ? apps[0] : void 0;
298
+ if (!app) return null;
299
+ const meetings = (await db.query({ meetings: { $: { where: { applicationId: app.id, status: "scheduled" }, order: { createdAt: "desc" }, limit: 1 } } })).meetings;
300
+ const meeting = Array.isArray(meetings) ? meetings[0] : void 0;
301
+ const groups = (await db.query({ groups: { $: { where: { id: chapterId }, limit: 1 } } })).groups;
302
+ const group = Array.isArray(groups) ? groups[0] : void 0;
303
+ const timezone = resolveScheduling(group?.schedulingJson).timezone;
304
+ return memberApplication(app, meeting, timezone);
305
+ }
306
+ var handleConfig = async (_req, url, env, ctx) => {
307
+ if (url.pathname !== "/api/config") return null;
308
+ try {
309
+ const { clerkPublishableKey } = await ctx.getPublicConfig(env);
310
+ return json({ clerkPublishableKey: clerkPublishableKey ?? null, env: env.ODLA_ENV });
311
+ } catch {
312
+ return json({ clerkPublishableKey: null, env: env.ODLA_ENV });
313
+ }
314
+ };
315
+ var handleMe = async (req, url, env, ctx) => {
316
+ if (url.pathname !== "/api/me") return null;
317
+ const u = await ctx.verifyUser(req, env);
318
+ if (!u) return json({ authorized: false }, 401);
319
+ const db = ctx.makeDb(env);
320
+ const role = await ctx.roleFor(db, u);
321
+ const superAdmin = await ctx.isSuperAdminEmail(db, u.email);
322
+ const base = { authorized: isAdminRole(role, ctx.auth), role, superAdmin, email: u.email ?? null };
323
+ if (ctx.chapter.mode !== "chapter" || !u.email) return json(base);
324
+ const application = await memberSessionApplication(db, ctx.chapter.id, u.email);
325
+ return json({ ...base, application });
326
+ };
327
+ var handleCrm = async (req, url, env, ctx) => {
328
+ const crmBase = ctx.crmBase;
329
+ if (url.pathname !== crmBase && !url.pathname.startsWith(crmBase + "/")) return null;
330
+ const db = ctx.makeDb(env);
331
+ const routes = createCrmRoutes({
332
+ crm: ctx.chapter.crm,
333
+ db,
334
+ authorize: async (r) => {
335
+ const u = await ctx.verifyUser(r, env);
336
+ if (!u || !await ctx.isAdmin(db, u)) return null;
337
+ return u.email ? { userId: u.userId, email: u.email } : { userId: u.userId };
338
+ },
339
+ sender: ctx.crmSender(env),
340
+ from: env.EMAIL_FROM,
341
+ envName: env.ODLA_ENV,
342
+ baseUrl: url.origin,
343
+ basePath: crmBase
344
+ });
345
+ const res = await routes(req);
346
+ if (res) return res;
347
+ return json({ error: "not found" }, 404);
348
+ };
349
+ var handleNetworkShared = async (req, url, env, ctx) => {
350
+ if (req.method !== "POST" || url.pathname !== "/api/network/shared") return null;
351
+ const db = ctx.makeDb(env);
352
+ const secret = await getVaultSecret(db, "network_share_secret");
353
+ const provided = (req.headers.get("authorization") ?? "").replace(/^Bearer /, "");
354
+ if (!secret || provided.length !== secret.length || provided !== secret) {
355
+ return json({ error: "unauthorized" }, 401);
356
+ }
357
+ let person;
358
+ try {
359
+ person = JSON.parse(await req.text());
360
+ } catch {
361
+ return json({ error: "invalid JSON body" }, 400);
362
+ }
363
+ if (typeof person.email !== "string" || typeof person.hubRecordId !== "string") {
364
+ return json({ error: "email and hubRecordId are required" }, 400);
365
+ }
366
+ const { recordId } = await projectSharedRecord(
367
+ { crm: ctx.chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID() },
368
+ person
369
+ );
370
+ return json({ recordId });
371
+ };
372
+ var handleMember = async (req, url, env, ctx) => {
373
+ const chapter = ctx.chapter;
374
+ if (chapter.mode !== "chapter") return null;
375
+ if (req.method === "GET" && url.pathname === "/api/join-config") {
376
+ const db = ctx.makeDb(env);
377
+ const groupId = url.searchParams.get("group") ?? chapter.id;
378
+ const { groups } = await db.query({ groups: { $: { where: { id: groupId }, limit: 1 } } });
379
+ const group = Array.isArray(groups) ? groups[0] : void 0;
380
+ if (!group) return json({ error: "not found" }, 404);
381
+ const stripeKey = await getVaultSecret(db, "stripe_secret_key");
382
+ const paymentsReady = Boolean(group.stripePublishableKey && group.stripePriceId && stripeKey);
383
+ return json(joinConfig(group, paymentsReady));
384
+ }
385
+ if (req.method === "POST" && url.pathname === "/api/applications") {
386
+ const raw = await req.text();
387
+ if (raw.length > chapter.application.bodyCap) return json({ error: "request body too large" }, 413);
388
+ let parsed;
121
389
  try {
122
- const { payload } = await jwtVerify(token, jwks, { issuer });
123
- if (!payload.sub) return null;
124
- return {
125
- userId: payload.sub,
126
- email: typeof payload.email === "string" ? payload.email : void 0,
127
- payload
128
- };
390
+ parsed = JSON.parse(raw);
129
391
  } catch {
130
- return null;
392
+ return json({ error: "invalid JSON body" }, 400);
131
393
  }
394
+ const submissionId = typeof parsed.submissionId === "string" ? parsed.submissionId : void 0;
395
+ const result = await submitApplication(ctx.makeDb(env), chapter, parsed, {
396
+ submissionId,
397
+ groupId: chapter.id,
398
+ now: Date.now(),
399
+ newId: () => crypto.randomUUID()
400
+ });
401
+ if (!result.ok) return json({ error: result.error }, 400);
402
+ return json({ id: result.id, duplicate: result.duplicate, status: result.status });
132
403
  }
133
- function makeDb(env) {
134
- return initAdmin({ appId: env.ODLA_TENANT, adminToken: env.ODLA_API_KEY, endpoint: env.ODLA_ENDPOINT });
404
+ return null;
405
+ };
406
+
407
+ // src/worker-routes-schedule.ts
408
+ import { computeBookableSlots, initCalendar } from "@odla-ai/calendar";
409
+ function errCode(err) {
410
+ if (err && typeof err === "object") {
411
+ const code = err.code;
412
+ if (typeof code === "string") return code;
135
413
  }
136
- async function isAdminEmail(db, email) {
137
- if (!email) return false;
138
- const { admins } = await db.query({ admins: { $: { where: { email: email.toLowerCase() }, limit: 1 } } });
139
- return Array.isArray(admins) && admins.length > 0;
414
+ return "calendar_unavailable";
415
+ }
416
+ function makeCalendar(env) {
417
+ return initCalendar({ appId: env.ODLA_APP_ID, env: env.ODLA_ENV, adminToken: env.ODLA_API_KEY, endpoint: env.ODLA_PLATFORM });
418
+ }
419
+ async function firstRow(db, ns, q) {
420
+ const res = await db.query({ [ns]: { $: q } });
421
+ const rows = res[ns];
422
+ return Array.isArray(rows) ? rows[0] : void 0;
423
+ }
424
+ async function computeSlots(cal, cfg) {
425
+ const { from, to } = slotWindow(Date.now(), cfg.windowDays);
426
+ const fb = await cal.availability.freeBusy({ timeMin: from, timeMax: to });
427
+ return computeBookableSlots(fb.busy, {
428
+ from: fb.timeMin,
429
+ to: fb.timeMax,
430
+ timezone: cfg.timezone,
431
+ slotMinutes: cfg.slotMinutes,
432
+ businessHours: { days: [...cfg.days], startHour: cfg.startHour, endHour: cfg.endHour },
433
+ minNoticeMs: cfg.minNoticeHours * 36e5
434
+ });
435
+ }
436
+ async function bookSlot(req, env, ctx) {
437
+ let body;
438
+ try {
439
+ body = JSON.parse(await req.text());
440
+ } catch {
441
+ return json({ error: "invalid JSON body" }, 400);
140
442
  }
141
- async function isSuperAdminEmail(db, email) {
142
- if (!auth.superAdmins || !email) return false;
143
- const { superAdmins } = await db.query({ superAdmins: { $: { where: { email: email.toLowerCase() }, limit: 1 } } });
144
- return Array.isArray(superAdmins) && superAdmins.length > 0;
443
+ const applicationId = typeof body.applicationId === "string" ? body.applicationId : "";
444
+ const startAt = Number(body.startAt);
445
+ if (!applicationId || !Number.isFinite(startAt)) return json({ error: "applicationId and startAt required" }, 400);
446
+ const db = ctx.makeDb(env);
447
+ const app = await firstRow(db, "applications", { where: { id: applicationId }, limit: 1 });
448
+ if (!app) return json({ error: "not found" }, 404);
449
+ const status = String(app.status ?? "");
450
+ if (!canBookFrom(status)) return json({ error: `cannot book from status "${status}"` }, 409);
451
+ const group = await firstRow(db, "groups", { where: { id: String(app.groupId ?? ctx.chapter.id) }, limit: 1 });
452
+ if (!group) return json({ error: "group not found" }, 500);
453
+ const cfg = resolveScheduling(group.schedulingJson);
454
+ const endAt = endForSlot(startAt, cfg.slotMinutes);
455
+ const cal = makeCalendar(env);
456
+ let slots;
457
+ try {
458
+ slots = await computeSlots(cal, cfg);
459
+ } catch (err) {
460
+ return json({ error: "scheduling unavailable", code: errCode(err) }, 503);
145
461
  }
146
- async function roleFor(db, u) {
147
- if (auth.source === "claim") return roleFromClaim(u.payload, auth);
148
- return await isAdminEmail(db, u.email) ? auth.adminRole : auth.ladder[0];
462
+ if (!isSlotAvailable(slots, startAt)) return json({ error: "slot no longer available", code: "calendar_slot_unavailable" }, 409);
463
+ const summary = renderSummary(cfg.summaryTemplate, { firstName: app.firstName, lastName: app.lastName });
464
+ const existing = await firstRow(db, "meetings", {
465
+ where: { applicationId, status: "scheduled" },
466
+ order: { createdAt: "desc" },
467
+ limit: 1
468
+ });
469
+ const decision = bookingDecision(existing);
470
+ let meetUrl = null;
471
+ let htmlLink = null;
472
+ let meetingOp;
473
+ try {
474
+ if (decision.reschedule && decision.eventId) {
475
+ await cal.actions.reschedule(decision.eventId, { startAt, endAt });
476
+ meetUrl = existing?.meetUrl ?? null;
477
+ htmlLink = existing?.htmlLink ?? null;
478
+ meetingOp = { t: "update", ns: "meetings", id: String(existing?.id), attrs: meetingRescheduleUpdate(startAt, endAt) };
479
+ } else {
480
+ const { booking } = await cal.actions.create(
481
+ { summary, startAt, endAt, attendees: [String(app.email)], timezone: cfg.timezone, meet: true },
482
+ { idempotencyKey: introIdempotencyKey(applicationId) }
483
+ );
484
+ meetUrl = booking.meetUrl ?? null;
485
+ htmlLink = booking.htmlLink ?? null;
486
+ const meetingId = crypto.randomUUID();
487
+ meetingOp = {
488
+ t: "update",
489
+ ns: "meetings",
490
+ id: meetingId,
491
+ attrs: meetingCreateRow({
492
+ meetingId,
493
+ applicationId,
494
+ groupId: String(group.id),
495
+ startAt,
496
+ endAt,
497
+ timezone: cfg.timezone,
498
+ googleEventId: booking.eventId,
499
+ meetUrl: booking.meetUrl,
500
+ htmlLink: booking.htmlLink,
501
+ createdAt: Date.now()
502
+ })
503
+ };
504
+ }
505
+ } catch (err) {
506
+ const code = errCode(err);
507
+ if (code === "calendar_slot_unavailable") return json({ error: "slot no longer available", code }, 409);
508
+ return json({ error: "booking failed", code }, 502);
149
509
  }
150
- async function isAdmin(db, u) {
151
- if (auth.source === "claim") return isAdminRole(roleFromClaim(u.payload, auth), auth);
152
- return isAdminEmail(db, u.email);
510
+ const appOp = { t: "update", ns: "applications", id: applicationId, attrs: applicationBookingUpdate(status, startAt, htmlLink) };
511
+ await db.transact([meetingOp, appOp]);
512
+ return json({ ok: true, startAt, endAt, meetUrl, rescheduled: decision.reschedule });
513
+ }
514
+ var handleSchedule = async (req, url, env, ctx) => {
515
+ if (ctx.chapter.mode !== "chapter") return null;
516
+ if (req.method === "GET" && url.pathname === "/api/schedule/slots") {
517
+ const db = ctx.makeDb(env);
518
+ const group = await firstRow(db, "groups", { where: { id: url.searchParams.get("group") ?? ctx.chapter.id }, limit: 1 });
519
+ if (!group) return json({ error: "not found" }, 404);
520
+ const cfg = resolveScheduling(group.schedulingJson);
521
+ try {
522
+ const slots = await computeSlots(makeCalendar(env), cfg);
523
+ return json({ schedulingReady: true, timezone: cfg.timezone, slotMinutes: cfg.slotMinutes, slots });
524
+ } catch (err) {
525
+ return json({ schedulingReady: false, code: errCode(err) });
526
+ }
153
527
  }
154
- function crmSender(env) {
155
- if (!env.SEND_EMAIL || !env.EMAIL_FROM) return void 0;
156
- const binding = env.SEND_EMAIL;
157
- return { async send(payload) {
158
- return binding.send(payload);
159
- } };
528
+ if (req.method === "POST" && url.pathname === "/api/schedule/book") {
529
+ return bookSlot(req, env, ctx);
160
530
  }
161
- const handler = {
162
- async fetch(req, env) {
163
- const url = new URL(req.url);
164
- if (url.pathname === "/api/config") {
165
- try {
166
- const { clerkPublishableKey } = await getPublicConfig(env);
167
- return json({ clerkPublishableKey: clerkPublishableKey ?? null, env: env.ODLA_ENV });
168
- } catch {
169
- return json({ clerkPublishableKey: null, env: env.ODLA_ENV });
170
- }
531
+ return null;
532
+ };
533
+
534
+ // src/payments.ts
535
+ function parseSigHeader(header) {
536
+ const parts = {};
537
+ for (const p of header.split(",")) {
538
+ const [k, v] = p.split("=", 2);
539
+ if (k && v !== void 0) parts[k] = v;
540
+ }
541
+ return { t: parts.t, v1: parts.v1 };
542
+ }
543
+ function toHex(buf) {
544
+ return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
545
+ }
546
+ function timingSafeEqual(a, b) {
547
+ if (a.length !== b.length) return false;
548
+ let diff = 0;
549
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
550
+ return diff === 0;
551
+ }
552
+ async function verifyStripeSignature(payload, header, secret, opts = {}) {
553
+ const { t, v1 } = parseSigHeader(header);
554
+ if (!t || !v1) return false;
555
+ const ts = Number(t);
556
+ if (!Number.isFinite(ts)) return false;
557
+ const nowSec = (opts.now ?? Date.now()) / 1e3;
558
+ const tolerance = opts.toleranceSec ?? 300;
559
+ if (Math.abs(nowSec - ts) > tolerance) return false;
560
+ const enc = new TextEncoder();
561
+ const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
562
+ const mac = await crypto.subtle.sign("HMAC", key, enc.encode(`${t}.${payload}`));
563
+ return timingSafeEqual(toHex(mac), v1);
564
+ }
565
+ function stripeForm(params) {
566
+ const out = new URLSearchParams();
567
+ for (const [k, v] of Object.entries(params)) {
568
+ if (v === void 0 || v === null) continue;
569
+ if (typeof v === "object") {
570
+ for (const [k2, v2] of Object.entries(v)) {
571
+ if (v2 !== void 0 && v2 !== null) out.append(`${k}[${k2}]`, String(v2));
171
572
  }
172
- if (url.pathname === "/api/me") {
173
- const u = await verifyUser(req, env);
174
- if (!u) return json({ authorized: false }, 401);
175
- const db = makeDb(env);
176
- const role = await roleFor(db, u);
177
- const superAdmin = await isSuperAdminEmail(db, u.email);
178
- return json({ authorized: isAdminRole(role, auth), role, superAdmin, email: u.email ?? null });
573
+ } else {
574
+ out.append(k, String(v));
575
+ }
576
+ }
577
+ return out.toString();
578
+ }
579
+ function webhookMutationId(eventId) {
580
+ return `stripe:${eventId}`;
581
+ }
582
+ function findApplicationRef(obj) {
583
+ const metaOf = (v) => v && typeof v === "object" ? v.metadata ?? {} : {};
584
+ const pick = (m) => typeof m.applicationId === "string" ? m.applicationId : void 0;
585
+ const applicationId = pick(metaOf(obj)) ?? pick(metaOf(obj.subscription_details)) ?? pick(metaOf(obj.parent?.subscription_details));
586
+ const customerId = typeof obj.customer === "string" ? obj.customer : void 0;
587
+ return { ...applicationId ? { applicationId } : {}, ...customerId ? { customerId } : {} };
588
+ }
589
+ function normalizeWebhookEvent(event) {
590
+ const obj = event.data?.object ?? {};
591
+ const ref = findApplicationRef(obj);
592
+ switch (event.type) {
593
+ case "invoice.paid": {
594
+ const lines = obj.lines?.data ?? [];
595
+ const periodEnd = lines[0]?.period?.end;
596
+ const renewalAt = typeof periodEnd === "number" ? periodEnd * 1e3 : void 0;
597
+ const kind = obj.billing_reason === "subscription_create" ? "first_payment" : "renewal";
598
+ return { kind, ...ref, ...renewalAt !== void 0 ? { renewalAt } : {} };
599
+ }
600
+ case "charge.refunded":
601
+ return { kind: "refunded", ...ref };
602
+ case "customer.subscription.deleted":
603
+ return { kind: "canceled", ...ref };
604
+ default:
605
+ return { kind: "ignored", type: event.type };
606
+ }
607
+ }
608
+ function firstPaymentPatch(currentStatus, renewalAt) {
609
+ return {
610
+ ...currentStatus === "submitted" ? { status: "paid_pending_vetting" } : {},
611
+ ...renewalAt !== void 0 ? { renewalAt } : {}
612
+ };
613
+ }
614
+ function renewalPatch(renewalAt) {
615
+ return { renewalAt };
616
+ }
617
+ function refundedPatch() {
618
+ return { status: "refunded" };
619
+ }
620
+ function canceledPatch() {
621
+ return { canceled: true };
622
+ }
623
+
624
+ // src/payments-stripe.ts
625
+ async function stripeCall(sk, method, path, params, idempotencyKey) {
626
+ const qs = method === "GET" && params ? `?${stripeForm(params)}` : "";
627
+ const headers = { authorization: `Bearer ${sk}` };
628
+ if (idempotencyKey) headers["idempotency-key"] = idempotencyKey;
629
+ const init = { method, headers };
630
+ if (method === "POST" && params) {
631
+ headers["content-type"] = "application/x-www-form-urlencoded";
632
+ init.body = stripeForm(params);
633
+ }
634
+ const res = await fetch(`https://api.stripe.com${path}${qs}`, init);
635
+ const body = await res.json().catch(() => ({}));
636
+ return { ok: res.ok, status: res.status, body };
637
+ }
638
+ function fail(op, r) {
639
+ const err = new Error(`stripe ${op} failed: ${r.status}`);
640
+ err.code = "stripe_error";
641
+ throw err;
642
+ }
643
+ function clientSecretOf(sub) {
644
+ const inv = sub.latest_invoice;
645
+ const confirmation = inv?.confirmation_secret;
646
+ const intent = inv?.payment_intent;
647
+ const secret = confirmation?.client_secret ?? intent?.client_secret;
648
+ return typeof secret === "string" ? secret : void 0;
649
+ }
650
+ function requireSecret(secretKey) {
651
+ if (!secretKey) {
652
+ const err = new Error("stripe secret key missing");
653
+ err.code = "not_configured";
654
+ throw err;
655
+ }
656
+ return secretKey;
657
+ }
658
+ function createStripeProvider(config) {
659
+ const { secretKey, webhookSecret } = config;
660
+ return {
661
+ async createSubscription(input) {
662
+ const sk = requireSecret(secretKey);
663
+ const meta = { applicationId: input.applicationId, groupId: input.groupId, email: input.email };
664
+ let customerId = input.existingCustomerId;
665
+ if (!customerId) {
666
+ const cust = await stripeCall(
667
+ sk,
668
+ "POST",
669
+ "/v1/customers",
670
+ { email: input.email, name: input.name, metadata: meta },
671
+ `cus:${input.applicationId}`
672
+ );
673
+ if (!cust.ok) fail("customer create", cust);
674
+ customerId = String(cust.body.id);
179
675
  }
180
- if (url.pathname === crmBase || url.pathname.startsWith(crmBase + "/")) {
181
- const db = makeDb(env);
182
- const routes = createCrmRoutes({
183
- crm: chapter.crm,
184
- db,
185
- authorize: async (r) => {
186
- const u = await verifyUser(r, env);
187
- if (!u || !await isAdmin(db, u)) return null;
188
- return u.email ? { userId: u.userId, email: u.email } : { userId: u.userId };
189
- },
190
- sender: crmSender(env),
191
- from: env.EMAIL_FROM,
192
- envName: env.ODLA_ENV,
193
- baseUrl: url.origin,
194
- basePath: crmBase
195
- });
196
- const res = await routes(req);
197
- if (res) return res;
198
- return json({ error: "not found" }, 404);
676
+ const sub = await stripeCall(
677
+ sk,
678
+ "POST",
679
+ "/v1/subscriptions",
680
+ {
681
+ customer: customerId,
682
+ "items[0][price]": input.priceId,
683
+ payment_behavior: "default_incomplete",
684
+ "payment_settings[save_default_payment_method]": "on_subscription",
685
+ "payment_settings[payment_method_types][0]": "card",
686
+ "expand[0]": "latest_invoice.confirmation_secret",
687
+ metadata: meta
688
+ },
689
+ // The hardening S&S lacked: one subscription per application, so a client
690
+ // retry between create and the db write can't orphan a second one.
691
+ `sub:${input.applicationId}`
692
+ );
693
+ if (!sub.ok) fail("subscription create", sub);
694
+ const clientSecret = clientSecretOf(sub.body);
695
+ if (!clientSecret) fail("subscription confirmation-secret missing", sub);
696
+ return { customerId, subscriptionId: String(sub.body.id), clientSecret };
697
+ },
698
+ async ingestWebhook(rawBody, sigHeader) {
699
+ if (!webhookSecret || !await verifyStripeSignature(rawBody, sigHeader, webhookSecret)) {
700
+ return { ok: false, reason: "bad_signature" };
199
701
  }
200
- if (req.method === "POST" && url.pathname === "/api/network/shared") {
201
- const db = makeDb(env);
202
- const secret = await getVaultSecret(db, "network_share_secret");
203
- const provided = (req.headers.get("authorization") ?? "").replace(/^Bearer /, "");
204
- if (!secret || provided.length !== secret.length || provided !== secret) {
205
- return json({ error: "unauthorized" }, 401);
206
- }
207
- let person;
208
- try {
209
- person = JSON.parse(await req.text());
210
- } catch {
211
- return json({ error: "invalid JSON body" }, 400);
212
- }
213
- if (typeof person.email !== "string" || typeof person.hubRecordId !== "string") {
214
- return json({ error: "email and hubRecordId are required" }, 400);
215
- }
216
- const { recordId } = await projectSharedRecord(
217
- { crm: chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID() },
218
- person
219
- );
220
- return json({ recordId });
702
+ const event = JSON.parse(rawBody);
703
+ return { ok: true, eventId: event.id, event: normalizeWebhookEvent(event) };
704
+ },
705
+ async refund(input) {
706
+ const sk = requireSecret(secretKey);
707
+ const charges = await stripeCall(sk, "GET", "/v1/charges", { customer: input.customerId, limit: 100 });
708
+ if (!charges.ok) fail("charges list", charges);
709
+ const rows = charges.body.data ?? [];
710
+ const paid = rows.filter((c) => c.status === "succeeded" && c.refunded !== true);
711
+ const charge = paid[paid.length - 1];
712
+ if (!charge) {
713
+ const err = new Error("no paid charge to refund");
714
+ err.code = "no_charge";
715
+ throw err;
221
716
  }
222
- if (chapter.mode === "chapter") {
223
- if (req.method === "GET" && url.pathname === "/api/join-config") {
224
- const db = makeDb(env);
225
- const groupId = url.searchParams.get("group") ?? chapter.id;
226
- const { groups } = await db.query({ groups: { $: { where: { id: groupId }, limit: 1 } } });
227
- const group = Array.isArray(groups) ? groups[0] : void 0;
228
- if (!group) return json({ error: "not found" }, 404);
229
- const stripeKey = await getVaultSecret(db, "stripe_secret_key");
230
- const paymentsReady = Boolean(group.stripePublishableKey && group.stripePriceId && stripeKey);
231
- return json(joinConfig(group, paymentsReady));
232
- }
233
- if (req.method === "POST" && url.pathname === "/api/applications") {
234
- const raw = await req.text();
235
- if (raw.length > chapter.application.bodyCap) return json({ error: "request body too large" }, 413);
236
- let parsed;
237
- try {
238
- parsed = JSON.parse(raw);
239
- } catch {
240
- return json({ error: "invalid JSON body" }, 400);
241
- }
242
- const submissionId = typeof parsed.submissionId === "string" ? parsed.submissionId : void 0;
243
- const result = await submitApplication(makeDb(env), chapter, parsed, {
244
- submissionId,
245
- groupId: chapter.id,
246
- now: Date.now(),
247
- newId: () => crypto.randomUUID()
248
- });
249
- if (!result.ok) return json({ error: result.error }, 400);
250
- return json({ id: result.id, duplicate: result.duplicate, status: result.status });
251
- }
717
+ const refund = await stripeCall(sk, "POST", "/v1/refunds", { charge: String(charge.id) });
718
+ if (!refund.ok) fail("refund", refund);
719
+ const cancel = await stripeCall(sk, "DELETE", `/v1/subscriptions/${input.subscriptionId}`);
720
+ const amount = refund.body.amount;
721
+ return { refundedCents: typeof amount === "number" ? amount : null, subscriptionCanceled: cancel.ok };
722
+ }
723
+ };
724
+ }
725
+
726
+ // src/worker-routes-payments.ts
727
+ var codeOf = (err) => err && typeof err === "object" && typeof err.code === "string" ? err.code : "unknown";
728
+ async function firstRow2(db, ns, q) {
729
+ const res = await db.query({ [ns]: { $: q } });
730
+ const rows = res[ns];
731
+ return Array.isArray(rows) ? rows[0] : void 0;
732
+ }
733
+ function lineItems(group) {
734
+ const standard = Number(group.standardPriceCents ?? 0);
735
+ const discount = Number(group.foundingDiscountCents ?? 0);
736
+ return { standardCents: standard, discountCents: discount, dueTodayCents: standard - discount };
737
+ }
738
+ async function findApplication(db, event) {
739
+ if ("applicationId" in event && event.applicationId) {
740
+ return firstRow2(db, "applications", { where: { id: event.applicationId }, limit: 1 });
741
+ }
742
+ if ("customerId" in event && event.customerId) {
743
+ return firstRow2(db, "applications", { where: { stripeCustomerId: event.customerId }, order: { createdAt: "desc" }, limit: 1 });
744
+ }
745
+ return void 0;
746
+ }
747
+ function webhookPatch(event, status) {
748
+ switch (event.kind) {
749
+ case "first_payment":
750
+ return firstPaymentPatch(status, event.renewalAt);
751
+ case "renewal":
752
+ return event.renewalAt !== void 0 ? renewalPatch(event.renewalAt) : {};
753
+ case "refunded":
754
+ return refundedPatch();
755
+ case "canceled":
756
+ return canceledPatch();
757
+ default:
758
+ return {};
759
+ }
760
+ }
761
+ async function startSubscription(req, env, ctx) {
762
+ let body;
763
+ try {
764
+ body = JSON.parse(await req.text());
765
+ } catch {
766
+ return json({ error: "invalid JSON body" }, 400);
767
+ }
768
+ const applicationId = typeof body.applicationId === "string" ? body.applicationId : "";
769
+ if (!applicationId) return json({ error: "applicationId required" }, 400);
770
+ if (body.refundPolicyAck !== true) return json({ error: "refundPolicyAck required" }, 400);
771
+ const db = ctx.makeDb(env);
772
+ const app = await firstRow2(db, "applications", { where: { id: applicationId }, limit: 1 });
773
+ if (!app) return json({ error: "not found" }, 404);
774
+ if (app.status !== "submitted") return json({ error: "already processed" }, 409);
775
+ const group = await firstRow2(db, "groups", { where: { id: String(app.groupId ?? ctx.chapter.id) }, limit: 1 });
776
+ const priceId = group?.stripePriceId;
777
+ const secretKey = await getVaultSecret(db, "stripe_secret_key");
778
+ if (!group || !priceId || !secretKey) return json({ error: "payments not configured" }, 503);
779
+ const provider = createStripeProvider({ secretKey });
780
+ let result;
781
+ try {
782
+ result = await provider.createSubscription({
783
+ applicationId,
784
+ groupId: String(group.id),
785
+ email: String(app.email ?? ""),
786
+ name: `${app.firstName ?? ""} ${app.lastName ?? ""}`.trim(),
787
+ priceId: String(priceId),
788
+ existingCustomerId: typeof app.stripeCustomerId === "string" ? app.stripeCustomerId : void 0
789
+ });
790
+ } catch (err) {
791
+ return json({ error: "payment setup failed", code: codeOf(err) }, 502);
792
+ }
793
+ await db.transact([
794
+ {
795
+ t: "update",
796
+ ns: "applications",
797
+ id: applicationId,
798
+ attrs: { stripeCustomerId: result.customerId, stripeSubscriptionId: result.subscriptionId, refundPolicyAckAt: Date.now() }
799
+ }
800
+ ]);
801
+ return json({ clientSecret: result.clientSecret, publishableKey: group.stripePublishableKey ?? null, lineItems: lineItems(group) });
802
+ }
803
+ async function ingestWebhook(req, env, ctx) {
804
+ const db = ctx.makeDb(env);
805
+ const webhookSecret = await getVaultSecret(db, "stripe_webhook_secret");
806
+ if (!webhookSecret) return json({ error: "webhook not configured" }, 503);
807
+ const rawBody = await req.text();
808
+ const ingest = await createStripeProvider({ webhookSecret }).ingestWebhook(rawBody, req.headers.get("stripe-signature") ?? "");
809
+ if (!ingest.ok) return json({ error: "invalid signature" }, 400);
810
+ const { eventId, event } = ingest;
811
+ if (event.kind === "ignored") return json({ ok: true, ignored: event.type });
812
+ const app = await findApplication(db, event);
813
+ if (!app) return json({ ok: true, matched: false });
814
+ const patch = webhookPatch(event, String(app.status ?? ""));
815
+ if (Object.keys(patch).length) {
816
+ await db.transact([{ t: "update", ns: "applications", id: String(app.id), attrs: patch }], { mutationId: webhookMutationId(eventId) });
817
+ }
818
+ return json({ ok: true });
819
+ }
820
+ async function refundApplication(req, url, env, ctx) {
821
+ const rawDb = ctx.makeDb(env);
822
+ const u = await ctx.verifyUser(req, env);
823
+ if (!u || !await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
824
+ const id = url.pathname.split("/")[4] ?? "";
825
+ const db = rawDb;
826
+ const app = await firstRow2(db, "applications", { where: { id }, limit: 1 });
827
+ if (!app) return json({ error: "not found" }, 404);
828
+ if (app.status === "refunded") return json({ error: "already refunded" }, 409);
829
+ if (app.status === "approved") return json({ error: "approved memberships are non-refundable" }, 409);
830
+ if (!app.stripeSubscriptionId) return json({ error: "no subscription on file" }, 409);
831
+ if (!app.stripeCustomerId) return json({ error: "no customer on file" }, 409);
832
+ const secretKey = await getVaultSecret(db, "stripe_secret_key");
833
+ if (!secretKey) return json({ error: "payments not configured" }, 503);
834
+ try {
835
+ const result = await createStripeProvider({ secretKey }).refund({
836
+ customerId: String(app.stripeCustomerId),
837
+ subscriptionId: String(app.stripeSubscriptionId)
838
+ });
839
+ return json({ ok: true, refundedCents: result.refundedCents, subscriptionCanceled: result.subscriptionCanceled });
840
+ } catch (err) {
841
+ const code = codeOf(err);
842
+ return json({ error: "refund failed", code }, code === "no_charge" ? 409 : 502);
843
+ }
844
+ }
845
+ var REFUND_PATH = /^\/api\/admin\/applications\/[^/]+\/refund$/;
846
+ var handlePayments = async (req, url, env, ctx) => {
847
+ if (ctx.chapter.mode !== "chapter") return null;
848
+ if (req.method === "POST" && url.pathname === "/api/payments/subscription") return startSubscription(req, env, ctx);
849
+ if (req.method === "POST" && url.pathname === "/api/webhooks/stripe") return ingestWebhook(req, env, ctx);
850
+ if (req.method === "POST" && REFUND_PATH.test(url.pathname)) return refundApplication(req, url, env, ctx);
851
+ return null;
852
+ };
853
+
854
+ // src/worker.ts
855
+ var ROUTES = [handleConfig, handleMe, handleCrm, handleNetworkShared, handleMember, handleSchedule, handlePayments];
856
+ function chapterWorker(options) {
857
+ const ctx = createWorkerContext(options);
858
+ return {
859
+ async fetch(req, env) {
860
+ const url = new URL(req.url);
861
+ for (const route of ROUTES) {
862
+ const res = await route(req, url, env, ctx);
863
+ if (res) return res;
252
864
  }
253
865
  return env.ASSETS.fetch(req);
254
866
  }
255
867
  };
256
- return handler;
257
868
  }
258
869
  export {
259
870
  chapterWorker