@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.
@@ -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,10 +149,25 @@ 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
160
+ var IDENTITY_FIELDS = /* @__PURE__ */ new Set(["email", "firstName", "lastName"]);
161
+ function applicantProfile(chapter, fields) {
162
+ const profile = {};
163
+ for (const f of [...chapter.application.required, ...chapter.application.optional]) {
164
+ if (IDENTITY_FIELDS.has(f)) continue;
165
+ const v = fields[f];
166
+ if (typeof v === "string" && v.trim() !== "") profile[f] = v.trim();
167
+ }
168
+ if (fields.focus !== void 0) profile.focus = fields.focus;
169
+ return Object.keys(profile).length > 0 ? profile : void 0;
170
+ }
142
171
  async function submitApplication(db, chapter, fields, opts) {
143
172
  const app = chapter.application;
144
173
  for (const f of app.required) {
@@ -157,6 +186,7 @@ async function submitApplication(db, chapter, fields, opts) {
157
186
  }
158
187
  if (fields.focus !== void 0) row.focus = fields.focus;
159
188
  if (opts.groupId) row.groupId = opts.groupId;
189
+ if (fields.disclaimerAck === true || fields.disclaimerAck === "true") row.disclaimerAckAt = opts.now;
160
190
  const { duplicate } = await db.transact(
161
191
  [{ t: "update", ns: "applications", id, attrs: row }],
162
192
  opts.submissionId ? { mutationId: `join:${opts.submissionId}` } : void 0
@@ -238,6 +268,12 @@ function isValidTimeZone(tz) {
238
268
  }
239
269
  }
240
270
  function resolveScheduling(config) {
271
+ const result = validateScheduling(config);
272
+ if (result.ok) return result.value;
273
+ const detail = Object.entries(result.errors).map(([field, message]) => `${field}: ${message}`).join(" ");
274
+ throw new Error(`scheduling: ${detail}`);
275
+ }
276
+ function validateScheduling(config) {
241
277
  const d = config ?? {};
242
278
  const c = {
243
279
  slotMinutes: d.slotMinutes ?? SCHEDULING_DEFAULTS.slotMinutes,
@@ -249,20 +285,29 @@ function resolveScheduling(config) {
249
285
  windowDays: d.windowDays ?? SCHEDULING_DEFAULTS.windowDays,
250
286
  summaryTemplate: d.summaryTemplate ?? SCHEDULING_DEFAULTS.summaryTemplate
251
287
  };
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");
288
+ const errors = {};
289
+ if (!(c.slotMinutes >= 15 && c.slotMinutes <= 240)) {
290
+ errors.slotMinutes = "Slot length must be between 15 and 240 minutes.";
291
+ }
292
+ if (!(c.windowDays >= 1 && c.windowDays <= 62)) {
293
+ errors.windowDays = "Booking window must be between 1 and 62 days (the calendar caps look-ahead at 62).";
294
+ }
295
+ if (!(c.minNoticeHours >= 0 && c.minNoticeHours <= 336)) {
296
+ errors.minNoticeHours = "Minimum notice must be between 0 and 336 hours.";
297
+ }
298
+ if (!(c.startHour >= 0 && c.startHour < c.endHour && c.endHour <= 24)) {
299
+ errors.hours = "Hours must satisfy 0 \u2264 start < end \u2264 24.";
300
+ }
259
301
  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");
302
+ if (!days.length) errors.days = "Pick at least one day.";
303
+ else if (!days.every((n) => Number.isInteger(n) && n >= 0 && n <= 6)) {
304
+ errors.days = "Days must be weekday numbers, 0 (Sunday) through 6 (Saturday).";
305
+ }
306
+ if (typeof c.timezone !== "string" || !isValidTimeZone(c.timezone)) {
307
+ errors.timezone = `"${String(c.timezone)}" is not a valid IANA timezone (for example "America/Los_Angeles").`;
262
308
  }
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 };
309
+ if (typeof c.summaryTemplate !== "string") errors.summaryTemplate = "Calendar summary template must be text.";
310
+ return Object.keys(errors).length > 0 ? { ok: false, errors } : { ok: true, value: { ...c, days } };
266
311
  }
267
312
  function slotWindow(now, windowDays) {
268
313
  return { from: now, to: now + windowDays * 864e5 };
@@ -455,8 +500,13 @@ async function provisionApplicant(db, chapter, applicationId, fields) {
455
500
  try {
456
501
  const secret = await getVaultSecret(db, "clerk_secret_key");
457
502
  if (secret) {
458
- if (chapter.account === "create") await createClerkUser(secret, { email, firstName: s(fields.firstName), lastName: s(fields.lastName) });
459
- else await createClerkInvitation(secret, { email });
503
+ const profile = applicantProfile(chapter, fields);
504
+ const publicMetadata = { applicationId, ...profile ? { profile } : {} };
505
+ if (chapter.account === "create") {
506
+ await createClerkUser(secret, { email, firstName: s(fields.firstName), lastName: s(fields.lastName), publicMetadata });
507
+ } else {
508
+ await createClerkInvitation(secret, { email, publicMetadata });
509
+ }
460
510
  }
461
511
  } catch {
462
512
  }
@@ -592,7 +642,9 @@ var handleMember = async (req, url, env, ctx) => {
592
642
  });
593
643
  if (!result.ok) return json({ error: result.error }, 400);
594
644
  if (!result.duplicate) {
595
- await notifyAdminOfApplication(db, env, chapter.id, result.id, parsed);
645
+ if (chapter.sends.adminNotification === "submit") {
646
+ await notifyAdminOfApplication(db, env, chapter.id, result.id, parsed);
647
+ }
596
648
  await provisionApplicant(db, chapter, result.id, parsed);
597
649
  }
598
650
  return json({ id: result.id, duplicate: result.duplicate, status: result.status });
@@ -972,6 +1024,25 @@ async function notifyPaymentConfirmed(db, env, eventId, app) {
972
1024
  } catch {
973
1025
  }
974
1026
  }
1027
+ async function notifyAdminOfPayment(db, env, eventId, app) {
1028
+ try {
1029
+ const group = await firstRow2(db, "groups", { where: { id: String(app.groupId ?? "") }, limit: 1 });
1030
+ if (!group || typeof group.notificationEmail !== "string" || !group.notificationEmail) return;
1031
+ const s = (v) => typeof v === "string" ? v : "";
1032
+ await sendTemplated(
1033
+ { db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
1034
+ {
1035
+ group: emailGroupFrom(group),
1036
+ template: "adminNotification",
1037
+ to: group.notificationEmail,
1038
+ vars: { firstName: s(app.firstName), lastName: s(app.lastName), email: s(app.email), phone: s(app.phone), state: s(app.state) },
1039
+ dedupeKey: `${eventId}:admin`,
1040
+ applicationId: String(app.id)
1041
+ }
1042
+ );
1043
+ } catch {
1044
+ }
1045
+ }
975
1046
  function webhookPatch(event, status) {
976
1047
  switch (event.kind) {
977
1048
  case "first_payment":
@@ -1043,7 +1114,10 @@ async function ingestWebhook(req, env, ctx) {
1043
1114
  if (Object.keys(patch).length) {
1044
1115
  await db.transact([{ t: "update", ns: "applications", id: String(app.id), attrs: patch }], { mutationId: webhookMutationId(eventId) });
1045
1116
  }
1046
- if (event.kind === "first_payment") await notifyPaymentConfirmed(db, env, eventId, app);
1117
+ if (event.kind === "first_payment") {
1118
+ await notifyPaymentConfirmed(db, env, eventId, app);
1119
+ if (ctx.chapter.sends.adminNotification === "payment") await notifyAdminOfPayment(db, env, eventId, app);
1120
+ }
1047
1121
  return json({ ok: true });
1048
1122
  }
1049
1123
  async function refundApplication(req, url, env, ctx) {
@@ -1140,14 +1214,10 @@ var handleAdminScheduling = async (req, url, env, ctx) => {
1140
1214
  } catch {
1141
1215
  return json({ error: "invalid JSON body" }, 400);
1142
1216
  }
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 });
1217
+ const checked = validateScheduling(body);
1218
+ if (!checked.ok) return json({ error: "invalid scheduling config", errors: checked.errors }, 400);
1219
+ await db.transact([{ t: "update", ns: "groups", id: String(group.id), attrs: { schedulingJson: checked.value } }]);
1220
+ return json({ scheduling: checked.value });
1151
1221
  };
1152
1222
  async function upcomingEvents(env) {
1153
1223
  const cal = initCalendar2({ appId: env.ODLA_APP_ID, env: env.ODLA_ENV, adminToken: env.ODLA_API_KEY, endpoint: env.ODLA_PLATFORM });
@@ -1170,8 +1240,15 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
1170
1240
  const u = await ctx.verifyUser(req, env);
1171
1241
  if (!u || !await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
1172
1242
  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 : [];
1243
+ const all = url.searchParams.get("all") === "1";
1244
+ const from = Number(url.searchParams.get("from"));
1245
+ const to = Number(url.searchParams.get("to"));
1246
+ const query = await db.query({
1247
+ meetings: { $: { where: all ? {} : { status: "scheduled" }, order: { startAt: "asc" }, limit: 500 } }
1248
+ });
1249
+ let rows = Array.isArray(query.meetings) ? query.meetings : [];
1250
+ if (Number.isFinite(from)) rows = rows.filter((m) => Number(m.startAt ?? 0) >= from);
1251
+ if (Number.isFinite(to)) rows = rows.filter((m) => Number(m.startAt ?? 0) <= to);
1175
1252
  let decisions = [];
1176
1253
  try {
1177
1254
  decisions = reconcileMeetings(toReconcile(rows), await upcomingEvents(env), Date.now());
@@ -1189,8 +1266,20 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
1189
1266
  } catch {
1190
1267
  }
1191
1268
  }
1192
- const meetings = rows.map((m) => ({ ...m, ...patched.get(String(m.id)) ?? {} })).filter((m) => m.status === "scheduled");
1193
- return json({ meetings, adopted: decisions.length });
1269
+ const appQuery = await db.query({ applications: { $: { limit: 1e3 } } });
1270
+ const byId = /* @__PURE__ */ new Map();
1271
+ for (const a of Array.isArray(appQuery.applications) ? appQuery.applications : []) {
1272
+ byId.set(String(a.id), a);
1273
+ }
1274
+ const applicantOf = (m) => {
1275
+ const a = byId.get(String(m.applicationId));
1276
+ if (!a) return null;
1277
+ return { id: a.id, firstName: a.firstName, lastName: a.lastName, email: a.email, status: a.status };
1278
+ };
1279
+ const group = (await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups?.[0];
1280
+ const timezone = resolveScheduling(group?.schedulingJson).timezone;
1281
+ const meetings = rows.map((m) => ({ ...m, ...patched.get(String(m.id)) ?? {}, applicant: applicantOf(m) })).filter((m) => all || m.status === "scheduled");
1282
+ return json({ meetings, adopted: decisions.length, timezone });
1194
1283
  };
1195
1284
 
1196
1285
  // src/worker.ts