@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.
- package/README.md +48 -14
- package/dist/index.cjs +68 -13
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +47 -2
- package/dist/index.d.ts +47 -2
- package/dist/index.js +68 -13
- package/dist/index.js.map +1 -1
- package/dist/worker/index.cjs +118 -29
- 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 +118 -29
- 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,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
|
-
|
|
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
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if (!(c.windowDays >= 1 && c.windowDays <= 62))
|
|
257
|
-
|
|
258
|
-
|
|
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
|
|
261
|
-
|
|
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.
|
|
264
|
-
|
|
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
|
-
|
|
459
|
-
|
|
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
|
-
|
|
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")
|
|
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
|
-
|
|
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 });
|
|
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
|
|
1174
|
-
const
|
|
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
|
|
1193
|
-
|
|
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
|