@odla-ai/chapter 0.8.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
  }
@@ -95,13 +95,15 @@ function createWorkerContext(options) {
95
95
  import { createCrmRoutes } from "@odla-ai/crm";
96
96
 
97
97
  // src/clerk.ts
98
+ var heal = (status) => status === 422 ? { ok: true, status, existed: true } : { ok: false, status };
98
99
  function clerkInviteRequest(input) {
99
100
  return {
100
101
  path: "/v1/invitations",
101
102
  body: {
102
103
  email_address: input.email,
103
104
  notify: true,
104
- ...input.redirectUrl ? { redirect_url: input.redirectUrl } : {}
105
+ ...input.redirectUrl ? { redirect_url: input.redirectUrl } : {},
106
+ ...input.publicMetadata ? { public_metadata: input.publicMetadata } : {}
105
107
  }
106
108
  };
107
109
  }
@@ -112,7 +114,7 @@ async function createClerkInvitation(secretKey, input, fetchImpl = fetch) {
112
114
  headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
113
115
  body: JSON.stringify(body)
114
116
  });
115
- return { ok: res.ok, status: res.status };
117
+ return res.ok ? { ok: true, status: res.status } : heal(res.status);
116
118
  }
117
119
  function clerkUserRequest(input) {
118
120
  return {
@@ -121,10 +123,25 @@ function clerkUserRequest(input) {
121
123
  email_address: [input.email],
122
124
  skip_password_requirement: true,
123
125
  ...input.firstName ? { first_name: input.firstName } : {},
124
- ...input.lastName ? { last_name: input.lastName } : {}
126
+ ...input.lastName ? { last_name: input.lastName } : {},
127
+ ...input.publicMetadata ? { public_metadata: input.publicMetadata } : {}
125
128
  }
126
129
  };
127
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
+ }
128
145
  async function createClerkUser(secretKey, input, fetchImpl = fetch) {
129
146
  const { path, body } = clerkUserRequest(input);
130
147
  const res = await fetchImpl(`https://api.clerk.com${path}`, {
@@ -132,7 +149,11 @@ async function createClerkUser(secretKey, input, fetchImpl = fetch) {
132
149
  headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
133
150
  body: JSON.stringify(body)
134
151
  });
135
- return { ok: res.ok, status: 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 };
136
157
  }
137
158
 
138
159
  // src/member.ts
@@ -235,6 +256,12 @@ function isValidTimeZone(tz) {
235
256
  }
236
257
  }
237
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) {
238
265
  const d = config ?? {};
239
266
  const c = {
240
267
  slotMinutes: d.slotMinutes ?? SCHEDULING_DEFAULTS.slotMinutes,
@@ -246,20 +273,29 @@ function resolveScheduling(config) {
246
273
  windowDays: d.windowDays ?? SCHEDULING_DEFAULTS.windowDays,
247
274
  summaryTemplate: d.summaryTemplate ?? SCHEDULING_DEFAULTS.summaryTemplate
248
275
  };
249
- const fail2 = (msg) => {
250
- throw new Error(`scheduling: ${msg}`);
251
- };
252
- if (!(c.slotMinutes >= 15 && c.slotMinutes <= 240)) fail2("slotMinutes must be 15\u2013240");
253
- if (!(c.windowDays >= 1 && c.windowDays <= 62)) fail2("windowDays must be 1\u201362 (FreeBusy caps at 62)");
254
- if (!(c.minNoticeHours >= 0 && c.minNoticeHours <= 336)) fail2("minNoticeHours must be 0\u2013336");
255
- 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
+ }
256
289
  const days = [...c.days];
257
- if (!days.length || !days.every((n) => Number.isInteger(n) && n >= 0 && n <= 6)) {
258
- 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).";
293
+ }
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").`;
259
296
  }
260
- if (typeof c.timezone !== "string" || !isValidTimeZone(c.timezone)) fail2(`invalid IANA timezone "${c.timezone}"`);
261
- if (typeof c.summaryTemplate !== "string") fail2("summaryTemplate must be a string");
262
- return { ...c, days };
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 } };
263
299
  }
264
300
  function slotWindow(now, windowDays) {
265
301
  return { from: now, to: now + windowDays * 864e5 };
@@ -589,7 +625,9 @@ var handleMember = async (req, url, env, ctx) => {
589
625
  });
590
626
  if (!result.ok) return json({ error: result.error }, 400);
591
627
  if (!result.duplicate) {
592
- 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
+ }
593
631
  await provisionApplicant(db, chapter, result.id, parsed);
594
632
  }
595
633
  return json({ id: result.id, duplicate: result.duplicate, status: result.status });
@@ -969,6 +1007,25 @@ async function notifyPaymentConfirmed(db, env, eventId, app) {
969
1007
  } catch {
970
1008
  }
971
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
+ }
972
1029
  function webhookPatch(event, status) {
973
1030
  switch (event.kind) {
974
1031
  case "first_payment":
@@ -1040,7 +1097,10 @@ async function ingestWebhook(req, env, ctx) {
1040
1097
  if (Object.keys(patch).length) {
1041
1098
  await db.transact([{ t: "update", ns: "applications", id: String(app.id), attrs: patch }], { mutationId: webhookMutationId(eventId) });
1042
1099
  }
1043
- 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
+ }
1044
1104
  return json({ ok: true });
1045
1105
  }
1046
1106
  async function refundApplication(req, url, env, ctx) {
@@ -1137,14 +1197,10 @@ var handleAdminScheduling = async (req, url, env, ctx) => {
1137
1197
  } catch {
1138
1198
  return json({ error: "invalid JSON body" }, 400);
1139
1199
  }
1140
- let resolved;
1141
- try {
1142
- resolved = resolveScheduling(body);
1143
- } catch (e) {
1144
- return json({ error: e instanceof Error ? e.message : "invalid scheduling config" }, 400);
1145
- }
1146
- await db.transact([{ t: "update", ns: "groups", id: String(group.id), attrs: { schedulingJson: resolved } }]);
1147
- 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 });
1148
1204
  };
1149
1205
  async function upcomingEvents(env) {
1150
1206
  const cal = initCalendar2({ appId: env.ODLA_APP_ID, env: env.ODLA_ENV, adminToken: env.ODLA_API_KEY, endpoint: env.ODLA_PLATFORM });
@@ -1167,8 +1223,15 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
1167
1223
  const u = await ctx.verifyUser(req, env);
1168
1224
  if (!u || !await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
1169
1225
  const db = rawDb;
1170
- const query = await db.query({ meetings: { $: { where: { status: "scheduled" }, order: { startAt: "asc" }, limit: 500 } } });
1171
- 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);
1172
1235
  let decisions = [];
1173
1236
  try {
1174
1237
  decisions = reconcileMeetings(toReconcile(rows), await upcomingEvents(env), Date.now());
@@ -1186,8 +1249,20 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
1186
1249
  } catch {
1187
1250
  }
1188
1251
  }
1189
- const meetings = rows.map((m) => ({ ...m, ...patched.get(String(m.id)) ?? {} })).filter((m) => m.status === "scheduled");
1190
- 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 });
1191
1266
  };
1192
1267
 
1193
1268
  // src/worker.ts