@odla-ai/chapter 0.4.0 → 0.7.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,4 +1,5 @@
1
1
  import { CrmConfig, Crm } from '@odla-ai/crm';
2
+ import { initAdmin } from '@odla-ai/db';
2
3
 
3
4
  /** Which feature profile a site runs. `chapter` is the full public member site
4
5
  * (join, Stripe membership, booking, member area, admin, CRM); `hub` is
@@ -246,15 +247,68 @@ interface ChapterWorkerOptions {
246
247
  chapter: Chapter;
247
248
  /** CRM mount point. Default "/api/crm". */
248
249
  crmBasePath?: string;
250
+ /** Host routes, tried BEFORE the built-ins — so a wrapping site can add its own
251
+ * routes (or override/alias a built-in path) and reuse chapter's auth via the
252
+ * shared {@link WorkerContext}, instead of re-verifying JWTs itself. */
253
+ routes?: Route[];
249
254
  }
255
+ /** The registry public-config a site reads to boot Clerk sign-in. */
256
+ type PublicConfig = {
257
+ env?: string;
258
+ clerkPublishableKey?: string | null;
259
+ issuer?: string | null;
260
+ };
261
+ /** The odla-db admin client type. */
262
+ type Db = ReturnType<typeof initAdmin>;
263
+ /** A verified session: the Clerk `sub`, optional email, and the raw JWT payload
264
+ * (so the role claim can be read for auth source "claim"). */
265
+ interface Verified {
266
+ userId: string;
267
+ email?: string;
268
+ payload: Record<string, unknown>;
269
+ }
270
+ /**
271
+ * Build the per-site Worker context: env-independent helpers closing over the
272
+ * public-config and JWKS caches, plus the source-aware auth gate (JWT claim or
273
+ * the odla-db `admins` allowlist). Constructed once per `chapterWorker` and
274
+ * shared by every route module.
275
+ */
276
+ declare function createWorkerContext(options: ChapterWorkerOptions): {
277
+ chapter: Chapter;
278
+ auth: ResolvedAuth;
279
+ crmBase: string;
280
+ getPublicConfig: (env: ChapterEnv) => Promise<PublicConfig>;
281
+ verifyUser: (req: Request, env: ChapterEnv) => Promise<Verified | null>;
282
+ makeDb: (env: ChapterEnv) => Db;
283
+ isAdminEmail: (db: Db, email: string | undefined) => Promise<boolean>;
284
+ isSuperAdminEmail: (db: Db, email: string | undefined) => Promise<boolean>;
285
+ roleFor: (db: Db, u: Verified) => Promise<string>;
286
+ isAdmin: (db: Db, u: Verified) => Promise<boolean>;
287
+ crmSender: (env: ChapterEnv) => {
288
+ send(payload: EmailPayload): Promise<{
289
+ messageId: string;
290
+ }>;
291
+ } | undefined;
292
+ };
293
+ /** The value returned by {@link createWorkerContext}, threaded to every route. */
294
+ type WorkerContext = ReturnType<typeof createWorkerContext>;
295
+ /** A worker route handler: owns the request (returns a Response) or falls through
296
+ * (returns null). Host routes passed to `chapterWorker` compose against the same
297
+ * {@link WorkerContext} the built-ins receive. */
298
+ type Route = (req: Request, url: URL, env: ChapterEnv, ctx: WorkerContext) => Promise<Response | null>;
250
299
 
