@odla-ai/chapter 0.9.0 → 0.10.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.
@@ -155,6 +155,20 @@ function clerkUserRequest(input) {
155
155
  }
156
156
  };
157
157
  }
158
+ async function refreshUserMetadata(secretKey, email, publicMetadata, fetchImpl) {
159
+ const auth = { authorization: `Bearer ${secretKey}` };
160
+ const found = await fetchImpl(`https://api.clerk.com/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, { headers: auth });
161
+ if (!found.ok) return false;
162
+ const users = await found.json().catch(() => null);
163
+ const id = Array.isArray(users) && typeof users[0]?.id === "string" ? users[0].id : void 0;
164
+ if (!id) return false;
165
+ const patched = await fetchImpl(`https://api.clerk.com/v1/users/${id}/metadata`, {
166
+ method: "PATCH",
167
+ headers: { ...auth, "content-type": "application/json" },
168
+ body: JSON.stringify({ public_metadata: publicMetadata })
169
+ });
170
+ return patched.ok;
171
+ }
158
172
  async function createClerkUser(secretKey, input, fetchImpl = fetch) {
159
173
  const { path, body } = clerkUserRequest(input);
160
174
  const res = await fetchImpl(`https://api.clerk.com${path}`, {
@@ -162,7 +176,11 @@ async function createClerkUser(secretKey, input, fetchImpl = fetch) {
162
176
  headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
163
177
  body: JSON.stringify(body)
164
178
  });
165
- return res.ok ? { ok: true, status: res.status } : heal(res.status);
179
+ if (res.ok) return { ok: true, status: res.status };
180
+ const healed = heal(res.status);
181
+ if (!healed.existed || !input.publicMetadata) return healed;
182
+ const refreshed = await refreshUserMetadata(secretKey, input.email, input.publicMetadata, fetchImpl).catch(() => false);
183
+ return { ...healed, refreshed };
166
184
  }
167
185
 
168
186
  // src/member.ts
@@ -265,6 +283,12 @@ function isValidTimeZone(tz) {
265
283
  }
266
284
  }
