@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.
@@ -194,9 +194,24 @@ interface ChapterConfig {
194
194
  * account is ready), `"none"` skips it. Any of these needs a `clerk_secret_key`
195
195
  * vault secret to act. */
196
196
  account?: AccountModel;
197
+ /** WHEN lifecycle email fires. Addressing and content live on the group row
198
+ * (owner-editable at runtime); this is the trigger, which is a build-time
199
+ * decision. See {@link ChapterSends}. */
200
+ sends?: ChapterSends;
197
201
  }
198
202
  /** Apply-time Clerk account provisioning model. */
199
203
  type AccountModel = "invite" | "create" | "none";
204
+ /** When the admin notification fires: on application `submit` (default), on the
205
+ * first successful `payment`, or `never` (the site drives it itself). */
206
+ type AdminNotificationTrigger = "submit" | "payment" | "never";
207
+ /** Send-policy config: the trigger for each lifecycle email chapter owns. */
208
+ interface ChapterSends {
209
+ adminNotification?: AdminNotificationTrigger;
210
+ }
211
+ /** Resolved send policy (every trigger present). */
212
+ interface ResolvedSends {
213
+ adminNotification: AdminNotificationTrigger;
214
+ }
200
215
  /** The resolved engine `defineChapter()` returns. */
201
216
  interface Chapter {
202
217
  config: ChapterConfig;
@@ -218,6 +233,8 @@ interface Chapter {
218
233
  services: readonly string[];
219
234
  /** Resolved apply-time account provisioning model (default `"invite"`). */
220
235
  account: AccountModel;
236
+ /** Resolved send policy — when each lifecycle email fires. */
237
+ sends: ResolvedSends;
221
238
  /** The seed `groups` row derived from config (chapter mode), else `null`. */
222
239
  groupSeed(): Record<string, unknown> | null;
223
240
  }
@@ -194,9 +194,24 @@ interface ChapterConfig {
194
194
  * account is ready), `"none"` skips it. Any of these needs a `clerk_secret_key`
195
195
  * vault secret to act. */
196
196
  account?: AccountModel;
197
+ /** WHEN lifecycle email fires. Addressing and content live on the group row
198
+ * (owner-editable at runtime); this is the trigger, which is a build-time
199
+ * decision. See {@link ChapterSends}. */
200
+ sends?: ChapterSends;
197
201
  }
198
202
  /** Apply-time Clerk account provisioning model. */
199
203
  type AccountModel = "invite" | "create" | "none";
204
+ /** When the admin notification fires: on application `submit` (default), on the
205
+ * first successful `payment`, or `never` (the site drives it itself). */
206
+ type AdminNotificationTrigger = "submit" | "payment" | "never";
207
+ /** Send-policy config: the trigger for each lifecycle email chapter owns. */
208
+ interface ChapterSends {
209
+ adminNotification?: AdminNotificationTrigger;
210
+ }
211
+ /** Resolved send policy (every trigger present). */
212
+ interface ResolvedSends {
213
+ adminNotification: AdminNotificationTrigger;
214
+ }
200
215
  /** The resolved engine `defineChapter()` returns. */
201
216
  interface Chapter {
202
217
  config: ChapterConfig;
@@ -218,6 +233,8 @@ interface Chapter {
218
233
  services: readonly string[];
219
234
  /** Resolved apply-time account provisioning model (default `"invite"`). */
220
235
  account: AccountModel;
236
+ /** Resolved send policy — when each lifecycle email fires. */
237
+ sends: ResolvedSends;
221
238
  /** The seed `groups` row derived from config (chapter mode), else `null`. */
222
239
  groupSeed(): Record<string, unknown> | null;
223
240
  }
@@ -128,6 +128,20 @@ function clerkUserRequest(input) {
128
128
  }
129
129
  };
130
130
  }
