@odla-ai/chapter 0.5.0 → 0.8.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.
@@ -20,7 +20,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/worker.ts
21
21
  var worker_exports = {};
22
22
  __export(worker_exports, {
23
- chapterWorker: () => chapterWorker
23
+ chapterWorker: () => chapterWorker,
24
+ createWorkerContext: () => createWorkerContext
24
25
  });
25
26
  module.exports = __toCommonJS(worker_exports);
26
27
 
@@ -120,6 +121,47 @@ function createWorkerContext(options) {
120
121
  // src/worker-routes.ts
121
122
  var import_crm2 = require("@odla-ai/crm");
122
123
 
124
+ // src/clerk.ts
125
+ function clerkInviteRequest(input) {
126
+ return {
127
+ path: "/v1/invitations",
128
+ body: {
129
+ email_address: input.email,
130
+ notify: true,
131
+ ...input.redirectUrl ? { redirect_url: input.redirectUrl } : {}
132
+ }
133
+ };
134
+ }
135
+ async function createClerkInvitation(secretKey, input, fetchImpl = fetch) {
136
+ const { path, body } = clerkInviteRequest(input);
137
+ const res = await fetchImpl(`https://api.clerk.com${path}`, {
138
+ method: "POST",
139
+ headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
140
+ body: JSON.stringify(body)
141
+ });
142
+ return { ok: res.ok, status: res.status };
143
+ }
144
+ function clerkUserRequest(input) {
145
+ return {
146
+ path: "/v1/users",
147
+ body: {
148
+ email_address: [input.email],
149
+ skip_password_requirement: true,
150
+ ...input.firstName ? { first_name: input.firstName } : {},
151
+ ...input.lastName ? { last_name: input.lastName } : {}
152
+ }
153
+ };
154
+ }
155
+ async function createClerkUser(secretKey, input, fetchImpl = fetch) {
156
+ const { path, body } = clerkUserRequest(input);
157
+ const res = await fetchImpl(`https://api.clerk.com${path}`, {
158
+ method: "POST",
159
+ headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
160
+ body: JSON.stringify(body)
161
+ });
162
+ return { ok: res.ok, status: res.status };
163
+ }
164
+
123
165
  // src/member.ts
124
166
  async function submitApplication(db, chapter, fields, opts) {
125
167
  const app = chapter.application;
@@ -173,21 +215,32 @@ function sharedPersonInput(person) {
173
215
  if (person.linkedin) input.linkedin = person.linkedin;
174
216
  return input;
175
217
  }
176
- async function projectSharedRecord(deps, person) {
177
- const email = person.email.toLowerCase();
178
- const input = sharedPersonInput(person);
218
+ async function upsertPerson(deps, opts) {
219
+ const email = opts.email.toLowerCase();
179
220
  const crmDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
180
- const { crm_record } = await deps.db.query({
181
- crm_record: { $: { where: { type: "person", primaryEmail: email }, limit: 1 } }
182
- });
221
+ const { crm_record } = await deps.db.query({ crm_record: { $: { where: { type: "person", primaryEmail: email }, limit: 1 } } });
183
222
  const existing = crm_record?.[0];
184
223
  if (existing && typeof existing.id === "string") {
185
- await (0, import_crm.updateRecord)(crmDeps, { id: existing.id, input });
224
+ await (0, import_crm.updateRecord)(crmDeps, { id: existing.id, input: opts.input });
186
225
  return { recordId: existing.id };
187
226
  }
188
- const created = await (0, import_crm.createRecord)(crmDeps, { type: "person", input, mutationId: `share:${person.hubRecordId}` });
227
+ const created = await (0, import_crm.createRecord)(crmDeps, { type: "person", input: opts.input, mutationId: opts.mutationId });
189
228
  return { recordId: created.id };
190
229
  }
230
+ async function projectSharedRecord(deps, person) {
231
+ return upsertPerson(deps, { email: person.email, input: sharedPersonInput(person), mutationId: `share:${person.hubRecordId}` });
232
+ }
233
+ async function projectApplicant(deps, applicant) {
234
+ const input = sharedPersonInput({
235
+ email: applicant.email,
236
+ firstName: applicant.firstName,
237
+ lastName: applicant.lastName,
238
+ phone: applicant.phone,
239
+ linkedin: applicant.linkedin,
240
+ hubRecordId: applicant.applicationId
241
+ });
242
+ return upsertPerson(deps, { email: applicant.email, input, mutationId: `apply:${applicant.applicationId}` });
243
+ }
191
244
 
192
245
  // src/scheduling.ts
193
246
  var SCHEDULING_DEFAULTS = {
@@ -235,10 +288,6 @@ function resolveScheduling(config) {
235
288
  if (typeof c.summaryTemplate !== "string") fail2("summaryTemplate must be a string");
236
289
  return { ...c, days };
237
290
  }
238
- var BOOKABLE_STATUSES = ["submitted", "paid_pending_vetting", "call_scheduled"];
239
- function canBookFrom(status) {
240
- return BOOKABLE_STATUSES.includes(status);
241
- }
242
291
  function slotWindow(now, windowDays) {
243
292
  return { from: now, to: now + windowDays * 864e5 };
244
293
  }
@@ -317,7 +366,146 @@ function memberApplication(app, meeting, defaultTimezone) {
317
366
  return { ...summary, meetingAt, meetUrl, timezone };
318
367
  }
319
368
 
369
+ // src/email.ts
370
+ function render(template, vars) {
371
+ return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? "");
372
+ }
373
+ function groupVars(group, vars) {
374
+ return {
375
+ ...vars,
376
+ refundPolicyText: group.refundPolicyText ?? "",
377
+ commitmentText: group.commitmentText ?? "",
378
+ normsText: group.normsText ?? ""
379
+ };
380
+ }
381
+ function isAlreadySent(priorRows) {
382
+ return priorRows.some((row) => !row.error);
383
+ }
384
+ function planDelivery(input) {
385
+ const tpl = input.group.emailTemplates?.[input.template];
386
+ if (!tpl) return { deliver: false, reason: "template-missing" };
387
+ if (tpl.enabled === false && !input.force) return { deliver: false, reason: "disabled" };
388
+ const vars = groupVars(input.group, input.vars);
389
+ const isProd = input.envName === "prod";
390
+ const redirect = !isProd && !!input.group.debugEmail;
391
+ const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
392
+ const to = redirect ? input.group.debugEmail : input.to;
393
+ const subject = (redirect ? "[dev] " : "") + render(tpl.subject, vars);
394
+ const text = redirect ? `(dev redirect; original recipient: ${input.to})
395
+
396
+ ` + render(tpl.text, vars) : render(tpl.text, vars);
397
+ return { deliver: true, transport, to, subject, text, redirected: redirect };
398
+ }
399
+
400
+ // src/notify.ts
401
+ async function sendTemplated(deps, input) {
402
+ const { emailLog } = await deps.db.query({ emailLog: { $: { where: { dedupeKey: input.dedupeKey } } } });
403
+ const prior = Array.isArray(emailLog) ? emailLog : [];
404
+ if (isAlreadySent(prior)) return { sent: true, reason: "already-sent" };
405
+ const cloudflareReady = Boolean(deps.sender && deps.from);
406
+ const decision = planDelivery({
407
+ envName: deps.envName,
408
+ group: input.group,
409
+ template: input.template,
410
+ to: input.to,
411
+ vars: input.vars,
412
+ cloudflareReady,
413
+ force: input.force
414
+ });
415
+ if (!decision.deliver) return { sent: false, reason: decision.reason };
416
+ let error;
417
+ let messageId;
418
+ if (decision.transport === "cloudflare" && deps.sender && deps.from) {
419
+ try {
420
+ const res = await deps.sender.send({
421
+ from: deps.from,
422
+ to: [decision.to],
423
+ subject: decision.subject,
424
+ text: decision.text,
425
+ replyTo: input.group.replyTo
426
+ });
427
+ messageId = res.messageId;
428
+ } catch (e) {
429
+ error = e instanceof Error ? e.message : String(e);
430
+ }
431
+ }
432
+ const id = deps.newId();
433
+ const row = {
434
+ id,
435
+ groupId: input.group.id,
436
+ to: decision.to,
437
+ template: input.template,
438
+ subject: decision.subject,
439
+ body: decision.text,
440
+ transport: decision.transport,
441
+ redirected: decision.redirected,
442
+ dedupeKey: input.dedupeKey,
443
+ sentAt: deps.now(),
444
+ ...input.applicationId ? { applicationId: input.applicationId } : {},
445
+ ...messageId ? { messageId } : {},
446
+ ...error ? { error } : {}
447
+ };
448
+ await deps.db.transact([{ t: "update", ns: "emailLog", id, attrs: row }], error ? void 0 : { mutationId: `email:${input.dedupeKey}` });
449
+ return error ? { sent: false, reason: error } : { sent: true };
450
+ }
451
+ function emailGroupFrom(row) {
452
+ const str = (v) => typeof v === "string" ? v : void 0;
453
+ const templates = row.emailTemplates && typeof row.emailTemplates === "object" ? row.emailTemplates : {};
454
+ return {
455
+ id: String(row.id),
456
+ name: String(row.name ?? ""),
457
+ replyTo: str(row.replyTo) ?? "",
458
+ debugEmail: str(row.debugEmail),
459
+ refundPolicyText: str(row.refundPolicyText),
460
+ commitmentText: str(row.commitmentText),
461
+ normsText: str(row.normsText),
462
+ emailTemplates: templates
463
+ };
464
+ }
465
+
320
466
  // src/worker-routes.ts
467
+ async function provisionApplicant(db, chapter, applicationId, fields) {
468
+ const email = typeof fields.email === "string" ? fields.email : "";
469
+ if (!email) return;
470
+ const s = (v) => typeof v === "string" ? v : void 0;
471
+ try {
472
+ await projectApplicant(
473
+ { crm: chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID() },
474
+ { applicationId, email, firstName: s(fields.firstName), lastName: s(fields.lastName), phone: s(fields.phone), linkedin: s(fields.linkedin) }
475
+ );
476
+ } catch {
477
+ }
478
+ if (chapter.account !== "none") {
479
+ try {
480
+ const secret = await getVaultSecret(db, "clerk_secret_key");
481
+ if (secret) {
482
+ if (chapter.account === "create") await createClerkUser(secret, { email, firstName: s(fields.firstName), lastName: s(fields.lastName) });
483
+ else await createClerkInvitation(secret, { email });
484
+ }
485
+ } catch {
486
+ }
487
+ }
488
+ }
489
+ async function notifyAdminOfApplication(db, env, chapterId, applicationId, fields) {
490
+ try {
491
+ const groups = (await db.query({ groups: { $: { where: { id: chapterId }, limit: 1 } } })).groups;
492
+ const group = Array.isArray(groups) ? groups[0] : void 0;
493
+ if (!group || typeof group.notificationEmail !== "string" || !group.notificationEmail) return;
494
+ const s = (v) => typeof v === "string" ? v : "";
495
+ await sendTemplated(
496
+ { db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
497
+ {
498
+ group: emailGroupFrom(group),
499
+ template: "adminNotification",
500
+ to: group.notificationEmail,
501
+ vars: { firstName: s(fields.firstName), lastName: s(fields.lastName), email: s(fields.email), phone: s(fields.phone), state: s(fields.state) },
502
+ dedupeKey: `apply:${applicationId}:admin`,
503
+ applicationId
504
+ }
505
+ );
506
+ } catch {
507
+ }
508
+ }
321
509
  async function memberSessionApplication(db, chapterId, email) {
322
510
  const apps = (await db.query({ applications: { $: { where: { email }, order: { createdAt: "desc" }, limit: 1 } } })).applications;
323
511
  const app = Array.isArray(apps) ? apps[0] : void 0;
@@ -329,6 +517,7 @@ async function memberSessionApplication(db, chapterId, email) {
329
517
  const timezone = resolveScheduling(group?.schedulingJson).timezone;
330
518
  return memberApplication(app, meeting, timezone);
331
519
  }
520
+ var handleHealth = async (_req, url) => url.pathname === "/api/health" ? json({ ok: true }) : null;
332
521
  var handleConfig = async (_req, url, env, ctx) => {
333
522
  if (url.pathname !== "/api/config") return null;
334
523
  try {
@@ -418,13 +607,18 @@ var handleMember = async (req, url, env, ctx) => {
418
607
  return json({ error: "invalid JSON body" }, 400);
419
608
  }
420
609
  const submissionId = typeof parsed.submissionId === "string" ? parsed.submissionId : void 0;
421
- const result = await submitApplication(ctx.makeDb(env), chapter, parsed, {
610
+ const db = ctx.makeDb(env);
611
+ const result = await submitApplication(db, chapter, parsed, {
422
612
  submissionId,
423
613
  groupId: chapter.id,
424
614
  now: Date.now(),
425
615
  newId: () => crypto.randomUUID()
426
616
  });
427
617
  if (!result.ok) return json({ error: result.error }, 400);
618
+ if (!result.duplicate) {
619
+ await notifyAdminOfApplication(db, env, chapter.id, result.id, parsed);
620
+ await provisionApplicant(db, chapter, result.id, parsed);
621
+ }
428
622
  return json({ id: result.id, duplicate: result.duplicate, status: result.status });
429
623
  }
430
624
  return null;
@@ -432,6 +626,13 @@ var handleMember = async (req, url, env, ctx) => {
432
626
 
433
627
  // src/worker-routes-schedule.ts
434
628
  var import_calendar = require("@odla-ai/calendar");
629
+
630
+ // src/pipeline.ts
631
+ function canBook(status, p) {
632
+ return p.bookableFrom.includes(status);
633
+ }
634
+
635
+ // src/worker-routes-schedule.ts
435
636
  function errCode(err) {
436
637
  if (err && typeof err === "object") {
437
638
  const code = err.code;
@@ -473,7 +674,7 @@ async function bookSlot(req, env, ctx) {
473
674
  const app = await firstRow(db, "applications", { where: { id: applicationId }, limit: 1 });
474
675
  if (!app) return json({ error: "not found" }, 404);
475
676
  const status = String(app.status ?? "");
476
- if (!canBookFrom(status)) return json({ error: `cannot book from status "${status}"` }, 409);
677
+ if (!canBook(status, ctx.chapter.pipeline)) return json({ error: `cannot book from status "${status}"` }, 409);
477
678
  const group = await firstRow(db, "groups", { where: { id: String(app.groupId ?? ctx.chapter.id) }, limit: 1 });
478
679
  if (!group) return json({ error: "group not found" }, 500);
479
680
  const cfg = resolveScheduling(group.schedulingJson);
@@ -535,6 +736,12 @@ async function bookSlot(req, env, ctx) {
535
736
  }
536
737
  const appOp = { t: "update", ns: "applications", id: applicationId, attrs: applicationBookingUpdate(status, startAt, htmlLink) };
537
738
  await db.transact([meetingOp, appOp]);
739
+ if (typeof app.email === "string" && app.email) {
740
+ await sendTemplated(
741
+ { db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
742
+ { group: emailGroupFrom(group), template: "prepEmail", to: app.email, vars: { firstName: String(app.firstName ?? "") }, dedupeKey: `prep:${applicationId}`, applicationId }
743
+ ).catch(() => void 0);
744
+ }
538
745
  return json({ ok: true, startAt, endAt, meetUrl, rescheduled: decision.reschedule });
539
746
  }
540
747
  var handleSchedule = async (req, url, env, ctx) => {
@@ -770,6 +977,25 @@ async function findApplication(db, event) {
770
977
  }
771
978
  return void 0;
772
979
  }
980
+ async function notifyPaymentConfirmed(db, env, eventId, app) {
981
+ try {
982
+ if (typeof app.email !== "string" || !app.email) return;
983
+ const group = await firstRow2(db, "groups", { where: { id: String(app.groupId ?? "") }, limit: 1 });
984
+ if (!group) return;
985
+ await sendTemplated(
986
+ { db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
987
+ {
988
+ group: emailGroupFrom(group),
989
+ template: "paymentConfirmation",
990
+ to: app.email,
991
+ vars: { firstName: typeof app.firstName === "string" ? app.firstName : "" },
992
+ dedupeKey: `${eventId}:confirm`,
993
+ applicationId: String(app.id)
994
+ }
995
+ );
996
+ } catch {
997
+ }
998
+ }
773
999
  function webhookPatch(event, status) {
774
1000
  switch (event.kind) {
775
1001
  case "first_payment":
@@ -841,6 +1067,7 @@ async function ingestWebhook(req, env, ctx) {
841
1067
  if (Object.keys(patch).length) {
842
1068
  await db.transact([{ t: "update", ns: "applications", id: String(app.id), attrs: patch }], { mutationId: webhookMutationId(eventId) });
843
1069
  }
1070
+ if (event.kind === "first_payment") await notifyPaymentConfirmed(db, env, eventId, app);
844
1071
  return json({ ok: true });
845
1072
  }
846
1073
  async function refundApplication(req, url, env, ctx) {
@@ -877,14 +1104,139 @@ var handlePayments = async (req, url, env, ctx) => {
877
1104
  return null;
878
1105
  };
879
1106
 
1107
+ // src/worker-routes-admin.ts
1108
+ var import_calendar2 = require("@odla-ai/calendar");
1109
+
1110
+ // src/reconcile.ts
1111
+ function isReconcilable(meeting, now) {
1112
+ return meeting.status === "scheduled" && Boolean(meeting.googleEventId) && (meeting.startAt ?? 0) > now - 36e5;
1113
+ }
1114
+ function reconcileMeetings(meetings, events, now) {
1115
+ const byEvent = new Map(events.map((e) => [e.eventId, e]));
1116
+ const decisions = [];
1117
+ for (const m of meetings) {
1118
+ if (!isReconcilable(m, now) || !m.googleEventId) continue;
1119
+ const g = byEvent.get(m.googleEventId);
1120
+ if (!g || g.status === "cancelled") {
1121
+ decisions.push({
1122
+ meetingId: m.id,
1123
+ applicationId: m.applicationId,
1124
+ kind: "cancelled",
1125
+ meetingPatch: { status: "cancelled", drift: "none", adoptedFromGoogleAt: now },
1126
+ applicationPatch: { meetingAt: 0, meetingLink: "" }
1127
+ });
1128
+ } else if (g.startAt !== void 0 && g.startAt !== m.startAt) {
1129
+ const duration = (m.endAt ?? 0) - (m.startAt ?? 0);
1130
+ decisions.push({
1131
+ meetingId: m.id,
1132
+ applicationId: m.applicationId,
1133
+ kind: "moved",
1134
+ meetingPatch: { startAt: g.startAt, endAt: g.endAt ?? g.startAt + duration, drift: "none", adoptedFromGoogleAt: now },
1135
+ applicationPatch: { meetingAt: g.startAt }
1136
+ });
1137
+ }
1138
+ }
1139
+ return decisions;
1140
+ }
1141
+
1142
+ // src/worker-routes-admin.ts
1143
+ async function adminGroup(req, env, ctx, url) {
1144
+ const rawDb = ctx.makeDb(env);
1145
+ const u = await ctx.verifyUser(req, env);
1146
+ if (!u || !await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
1147
+ const db = rawDb;
1148
+ const groupId = url.searchParams.get("group") ?? ctx.chapter.id;
1149
+ const group = (await db.query({ groups: { $: { where: { id: groupId }, limit: 1 } } })).groups?.[0];
1150
+ if (!group) return json({ error: "not found" }, 404);
1151
+ return { db, group };
1152
+ }
1153
+ var handleAdminScheduling = async (req, url, env, ctx) => {
1154
+ if (url.pathname !== "/api/admin/scheduling" || req.method !== "GET" && req.method !== "PUT") return null;
1155
+ const got = await adminGroup(req, env, ctx, url);
1156
+ if (got instanceof Response) return got;
1157
+ const { db, group } = got;
1158
+ if (req.method === "GET") {
1159
+ return json({ scheduling: resolveScheduling(group.schedulingJson) });
1160
+ }
1161
+ let body;
1162
+ try {
1163
+ body = JSON.parse(await req.text());
1164
+ } catch {
1165
+ return json({ error: "invalid JSON body" }, 400);
1166
+ }
1167
+ let resolved;
1168
+ try {
1169
+ resolved = resolveScheduling(body);
1170
+ } catch (e) {
1171
+ return json({ error: e instanceof Error ? e.message : "invalid scheduling config" }, 400);
1172
+ }
1173
+ await db.transact([{ t: "update", ns: "groups", id: String(group.id), attrs: { schedulingJson: resolved } }]);
1174
+ return json({ scheduling: resolved });
1175
+ };
1176
+ async function upcomingEvents(env) {
1177
+ const cal = (0, import_calendar2.initCalendar)({ appId: env.ODLA_APP_ID, env: env.ODLA_ENV, adminToken: env.ODLA_API_KEY, endpoint: env.ODLA_PLATFORM });
1178
+ const res = await cal.availability.upcoming();
1179
+ return res.events.map((e) => ({ eventId: e.eventId, status: e.status, startAt: e.startAt, endAt: e.endAt }));
1180
+ }
1181
+ function toReconcile(rows) {
1182
+ return rows.map((m) => ({
1183
+ id: String(m.id),
1184
+ applicationId: String(m.applicationId),
1185
+ googleEventId: typeof m.googleEventId === "string" ? m.googleEventId : null,
1186
+ status: String(m.status ?? ""),
1187
+ startAt: typeof m.startAt === "number" ? m.startAt : null,
1188
+ endAt: typeof m.endAt === "number" ? m.endAt : null
1189
+ }));
1190
+ }
1191
+ var handleAdminMeetings = async (req, url, env, ctx) => {
1192
+ if (req.method !== "GET" || url.pathname !== "/api/admin/meetings") return null;
1193
+ const rawDb = ctx.makeDb(env);
1194
+ const u = await ctx.verifyUser(req, env);
1195
+ if (!u || !await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
1196
+ const db = rawDb;
1197
+ const query = await db.query({ meetings: { $: { where: { status: "scheduled" }, order: { startAt: "asc" }, limit: 500 } } });
1198
+ const rows = Array.isArray(query.meetings) ? query.meetings : [];
1199
+ let decisions = [];
1200
+ try {
1201
+ decisions = reconcileMeetings(toReconcile(rows), await upcomingEvents(env), Date.now());
1202
+ } catch {
1203
+ }
1204
+ const patched = /* @__PURE__ */ new Map();
1205
+ for (const d of decisions) {
1206
+ const ops = [
1207
+ { t: "update", ns: "meetings", id: d.meetingId, attrs: d.meetingPatch },
1208
+ { t: "update", ns: "applications", id: d.applicationId, attrs: d.applicationPatch }
1209
+ ];
1210
+ try {
1211
+ await db.transact(ops);
1212
+ patched.set(d.meetingId, d.meetingPatch);
1213
+ } catch {
1214
+ }
1215
+ }
1216
+ const meetings = rows.map((m) => ({ ...m, ...patched.get(String(m.id)) ?? {} })).filter((m) => m.status === "scheduled");
1217
+ return json({ meetings, adopted: decisions.length });
1218
+ };
1219
+
880
1220
  // src/worker.ts
881
- var ROUTES = [handleConfig, handleMe, handleCrm, handleNetworkShared, handleMember, handleSchedule, handlePayments];
1221
+ var BUILTIN_ROUTES = [
1222
+ handleHealth,
1223
+ handleConfig,
1224
+ handleMe,
1225
+ handleCrm,
1226
+ handleNetworkShared,
1227
+ handleMember,
1228
+ handleSchedule,
1229
+ handlePayments,
1230
+ handleAdminMeetings,
1231
+ handleAdminScheduling
1232
+ ];
882
1233
  function chapterWorker(options) {
883
1234
  const ctx = createWorkerContext(options);
1235
+ const routes = [...options.routes ?? [], ...BUILTIN_ROUTES];
884
1236
  return {
885
1237
  async fetch(req, env) {
886
1238
  const url = new URL(req.url);
887
- for (const route of ROUTES) {
1239
+ for (const route of routes) {
888
1240
  const res = await route(req, url, env, ctx);
889
1241
  if (res) return res;
890
1242
  }