@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.
@@ -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,27 @@ 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
+
123
145
  // src/member.ts
124
146
  async function submitApplication(db, chapter, fields, opts) {
125
147
  const app = chapter.application;
@@ -173,21 +195,32 @@ function sharedPersonInput(person) {
173
195
  if (person.linkedin) input.linkedin = person.linkedin;
174
196
  return input;
175
197
  }
176
- async function projectSharedRecord(deps, person) {
177
- const email = person.email.toLowerCase();
178
- const input = sharedPersonInput(person);
198
+ async function upsertPerson(deps, opts) {
199
+ const email = opts.email.toLowerCase();
179
200
  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
- });
201
+ const { crm_record } = await deps.db.query({ crm_record: { $: { where: { type: "person", primaryEmail: email }, limit: 1 } } });
183
202
  const existing = crm_record?.[0];
184
203
  if (existing && typeof existing.id === "string") {
185
- await (0, import_crm.updateRecord)(crmDeps, { id: existing.id, input });
204
+ await (0, import_crm.updateRecord)(crmDeps, { id: existing.id, input: opts.input });
186
205
  return { recordId: existing.id };
187
206
  }
188
- const created = await (0, import_crm.createRecord)(crmDeps, { type: "person", input, mutationId: `share:${person.hubRecordId}` });
207
+ const created = await (0, import_crm.createRecord)(crmDeps, { type: "person", input: opts.input, mutationId: opts.mutationId });
189
208
  return { recordId: created.id };
190
209
  }
210
+ async function projectSharedRecord(deps, person) {
211
+ return upsertPerson(deps, { email: person.email, input: sharedPersonInput(person), mutationId: `share:${person.hubRecordId}` });
212
+ }
213
+ async function projectApplicant(deps, applicant) {
214
+ const input = sharedPersonInput({
215
+ email: applicant.email,
216
+ firstName: applicant.firstName,
217
+ lastName: applicant.lastName,
218
+ phone: applicant.phone,
219
+ linkedin: applicant.linkedin,
220
+ hubRecordId: applicant.applicationId
221
+ });
222
+ return upsertPerson(deps, { email: applicant.email, input, mutationId: `apply:${applicant.applicationId}` });
223
+ }
191
224
 
192
225
  // src/scheduling.ts
