@odla-ai/chapter 0.0.2 → 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,18 +1,35 @@
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";
4
+
5
+ // src/auth.ts
6
+ function roleFromClaim(payload, auth) {
7
+ const raw = payload[auth.claim];
8
+ return typeof raw === "string" && auth.ladder.includes(raw) ? raw : auth.ladder[0];
9
+ }
10
+ function isAdminRole(role, auth) {
11
+ return role === auth.adminRole;
12
+ }
13
+ async function getVaultSecret(db, name) {
14
+ try {
15
+ const value = await db.secrets.get(name);
16
+ return typeof value === "string" && value !== "" ? value : void 0;
17
+ } catch {
18
+ return void 0;
19
+ }
20
+ }
21
+
22
+ // src/worker-context.ts
5
23
  var json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
6
- function chapterWorker(options) {
24
+ function createWorkerContext(options) {
7
25
  const { chapter } = options;
26
+ const auth = chapter.auth;
8
27
  const crmBase = options.crmBasePath ?? "/api/crm";
9
28
  let publicConfigCache = null;
10
29
  const jwksByIssuer = /* @__PURE__ */ new Map();
11
30
  async function getPublicConfig(env) {
12
31
  if (publicConfigCache && Date.now() - publicConfigCache.at < 5 * 6e4) return publicConfigCache.value;
13
- const res = await fetch(
14
- `${env.ODLA_PLATFORM}/registry/apps/${env.ODLA_APP_ID}/public-config?env=${env.ODLA_ENV}`
15
- );
32
+ const res = await fetch(`${env.ODLA_PLATFORM}/registry/apps/${env.ODLA_APP_ID}/public-config?env=${env.ODLA_ENV}`);
16
33
  if (!res.ok) throw new Error(`public-config fetch failed: ${res.status}`);
17
34
  const value = await res.json();
18
35
  publicConfigCache = { value, at: Date.now() };
@@ -32,7 +49,11 @@ function chapterWorker(options) {
32
49
  try {
33
50
  const { payload } = await jwtVerify(token, jwks, { issuer });
34
51
  if (!payload.sub) return null;
35
- return { userId: payload.sub, email: typeof payload.email === "string" ? payload.email : void 0 };
52
+ return {
53
+ userId: payload.sub,
54
+ email: typeof payload.email === "string" ? payload.email : void 0,
55
+ payload
56
+ };
36
57
  } catch {
37
58
  return null;
38
59
  }
@@ -45,54 +66,805 @@ function chapterWorker(options) {
45
66
  const { admins } = await db.query({ admins: { $: { where: { email: email.toLowerCase() }, limit: 1 } } });
46
67
  return Array.isArray(admins) && admins.length > 0;
47
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
+ }
48
82
  function crmSender(env) {
49
83
  if (!env.SEND_EMAIL || !env.EMAIL_FROM) return void 0;
50
84
  const binding = env.SEND_EMAIL;
51
- return { async send(payload) {
52
- return binding.send(payload);
53
- } };
85
+ return {
86
+ async send(payload) {
87
+ return binding.send(payload);
88
+ }
89
+ };
54
90
  }
55
- const handler = {
56
- async fetch(req, env) {
57
- const url = new URL(req.url);
58
- if (url.pathname === "/api/config") {
59
- try {
60
- const { clerkPublishableKey } = await getPublicConfig(env);
61
- return json({ clerkPublishableKey: clerkPublishableKey ?? null, env: env.ODLA_ENV });
62
- } catch {
63
- return json({ clerkPublishableKey: null, env: env.ODLA_ENV });
64
- }
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
+
97
+ // src/member.ts
98
+ async function submitApplication(db, chapter, fields, opts) {
99
+ const app = chapter.application;
100
+ for (const f of app.required) {
101
+ const v = fields[f];
102
+ if (typeof v !== "string" || v.trim() === "") return { ok: false, error: `${f} is required` };
103
+ }
104
+ for (const f of [...app.required, ...app.optional]) {
105
+ const v = fields[f];
106
+ const cap = app.maxLen[f] ?? app.defaultMaxLen;
107
+ if (typeof v === "string" && v.length > cap) return { ok: false, error: `${f} exceeds ${cap} characters` };
108
+ }
109
+ const id = opts.newId();
110
+ const row = { id, status: chapter.pipeline.initial, createdAt: opts.now };
111
+ for (const f of [...app.required, ...app.optional]) {
112
+ if (typeof fields[f] === "string") row[f] = fields[f].trim();
113
+ }
114
+ if (fields.focus !== void 0) row.focus = fields.focus;
115
+ if (opts.groupId) row.groupId = opts.groupId;
116
+ const { duplicate } = await db.transact(
117
+ [{ t: "update", ns: "applications", id, attrs: row }],
118
+ opts.submissionId ? { mutationId: `join:${opts.submissionId}` } : void 0
119
+ );
120
+ return { ok: true, id, duplicate, status: chapter.pipeline.initial };
121
+ }
122
+ function joinConfig(group, paymentsReady) {
123
+ return {
124
+ id: group.id,
125
+ name: group.name,
126
+ standardPriceCents: group.standardPriceCents ?? 0,
127
+ foundingDiscountCents: group.foundingDiscountCents ?? 0,
128
+ disclaimerText: group.disclaimerText ?? "",
129
+ refundPolicyText: group.refundPolicyText ?? "",
130
+ trustCopy: group.trustCopy ?? "",
131
+ commitmentText: group.commitmentText ?? "",
132
+ normsText: group.normsText ?? "",
133
+ paymentsReady
134
+ };
135
+ }
136
+
137
+ // src/network.ts
138
+ import { createRecord, updateRecord } from "@odla-ai/crm";
139
+ function sharedPersonInput(person) {
140
+ const email = person.email.toLowerCase();
141
+ const fullName = [person.firstName, person.lastName].filter(Boolean).join(" ").trim();
142
+ const input = { name: person.name ?? fullName ?? email, email };
143
+ if (input.name === "") input.name = email;
144
+ if (person.firstName) input.firstName = person.firstName;
145
+ if (person.lastName) input.lastName = person.lastName;
146
+ if (person.phone) input.phone = person.phone;
147
+ if (person.linkedin) input.linkedin = person.linkedin;
148
+ return input;
149
+ }
150
+ async function projectSharedRecord(deps, person) {
151
+ const email = person.email.toLowerCase();
152
+ const input = sharedPersonInput(person);
153
+ const crmDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
154
+ const { crm_record } = await deps.db.query({
155
+ crm_record: { $: { where: { type: "person", primaryEmail: email }, limit: 1 } }
156
+ });
157
+ const existing = crm_record?.[0];
158
+ if (existing && typeof existing.id === "string") {
159
+ await updateRecord(crmDeps, { id: existing.id, input });
160
+ return { recordId: existing.id };
161
+ }
162
+ const created = await createRecord(crmDeps, { type: "person", input, mutationId: `share:${person.hubRecordId}` });
163
+ return { recordId: created.id };
164
+ }
165
+
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;
183
+ }
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;
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;
389
+ try {
390
+ parsed = JSON.parse(raw);
391
+ } catch {
392
+ return json({ error: "invalid JSON body" }, 400);
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 });
403
+ }
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;
413
+ }
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);
442
+ }
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);
461
+ }
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);
509
+ }
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
+ }
527
+ }
528
+ if (req.method === "POST" && url.pathname === "/api/schedule/book") {
529
+ return bookSlot(req, env, ctx);
530
+ }
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));
65
572
  }
