@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.
- package/README.md +36 -14
- package/dist/index.cjs +55 -13
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +39 -2
- package/dist/index.d.ts +39 -2
- package/dist/index.js +55 -13
- package/dist/index.js.map +1 -1
- package/dist/worker/index.cjs +99 -27
- package/dist/worker/index.cjs.map +1 -1
- package/dist/worker/index.d.cts +17 -0
- package/dist/worker/index.d.ts +17 -0
- package/dist/worker/index.js +99 -27
- package/dist/worker/index.js.map +1 -1
- package/package.json +1 -5
package/dist/worker/index.d.cts
CHANGED
|
@@ -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
|
}
|
package/dist/worker/index.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/worker/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if (!(c.windowDays >= 1 && c.windowDays <= 62))
|
|
257
|
-
|
|
258
|
-
|
|
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
|
|
261
|
-
|
|
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))
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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")
|
|
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
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
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
|
|
1174
|
-
const
|
|
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
|
|
1193
|
-
|
|
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
|