193
226
  var SCHEDULING_DEFAULTS = {
@@ -235,10 +268,6 @@ function resolveScheduling(config) {
235
268
  if (typeof c.summaryTemplate !== "string") fail2("summaryTemplate must be a string");
236
269
  return { ...c, days };
237
270
  }
238
- var BOOKABLE_STATUSES = ["submitted", "paid_pending_vetting", "call_scheduled"];
239
- function canBookFrom(status) {
240
- return BOOKABLE_STATUSES.includes(status);
241
- }
242
271
  function slotWindow(now, windowDays) {
243
272
  return { from: now, to: now + windowDays * 864e5 };
244
273
  }
@@ -317,7 +346,141 @@ function memberApplication(app, meeting, defaultTimezone) {
317
346
  return { ...summary, meetingAt, meetUrl, timezone };
318
347
  }
319
348
 
349
+ // src/email.ts
350
+ function render(template, vars) {
351
+ return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? "");
352
+ }
353
+ function groupVars(group, vars) {
354
+ return {
355
+ ...vars,
356
+ refundPolicyText: group.refundPolicyText ?? "",
357
+ commitmentText: group.commitmentText ?? "",
358
+ normsText: group.normsText ?? ""
359
+ };
360
+ }
361
+ function isAlreadySent(priorRows) {
362
+ return priorRows.some((row) => !row.error);
363
+ }
364
+ function planDelivery(input) {
365
+ const tpl = input.group.emailTemplates?.[input.template];
366
+ if (!tpl) return { deliver: false, reason: "template-missing" };
367
+ if (tpl.enabled === false && !input.force) return { deliver: false, reason: "disabled" };
368
+ const vars = groupVars(input.group, input.vars);
369
+ const isProd = input.envName === "prod";
370
+ const redirect = !isProd && !!input.group.debugEmail;
371
+ const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
372
+ const to = redirect ? input.group.debugEmail : input.to;
373
+ const subject = (redirect ? "[dev] " : "") + render(tpl.subject, vars);
374
+ const text = redirect ? `(dev redirect; original recipient: ${input.to})
375
+
376
+ ` + render(tpl.text, vars) : render(tpl.text, vars);
377
+ return { deliver: true, transport, to, subject, text, redirected: redirect };
378
+ }
379
+
380
+ // src/notify.ts
381
+ async function sendTemplated(deps, input) {
382
+ const { emailLog } = await deps.db.query({ emailLog: { $: { where: { dedupeKey: input.dedupeKey } } } });
383
+ const prior = Array.isArray(emailLog) ? emailLog : [];
384
+ if (isAlreadySent(prior)) return { sent: true, reason: "already-sent" };
385
+ const cloudflareReady = Boolean(deps.sender && deps.from);
386
+ const decision = planDelivery({
387
+ envName: deps.envName,
388
+ group: input.group,
389
+ template: input.template,
390
+ to: input.to,
391
+ vars: input.vars,
392
+ cloudflareReady,
393
+ force: input.force
394
+ });
395
+ if (!decision.deliver) return { sent: false, reason: decision.reason };
396
+ let error;
397
+ let messageId;
398
+ if (decision.transport === "cloudflare" && deps.sender && deps.from) {
399
+ try {
400
+ const res = await deps.sender.send({
401
+ from: deps.from,
402
+ to: [decision.to],
403
+ subject: decision.subject,
404
+ text: decision.text,
405
+ replyTo: input.group.replyTo
406
+ });
407
+ messageId = res.messageId;
408
+ } catch (e) {
409
+ error = e instanceof Error ? e.message : String(e);
410
+ }
411
+ }
412
+ const id = deps.newId();
413
+ const row = {
414
+ id,
415
+ groupId: input.group.id,
416
+ to: decision.to,
417
+ template: input.template,
418
+ subject: decision.subject,
419
+ body: decision.text,
420
+ transport: decision.transport,
421
+ redirected: decision.redirected,
422
+ dedupeKey: input.dedupeKey,
423
+ sentAt: deps.now(),
424
+ ...input.applicationId ? { applicationId: input.applicationId } : {},
425
+ ...messageId ? { messageId } : {},
426
+ ...error ? { error } : {}
427
+ };
428
+ await deps.db.transact([{ t: "update", ns: "emailLog", id, attrs: row }], error ? void 0 : { mutationId: `email:${input.dedupeKey}` });
429
+ return error ? { sent: false, reason: error } : { sent: true };
430
+ }
431
+ function emailGroupFrom(row) {
432
+ const str = (v) => typeof v === "string" ? v : void 0;
433
+ const templates = row.emailTemplates && typeof row.emailTemplates === "object" ? row.emailTemplates : {};
434
+ return {
435
+ id: String(row.id),
436
+ name: String(row.name ?? ""),
437
+ replyTo: str(row.replyTo) ?? "",
438
+ debugEmail: str(row.debugEmail),
439
+ refundPolicyText: str(row.refundPolicyText),
440
+ commitmentText: str(row.commitmentText),
441
+ normsText: str(row.normsText),
442
+ emailTemplates: templates
443
+ };
444
+ }
445
+
320
446
  // src/worker-routes.ts
