@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/README.md
CHANGED
|
@@ -135,15 +135,39 @@ These bite silently — a smoke test won't catch them:
|
|
|
135
135
|
- **`services` default** is `["db","calendar","o11y"]`; `smoke` compares config
|
|
136
136
|
against the platform, so set `services` explicitly if you don't run the o11y
|
|
137
137
|
collector.
|
|
138
|
-
- **Which email fires from which route.**
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
138
|
+
- **Which email fires from which route.** Content and addressing are owner-editable
|
|
139
|
+
on the `groups` row; the *trigger* is config:
|
|
140
|
+
|
|
141
|
+
| Template | Fires from | Configurable |
|
|
142
|
+
|---|---|---|
|
|
143
|
+
| `adminNotification` | `POST /api/applications` (submit) — or the Stripe webhook (first payment) | `sends.adminNotification: "submit" \| "payment" \| "never"` (default `"submit"`) |
|
|
144
|
+
| `prepEmail` | `POST /api/schedule/book` | template `enabled` flag |
|
|
145
|
+
| `paymentConfirmation` | Stripe webhook, first successful invoice | template `enabled` flag |
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
defineChapter({ /* … */ sends: { adminNotification: "payment" } });
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
**Re-check this on every chapter upgrade.** Wiring a send changes a site's
|
|
152
|
+
outbound mail with no local diff — release notes call out send changes
|
|
153
|
+
explicitly for that reason.
|
|
142
154
|
- **Account model.** `account: "invite"` (default) mints a Clerk invitation,
|
|
143
155
|
`"create"` makes the account server-side (so join can say the account is
|
|
144
156
|
ready), `"none"` skips it — all need `clerk_secret_key` in the tenant vault. A
|
|
145
157
|
site that wants server-side create must set `account: "create"`; inheriting the
|
|
146
158
|
default silently changes the model.
|
|
159
|
+
- **What lands on the Clerk account.** Both models write `public_metadata` as
|
|
160
|
+
`{ applicationId, profile }`, where `profile` is every configured
|
|
161
|
+
`application.required`/`optional` field Clerk doesn't already carry natively
|
|
162
|
+
(so *not* email/firstName/lastName), plus `focus`. It follows your field names —
|
|
163
|
+
`applicantProfile(chapter, fields)` is exported if you want to assert the exact
|
|
164
|
+
shape in a test before deleting a local override.
|
|
165
|
+
- **Disclaimer acknowledgement.** A truthy `disclaimerAck` on the submit body
|
|
166
|
+
(boolean or the string a plain form posts) stamps `disclaimerAckAt` from the
|
|
167
|
+
*server* clock; no ack leaves the attr absent, and a client-supplied
|
|
168
|
+
`disclaimerAckAt` is ignored. If your join page collects consent, post the flag —
|
|
169
|
+
an unstamped row is the honest record that nobody acked, so a missing flag
|
|
170
|
+
fails silently in exactly the way a compliance record must not.
|
|
147
171
|
- **CRM projection points.** chapter projects the person on application submit
|
|
148
172
|
(`projectApplicant`), not on booking or on webhook status change. If you mirror
|
|
149
173
|
pipeline stage into the CRM, keep those routes.
|
|
@@ -153,20 +177,30 @@ These bite silently — a smoke test won't catch them:
|
|
|
153
177
|
|
|
154
178
|
### Install + scope notes
|
|
155
179
|
|
|
156
|
-
- **`@odla-ai/auth-clerk` is
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
`@odla-ai/
|
|
180
|
+
- **`@odla-ai/auth-clerk` is not a chapter peer.** It is deliberately absent from
|
|
181
|
+
this package's manifest, because only one entry imports it: the worker verifies
|
|
182
|
+
JWTs with `jose` via `ctx.verifyUser`, and `@odla-ai/chapter/ui/member` is
|
|
183
|
+
react-only. **Install `@odla-ai/auth-clerk` yourself if (and only if) you adopt
|
|
184
|
+
`@odla-ai/chapter/ui/admin`** or want the themed sign-in components; reach for
|
|
160
185
|
`@odla-ai/auth-clerk/invitations` when you want to send your own branded
|
|
161
|
-
invitation mail. Importing the full `@odla-ai/chapter/ui` barrel pulls
|
|
162
|
-
prefer the narrower entry.
|
|
186
|
+
invitation mail. Importing the full `@odla-ai/chapter/ui` barrel pulls the admin
|
|
187
|
+
half, so prefer the narrower entry.
|
|
188
|
+
- **Known-good version set** (installs clean, no flags): `@odla-ai/chapter` 0.10.0,
|
|
189
|
+
`@odla-ai/ui` 0.9.0, `@odla-ai/crm` 0.1.4, `@odla-ai/db` 0.6.6,
|
|
190
|
+
`@odla-ai/calendar` 0.2.0, `@odla-ai/email` 0.3.1, CLI 0.16.7, plus `jose` and
|
|
191
|
+
react (or `preact` + `preact/compat`). chapter is the package that pulls the
|
|
192
|
+
others together, so this is the line to check when an install complains.
|
|
163
193
|
- **`--legacy-peer-deps` is a diagnostic, not a setting.** It suppresses exactly
|
|
164
194
|
the peer conflict that tells you a pair is unsupported. If you need it, find out
|
|
165
195
|
why first.
|
|
166
|
-
- **The admin surface is intentionally minimal
|
|
167
|
-
`/api/admin/
|
|
168
|
-
|
|
169
|
-
|
|
196
|
+
- **The admin surface is intentionally minimal, and staying that way.** chapter
|
|
197
|
+
ships `/api/admin/scheduling` (booking rules, with field-keyed validation
|
|
198
|
+
messages) and `/api/admin/meetings` (the reconciled agenda, with the applicant
|
|
199
|
+
joined, `?all=1`, `from`/`to`, drift fields, Meet/Calendar links and the group
|
|
200
|
+
timezone), plus `peopleSection`. **Dashboard, billing, email config/log/test,
|
|
201
|
+
per-person comms, people list, role changes, approve and refund are out of
|
|
202
|
+
scope** — they are yours as host routes via the seam. Don't plan around them
|
|
203
|
+
shipping.
|
|
170
204
|
|
|
171
205
|
### Verify from the types, not this file
|
|
172
206
|
|
package/dist/index.cjs
CHANGED
|
@@ -21,6 +21,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
23
|
SCHEDULING_DEFAULTS: () => SCHEDULING_DEFAULTS,
|
|
24
|
+
applicantProfile: () => applicantProfile,
|
|
24
25
|
applicationBookingUpdate: () => applicationBookingUpdate,
|
|
25
26
|
applicationSummary: () => applicationSummary,
|
|
26
27
|
bookingDecision: () => bookingDecision,
|
|
@@ -77,6 +78,7 @@ __export(index_exports, {
|
|
|
77
78
|
stripeForm: () => stripeForm,
|
|
78
79
|
submitApplication: () => submitApplication,
|
|
79
80
|
subscriptionIdempotencyKey: () => subscriptionIdempotencyKey,
|
|
81
|
+
validateScheduling: () => validateScheduling,
|
|
80
82
|
verifyStripeSignature: () => verifyStripeSignature,
|
|
81
83
|
webhookMutationId: () => webhookMutationId
|
|
82
84
|
});
|
|
@@ -487,6 +489,17 @@ function resolveApplication(a) {
|
|
|
487
489
|
bodyCap: a?.bodyCap ?? 32768
|
|
488
490
|
};
|
|
489
491
|
}
|
|
492
|
+
var IDENTITY_FIELDS = /* @__PURE__ */ new Set(["email", "firstName", "lastName"]);
|
|
493
|
+
function applicantProfile(chapter, fields) {
|
|
494
|
+
const profile = {};
|
|
495
|
+
for (const f of [...chapter.application.required, ...chapter.application.optional]) {
|
|
496
|
+
if (IDENTITY_FIELDS.has(f)) continue;
|
|
497
|
+
const v = fields[f];
|
|
498
|
+
if (typeof v === "string" && v.trim() !== "") profile[f] = v.trim();
|
|
499
|
+
}
|
|
500
|
+
if (fields.focus !== void 0) profile.focus = fields.focus;
|
|
501
|
+
return Object.keys(profile).length > 0 ? profile : void 0;
|
|
502
|
+
}
|
|
490
503
|
async function submitApplication(db, chapter, fields, opts) {
|
|
491
504
|
const app = chapter.application;
|
|
492
505
|
for (const f of app.required) {
|
|
@@ -505,6 +518,7 @@ async function submitApplication(db, chapter, fields, opts) {
|
|
|
505
518
|
}
|
|
506
519
|
if (fields.focus !== void 0) row.focus = fields.focus;
|
|
507
520
|
if (opts.groupId) row.groupId = opts.groupId;
|
|
521
|
+
if (fields.disclaimerAck === true || fields.disclaimerAck === "true") row.disclaimerAckAt = opts.now;
|
|
508
522
|
const { duplicate } = await db.transact(
|
|
509
523
|
[{ t: "update", ns: "applications", id: id2, attrs: row }],
|
|
510
524
|
opts.submissionId ? { mutationId: `join:${opts.submissionId}` } : void 0
|
|
@@ -558,6 +572,13 @@ function defineChapter(config) {
|
|
|
558
572
|
if (account !== "invite" && account !== "create" && account !== "none") {
|
|
559
573
|
throw new Error(`defineChapter.account: must be "invite", "create", or "none" \u2014 got "${String(account)}"`);
|
|
560
574
|
}
|
|
575
|
+
const adminNotification = config.sends?.adminNotification ?? "submit";
|
|
576
|
+
if (adminNotification !== "submit" && adminNotification !== "payment" && adminNotification !== "never") {
|
|
577
|
+
throw new Error(
|
|
578
|
+
`defineChapter.sends.adminNotification: must be "submit", "payment", or "never" \u2014 got "${String(adminNotification)}"`
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
const sends = { adminNotification };
|
|
561
582
|
const chapter = {
|
|
562
583
|
config,
|
|
563
584
|
id: id2,
|
|
@@ -571,6 +592,7 @@ function defineChapter(config) {
|
|
|
571
592
|
rules,
|
|
572
593
|
services,
|
|
573
594
|
account,
|
|
595
|
+
sends,
|
|
574
596
|
groupSeed: () => mode === "chapter" ? buildGroupSeed(config) : null
|
|
575
597
|
};
|
|
576
598
|
if (config.url !== void 0) chapter.url = config.url;
|
|
@@ -881,6 +903,20 @@ function clerkUserRequest(input) {
|
|
|
881
903
|
}
|
|
882
904
|
};
|
|
883
905
|
}
|
|
906
|
+
async function refreshUserMetadata(secretKey, email, publicMetadata, fetchImpl) {
|
|
907
|
+
const auth = { authorization: `Bearer ${secretKey}` };
|
|
908
|
+
const found = await fetchImpl(`https://api.clerk.com/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, { headers: auth });
|
|
909
|
+
if (!found.ok) return false;
|
|
910
|
+
const users = await found.json().catch(() => null);
|
|
911
|
+
const id2 = Array.isArray(users) && typeof users[0]?.id === "string" ? users[0].id : void 0;
|
|
912
|
+
if (!id2) return false;
|
|
913
|
+
const patched = await fetchImpl(`https://api.clerk.com/v1/users/${id2}/metadata`, {
|
|
914
|
+
method: "PATCH",
|
|
915
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
916
|
+
body: JSON.stringify({ public_metadata: publicMetadata })
|
|
917
|
+
});
|
|
918
|
+
return patched.ok;
|
|
919
|
+
}
|
|
884
920
|
async function createClerkUser(secretKey, input, fetchImpl = fetch) {
|
|
885
921
|
const { path, body } = clerkUserRequest(input);
|
|
886
922
|
const res = await fetchImpl(`https://api.clerk.com${path}`, {
|
|
@@ -888,7 +924,11 @@ async function createClerkUser(secretKey, input, fetchImpl = fetch) {
|
|
|
888
924
|
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
889
925
|
body: JSON.stringify(body)
|
|
890
926
|
});
|
|
891
|
-
|
|
927
|
+
if (res.ok) return { ok: true, status: res.status };
|
|
928
|
+
const healed = heal(res.status);
|
|
929
|
+
if (!healed.existed || !input.publicMetadata) return healed;
|
|
930
|
+
const refreshed = await refreshUserMetadata(secretKey, input.email, input.publicMetadata, fetchImpl).catch(() => false);
|
|
931
|
+
return { ...healed, refreshed };
|
|
892
932
|
}
|
|
893
933
|
|
|
894
934
|
// src/session.ts
|
|
@@ -1007,6 +1047,12 @@ function isValidTimeZone(tz) {
|
|
|
1007
1047
|
}
|
|
1008
1048
|
}
|
|
1009
1049
|
function resolveScheduling(config) {
|
|
1050
|
+
const result = validateScheduling(config);
|
|
1051
|
+
if (result.ok) return result.value;
|
|
1052
|
+
const detail = Object.entries(result.errors).map(([field, message]) => `${field}: ${message}`).join(" ");
|
|
1053
|
+
throw new Error(`scheduling: ${detail}`);
|
|
1054
|
+
}
|
|
1055
|
+
function validateScheduling(config) {
|
|
1010
1056
|
const d = config ?? {};
|
|
1011
1057
|
const c = {
|
|
1012
1058
|
slotMinutes: d.slotMinutes ?? SCHEDULING_DEFAULTS.slotMinutes,
|
|
@@ -1018,20 +1064,29 @@ function resolveScheduling(config) {
|
|
|
1018
1064
|
windowDays: d.windowDays ?? SCHEDULING_DEFAULTS.windowDays,
|
|
1019
1065
|
summaryTemplate: d.summaryTemplate ?? SCHEDULING_DEFAULTS.summaryTemplate
|
|
1020
1066
|
};
|
|
1021
|
-
const
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
if (!(c.windowDays >= 1 && c.windowDays <= 62))
|
|
1026
|
-
|
|
1027
|
-
|
|
1067
|
+
const errors = {};
|
|
1068
|
+
if (!(c.slotMinutes >= 15 && c.slotMinutes <= 240)) {
|
|
1069
|
+
errors.slotMinutes = "Slot length must be between 15 and 240 minutes.";
|
|
1070
|
+
}
|
|
1071
|
+
if (!(c.windowDays >= 1 && c.windowDays <= 62)) {
|
|
1072
|
+
errors.windowDays = "Booking window must be between 1 and 62 days (the calendar caps look-ahead at 62).";
|
|
1073
|
+
}
|
|
1074
|
+
if (!(c.minNoticeHours >= 0 && c.minNoticeHours <= 336)) {
|
|
1075
|
+
errors.minNoticeHours = "Minimum notice must be between 0 and 336 hours.";
|
|
1076
|
+
}
|
|
1077
|
+
if (!(c.startHour >= 0 && c.startHour < c.endHour && c.endHour <= 24)) {
|
|
1078
|
+
errors.hours = "Hours must satisfy 0 \u2264 start < end \u2264 24.";
|
|
1079
|
+
}
|
|
1028
1080
|
const days = [...c.days];
|
|
1029
|
-
if (!days.length
|
|
1030
|
-
|
|
1081
|
+
if (!days.length) errors.days = "Pick at least one day.";
|
|
1082
|
+
else if (!days.every((n) => Number.isInteger(n) && n >= 0 && n <= 6)) {
|
|
1083
|
+
errors.days = "Days must be weekday numbers, 0 (Sunday) through 6 (Saturday).";
|
|
1084
|
+
}
|
|
1085
|
+
if (typeof c.timezone !== "string" || !isValidTimeZone(c.timezone)) {
|
|
1086
|
+
errors.timezone = `"${String(c.timezone)}" is not a valid IANA timezone (for example "America/Los_Angeles").`;
|
|
1031
1087
|
}
|
|
1032
|
-
if (typeof c.
|
|
1033
|
-
|
|
1034
|
-
return { ...c, days };
|
|
1088
|
+
if (typeof c.summaryTemplate !== "string") errors.summaryTemplate = "Calendar summary template must be text.";
|
|
1089
|
+
return Object.keys(errors).length > 0 ? { ok: false, errors } : { ok: true, value: { ...c, days } };
|
|
1035
1090
|
}
|
|
1036
1091
|
function slotWindow(now, windowDays) {
|
|
1037
1092
|
return { from: now, to: now + windowDays * 864e5 };
|