251
300
  /**
252
301
  * Build the Cloudflare `ExportedHandler` for a chapter/hub site: Clerk-JWT
253
- * verification, the odla-db admins-allowlist gate, the mounted @odla-ai/crm
254
- * routes, the hub→chapter network projection, and the static-asset fallback. In
255
- * hub mode it serves /api/config, /api/me, /api/crm/*, /api/network/shared;
256
- * chapter mode adds the public member surface (join/apply; Stripe + booking
257
- * land in the member-experience release).
302
+ * verification, the source-aware admin gate, the mounted @odla-ai/crm routes, the
303
+ * hub→chapter network projection, and the static-asset fallback. Hub mode serves
304
+ * /api/health, /api/config, /api/me, /api/crm/*, /api/network/shared; chapter mode
305
+ * adds the public member surface (join/apply/pay/book) and the admin surface
306
+ * (/api/admin/*).
307
+ *
308
+ * A wrapping site adds its own routes via `options.routes` — each receives the
309
+ * same {@link WorkerContext} the built-ins get (so it reuses chapter's JWT
310
+ * verify, db client, and role resolution instead of duplicating them), and runs
311
+ * BEFORE the built-ins so it can override or alias a path.
258
312
  *
259
313
  * Observability is a host concern, not a chapter dependency. To trace, wrap the
260
314
  * result in your worker entry — `export default withObservability(chapterWorker(
@@ -265,4 +319,4 @@ declare function chapterWorker(options: ChapterWorkerOptions): {
265
319
  fetch(req: Request, env: ChapterEnv): Promise<Response>;
266
320
  };
267
321
 
268
- export { type ChapterEnv, type ChapterWorkerOptions, chapterWorker };
322
+ export { type ChapterEnv, type ChapterWorkerOptions, type Route, type WorkerContext, chapterWorker, createWorkerContext };
@@ -1,4 +1,5 @@
1
1
  import { CrmConfig, Crm } from '@odla-ai/crm';
2
+ import { initAdmin } from '@odla-ai/db';
2
3
 
3
4
  /** Which feature profile a site runs. `chapter` is the full public member site
4
5
  * (join, Stripe membership, booking, member area, admin, CRM); `hub` is
@@ -246,15 +247,68 @@ interface ChapterWorkerOptions {
246
247
  chapter: Chapter;
247
248
  /** CRM mount point. Default "/api/crm". */
248
249
  crmBasePath?: string;
250
+ /** Host routes, tried BEFORE the built-ins — so a wrapping site can add its own
251
+ * routes (or override/alias a built-in path) and reuse chapter's auth via the
252
+ * shared {@link WorkerContext}, instead of re-verifying JWTs itself. */
253
+ routes?: Route[];
249
254
  }
255
+ /** The registry public-config a site reads to boot Clerk sign-in. */
256
+ type PublicConfig = {
257
+ env?: string;
258
+ clerkPublishableKey?: string | null;
259
+ issuer?: string | null;
260
+ };
261
+ /** The odla-db admin client type. */
262
+ type Db = ReturnType<typeof initAdmin>;
263
+ /** A verified session: the Clerk `sub`, optional email, and the raw JWT payload
264
+ * (so the role claim can be read for auth source "claim"). */
265
+ interface Verified {
266
+ userId: string;
267
+ email?: string;
268
+ payload: Record<string, unknown>;
269
+ }
270
+ /**
271
+ * Build the per-site Worker context: env-independent helpers closing over the
272
+ * public-config and JWKS caches, plus the source-aware auth gate (JWT claim or
273
+ * the odla-db `admins` allowlist). Constructed once per `chapterWorker` and
274
+ * shared by every route module.
275
+ */
276
+ declare function createWorkerContext(options: ChapterWorkerOptions): {
277
+ chapter: Chapter;
278
+ auth: ResolvedAuth;
279
+ crmBase: string;
280
+ getPublicConfig: (env: ChapterEnv) => Promise<PublicConfig>;
281
+ verifyUser: (req: Request, env: ChapterEnv) => Promise<Verified | null>;
282
+ makeDb: (env: ChapterEnv) => Db;
283
+ isAdminEmail: (db: Db, email: string | undefined) => Promise<boolean>;
284
+ isSuperAdminEmail: (db: Db, email: string | undefined) => Promise<boolean>;
285
+ roleFor: (db: Db, u: Verified) => Promise<string>;
286
+ isAdmin: (db: Db, u: Verified) => Promise<boolean>;
287
+ crmSender: (env: ChapterEnv) => {
288
+ send(payload: EmailPayload): Promise<{
289
+ messageId: string;
290
+ }>;
291
+ } | undefined;
292
+ };
293
+ /** The value returned by {@link createWorkerContext}, threaded to every route. */
294
+ type WorkerContext = ReturnType<typeof createWorkerContext>;
295
+ /** A worker route handler: owns the request (returns a Response) or falls through
296
+ * (returns null). Host routes passed to `chapterWorker` compose against the same
297
+ * {@link WorkerContext} the built-ins receive. */
298
+ type Route = (req: Request, url: URL, env: ChapterEnv, ctx: WorkerContext) => Promise<Response | null>;
250
299
 