131
+ async function refreshUserMetadata(secretKey, email, publicMetadata, fetchImpl) {
132
+ const auth = { authorization: `Bearer ${secretKey}` };
133
+ const found = await fetchImpl(`https://api.clerk.com/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, { headers: auth });
134
+ if (!found.ok) return false;
135
+ const users = await found.json().catch(() => null);
136
+ const id = Array.isArray(users) && typeof users[0]?.id === "string" ? users[0].id : void 0;
137
+ if (!id) return false;
138
+ const patched = await fetchImpl(`https://api.clerk.com/v1/users/${id}/metadata`, {
139
+ method: "PATCH",
140
+ headers: { ...auth, "content-type": "application/json" },
141
+ body: JSON.stringify({ public_metadata: publicMetadata })
142
+ });
143
+ return patched.ok;
144
+ }
131
145
  async function createClerkUser(secretKey, input, fetchImpl = fetch) {
132
146
  const { path, body } = clerkUserRequest(input);
133
147
  const res = await fetchImpl(`https://api.clerk.com${path}`, {
@@ -135,7 +149,11 @@ async function createClerkUser(secretKey, input, fetchImpl = fetch) {
135
149
  headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
136
150
  body: JSON.stringify(body)
137
151
  });
138
- return res.ok ? { ok: true, status: res.status } : heal(res.status);
152
+ if (res.ok) return { ok: true, status: res.status };
153
+ const healed = heal(res.status);
154
+ if (!healed.existed || !input.publicMetadata) return healed;
155
+ const refreshed = await refreshUserMetadata(secretKey, input.email, input.publicMetadata, fetchImpl).catch(() => false);
156
+ return { ...healed, refreshed };
139
157
  }
140
158
 
141
159
  // src/member.ts
@@ -238,6 +256,12 @@ function isValidTimeZone(tz) {
238
256
  }
239
257
  }