66
- if (url.pathname === "/api/me") {
67
- const u = await verifyUser(req, env);
68
- if (!u) return json({ authorized: false }, 401);
69
- const authorized = await isAdminEmail(makeDb(env), u.email);
70
- return json({ authorized, 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);
675
+ }
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" };
701
+ }
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;
71
716
  }
72
- if (url.pathname === crmBase || url.pathname.startsWith(crmBase + "/")) {
73
- const db = makeDb(env);
74
- const routes = createCrmRoutes({
75
- crm: chapter.crm,
76
- db,
77
- authorize: async (r) => {
78
- const u = await verifyUser(r, env);
79
- if (!u || !await isAdminEmail(db, u.email)) return null;
80
- return u.email ? { userId: u.userId, email: u.email } : { userId: u.userId };
81
- },
82
- sender: crmSender(env),
83
- from: env.EMAIL_FROM,
84
- envName: env.ODLA_ENV,
85
- baseUrl: url.origin,
86
- basePath: crmBase
87
- });
88
- const res = await routes(req);
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);
89
863
  if (res) return res;
90
- return json({ error: "not found" }, 404);
91
864
  }
92
865
  return env.ASSETS.fetch(req);
93
866
  }
94
867
  };
95
- return handler;
96
868
  }
97
869
  export {
98
870
  chapterWorker