251
300
  /**
252
301
  * Build the Cloudflare `ExportedHandler` for a chapter/hub site: Clerk-JWT
253
- * verification, the odla-db admins-allowlist gate, the mounted @odla-ai/crm
254
- * routes, the hub→chapter network projection, and the static-asset fallback. In
255
- * hub mode it serves /api/config, /api/me, /api/crm/*, /api/network/shared;
256
- * chapter mode adds the public member surface (join/apply; Stripe + booking
257
- * land in the member-experience release).
302
+ * verification, the source-aware admin gate, the mounted @odla-ai/crm routes, the
303
+ * hub→chapter network projection, and the static-asset fallback. Hub mode serves
304
+ * /api/health, /api/config, /api/me, /api/crm/*, /api/network/shared; chapter mode
305
+ * adds the public member surface (join/apply/pay/book) and the admin surface
306
+ * (/api/admin/*).
307
+ *
308
+ * A wrapping site adds its own routes via `options.routes` — each receives the
309
+ * same {@link WorkerContext} the built-ins get (so it reuses chapter's JWT
310
+ * verify, db client, and role resolution instead of duplicating them), and runs
311
+ * BEFORE the built-ins so it can override or alias a path.
258
312
  *
259
313
  * Observability is a host concern, not a chapter dependency. To trace, wrap the
260
314
  * result in your worker entry — `export default withObservability(chapterWorker(
@@ -265,4 +319,4 @@ declare function chapterWorker(options: ChapterWorkerOptions): {
265
319
  fetch(req: Request, env: ChapterEnv): Promise<Response>;
266
320
  };
267
321
 
268
- export { type ChapterEnv, type ChapterWorkerOptions, chapterWorker };
322
+ export { type ChapterEnv, type ChapterWorkerOptions, type Route, type WorkerContext, chapterWorker, createWorkerContext };
@@ -94,6 +94,27 @@ function createWorkerContext(options) {
94
94
  // src/worker-routes.ts
95
95
  import { createCrmRoutes } from "@odla-ai/crm";
96
96
 
97
+ // src/clerk.ts
98
+ function clerkInviteRequest(input) {
99
+ return {
100
+ path: "/v1/invitations",
101
+ body: {
102
+ email_address: input.email,
103
+ notify: true,
104
+ ...input.redirectUrl ? { redirect_url: input.redirectUrl } : {}
105
+ }
106
+ };
107
+ }
108
+ async function createClerkInvitation(secretKey, input, fetchImpl = fetch) {
109
+ const { path, body } = clerkInviteRequest(input);
110
+ const res = await fetchImpl(`https://api.clerk.com${path}`, {
111
+ method: "POST",
112
+ headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
113
+ body: JSON.stringify(body)
114
+ });
115
+ return { ok: res.ok, status: res.status };
116
+ }
117
+
97
118
  // src/member.ts
98
119
  async function submitApplication(db, chapter, fields, opts) {
99
120
  const app = chapter.application;
@@ -147,21 +168,32 @@ function sharedPersonInput(person) {
147
168
  if (person.linkedin) input.linkedin = person.linkedin;
148
169
  return input;
149
170
  }
150
- async function projectSharedRecord(deps, person) {
151
- const email = person.email.toLowerCase();
152
- const input = sharedPersonInput(person);
171
+ async function upsertPerson(deps, opts) {
172
+ const email = opts.email.toLowerCase();
153
173
  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
- });
174
+ const { crm_record } = await deps.db.query({ crm_record: { $: { where: { type: "person", primaryEmail: email }, limit: 1 } } });
157
175
  const existing = crm_record?.[0];
158
176
  if (existing && typeof existing.id === "string") {
159
- await updateRecord(crmDeps, { id: existing.id, input });
177
+ await updateRecord(crmDeps, { id: existing.id, input: opts.input });
160
178
  return { recordId: existing.id };
161
179
  }
162
- const created = await createRecord(crmDeps, { type: "person", input, mutationId: `share:${person.hubRecordId}` });
180
+ const created = await createRecord(crmDeps, { type: "person", input: opts.input, mutationId: opts.mutationId });
163
181
  return { recordId: created.id };
164
182
  }
183
+ async function projectSharedRecord(deps, person) {
184
+ return upsertPerson(deps, { email: person.email, input: sharedPersonInput(person), mutationId: `share:${person.hubRecordId}` });
185
+ }
186
+ async function projectApplicant(deps, applicant) {
187
+ const input = sharedPersonInput({
188
+ email: applicant.email,
189
+ firstName: applicant.firstName,
190
+ lastName: applicant.lastName,
191
+ phone: applicant.phone,
192
+ linkedin: applicant.linkedin,
193
+ hubRecordId: applicant.applicationId
194
+ });
195
+ return upsertPerson(deps, { email: applicant.email, input, mutationId: `apply:${applicant.applicationId}` });
196
+ }
165
197
 
166
198
  // src/scheduling.ts
167
199
  var SCHEDULING_DEFAULTS = {
@@ -209,10 +241,6 @@ function resolveScheduling(config) {
209
241
  if (typeof c.summaryTemplate !== "string") fail2("summaryTemplate must be a string");
210
242
  return { ...c, days };
211
243
  }
212
- var BOOKABLE_STATUSES = ["submitted", "paid_pending_vetting", "call_scheduled"];
213
- function canBookFrom(status) {
214
- return BOOKABLE_STATUSES.includes(status);
215
- }
216
244
  function slotWindow(now, windowDays) {
217
245
  return { from: now, to: now + windowDays * 864e5 };
218
246
  }
@@ -291,7 +319,141 @@ function memberApplication(app, meeting, defaultTimezone) {
291
319
  return { ...summary, meetingAt, meetUrl, timezone };
292
320
  }
293
321
 
322
+ // src/email.ts
323
+ function render(template, vars) {
324
+ return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? "");
325
+ }
326
+ function groupVars(group, vars) {
327
+ return {
328
+ ...vars,
329
+ refundPolicyText: group.refundPolicyText ?? "",
330
+ commitmentText: group.commitmentText ?? "",
331
+ normsText: group.normsText ?? ""
332
+ };
333
+ }
334
+ function isAlreadySent(priorRows) {
335
+ return priorRows.some((row) => !row.error);
336
+ }
337
+ function planDelivery(input) {
338
+ const tpl = input.group.emailTemplates?.[input.template];
339
+ if (!tpl) return { deliver: false, reason: "template-missing" };
340
+ if (tpl.enabled === false && !input.force) return { deliver: false, reason: "disabled" };
341
+ const vars = groupVars(input.group, input.vars);
342
+ const isProd = input.envName === "prod";
343
+ const redirect = !isProd && !!input.group.debugEmail;
344
+ const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
345
+ const to = redirect ? input.group.debugEmail : input.to;
346
+ const subject = (redirect ? "[dev] " : "") + render(tpl.subject, vars);
347
+ const text = redirect ? `(dev redirect; original recipient: ${input.to})
348
+
349
+ ` + render(tpl.text, vars) : render(tpl.text, vars);
350
+ return { deliver: true, transport, to, subject, text, redirected: redirect };
351
+ }
352
+
353
+ // src/notify.ts
354
+ async function sendTemplated(deps, input) {
355
+ const { emailLog } = await deps.db.query({ emailLog: { $: { where: { dedupeKey: input.dedupeKey } } } });
356
+ const prior = Array.isArray(emailLog) ? emailLog : [];
357
+ if (isAlreadySent(prior)) return { sent: true, reason: "already-sent" };
358
+ const cloudflareReady = Boolean(deps.sender && deps.from);
359
+ const decision = planDelivery({
360
+ envName: deps.envName,
361
+ group: input.group,
362
+ template: input.template,
363
+ to: input.to,
364
+ vars: input.vars,
365
+ cloudflareReady,
366
+ force: input.force
367
+ });
368
+ if (!decision.deliver) return { sent: false, reason: decision.reason };
369
+ let error;
370
+ let messageId;
371
+ if (decision.transport === "cloudflare" && deps.sender && deps.from) {
372
+ try {
373
+ const res = await deps.sender.send({
374
+ from: deps.from,
375
+ to: [decision.to],
376
+ subject: decision.subject,
377
+ text: decision.text,
378
+ replyTo: input.group.replyTo
379
+ });
380
+ messageId = res.messageId;
381
+ } catch (e) {
382
+ error = e instanceof Error ? e.message : String(e);
383
+ }
384
+ }
385
+ const id = deps.newId();
386
+ const row = {
387
+ id,
388
+ groupId: input.group.id,
389
+ to: decision.to,
390
+ template: input.template,
391
+ subject: decision.subject,
392
+ body: decision.text,
393
+ transport: decision.transport,
394
+ redirected: decision.redirected,
395
+ dedupeKey: input.dedupeKey,
396
+ sentAt: deps.now(),
397
+ ...input.applicationId ? { applicationId: input.applicationId } : {},
398
+ ...messageId ? { messageId } : {},
399
+ ...error ? { error } : {}
400
+ };
401
+ await deps.db.transact([{ t: "update", ns: "emailLog", id, attrs: row }], error ? void 0 : { mutationId: `email:${input.dedupeKey}` });
402
+ return error ? { sent: false, reason: error } : { sent: true };
403
+ }
404
+ function emailGroupFrom(row) {
405
+ const str = (v) => typeof v === "string" ? v : void 0;
406
+ const templates = row.emailTemplates && typeof row.emailTemplates === "object" ? row.emailTemplates : {};
407
+ return {
408
+ id: String(row.id),
409
+ name: String(row.name ?? ""),
410
+ replyTo: str(row.replyTo) ?? "",
411
+ debugEmail: str(row.debugEmail),
412
+ refundPolicyText: str(row.refundPolicyText),
413
+ commitmentText: str(row.commitmentText),
414
+ normsText: str(row.normsText),
415
+ emailTemplates: templates
416
+ };
417
+ }
418
+
294
419
  // src/worker-routes.ts
420
+ async function provisionApplicant(db, chapter, applicationId, fields) {
421
+ const email = typeof fields.email === "string" ? fields.email : "";
422
+ if (!email) return;
423
+ const s = (v) => typeof v === "string" ? v : void 0;
424
+ try {
425
+ await projectApplicant(
426
+ { crm: chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID() },
427
+ { applicationId, email, firstName: s(fields.firstName), lastName: s(fields.lastName), phone: s(fields.phone), linkedin: s(fields.linkedin) }
428
+ );
429
+ } catch {
430
+ }
431
+ try {
432
+ const secret = await getVaultSecret(db, "clerk_secret_key");
433
+ if (secret) await createClerkInvitation(secret, { email });
434
+ } catch {
435
+ }
436
+ }
437
+ async function notifyAdminOfApplication(db, env, chapterId, applicationId, fields) {
438
+ try {
439
+ const groups = (await db.query({ groups: { $: { where: { id: chapterId }, limit: 1 } } })).groups;
440
+ const group = Array.isArray(groups) ? groups[0] : void 0;
441
+ if (!group || typeof group.notificationEmail !== "string" || !group.notificationEmail) return;
442
+ const s = (v) => typeof v === "string" ? v : "";
443
+ await sendTemplated(
444
+ { db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
445
+ {
446
+ group: emailGroupFrom(group),
447
+ template: "adminNotification",
448
+ to: group.notificationEmail,
449
+ vars: { firstName: s(fields.firstName), lastName: s(fields.lastName), email: s(fields.email), phone: s(fields.phone), state: s(fields.state) },
450
+ dedupeKey: `apply:${applicationId}:admin`,
451
+ applicationId
452
+ }
453
+ );
454
+ } catch {
455
+ }
456
+ }
295
457
  async function memberSessionApplication(db, chapterId, email) {
296
458
  const apps = (await db.query({ applications: { $: { where: { email }, order: { createdAt: "desc" }, limit: 1 } } })).applications;
297
459
  const app = Array.isArray(apps) ? apps[0] : void 0;
@@ -303,6 +465,7 @@ async function memberSessionApplication(db, chapterId, email) {
303
465
  const timezone = resolveScheduling(group?.schedulingJson).timezone;
304
466
  return memberApplication(app, meeting, timezone);
305
467
  }
468
+ var handleHealth = async (_req, url) => url.pathname === "/api/health" ? json({ ok: true }) : null;
306
469
  var handleConfig = async (_req, url, env, ctx) => {
307
470
  if (url.pathname !== "/api/config") return null;
308
471
  try {
@@ -392,13 +555,18 @@ var handleMember = async (req, url, env, ctx) => {
392
555
  return json({ error: "invalid JSON body" }, 400);
393
556
  }
394
557
  const submissionId = typeof parsed.submissionId === "string" ? parsed.submissionId : void 0;
395
- const result = await submitApplication(ctx.makeDb(env), chapter, parsed, {
558
+ const db = ctx.makeDb(env);
559
+ const result = await submitApplication(db, chapter, parsed, {
396
560
  submissionId,
397
561
  groupId: chapter.id,
398
562
  now: Date.now(),
399
563
  newId: () => crypto.randomUUID()
400
564
  });
401
565
  if (!result.ok) return json({ error: result.error }, 400);
566
+ if (!result.duplicate) {
567
+ await notifyAdminOfApplication(db, env, chapter.id, result.id, parsed);
568
+ await provisionApplicant(db, chapter, result.id, parsed);
569
+ }
402
570
  return json({ id: result.id, duplicate: result.duplicate, status: result.status });
403
571
  }
404
572
  return null;
@@ -406,6 +574,13 @@ var handleMember = async (req, url, env, ctx) => {
406
574
 
407
575
  // src/worker-routes-schedule.ts
408
576
  import { computeBookableSlots, initCalendar } from "@odla-ai/calendar";
577
+
578
+ // src/pipeline.ts
579
+ function canBook(status, p) {
580
+ return p.bookableFrom.includes(status);
581
+ }
582
+
583
+ // src/worker-routes-schedule.ts
409
584
  function errCode(err) {
410
585
  if (err && typeof err === "object") {
411
586
  const code = err.code;
@@ -447,7 +622,7 @@ async function bookSlot(req, env, ctx) {
447
622
  const app = await firstRow(db, "applications", { where: { id: applicationId }, limit: 1 });
448
623
  if (!app) return json({ error: "not found" }, 404);
449
624
  const status = String(app.status ?? "");
450
- if (!canBookFrom(status)) return json({ error: `cannot book from status "${status}"` }, 409);
625
+ if (!canBook(status, ctx.chapter.pipeline)) return json({ error: `cannot book from status "${status}"` }, 409);
451
626
  const group = await firstRow(db, "groups", { where: { id: String(app.groupId ?? ctx.chapter.id) }, limit: 1 });
452
627
  if (!group) return json({ error: "group not found" }, 500);
453
628
  const cfg = resolveScheduling(group.schedulingJson);
@@ -509,6 +684,12 @@ async function bookSlot(req, env, ctx) {
509
684
  }
510
685
  const appOp = { t: "update", ns: "applications", id: applicationId, attrs: applicationBookingUpdate(status, startAt, htmlLink) };
511
686
  await db.transact([meetingOp, appOp]);
687
+ if (typeof app.email === "string" && app.email) {
688
+ await sendTemplated(
689
+ { db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
690
+ { group: emailGroupFrom(group), template: "prepEmail", to: app.email, vars: { firstName: String(app.firstName ?? "") }, dedupeKey: `prep:${applicationId}`, applicationId }
691
+ ).catch(() => void 0);
692
+ }
512
693
  return json({ ok: true, startAt, endAt, meetUrl, rescheduled: decision.reschedule });
513
694
  }
514
695
  var handleSchedule = async (req, url, env, ctx) => {
@@ -744,6 +925,25 @@ async function findApplication(db, event) {
744
925
  }
745
926
  return void 0;
746
927
  }
928
+ async function notifyPaymentConfirmed(db, env, eventId, app) {
929
+ try {
930
+ if (typeof app.email !== "string" || !app.email) return;
931
+ const group = await firstRow2(db, "groups", { where: { id: String(app.groupId ?? "") }, limit: 1 });
932
+ if (!group) return;
933
+ await sendTemplated(
934
+ { db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
935
+ {
936
+ group: emailGroupFrom(group),
937
+ template: "paymentConfirmation",
938
+ to: app.email,
939
+ vars: { firstName: typeof app.firstName === "string" ? app.firstName : "" },
940
+ dedupeKey: `${eventId}:confirm`,
941
+ applicationId: String(app.id)
942
+ }
943
+ );
944
+ } catch {
945
+ }
946
+ }
747
947
  function webhookPatch(event, status) {
748
948
  switch (event.kind) {
749
949
  case "first_payment":
@@ -815,6 +1015,7 @@ async function ingestWebhook(req, env, ctx) {
815
1015
  if (Object.keys(patch).length) {
816
1016
  await db.transact([{ t: "update", ns: "applications", id: String(app.id), attrs: patch }], { mutationId: webhookMutationId(eventId) });
817
1017
  }
1018
+ if (event.kind === "first_payment") await notifyPaymentConfirmed(db, env, eventId, app);
818
1019
  return json({ ok: true });
819
1020
  }
820
1021
  async function refundApplication(req, url, env, ctx) {
@@ -851,14 +1052,139 @@ var handlePayments = async (req, url, env, ctx) => {
851
1052
  return null;
852
1053
  };
853
1054
 
1055
+ // src/worker-routes-admin.ts
1056
+ import { initCalendar as initCalendar2 } from "@odla-ai/calendar";
1057
+
1058
+ // src/reconcile.ts
1059
+ function isReconcilable(meeting, now) {
1060
+ return meeting.status === "scheduled" && Boolean(meeting.googleEventId) && (meeting.startAt ?? 0) > now - 36e5;
1061
+ }
1062
+ function reconcileMeetings(meetings, events, now) {
1063
+ const byEvent = new Map(events.map((e) => [e.eventId, e]));
1064
+ const decisions = [];
1065
+ for (const m of meetings) {
1066
+ if (!isReconcilable(m, now) || !m.googleEventId) continue;
1067
+ const g = byEvent.get(m.googleEventId);
1068
+ if (!g || g.status === "cancelled") {
1069
+ decisions.push({
1070
+ meetingId: m.id,
1071
+ applicationId: m.applicationId,
1072
+ kind: "cancelled",
1073
+ meetingPatch: { status: "cancelled", drift: "none", adoptedFromGoogleAt: now },
1074
+ applicationPatch: { meetingAt: 0, meetingLink: "" }
1075
+ });
1076
+ } else if (g.startAt !== void 0 && g.startAt !== m.startAt) {
1077
+ const duration = (m.endAt ?? 0) - (m.startAt ?? 0);
1078
+ decisions.push({
1079
+ meetingId: m.id,
1080
+ applicationId: m.applicationId,
1081
+ kind: "moved",
1082
+ meetingPatch: { startAt: g.startAt, endAt: g.endAt ?? g.startAt + duration, drift: "none", adoptedFromGoogleAt: now },
1083
+ applicationPatch: { meetingAt: g.startAt }
1084
+ });
1085
+ }
1086
+ }
1087
+ return decisions;
1088
+ }
1089
+
1090
+ // src/worker-routes-admin.ts
1091
+ async function adminGroup(req, env, ctx, url) {
1092
+ const rawDb = ctx.makeDb(env);
1093
+ const u = await ctx.verifyUser(req, env);
1094
+ if (!u || !await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
1095
+ const db = rawDb;
1096
+ const groupId = url.searchParams.get("group") ?? ctx.chapter.id;
1097
+ const group = (await db.query({ groups: { $: { where: { id: groupId }, limit: 1 } } })).groups?.[0];
1098
+ if (!group) return json({ error: "not found" }, 404);
1099
+ return { db, group };
1100
+ }
1101
+ var handleAdminScheduling = async (req, url, env, ctx) => {
1102
+ if (url.pathname !== "/api/admin/scheduling" || req.method !== "GET" && req.method !== "PUT") return null;
1103
+ const got = await adminGroup(req, env, ctx, url);
1104
+ if (got instanceof Response) return got;
1105
+ const { db, group } = got;
1106
+ if (req.method === "GET") {
1107
+ return json({ scheduling: resolveScheduling(group.schedulingJson) });
1108
+ }
1109
+ let body;
1110
+ try {
1111
+ body = JSON.parse(await req.text());
1112
+ } catch {
1113
+ return json({ error: "invalid JSON body" }, 400);
1114
+ }
1115
+ let resolved;
1116
+ try {
1117
+ resolved = resolveScheduling(body);
1118
+ } catch (e) {
1119
+ return json({ error: e instanceof Error ? e.message : "invalid scheduling config" }, 400);
1120
+ }
1121
+ await db.transact([{ t: "update", ns: "groups", id: String(group.id), attrs: { schedulingJson: resolved } }]);
1122
+ return json({ scheduling: resolved });
1123
+ };
1124
+ async function upcomingEvents(env) {
1125
+ const cal = initCalendar2({ appId: env.ODLA_APP_ID, env: env.ODLA_ENV, adminToken: env.ODLA_API_KEY, endpoint: env.ODLA_PLATFORM });
1126
+ const res = await cal.availability.upcoming();
1127
+ return res.events.map((e) => ({ eventId: e.eventId, status: e.status, startAt: e.startAt, endAt: e.endAt }));
1128
+ }
1129
+ function toReconcile(rows) {
1130
+ return rows.map((m) => ({
1131
+ id: String(m.id),
1132
+ applicationId: String(m.applicationId),
1133
+ googleEventId: typeof m.googleEventId === "string" ? m.googleEventId : null,
1134
+ status: String(m.status ?? ""),
1135
+ startAt: typeof m.startAt === "number" ? m.startAt : null,
1136
+ endAt: typeof m.endAt === "number" ? m.endAt : null
1137
+ }));
1138
+ }
1139
+ var handleAdminMeetings = async (req, url, env, ctx) => {
1140
+ if (req.method !== "GET" || url.pathname !== "/api/admin/meetings") return null;
1141
+ const rawDb = ctx.makeDb(env);
1142
+ const u = await ctx.verifyUser(req, env);
1143
+ if (!u || !await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
1144
+ const db = rawDb;
1145
+ const query = await db.query({ meetings: { $: { where: { status: "scheduled" }, order: { startAt: "asc" }, limit: 500 } } });
1146
+ const rows = Array.isArray(query.meetings) ? query.meetings : [];
1147
+ let decisions = [];
1148
+ try {
1149
+ decisions = reconcileMeetings(toReconcile(rows), await upcomingEvents(env), Date.now());
1150
+ } catch {
1151
+ }
1152
+ const patched = /* @__PURE__ */ new Map();
1153
+ for (const d of decisions) {
1154
+ const ops = [
1155
+ { t: "update", ns: "meetings", id: d.meetingId, attrs: d.meetingPatch },
1156
+ { t: "update", ns: "applications", id: d.applicationId, attrs: d.applicationPatch }
1157
+ ];
1158
+ try {
1159
+ await db.transact(ops);
1160
+ patched.set(d.meetingId, d.meetingPatch);
1161
+ } catch {
1162
+ }
1163
+ }
1164
+ const meetings = rows.map((m) => ({ ...m, ...patched.get(String(m.id)) ?? {} })).filter((m) => m.status === "scheduled");
1165
+ return json({ meetings, adopted: decisions.length });
1166
+ };
1167
+
854
1168
  // src/worker.ts