240
258
  function resolveScheduling(config) {
259
+ const result = validateScheduling(config);
260
+ if (result.ok) return result.value;
261
+ const detail = Object.entries(result.errors).map(([field, message]) => `${field}: ${message}`).join(" ");
262
+ throw new Error(`scheduling: ${detail}`);
263
+ }
264
+ function validateScheduling(config) {
241
265
  const d = config ?? {};
242
266
  const c = {
243
267
  slotMinutes: d.slotMinutes ?? SCHEDULING_DEFAULTS.slotMinutes,
@@ -249,20 +273,29 @@ function resolveScheduling(config) {
249
273
  windowDays: d.windowDays ?? SCHEDULING_DEFAULTS.windowDays,
250
274
  summaryTemplate: d.summaryTemplate ?? SCHEDULING_DEFAULTS.summaryTemplate
251
275
  };
252
- const fail2 = (msg) => {
253
- throw new Error(`scheduling: ${msg}`);
254
- };
255
- if (!(c.slotMinutes >= 15 && c.slotMinutes <= 240)) fail2("slotMinutes must be 15\u2013240");
256
- if (!(c.windowDays >= 1 && c.windowDays <= 62)) fail2("windowDays must be 1\u201362 (FreeBusy caps at 62)");
257
- if (!(c.minNoticeHours >= 0 && c.minNoticeHours <= 336)) fail2("minNoticeHours must be 0\u2013336");
258
- if (!(c.startHour >= 0 && c.startHour < c.endHour && c.endHour <= 24)) fail2("require 0 \u2264 startHour < endHour \u2264 24");
276
+ const errors = {};
277
+ if (!(c.slotMinutes >= 15 && c.slotMinutes <= 240)) {
278
+ errors.slotMinutes = "Slot length must be between 15 and 240 minutes.";
279
+ }
280
+ if (!(c.windowDays >= 1 && c.windowDays <= 62)) {
281
+ errors.windowDays = "Booking window must be between 1 and 62 days (the calendar caps look-ahead at 62).";
282
+ }
283
+ if (!(c.minNoticeHours >= 0 && c.minNoticeHours <= 336)) {
284
+ errors.minNoticeHours = "Minimum notice must be between 0 and 336 hours.";
285
+ }
286
+ if (!(c.startHour >= 0 && c.startHour < c.endHour && c.endHour <= 24)) {
287
+ errors.hours = "Hours must satisfy 0 \u2264 start < end \u2264 24.";
288
+ }
259
289
  const days = [...c.days];
260
- if (!days.length || !days.every((n) => Number.isInteger(n) && n >= 0 && n <= 6)) {
261
- fail2("days must be a non-empty list of weekday integers 0\u20136");
290
+ if (!days.length) errors.days = "Pick at least one day.";
291
+ else if (!days.every((n) => Number.isInteger(n) && n >= 0 && n <= 6)) {
292
+ errors.days = "Days must be weekday numbers, 0 (Sunday) through 6 (Saturday).";
262
293
  }
263
- if (typeof c.timezone !== "string" || !isValidTimeZone(c.timezone)) fail2(`invalid IANA timezone "${c.timezone}"`);
264
- if (typeof c.summaryTemplate !== "string") fail2("summaryTemplate must be a string");
265
- return { ...c, days };
294
+ if (typeof c.timezone !== "string" || !isValidTimeZone(c.timezone)) {
295
+ errors.timezone = `"${String(c.timezone)}" is not a valid IANA timezone (for example "America/Los_Angeles").`;
296
+ }
297
+ if (typeof c.summaryTemplate !== "string") errors.summaryTemplate = "Calendar summary template must be text.";
298
+ return Object.keys(errors).length > 0 ? { ok: false, errors } : { ok: true, value: { ...c, days } };
266
299
  }
267
300
  function slotWindow(now, windowDays) {
268
301
  return { from: now, to: now + windowDays * 864e5 };
@@ -592,7 +625,9 @@ var handleMember = async (req, url, env, ctx) => {
592
625
  });
593
626
  if (!result.ok) return json({ error: result.error }, 400);
594
627
  if (!result.duplicate) {
595
- await notifyAdminOfApplication(db, env, chapter.id, result.id, parsed);
628
+ if (chapter.sends.adminNotification === "submit") {
629
+ await notifyAdminOfApplication(db, env, chapter.id, result.id, parsed);
630
+ }
596
631
  await provisionApplicant(db, chapter, result.id, parsed);
597
632
  }
598
633
  return json({ id: result.id, duplicate: result.duplicate, status: result.status });
@@ -972,6 +1007,25 @@ async function notifyPaymentConfirmed(db, env, eventId, app) {
972
1007
  } catch {
973
1008
  }
974
1009
  }
1010
+ async function notifyAdminOfPayment(db, env, eventId, app) {
1011
+ try {
1012
+ const group = await firstRow2(db, "groups", { where: { id: String(app.groupId ?? "") }, limit: 1 });
1013
+ if (!group || typeof group.notificationEmail !== "string" || !group.notificationEmail) return;
1014
+ const s = (v) => typeof v === "string" ? v : "";
1015
+ await sendTemplated(
1016
+ { db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
1017
+ {
1018
+ group: emailGroupFrom(group),
1019
+ template: "adminNotification",
1020
+ to: group.notificationEmail,
1021
+ vars: { firstName: s(app.firstName), lastName: s(app.lastName), email: s(app.email), phone: s(app.phone), state: s(app.state) },
1022
+ dedupeKey: `${eventId}:admin`,
1023
+ applicationId: String(app.id)
1024
+ }
1025
+ );
1026
+ } catch {
1027
+ }
1028
+ }
975
1029
  function webhookPatch(event, status) {
976
1030
  switch (event.kind) {
977
1031
  case "first_payment":
@@ -1043,7 +1097,10 @@ async function ingestWebhook(req, env, ctx) {
1043
1097
  if (Object.keys(patch).length) {
1044
1098
  await db.transact([{ t: "update", ns: "applications", id: String(app.id), attrs: patch }], { mutationId: webhookMutationId(eventId) });
1045
1099
  }
1046
- if (event.kind === "first_payment") await notifyPaymentConfirmed(db, env, eventId, app);
1100
+ if (event.kind === "first_payment") {
1101
+ await notifyPaymentConfirmed(db, env, eventId, app);
1102
+ if (ctx.chapter.sends.adminNotification === "payment") await notifyAdminOfPayment(db, env, eventId, app);
1103
+ }
1047
1104
  return json({ ok: true });
1048
1105
  }
1049
1106
  async function refundApplication(req, url, env, ctx) {
@@ -1140,14 +1197,10 @@ var handleAdminScheduling = async (req, url, env, ctx) => {
1140
1197
  } catch {
1141
1198
  return json({ error: "invalid JSON body" }, 400);
1142
1199
  }
1143
- let resolved;
1144
- try {
1145
- resolved = resolveScheduling(body);
1146
- } catch (e) {
1147
- return json({ error: e instanceof Error ? e.message : "invalid scheduling config" }, 400);
1148
- }
1149
- await db.transact([{ t: "update", ns: "groups", id: String(group.id), attrs: { schedulingJson: resolved } }]);
1150
- return json({ scheduling: resolved });
1200
+ const checked = validateScheduling(body);
1201
+ if (!checked.ok) return json({ error: "invalid scheduling config", errors: checked.errors }, 400);
1202
+ await db.transact([{ t: "update", ns: "groups", id: String(group.id), attrs: { schedulingJson: checked.value } }]);
1203
+ return json({ scheduling: checked.value });
1151
1204
  };
1152
1205
  async function upcomingEvents(env) {
1153
1206
  const cal = initCalendar2({ appId: env.ODLA_APP_ID, env: env.ODLA_ENV, adminToken: env.ODLA_API_KEY, endpoint: env.ODLA_PLATFORM });
@@ -1170,8 +1223,15 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
1170
1223
  const u = await ctx.verifyUser(req, env);
1171
1224
  if (!u || !await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
1172
1225
  const db = rawDb;
1173
- const query = await db.query({ meetings: { $: { where: { status: "scheduled" }, order: { startAt: "asc" }, limit: 500 } } });
1174
- const rows = Array.isArray(query.meetings) ? query.meetings : [];
1226
+ const all = url.searchParams.get("all") === "1";
1227
+ const from = Number(url.searchParams.get("from"));
1228
+ const to = Number(url.searchParams.get("to"));
1229
+ const query = await db.query({
1230
+ meetings: { $: { where: all ? {} : { status: "scheduled" }, order: { startAt: "asc" }, limit: 500 } }
1231
+ });
1232
+ let rows = Array.isArray(query.meetings) ? query.meetings : [];
1233
+ if (Number.isFinite(from)) rows = rows.filter((m) => Number(m.startAt ?? 0) >= from);
1234
+ if (Number.isFinite(to)) rows = rows.filter((m) => Number(m.startAt ?? 0) <= to);
1175
1235
  let decisions = [];
1176
1236
  try {
1177
1237
  decisions = reconcileMeetings(toReconcile(rows), await upcomingEvents(env), Date.now());
@@ -1189,8 +1249,20 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
1189
1249
  } catch {
1190
1250
  }
1191
1251
  }
1192
- const meetings = rows.map((m) => ({ ...m, ...patched.get(String(m.id)) ?? {} })).filter((m) => m.status === "scheduled");
1193
- return json({ meetings, adopted: decisions.length });
1252
+ const appQuery = await db.query({ applications: { $: { limit: 1e3 } } });
1253
+ const byId = /* @__PURE__ */ new Map();
1254
+ for (const a of Array.isArray(appQuery.applications) ? appQuery.applications : []) {
1255
+ byId.set(String(a.id), a);
1256
+ }
1257
+ const applicantOf = (m) => {
1258
+ const a = byId.get(String(m.applicationId));
1259
+ if (!a) return null;
1260
+ return { id: a.id, firstName: a.firstName, lastName: a.lastName, email: a.email, status: a.status };
1261
+ };
1262
+ const group = (await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups?.[0];
1263
+ const timezone = resolveScheduling(group?.schedulingJson).timezone;
1264
+ const meetings = rows.map((m) => ({ ...m, ...patched.get(String(m.id)) ?? {}, applicant: applicantOf(m) })).filter((m) => all || m.status === "scheduled");
1265
+ return json({ meetings, adopted: decisions.length, timezone });
1194
1266
  };
1195
1267
 
1196
1268
  // src/worker.ts