@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.cjs
CHANGED
|
@@ -155,6 +155,20 @@ function clerkUserRequest(input) {
|
|
|
155
155
|
}
|
|
156
156
|
};
|
|
157
157
|
}
|
|
158
|
+
async function refreshUserMetadata(secretKey, email, publicMetadata, fetchImpl) {
|
|
159
|
+
const auth = { authorization: `Bearer ${secretKey}` };
|
|
160
|
+
const found = await fetchImpl(`https://api.clerk.com/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, { headers: auth });
|
|
161
|
+
if (!found.ok) return false;
|
|
162
|
+
const users = await found.json().catch(() => null);
|
|
163
|
+
const id = Array.isArray(users) && typeof users[0]?.id === "string" ? users[0].id : void 0;
|
|
164
|
+
if (!id) return false;
|
|
165
|
+
const patched = await fetchImpl(`https://api.clerk.com/v1/users/${id}/metadata`, {
|
|
166
|
+
method: "PATCH",
|
|
167
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
168
|
+
body: JSON.stringify({ public_metadata: publicMetadata })
|
|
169
|
+
});
|
|
170
|
+
return patched.ok;
|
|
171
|
+
}
|
|
158
172
|
async function createClerkUser(secretKey, input, fetchImpl = fetch) {
|
|
159
173
|
const { path, body } = clerkUserRequest(input);
|
|
160
174
|
const res = await fetchImpl(`https://api.clerk.com${path}`, {
|
|
@@ -162,10 +176,25 @@ async function createClerkUser(secretKey, input, fetchImpl = fetch) {
|
|
|
162
176
|
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
163
177
|
body: JSON.stringify(body)
|
|
164
178
|
});
|
|
165
|
-
|
|
179
|
+
if (res.ok) return { ok: true, status: res.status };
|
|
180
|
+
const healed = heal(res.status);
|
|
181
|
+
if (!healed.existed || !input.publicMetadata) return healed;
|
|
182
|
+
const refreshed = await refreshUserMetadata(secretKey, input.email, input.publicMetadata, fetchImpl).catch(() => false);
|
|
183
|
+
return { ...healed, refreshed };
|
|
166
184
|
}
|
|
167
185
|
|
|
168
186
|
// src/member.ts
|
|
187
|
+
var IDENTITY_FIELDS = /* @__PURE__ */ new Set(["email", "firstName", "lastName"]);
|
|
188
|
+
function applicantProfile(chapter, fields) {
|
|
189
|
+
const profile = {};
|
|
190
|
+
for (const f of [...chapter.application.required, ...chapter.application.optional]) {
|
|
191
|
+
if (IDENTITY_FIELDS.has(f)) continue;
|
|
192
|
+
const v = fields[f];
|
|
193
|
+
if (typeof v === "string" && v.trim() !== "") profile[f] = v.trim();
|
|
194
|
+
}
|
|
195
|
+
if (fields.focus !== void 0) profile.focus = fields.focus;
|
|
196
|
+
return Object.keys(profile).length > 0 ? profile : void 0;
|
|
197
|
+
}
|
|
169
198
|
async function submitApplication(db, chapter, fields, opts) {
|
|
170
199
|
const app = chapter.application;
|
|
171
200
|
for (const f of app.required) {
|
|
@@ -184,6 +213,7 @@ async function submitApplication(db, chapter, fields, opts) {
|
|
|
184
213
|
}
|
|
185
214
|
if (fields.focus !== void 0) row.focus = fields.focus;
|
|
186
215
|
if (opts.groupId) row.groupId = opts.groupId;
|
|
216
|
+
if (fields.disclaimerAck === true || fields.disclaimerAck === "true") row.disclaimerAckAt = opts.now;
|
|
187
217
|
const { duplicate } = await db.transact(
|
|
188
218
|
[{ t: "update", ns: "applications", id, attrs: row }],
|
|
189
219
|
opts.submissionId ? { mutationId: `join:${opts.submissionId}` } : void 0
|
|
@@ -265,6 +295,12 @@ function isValidTimeZone(tz) {
|
|
|
265
295
|
}
|
|
266
296
|
}
|
|
267
297
|
function resolveScheduling(config) {
|
|
298
|
+
const result = validateScheduling(config);
|
|
299
|
+
if (result.ok) return result.value;
|
|
300
|
+
const detail = Object.entries(result.errors).map(([field, message]) => `${field}: ${message}`).join(" ");
|
|
301
|
+
throw new Error(`scheduling: ${detail}`);
|
|
302
|
+
}
|
|
303
|
+
function validateScheduling(config) {
|
|
268
304
|
const d = config ?? {};
|
|
269
305
|
const c = {
|
|
270
306
|
slotMinutes: d.slotMinutes ?? SCHEDULING_DEFAULTS.slotMinutes,
|
|
@@ -276,20 +312,29 @@ function resolveScheduling(config) {
|
|
|
276
312
|
windowDays: d.windowDays ?? SCHEDULING_DEFAULTS.windowDays,
|
|
277
313
|
summaryTemplate: d.summaryTemplate ?? SCHEDULING_DEFAULTS.summaryTemplate
|
|
278
314
|
};
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
if (!(c.windowDays >= 1 && c.windowDays <= 62))
|
|
284
|
-
|
|
285
|
-
|
|
315
|
+
const errors = {};
|
|
316
|
+
if (!(c.slotMinutes >= 15 && c.slotMinutes <= 240)) {
|
|
317
|
+
errors.slotMinutes = "Slot length must be between 15 and 240 minutes.";
|
|
318
|
+
}
|
|
319
|
+
if (!(c.windowDays >= 1 && c.windowDays <= 62)) {
|
|
320
|
+
errors.windowDays = "Booking window must be between 1 and 62 days (the calendar caps look-ahead at 62).";
|
|
321
|
+
}
|
|
322
|
+
if (!(c.minNoticeHours >= 0 && c.minNoticeHours <= 336)) {
|
|
323
|
+
errors.minNoticeHours = "Minimum notice must be between 0 and 336 hours.";
|
|
324
|
+
}
|
|
325
|
+
if (!(c.startHour >= 0 && c.startHour < c.endHour && c.endHour <= 24)) {
|
|
326
|
+
errors.hours = "Hours must satisfy 0 \u2264 start < end \u2264 24.";
|
|
327
|
+
}
|
|
286
328
|
const days = [...c.days];
|
|
287
|
-
if (!days.length
|
|
288
|
-
|
|
329
|
+
if (!days.length) errors.days = "Pick at least one day.";
|
|
330
|
+
else if (!days.every((n) => Number.isInteger(n) && n >= 0 && n <= 6)) {
|
|
331
|
+
errors.days = "Days must be weekday numbers, 0 (Sunday) through 6 (Saturday).";
|
|
332
|
+
}
|
|
333
|
+
if (typeof c.timezone !== "string" || !isValidTimeZone(c.timezone)) {
|
|
334
|
+
errors.timezone = `"${String(c.timezone)}" is not a valid IANA timezone (for example "America/Los_Angeles").`;
|
|
289
335
|
}
|
|
290
|
-
if (typeof c.
|
|
291
|
-
|
|
292
|
-
return { ...c, days };
|
|
336
|
+
if (typeof c.summaryTemplate !== "string") errors.summaryTemplate = "Calendar summary template must be text.";
|
|
337
|
+
return Object.keys(errors).length > 0 ? { ok: false, errors } : { ok: true, value: { ...c, days } };
|
|
293
338
|
}
|
|
294
339
|
function slotWindow(now, windowDays) {
|
|
295
340
|
return { from: now, to: now + windowDays * 864e5 };
|
|
@@ -482,8 +527,13 @@ async function provisionApplicant(db, chapter, applicationId, fields) {
|
|
|
482
527
|
try {
|
|
483
528
|
const secret = await getVaultSecret(db, "clerk_secret_key");
|
|
484
529
|
if (secret) {
|
|
485
|
-
|
|
486
|
-
|
|
530
|
+
const profile = applicantProfile(chapter, fields);
|
|
531
|
+
const publicMetadata = { applicationId, ...profile ? { profile } : {} };
|
|
532
|
+
if (chapter.account === "create") {
|
|
533
|
+
await createClerkUser(secret, { email, firstName: s(fields.firstName), lastName: s(fields.lastName), publicMetadata });
|
|
534
|
+
} else {
|
|
535
|
+
await createClerkInvitation(secret, { email, publicMetadata });
|
|
536
|
+
}
|
|
487
537
|
}
|
|
488
538
|
} catch {
|
|
489
539
|
}
|
|
@@ -619,7 +669,9 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
619
669
|
});
|
|
620
670
|
if (!result.ok) return json({ error: result.error }, 400);
|
|
621
671
|
if (!result.duplicate) {
|
|
622
|
-
|
|
672
|
+
if (chapter.sends.adminNotification === "submit") {
|
|
673
|
+
await notifyAdminOfApplication(db, env, chapter.id, result.id, parsed);
|
|
674
|
+
}
|
|
623
675
|
await provisionApplicant(db, chapter, result.id, parsed);
|
|
624
676
|
}
|
|
625
677
|
return json({ id: result.id, duplicate: result.duplicate, status: result.status });
|
|
@@ -999,6 +1051,25 @@ async function notifyPaymentConfirmed(db, env, eventId, app) {
|
|
|
999
1051
|
} catch {
|
|
1000
1052
|
}
|
|
1001
1053
|
}
|
|
1054
|
+
async function notifyAdminOfPayment(db, env, eventId, app) {
|
|
1055
|
+
try {
|
|
1056
|
+
const group = await firstRow2(db, "groups", { where: { id: String(app.groupId ?? "") }, limit: 1 });
|
|
1057
|
+
if (!group || typeof group.notificationEmail !== "string" || !group.notificationEmail) return;
|
|
1058
|
+
const s = (v) => typeof v === "string" ? v : "";
|
|
1059
|
+
await sendTemplated(
|
|
1060
|
+
{ db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
|
|
1061
|
+
{
|
|
1062
|
+
group: emailGroupFrom(group),
|
|
1063
|
+
template: "adminNotification",
|
|
1064
|
+
to: group.notificationEmail,
|
|
1065
|
+
vars: { firstName: s(app.firstName), lastName: s(app.lastName), email: s(app.email), phone: s(app.phone), state: s(app.state) },
|
|
1066
|
+
dedupeKey: `${eventId}:admin`,
|
|
1067
|
+
applicationId: String(app.id)
|
|
1068
|
+
}
|
|
1069
|
+
);
|
|
1070
|
+
} catch {
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1002
1073
|
function webhookPatch(event, status) {
|
|
1003
1074
|
switch (event.kind) {
|
|
1004
1075
|
case "first_payment":
|
|
@@ -1070,7 +1141,10 @@ async function ingestWebhook(req, env, ctx) {
|
|
|
1070
1141
|
if (Object.keys(patch).length) {
|
|
1071
1142
|
await db.transact([{ t: "update", ns: "applications", id: String(app.id), attrs: patch }], { mutationId: webhookMutationId(eventId) });
|
|
1072
1143
|
}
|
|
1073
|
-
if (event.kind === "first_payment")
|
|
1144
|
+
if (event.kind === "first_payment") {
|
|
1145
|
+
await notifyPaymentConfirmed(db, env, eventId, app);
|
|
1146
|
+
if (ctx.chapter.sends.adminNotification === "payment") await notifyAdminOfPayment(db, env, eventId, app);
|
|
1147
|
+
}
|
|
1074
1148
|
return json({ ok: true });
|
|
1075
1149
|
}
|
|
1076
1150
|
async function refundApplication(req, url, env, ctx) {
|
|
@@ -1167,14 +1241,10 @@ var handleAdminScheduling = async (req, url, env, ctx) => {
|
|
|
1167
1241
|
} catch {
|
|
1168
1242
|
return json({ error: "invalid JSON body" }, 400);
|
|
1169
1243
|
}
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
return json({ error: e instanceof Error ? e.message : "invalid scheduling config" }, 400);
|
|
1175
|
-
}
|
|
1176
|
-
await db.transact([{ t: "update", ns: "groups", id: String(group.id), attrs: { schedulingJson: resolved } }]);
|
|
1177
|
-
return json({ scheduling: resolved });
|
|
1244
|
+
const checked = validateScheduling(body);
|
|
1245
|
+
if (!checked.ok) return json({ error: "invalid scheduling config", errors: checked.errors }, 400);
|
|
1246
|
+
await db.transact([{ t: "update", ns: "groups", id: String(group.id), attrs: { schedulingJson: checked.value } }]);
|
|
1247
|
+
return json({ scheduling: checked.value });
|
|
1178
1248
|
};
|
|
1179
1249
|
async function upcomingEvents(env) {
|
|
1180
1250
|
const cal = (0, import_calendar2.initCalendar)({ appId: env.ODLA_APP_ID, env: env.ODLA_ENV, adminToken: env.ODLA_API_KEY, endpoint: env.ODLA_PLATFORM });
|
|
@@ -1197,8 +1267,15 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
|
|
|
1197
1267
|
const u = await ctx.verifyUser(req, env);
|
|
1198
1268
|
if (!u || !await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
|
|
1199
1269
|
const db = rawDb;
|
|
1200
|
-
const
|
|
1201
|
-
const
|
|
1270
|
+
const all = url.searchParams.get("all") === "1";
|
|
1271
|
+
const from = Number(url.searchParams.get("from"));
|
|
1272
|
+
const to = Number(url.searchParams.get("to"));
|
|
1273
|
+
const query = await db.query({
|
|
1274
|
+
meetings: { $: { where: all ? {} : { status: "scheduled" }, order: { startAt: "asc" }, limit: 500 } }
|
|
1275
|
+
});
|
|
1276
|
+
let rows = Array.isArray(query.meetings) ? query.meetings : [];
|
|
1277
|
+
if (Number.isFinite(from)) rows = rows.filter((m) => Number(m.startAt ?? 0) >= from);
|
|
1278
|
+
if (Number.isFinite(to)) rows = rows.filter((m) => Number(m.startAt ?? 0) <= to);
|
|
1202
1279
|
let decisions = [];
|
|
1203
1280
|
try {
|
|
1204
1281
|
decisions = reconcileMeetings(toReconcile(rows), await upcomingEvents(env), Date.now());
|
|
@@ -1216,8 +1293,20 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
|
|
|
1216
1293
|
} catch {
|
|
1217
1294
|
}
|
|
1218
1295
|
}
|
|
1219
|
-
const
|
|
1220
|
-
|
|
1296
|
+
const appQuery = await db.query({ applications: { $: { limit: 1e3 } } });
|
|
1297
|
+
const byId = /* @__PURE__ */ new Map();
|
|
1298
|
+
for (const a of Array.isArray(appQuery.applications) ? appQuery.applications : []) {
|
|
1299
|
+
byId.set(String(a.id), a);
|
|
1300
|
+
}
|
|
1301
|
+
const applicantOf = (m) => {
|
|
1302
|
+
const a = byId.get(String(m.applicationId));
|
|
1303
|
+
if (!a) return null;
|
|
1304
|
+
return { id: a.id, firstName: a.firstName, lastName: a.lastName, email: a.email, status: a.status };
|
|
1305
|
+
};
|
|
1306
|
+
const group = (await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups?.[0];
|
|
1307
|
+
const timezone = resolveScheduling(group?.schedulingJson).timezone;
|
|
1308
|
+
const meetings = rows.map((m) => ({ ...m, ...patched.get(String(m.id)) ?? {}, applicant: applicantOf(m) })).filter((m) => all || m.status === "scheduled");
|
|
1309
|
+
return json({ meetings, adopted: decisions.length, timezone });
|
|
1221
1310
|
};
|
|
1222
1311
|
|
|
1223
1312
|
// src/worker.ts
|