855
- var ROUTES = [handleConfig, handleMe, handleCrm, handleNetworkShared, handleMember, handleSchedule, handlePayments];
1169
+ var BUILTIN_ROUTES = [
1170
+ handleHealth,
1171
+ handleConfig,
1172
+ handleMe,
1173
+ handleCrm,
1174
+ handleNetworkShared,
1175
+ handleMember,
1176
+ handleSchedule,
1177
+ handlePayments,
1178
+ handleAdminMeetings,
1179
+ handleAdminScheduling
1180
+ ];
856
1181
  function chapterWorker(options) {
857
1182
  const ctx = createWorkerContext(options);
1183
+ const routes = [...options.routes ?? [], ...BUILTIN_ROUTES];
858
1184
  return {
859
1185
  async fetch(req, env) {
860
1186
  const url = new URL(req.url);
861
- for (const route of ROUTES) {
1187
+ for (const route of routes) {
862
1188
  const res = await route(req, url, env, ctx);
863
1189
  if (res) return res;
864
1190
  }
@@ -867,6 +1193,7 @@ function chapterWorker(options) {
867
1193
  };
868
1194
  }
869
1195
  export {
870
- chapterWorker
1196
+ chapterWorker,
1197
+ createWorkerContext
871
1198
  };
872
1199
  //# sourceMappingURL=index.js.map