@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.
- package/README.md +55 -17
- package/dist/chunk-YO6DY5DL.js +281 -0
- package/dist/chunk-YO6DY5DL.js.map +1 -0
- package/dist/chunk-ZCZK6QLC.js +537 -0
- package/dist/chunk-ZCZK6QLC.js.map +1 -0
- package/dist/index.cjs +61 -16
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +60 -14
- package/dist/index.d.ts +60 -14
- package/dist/index.js +61 -16
- package/dist/index.js.map +1 -1
- package/dist/ui/admin/index.d.ts +71 -0
- package/dist/ui/admin/index.js +9 -0
- package/dist/ui/admin/index.js.map +1 -0
- package/dist/ui/index.d.ts +4 -219
- package/dist/ui/index.js +20 -795
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/member/index.d.ts +152 -0
- package/dist/ui/member/index.js +33 -0
- package/dist/ui/member/index.js.map +1 -0
- package/dist/worker/index.cjs +105 -30
- 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 +105 -30
- package/dist/worker/index.js.map +1 -1
- package/package.json +9 -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
|
@@ -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:
|
|
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:
|
|
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
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
if (!(c.windowDays >= 1 && c.windowDays <= 62))
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
258
|
-
|
|
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.
|
|
261
|
-
|
|
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
|
-
|
|
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")
|
|
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
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
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
|
|
1171
|
-
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);
|
|
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
|
|
1190
|
-
|
|
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
|