447
+ async function provisionApplicant(db, chapter, applicationId, fields) {
448
+ const email = typeof fields.email === "string" ? fields.email : "";
449
+ if (!email) return;
450
+ const s = (v) => typeof v === "string" ? v : void 0;
451
+ try {
452
+ await projectApplicant(
453
+ { crm: chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID() },
454
+ { applicationId, email, firstName: s(fields.firstName), lastName: s(fields.lastName), phone: s(fields.phone), linkedin: s(fields.linkedin) }
455
+ );
456
+ } catch {
457
+ }
458
+ try {
459
+ const secret = await getVaultSecret(db, "clerk_secret_key");
460
+ if (secret) await createClerkInvitation(secret, { email });
461
+ } catch {
462
+ }
463
+ }
464
+ async function notifyAdminOfApplication(db, env, chapterId, applicationId, fields) {
465
+ try {
466
+ const groups = (await db.query({ groups: { $: { where: { id: chapterId }, limit: 1 } } })).groups;
467
+ const group = Array.isArray(groups) ? groups[0] : void 0;
468
+ if (!group || typeof group.notificationEmail !== "string" || !group.notificationEmail) return;
469
+ const s = (v) => typeof v === "string" ? v : "";
470
+ await sendTemplated(
471
+ { db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
472
+ {
473
+ group: emailGroupFrom(group),
474
+ template: "adminNotification",
475
+ to: group.notificationEmail,
476
+ vars: { firstName: s(fields.firstName), lastName: s(fields.lastName), email: s(fields.email), phone: s(fields.phone), state: s(fields.state) },
477
+ dedupeKey: `apply:${applicationId}:admin`,
478
+ applicationId
479
+ }
480
+ );
481
+ } catch {
482
+ }
483
+ }
321
484
  async function memberSessionApplication(db, chapterId, email) {
322
485
  const apps = (await db.query({ applications: { $: { where: { email }, order: { createdAt: "desc" }, limit: 1 } } })).applications;
323
486
  const app = Array.isArray(apps) ? apps[0] : void 0;
@@ -329,6 +492,7 @@ async function memberSessionApplication(db, chapterId, email) {
329
492
  const timezone = resolveScheduling(group?.schedulingJson).timezone;
330
493
  return memberApplication(app, meeting, timezone);
331
494
  }
495
+ var handleHealth = async (_req, url) => url.pathname === "/api/health" ? json({ ok: true }) : null;
332
496
  var handleConfig = async (_req, url, env, ctx) => {
333
497
  if (url.pathname !== "/api/config") return null;
334
498
  try {
@@ -418,13 +582,18 @@ var handleMember = async (req, url, env, ctx) => {
418
582
  return json({ error: "invalid JSON body" }, 400);
419
583
  }
420
584
  const submissionId = typeof parsed.submissionId === "string" ? parsed.submissionId : void 0;
421
- const result = await submitApplication(ctx.makeDb(env), chapter, parsed, {
585
+ const db = ctx.makeDb(env);
586
+ const result = await submitApplication(db, chapter, parsed, {
422
587
  submissionId,
423
588
  groupId: chapter.id,
424
589
  now: Date.now(),
425
590
  newId: () => crypto.randomUUID()
426
591
  });
427
592
  if (!result.ok) return json({ error: result.error }, 400);
593
+ if (!result.duplicate) {
594
+ await notifyAdminOfApplication(db, env, chapter.id, result.id, parsed);
595
+ await provisionApplicant(db, chapter, result.id, parsed);
596
+ }
428
597
  return json({ id: result.id, duplicate: result.duplicate, status: result.status });
429
598
  }
430
599
  return null;
@@ -432,6 +601,13 @@ var handleMember = async (req, url, env, ctx) => {
432
601
 
433
602
  // src/worker-routes-schedule.ts
434
603
  var import_calendar = require("@odla-ai/calendar");
604
+
605
+ // src/pipeline.ts
606
+ function canBook(status, p) {
607
+ return p.bookableFrom.includes(status);
608
+ }
609
+
610
+ // src/worker-routes-schedule.ts
435
611
  function errCode(err) {
436
612
  if (err && typeof err === "object") {
437
613
  const code = err.code;
@@ -473,7 +649,7 @@ async function bookSlot(req, env, ctx) {
473
649
  const app = await firstRow(db, "applications", { where: { id: applicationId }, limit: 1 });
474
650
  if (!app) return json({ error: "not found" }, 404);
475
651
  const status = String(app.status ?? "");
476
- if (!canBookFrom(status)) return json({ error: `cannot book from status "${status}"` }, 409);
652
+ if (!canBook(status, ctx.chapter.pipeline)) return json({ error: `cannot book from status "${status}"` }, 409);
477
653
  const group = await firstRow(db, "groups", { where: { id: String(app.groupId ?? ctx.chapter.id) }, limit: 1 });
478
654
  if (!group) return json({ error: "group not found" }, 500);
479
655
  const cfg = resolveScheduling(group.schedulingJson);
@@ -535,6 +711,12 @@ async function bookSlot(req, env, ctx) {
535
711
  }
536
712
  const appOp = { t: "update", ns: "applications", id: applicationId, attrs: applicationBookingUpdate(status, startAt, htmlLink) };
537
713
  await db.transact([meetingOp, appOp]);
714
+ if (typeof app.email === "string" && app.email) {
715
+ await sendTemplated(
716
+ { db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
717
+ { group: emailGroupFrom(group), template: "prepEmail", to: app.email, vars: { firstName: String(app.firstName ?? "") }, dedupeKey: `prep:${applicationId}`, applicationId }
718
+ ).catch(() => void 0);
719
+ }
538
720
  return json({ ok: true, startAt, endAt, meetUrl, rescheduled: decision.reschedule });
539
721
  }
540
722
  var handleSchedule = async (req, url, env, ctx) => {
@@ -770,6 +952,25 @@ async function findApplication(db, event) {
770
952
  }
771
953
  return void 0;
772
954
  }
955
+ async function notifyPaymentConfirmed(db, env, eventId, app) {
956
+ try {
957
+ if (typeof app.email !== "string" || !app.email) return;
958
+ const group = await firstRow2(db, "groups", { where: { id: String(app.groupId ?? "") }, limit: 1 });
959
+ if (!group) return;
960
+ await sendTemplated(
961
+ { db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
962
+ {
963
+ group: emailGroupFrom(group),
964
+ template: "paymentConfirmation",
965
+ to: app.email,
966
+ vars: { firstName: typeof app.firstName === "string" ? app.firstName : "" },
967
+ dedupeKey: `${eventId}:confirm`,
968
+ applicationId: String(app.id)
969
+ }
970
+ );
971
+ } catch {
972
+ }
973
+ }
773
974
  function webhookPatch(event, status) {
774
975
  switch (event.kind) {
775
976
  case "first_payment":
@@ -841,6 +1042,7 @@ async function ingestWebhook(req, env, ctx) {
841
1042
  if (Object.keys(patch).length) {
842
1043
  await db.transact([{ t: "update", ns: "applications", id: String(app.id), attrs: patch }], { mutationId: webhookMutationId(eventId) });
843
1044
  }
1045
+ if (event.kind === "first_payment") await notifyPaymentConfirmed(db, env, eventId, app);
844
1046
  return json({ ok: true });
845
1047
  }
846
1048
  async function refundApplication(req, url, env, ctx) {
@@ -877,14 +1079,139 @@ var handlePayments = async (req, url, env, ctx) => {
877
1079
  return null;
878
1080
  };
879
1081
 
1082
+ // src/worker-routes-admin.ts
1083
+ var import_calendar2 = require("@odla-ai/calendar");
1084
+
1085
+ // src/reconcile.ts
1086
+ function isReconcilable(meeting, now) {
1087
+ return meeting.status === "scheduled" && Boolean(meeting.googleEventId) && (meeting.startAt ?? 0) > now - 36e5;
1088
+ }
1089
+ function reconcileMeetings(meetings, events, now) {
1090
+ const byEvent = new Map(events.map((e) => [e.eventId, e]));
1091
+ const decisions = [];
1092
+ for (const m of meetings) {
1093
+ if (!isReconcilable(m, now) || !m.googleEventId) continue;
1094
+ const g = byEvent.get(m.googleEventId);
1095
+ if (!g || g.status === "cancelled") {
1096
+ decisions.push({
1097
+ meetingId: m.id,
1098
+ applicationId: m.applicationId,
1099
+ kind: "cancelled",
1100
+ meetingPatch: { status: "cancelled", drift: "none", adoptedFromGoogleAt: now },
1101
+ applicationPatch: { meetingAt: 0, meetingLink: "" }
1102
+ });
1103
+ } else if (g.startAt !== void 0 && g.startAt !== m.startAt) {
1104
+ const duration = (m.endAt ?? 0) - (m.startAt ?? 0);
1105
+ decisions.push({
1106
+ meetingId: m.id,
1107
+ applicationId: m.applicationId,
1108
+ kind: "moved",
1109
+ meetingPatch: { startAt: g.startAt, endAt: g.endAt ?? g.startAt + duration, drift: "none", adoptedFromGoogleAt: now },
1110
+ applicationPatch: { meetingAt: g.startAt }
1111
+ });
1112
+ }
1113
+ }
1114
+ return decisions;
1115
+ }
1116
+
1117
+ // src/worker-routes-admin.ts
1118
+ async function adminGroup(req, env, ctx, url) {
1119
+ const rawDb = ctx.makeDb(env);
1120
+ const u = await ctx.verifyUser(req, env);
1121
+ if (!u || !await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
1122
+ const db = rawDb;
1123
+ const groupId = url.searchParams.get("group") ?? ctx.chapter.id;
1124
+ const group = (await db.query({ groups: { $: { where: { id: groupId }, limit: 1 } } })).groups?.[0];
1125
+ if (!group) return json({ error: "not found" }, 404);
1126
+ return { db, group };
1127
+ }
1128
+ var handleAdminScheduling = async (req, url, env, ctx) => {
1129
+ if (url.pathname !== "/api/admin/scheduling" || req.method !== "GET" && req.method !== "PUT") return null;
1130
+ const got = await adminGroup(req, env, ctx, url);
1131
+ if (got instanceof Response) return got;
1132
+ const { db, group } = got;
1133
+ if (req.method === "GET") {
1134
+ return json({ scheduling: resolveScheduling(group.schedulingJson) });
1135
+ }
1136
+ let body;
1137
+ try {
1138
+ body = JSON.parse(await req.text());
1139
+ } catch {
1140
+ return json({ error: "invalid JSON body" }, 400);
1141
+ }
1142
+ let resolved;
1143
+ try {
1144
+ resolved = resolveScheduling(body);
1145
+ } catch (e) {
1146
+ return json({ error: e instanceof Error ? e.message : "invalid scheduling config" }, 400);
1147
+ }
1148
+ await db.transact([{ t: "update", ns: "groups", id: String(group.id), attrs: { schedulingJson: resolved } }]);
1149
+ return json({ scheduling: resolved });
1150
+ };
1151
+ async function upcomingEvents(env) {
1152
+ const cal = (0, import_calendar2.initCalendar)({ appId: env.ODLA_APP_ID, env: env.ODLA_ENV, adminToken: env.ODLA_API_KEY, endpoint: env.ODLA_PLATFORM });
1153
+ const res = await cal.availability.upcoming();
1154
+ return res.events.map((e) => ({ eventId: e.eventId, status: e.status, startAt: e.startAt, endAt: e.endAt }));
1155
+ }
1156
+ function toReconcile(rows) {
1157
+ return rows.map((m) => ({
1158
+ id: String(m.id),
1159
+ applicationId: String(m.applicationId),
1160
+ googleEventId: typeof m.googleEventId === "string" ? m.googleEventId : null,
1161
+ status: String(m.status ?? ""),
1162
+ startAt: typeof m.startAt === "number" ? m.startAt : null,
1163
+ endAt: typeof m.endAt === "number" ? m.endAt : null
1164
+ }));
1165
+ }
1166
+ var handleAdminMeetings = async (req, url, env, ctx) => {
1167
+ if (req.method !== "GET" || url.pathname !== "/api/admin/meetings") return null;
1168
+ const rawDb = ctx.makeDb(env);
1169
+ const u = await ctx.verifyUser(req, env);
1170
+ if (!u || !await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
1171
+ const db = rawDb;
1172
+ const query = await db.query({ meetings: { $: { where: { status: "scheduled" }, order: { startAt: "asc" }, limit: 500 } } });
1173
+ const rows = Array.isArray(query.meetings) ? query.meetings : [];
1174
+ let decisions = [];
1175
+ try {
1176
+ decisions = reconcileMeetings(toReconcile(rows), await upcomingEvents(env), Date.now());
1177
+ } catch {
1178
+ }
1179
+ const patched = /* @__PURE__ */ new Map();
1180
+ for (const d of decisions) {
1181
+ const ops = [
1182
+ { t: "update", ns: "meetings", id: d.meetingId, attrs: d.meetingPatch },
1183
+ { t: "update", ns: "applications", id: d.applicationId, attrs: d.applicationPatch }
1184
+ ];
1185
+ try {
1186
+ await db.transact(ops);
1187
+ patched.set(d.meetingId, d.meetingPatch);
1188
+ } catch {
1189
+ }
1190
+ }
1191
+ const meetings = rows.map((m) => ({ ...m, ...patched.get(String(m.id)) ?? {} })).filter((m) => m.status === "scheduled");
1192
+ return json({ meetings, adopted: decisions.length });
1193
+ };
1194
+
880
1195
  // src/worker.ts
881
- var ROUTES = [handleConfig, handleMe, handleCrm, handleNetworkShared, handleMember, handleSchedule, handlePayments];
1196
+ var BUILTIN_ROUTES = [
1197
+ handleHealth,
1198
+ handleConfig,
1199
+ handleMe,
1200
+ handleCrm,
1201
+ handleNetworkShared,
1202
+ handleMember,
1203
+ handleSchedule,
1204
+ handlePayments,
1205
+ handleAdminMeetings,
1206
+ handleAdminScheduling
1207
+ ];
882
1208
  function chapterWorker(options) {
883
1209
  const ctx = createWorkerContext(options);
1210
+ const routes = [...options.routes ?? [], ...BUILTIN_ROUTES];
884
1211
  return {
885
1212
  async fetch(req, env) {
886
1213
  const url = new URL(req.url);
887
- for (const route of ROUTES) {
1214
+ for (const route of routes) {
888
1215
  const res = await route(req, url, env, ctx);
889
1216
  if (res) return res;
890
1217
  }