@odla-ai/chapter 0.9.0 → 0.10.1

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,10 +176,25 @@ 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
187
+ var IDENTITY_FIELDS = /* @__PURE__ */ new Set(["email", "firstName", "lastName"]);
188
+ function applicantProfile(chapter, fields) {
189
+ const profile = {};
190
+ for (const f of [...chapter.application.required, ...chapter.application.optional]) {
191
+ if (IDENTITY_FIELDS.has(f)) continue;
192
+ const v = fields[f];
193
+ if (typeof v === "string" && v.trim() !== "") profile[f] = v.trim();
194
+ }
195
+ if (fields.focus !== void 0) profile.focus = fields.focus;
196
+ return Object.keys(profile).length > 0 ? profile : void 0;
197
+ }
169
198
  async function submitApplication(db, chapter, fields, opts) {
170
199
  const app = chapter.application;
171
200
  for (const f of app.required) {
@@ -184,6 +213,7 @@ async function submitApplication(db, chapter, fields, opts) {
184
213
  }
185
214
  if (fields.focus !== void 0) row.focus = fields.focus;
186
215
  if (opts.groupId) row.groupId = opts.groupId;
216
+ if (fields.disclaimerAck === true || fields.disclaimerAck === "true") row.disclaimerAckAt = opts.now;
187
217
  const { duplicate } = await db.transact(
188
218
  [{ t: "update", ns: "applications", id, attrs: row }],
189
219
  opts.submissionId ? { mutationId: `join:${opts.submissionId}` } : void 0
@@ -265,6 +295,12 @@ function isValidTimeZone(tz) {
265
295
  }
266
296
  }
267
297
  function resolveScheduling(config) {
298
+ const result = validateScheduling(config);
299
+ if (result.ok) return result.value;
300
+ const detail = Object.entries(result.errors).map(([field, message]) => `${field}: ${message}`).join(" ");
301
+ throw new Error(`scheduling: ${detail}`);
302
+ }
303
+ function validateScheduling(config) {
268
304
  const d = config ?? {};
269
305
  const c = {
270
306
  slotMinutes: d.slotMinutes ?? SCHEDULING_DEFAULTS.slotMinutes,
@@ -276,20 +312,29 @@ function resolveScheduling(config) {
276
312
  windowDays: d.windowDays ?? SCHEDULING_DEFAULTS.windowDays,
277
313
  summaryTemplate: d.summaryTemplate ?? SCHEDULING_DEFAULTS.summaryTemplate
278
314
  };
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");
315
+ const errors = {};
316
+ if (!(c.slotMinutes >= 15 && c.slotMinutes <= 240)) {
317
+ errors.slotMinutes = "Slot length must be between 15 and 240 minutes.";
318
+ }
319
+ if (!(c.windowDays >= 1 && c.windowDays <= 62)) {
320
+ errors.windowDays = "Booking window must be between 1 and 62 days (the calendar caps look-ahead at 62).";
321
+ }
322
+ if (!(c.minNoticeHours >= 0 && c.minNoticeHours <= 336)) {
323
+ errors.minNoticeHours = "Minimum notice must be between 0 and 336 hours.";
324
+ }
325
+ if (!(c.startHour >= 0 && c.startHour < c.endHour && c.endHour <= 24)) {
326
+ errors.hours = "Hours must satisfy 0 \u2264 start < end \u2264 24.";
327
+ }
286
328
  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");
329
+ if (!days.length) errors.days = "Pick at least one day.";
330
+ else if (!days.every((n) => Number.isInteger(n) && n >= 0 && n <= 6)) {
331
+ errors.days = "Days must be weekday numbers, 0 (Sunday) through 6 (Saturday).";
332
+ }
333
+ if (typeof c.timezone !== "string" || !isValidTimeZone(c.timezone)) {
334
+ errors.timezone = `"${String(c.timezone)}" is not a valid IANA timezone (for example "America/Los_Angeles").`;
289
335
  }
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 };
336
+ if (typeof c.summaryTemplate !== "string") errors.summaryTemplate = "Calendar summary template must be text.";
337
+ return Object.keys(errors).length > 0 ? { ok: false, errors } : { ok: true, value: { ...c, days } };
293
338
  }
294
339
  function slotWindow(now, windowDays) {
295
340
  return { from: now, to: now + windowDays * 864e5 };
@@ -482,8 +527,13 @@ async function provisionApplicant(db, chapter, applicationId, fields) {
482
527
  try {
483
528
  const secret = await getVaultSecret(db, "clerk_secret_key");
484
529
  if (secret) {
485
- if (chapter.account === "create") await createClerkUser(secret, { email, firstName: s(fields.firstName), lastName: s(fields.lastName) });
486
- else await createClerkInvitation(secret, { email });
530
+ const profile = applicantProfile(chapter, fields);
531
+ const publicMetadata = { applicationId, ...profile ? { profile } : {} };
532
+ if (chapter.account === "create") {
533
+ await createClerkUser(secret, { email, firstName: s(fields.firstName), lastName: s(fields.lastName), publicMetadata });
534
+ } else {
535
+ await createClerkInvitation(secret, { email, publicMetadata });
536
+ }
487
537
  }
488
538
  } catch {
489
539
  }
@@ -619,7 +669,9 @@ var handleMember = async (req, url, env, ctx) => {
619
669
  });
620
670
  if (!result.ok) return json({ error: result.error }, 400);
621
671
  if (!result.duplicate) {
622
- await notifyAdminOfApplication(db, env, chapter.id, result.id, parsed);
672
+ if (chapter.sends.adminNotification === "submit") {
673
+ await notifyAdminOfApplication(db, env, chapter.id, result.id, parsed);
674
+ }
623
675
  await provisionApplicant(db, chapter, result.id, parsed);
624
676
  }
625
677
  return json({ id: result.id, duplicate: result.duplicate, status: result.status });
@@ -999,6 +1051,25 @@ async function notifyPaymentConfirmed(db, env, eventId, app) {
999
1051
  } catch {
1000
1052
  }
1001
1053
  }
1054
+ async function notifyAdminOfPayment(db, env, eventId, app) {
1055
+ try {
1056
+ const group = await firstRow2(db, "groups", { where: { id: String(app.groupId ?? "") }, limit: 1 });
1057
+ if (!group || typeof group.notificationEmail !== "string" || !group.notificationEmail) return;
1058
+ const s = (v) => typeof v === "string" ? v : "";
1059
+ await sendTemplated(
1060
+ { db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
1061
+ {
1062
+ group: emailGroupFrom(group),
1063
+ template: "adminNotification",
1064
+ to: group.notificationEmail,
1065
+ vars: { firstName: s(app.firstName), lastName: s(app.lastName), email: s(app.email), phone: s(app.phone), state: s(app.state) },
1066
+ dedupeKey: `${eventId}:admin`,
1067
+ applicationId: String(app.id)
1068
+ }
1069
+ );
1070
+ } catch {
1071
+ }
1072
+ }
1002
1073
  function webhookPatch(event, status) {
1003
1074
  switch (event.kind) {
1004
1075
  case "first_payment":
@@ -1070,7 +1141,10 @@ async function ingestWebhook(req, env, ctx) {
1070
1141
  if (Object.keys(patch).length) {
1071
1142
  await db.transact([{ t: "update", ns: "applications", id: String(app.id), attrs: patch }], { mutationId: webhookMutationId(eventId) });
1072
1143
  }
1073
- if (event.kind === "first_payment") await notifyPaymentConfirmed(db, env, eventId, app);
1144
+ if (event.kind === "first_payment") {
1145
+ await notifyPaymentConfirmed(db, env, eventId, app);
1146
+ if (ctx.chapter.sends.adminNotification === "payment") await notifyAdminOfPayment(db, env, eventId, app);
1147
+ }
1074
1148
  return json({ ok: true });
1075
1149
  }
1076
1150
  async function refundApplication(req, url, env, ctx) {
@@ -1167,14 +1241,10 @@ var handleAdminScheduling = async (req, url, env, ctx) => {
1167
1241
  } catch {
1168
1242
  return json({ error: "invalid JSON body" }, 400);
1169
1243
  }
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 });
1244
+ const checked = validateScheduling(body);
1245
+ if (!checked.ok) return json({ error: "invalid scheduling config", errors: checked.errors }, 400);
1246
+ await db.transact([{ t: "update", ns: "groups", id: String(group.id), attrs: { schedulingJson: checked.value } }]);
1247
+ return json({ scheduling: checked.value });
1178
1248
  };
1179
1249
  async function upcomingEvents(env) {
1180
1250
  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 +1267,15 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
1197
1267
  const u = await ctx.verifyUser(req, env);
1198
1268
  if (!u || !await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
1199
1269
  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 : [];
1270
+ const all = url.searchParams.get("all") === "1";
1271
+ const from = Number(url.searchParams.get("from"));
1272
+ const to = Number(url.searchParams.get("to"));
1273
+ const query = await db.query({
1274
+ meetings: { $: { where: all ? {} : { status: "scheduled" }, order: { startAt: "asc" }, limit: 500 } }
1275
+ });
1276
+ let rows = Array.isArray(query.meetings) ? query.meetings : [];
1277
+ if (Number.isFinite(from)) rows = rows.filter((m) => Number(m.startAt ?? 0) >= from);
1278
+ if (Number.isFinite(to)) rows = rows.filter((m) => Number(m.startAt ?? 0) <= to);
1202
1279
  let decisions = [];
1203
1280
  try {
1204
1281
  decisions = reconcileMeetings(toReconcile(rows), await upcomingEvents(env), Date.now());
@@ -1216,8 +1293,20 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
1216
1293
  } catch {
1217
1294
  }
1218
1295
  }
1219
- const meetings = rows.map((m) => ({ ...m, ...patched.get(String(m.id)) ?? {} })).filter((m) => m.status === "scheduled");
1220
- return json({ meetings, adopted: decisions.length });
1296
+ const appQuery = await db.query({ applications: { $: { limit: 1e3 } } });
1297
+ const byId = /* @__PURE__ */ new Map();
1298
+ for (const a of Array.isArray(appQuery.applications) ? appQuery.applications : []) {
1299
+ byId.set(String(a.id), a);
1300
+ }
1301
+ const applicantOf = (m) => {
1302
+ const a = byId.get(String(m.applicationId));
1303
+ if (!a) return null;
1304
+ return { id: a.id, firstName: a.firstName, lastName: a.lastName, email: a.email, status: a.status };
1305
+ };
1306
+ const group = (await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups?.[0];
1307
+ const timezone = resolveScheduling(group?.schedulingJson).timezone;
1308
+ const meetings = rows.map((m) => ({ ...m, ...patched.get(String(m.id)) ?? {}, applicant: applicantOf(m) })).filter((m) => all || m.status === "scheduled");
1309
+ return json({ meetings, adopted: decisions.length, timezone });
1221
1310
  };
1222
1311
 
1223
1312
  // src/worker.ts