267
285
  function resolveScheduling(config) {
286
+ const result = validateScheduling(config);
287
+ if (result.ok) return result.value;
288
+ const detail = Object.entries(result.errors).map(([field, message]) => `${field}: ${message}`).join(" ");
289
+ throw new Error(`scheduling: ${detail}`);
290
+ }
291
+ function validateScheduling(config) {
268
292
  const d = config ?? {};
269
293
  const c = {
270
294
  slotMinutes: d.slotMinutes ?? SCHEDULING_DEFAULTS.slotMinutes,
@@ -276,20 +300,29 @@ function resolveScheduling(config) {
276
300
  windowDays: d.windowDays ?? SCHEDULING_DEFAULTS.windowDays,
277
301
  summaryTemplate: d.summaryTemplate ?? SCHEDULING_DEFAULTS.summaryTemplate
278
302
  };
279
- const fail2 = (msg) => {
280
- throw new Error(`scheduling: ${msg}`);
281
- };
282
- if (!(c.slotMinutes >= 15 && c.slotMinutes <= 240)) fail2("slotMinutes must be 15\u2013240");
283
- if (!(c.windowDays >= 1 && c.windowDays <= 62)) fail2("windowDays must be 1\u201362 (FreeBusy caps at 62)");
284
- if (!(c.minNoticeHours >= 0 && c.minNoticeHours <= 336)) fail2("minNoticeHours must be 0\u2013336");
285
- if (!(c.startHour >= 0 && c.startHour < c.endHour && c.endHour <= 24)) fail2("require 0 \u2264 startHour < endHour \u2264 24");
303
+ const errors = {};
304
+ if (!(c.slotMinutes >= 15 && c.slotMinutes <= 240)) {
305
+ errors.slotMinutes = "Slot length must be between 15 and 240 minutes.";
306
+ }
307
+ if (!(c.windowDays >= 1 && c.windowDays <= 62)) {
308
+ errors.windowDays = "Booking window must be between 1 and 62 days (the calendar caps look-ahead at 62).";
309
+ }
310
+ if (!(c.minNoticeHours >= 0 && c.minNoticeHours <= 336)) {
311
+ errors.minNoticeHours = "Minimum notice must be between 0 and 336 hours.";
312
+ }
313
+ if (!(c.startHour >= 0 && c.startHour < c.endHour && c.endHour <= 24)) {
314
+ errors.hours = "Hours must satisfy 0 \u2264 start < end \u2264 24.";
315
+ }
286
316
  const days = [...c.days];
287
- if (!days.length || !days.every((n) => Number.isInteger(n) && n >= 0 && n <= 6)) {
288
- fail2("days must be a non-empty list of weekday integers 0\u20136");
317
+ if (!days.length) errors.days = "Pick at least one day.";
318
+ else if (!days.every((n) => Number.isInteger(n) && n >= 0 && n <= 6)) {
319
+ errors.days = "Days must be weekday numbers, 0 (Sunday) through 6 (Saturday).";
289
320
  }
290
- if (typeof c.timezone !== "string" || !isValidTimeZone(c.timezone)) fail2(`invalid IANA timezone "${c.timezone}"`);
291
- if (typeof c.summaryTemplate !== "string") fail2("summaryTemplate must be a string");
292
- return { ...c, days };
321
+ if (typeof c.timezone !== "string" || !isValidTimeZone(c.timezone)) {
322
+ errors.timezone = `"${String(c.timezone)}" is not a valid IANA timezone (for example "America/Los_Angeles").`;
323
+ }
324
+ if (typeof c.summaryTemplate !== "string") errors.summaryTemplate = "Calendar summary template must be text.";
325
+ return Object.keys(errors).length > 0 ? { ok: false, errors } : { ok: true, value: { ...c, days } };
293
326
  }
294
327
  function slotWindow(now, windowDays) {
295
328
  return { from: now, to: now + windowDays * 864e5 };
@@ -619,7 +652,9 @@ var handleMember = async (req, url, env, ctx) => {
619
652
  });
620
653
  if (!result.ok) return json({ error: result.error }, 400);
621
654
  if (!result.duplicate) {
622
- await notifyAdminOfApplication(db, env, chapter.id, result.id, parsed);
655
+ if (chapter.sends.adminNotification === "submit") {
656
+ await notifyAdminOfApplication(db, env, chapter.id, result.id, parsed);
657
+ }
623
658
  await provisionApplicant(db, chapter, result.id, parsed);
624
659
  }
625
660
  return json({ id: result.id, duplicate: result.duplicate, status: result.status });
@@ -999,6 +1034,25 @@ async function notifyPaymentConfirmed(db, env, eventId, app) {
999
1034
  } catch {
1000
1035
  }
1001
1036
  }
1037
+ async function notifyAdminOfPayment(db, env, eventId, app) {
1038
+ try {
1039
+ const group = await firstRow2(db, "groups", { where: { id: String(app.groupId ?? "") }, limit: 1 });
1040
+ if (!group || typeof group.notificationEmail !== "string" || !group.notificationEmail) return;
1041
+ const s = (v) => typeof v === "string" ? v : "";
1042
+ await sendTemplated(
1043
+ { db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
1044
+ {
1045
+ group: emailGroupFrom(group),
1046
+ template: "adminNotification",
1047
+ to: group.notificationEmail,
1048
+ vars: { firstName: s(app.firstName), lastName: s(app.lastName), email: s(app.email), phone: s(app.phone), state: s(app.state) },
1049
+ dedupeKey: `${eventId}:admin`,
1050
+ applicationId: String(app.id)
1051
+ }
1052
+ );
1053
+ } catch {
1054
+ }
1055
+ }
1002
1056
  function webhookPatch(event, status) {
1003
1057
  switch (event.kind) {
1004
1058
  case "first_payment":
@@ -1070,7 +1124,10 @@ async function ingestWebhook(req, env, ctx) {
1070
1124
  if (Object.keys(patch).length) {
1071
1125
  await db.transact([{ t: "update", ns: "applications", id: String(app.id), attrs: patch }], { mutationId: webhookMutationId(eventId) });
1072
1126
  }
1073
- if (event.kind === "first_payment") await notifyPaymentConfirmed(db, env, eventId, app);
1127
+ if (event.kind === "first_payment") {
1128
+ await notifyPaymentConfirmed(db, env, eventId, app);
1129
+ if (ctx.chapter.sends.adminNotification === "payment") await notifyAdminOfPayment(db, env, eventId, app);
1130
+ }
1074
1131
  return json({ ok: true });
1075
1132
  }
1076
1133
  async function refundApplication(req, url, env, ctx) {
@@ -1167,14 +1224,10 @@ var handleAdminScheduling = async (req, url, env, ctx) => {
1167
1224
  } catch {
1168
1225
  return json({ error: "invalid JSON body" }, 400);
1169
1226
  }
1170
- let resolved;
1171
- try {
1172
- resolved = resolveScheduling(body);
1173
- } catch (e) {
1174
- return json({ error: e instanceof Error ? e.message : "invalid scheduling config" }, 400);
1175
- }
1176
- await db.transact([{ t: "update", ns: "groups", id: String(group.id), attrs: { schedulingJson: resolved } }]);
1177
- return json({ scheduling: resolved });
1227
+ const checked = validateScheduling(body);
1228
+ if (!checked.ok) return json({ error: "invalid scheduling config", errors: checked.errors }, 400);
1229
+ await db.transact([{ t: "update", ns: "groups", id: String(group.id), attrs: { schedulingJson: checked.value } }]);
1230
+ return json({ scheduling: checked.value });
1178
1231
  };
1179
1232
  async function upcomingEvents(env) {
1180
1233
  const cal = (0, import_calendar2.initCalendar)({ appId: env.ODLA_APP_ID, env: env.ODLA_ENV, adminToken: env.ODLA_API_KEY, endpoint: env.ODLA_PLATFORM });
@@ -1197,8 +1250,15 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
1197
1250
  const u = await ctx.verifyUser(req, env);
1198
1251
  if (!u || !await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
1199
1252
  const db = rawDb;
1200
- const query = await db.query({ meetings: { $: { where: { status: "scheduled" }, order: { startAt: "asc" }, limit: 500 } } });
1201
- const rows = Array.isArray(query.meetings) ? query.meetings : [];
1253
+ const all = url.searchParams.get("all") === "1";
1254
+ const from = Number(url.searchParams.get("from"));
1255
+ const to = Number(url.searchParams.get("to"));
1256
+ const query = await db.query({
1257
+ meetings: { $: { where: all ? {} : { status: "scheduled" }, order: { startAt: "asc" }, limit: 500 } }
1258
+ });
1259
+ let rows = Array.isArray(query.meetings) ? query.meetings : [];
1260
+ if (Number.isFinite(from)) rows = rows.filter((m) => Number(m.startAt ?? 0) >= from);
1261
+ if (Number.isFinite(to)) rows = rows.filter((m) => Number(m.startAt ?? 0) <= to);
1202
1262
  let decisions = [];
1203
1263
  try {
1204
1264
  decisions = reconcileMeetings(toReconcile(rows), await upcomingEvents(env), Date.now());
@@ -1216,8 +1276,20 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
1216
1276
  } catch {
1217
1277
  }
1218
1278
  }
1219
- const meetings = rows.map((m) => ({ ...m, ...patched.get(String(m.id)) ?? {} })).filter((m) => m.status === "scheduled");
1220
- return json({ meetings, adopted: decisions.length });
1279
+ const appQuery = await db.query({ applications: { $: { limit: 1e3 } } });
1280
+ const byId = /* @__PURE__ */ new Map();
1281
+ for (const a of Array.isArray(appQuery.applications) ? appQuery.applications : []) {
1282
+ byId.set(String(a.id), a);
1283
+ }
1284
+ const applicantOf = (m) => {
1285
+ const a = byId.get(String(m.applicationId));
1286
+ if (!a) return null;
1287
+ return { id: a.id, firstName: a.firstName, lastName: a.lastName, email: a.email, status: a.status };
1288
+ };
1289
+ const group = (await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups?.[0];
1290
+ const timezone = resolveScheduling(group?.schedulingJson).timezone;
1291
+ const meetings = rows.map((m) => ({ ...m, ...patched.get(String(m.id)) ?? {}, applicant: applicantOf(m) })).filter((m) => all || m.status === "scheduled");
1292
+ return json({ meetings, adopted: decisions.length, timezone });
1221
1293
  };
1222
1294
 
1223
1295
  // src/worker.ts