@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 CHANGED
@@ -135,10 +135,22 @@ 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.** `adminNotification` fires from
139
- `POST /api/applications` (on submit), `prepEmail` from `/api/schedule/book`,
140
- `paymentConfirmation` from the Stripe webhook. If your policy differs (e.g.
141
- notify on payment, not submit), override that route.
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
@@ -153,20 +165,30 @@ These bite silently — a smoke test won't catch them:
153
165
 
154
166
  ### Install + scope notes
155
167
 
156
- - **`@odla-ai/auth-clerk` is only needed for the admin console.** The worker never
157
- imports it (JWTs are verified with `jose` via `ctx.verifyUser`), and neither
158
- does `@odla-ai/chapter/ui/member`. Install it when you adopt
159
- `@odla-ai/chapter/ui/admin` (or want the themed sign-in components); reach for
168
+ - **`@odla-ai/auth-clerk` is not a chapter peer.** It is deliberately absent from
169
+ this package's manifest, because only one entry imports it: the worker verifies
170
+ JWTs with `jose` via `ctx.verifyUser`, and `@odla-ai/chapter/ui/member` is
171
+ react-only. **Install `@odla-ai/auth-clerk` yourself if (and only if) you adopt
172
+ `@odla-ai/chapter/ui/admin`** or want the themed sign-in components; reach for
160
173
  `@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 it, so
162
- prefer the narrower entry.
174
+ invitation mail. Importing the full `@odla-ai/chapter/ui` barrel pulls the admin
175
+ half, so prefer the narrower entry.
176
+ - **Known-good version set** (installs clean, no flags): `@odla-ai/chapter` 0.10.0,
177
+ `@odla-ai/ui` 0.9.0, `@odla-ai/crm` 0.1.4, `@odla-ai/db` 0.6.6,
178
+ `@odla-ai/calendar` 0.2.0, `@odla-ai/email` 0.3.1, CLI 0.16.7, plus `jose` and
179
+ react (or `preact` + `preact/compat`). chapter is the package that pulls the
180
+ others together, so this is the line to check when an install complains.
163
181
  - **`--legacy-peer-deps` is a diagnostic, not a setting.** It suppresses exactly
164
182
  the peer conflict that tells you a pair is unsupported. If you need it, find out
165
183
  why first.
166
- - **The admin surface is intentionally minimal** `/api/admin/scheduling` +
167
- `/api/admin/meetings`, plus `peopleSection`. Dashboard, billing, per-person
168
- comms, approve/refund, etc. are your own host routes via the seam; don't plan
169
- around them shipping.
184
+ - **The admin surface is intentionally minimal, and staying that way.** chapter
185
+ ships `/api/admin/scheduling` (booking rules, with field-keyed validation
186
+ messages) and `/api/admin/meetings` (the reconciled agenda, with the applicant
187
+ joined, `?all=1`, `from`/`to`, drift fields, Meet/Calendar links and the group
188
+ timezone), plus `peopleSection`. **Dashboard, billing, email config/log/test,
189
+ per-person comms, people list, role changes, approve and refund are out of
190
+ scope** — they are yours as host routes via the seam. Don't plan around them
191
+ shipping.
170
192
 
171
193
  ### Verify from the types, not this file
172
194
 
package/dist/index.cjs CHANGED
@@ -77,6 +77,7 @@ __export(index_exports, {
77
77
  stripeForm: () => stripeForm,
78
78
  submitApplication: () => submitApplication,
79
79
  subscriptionIdempotencyKey: () => subscriptionIdempotencyKey,
80
+ validateScheduling: () => validateScheduling,
80
81
  verifyStripeSignature: () => verifyStripeSignature,
81
82
  webhookMutationId: () => webhookMutationId
82
83
  });
@@ -558,6 +559,13 @@ function defineChapter(config) {
558
559
  if (account !== "invite" && account !== "create" && account !== "none") {
559
560
  throw new Error(`defineChapter.account: must be "invite", "create", or "none" \u2014 got "${String(account)}"`);
560
561
  }
562
+ const adminNotification = config.sends?.adminNotification ?? "submit";
563
+ if (adminNotification !== "submit" && adminNotification !== "payment" && adminNotification !== "never") {
564
+ throw new Error(
565
+ `defineChapter.sends.adminNotification: must be "submit", "payment", or "never" \u2014 got "${String(adminNotification)}"`
566
+ );
567
+ }
568
+ const sends = { adminNotification };
561
569
  const chapter = {
562
570
  config,
563
571
  id: id2,
@@ -571,6 +579,7 @@ function defineChapter(config) {
571
579
  rules,
572
580
  services,
573
581
  account,
582
+ sends,
574
583
  groupSeed: () => mode === "chapter" ? buildGroupSeed(config) : null
575
584
  };
576
585
  if (config.url !== void 0) chapter.url = config.url;
@@ -881,6 +890,20 @@ function clerkUserRequest(input) {
881
890
  }
882
891
  };
883
892
  }
893
+ async function refreshUserMetadata(secretKey, email, publicMetadata, fetchImpl) {
894
+ const auth = { authorization: `Bearer ${secretKey}` };
895
+ const found = await fetchImpl(`https://api.clerk.com/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, { headers: auth });
896
+ if (!found.ok) return false;
897
+ const users = await found.json().catch(() => null);
898
+ const id2 = Array.isArray(users) && typeof users[0]?.id === "string" ? users[0].id : void 0;
899
+ if (!id2) return false;
900
+ const patched = await fetchImpl(`https://api.clerk.com/v1/users/${id2}/metadata`, {
901
+ method: "PATCH",
902
+ headers: { ...auth, "content-type": "application/json" },
903
+ body: JSON.stringify({ public_metadata: publicMetadata })
904
+ });
905
+ return patched.ok;
906
+ }
884
907
  async function createClerkUser(secretKey, input, fetchImpl = fetch) {
885
908
  const { path, body } = clerkUserRequest(input);
886
909
  const res = await fetchImpl(`https://api.clerk.com${path}`, {
@@ -888,7 +911,11 @@ async function createClerkUser(secretKey, input, fetchImpl = fetch) {
888
911
  headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
889
912
  body: JSON.stringify(body)
890
913
  });
891
- return res.ok ? { ok: true, status: res.status } : heal(res.status);
914
+ if (res.ok) return { ok: true, status: res.status };
915
+ const healed = heal(res.status);
916
+ if (!healed.existed || !input.publicMetadata) return healed;
917
+ const refreshed = await refreshUserMetadata(secretKey, input.email, input.publicMetadata, fetchImpl).catch(() => false);
918
+ return { ...healed, refreshed };
892
919
  }
893
920
 
894
921
  // src/session.ts
@@ -1007,6 +1034,12 @@ function isValidTimeZone(tz) {
1007
1034
  }
1008
1035
  }
1009
1036
  function resolveScheduling(config) {
1037
+ const result = validateScheduling(config);
1038
+ if (result.ok) return result.value;
1039
+ const detail = Object.entries(result.errors).map(([field, message]) => `${field}: ${message}`).join(" ");
1040
+ throw new Error(`scheduling: ${detail}`);
1041
+ }
1042
+ function validateScheduling(config) {
1010
1043
  const d = config ?? {};
1011
1044
  const c = {
1012
1045
  slotMinutes: d.slotMinutes ?? SCHEDULING_DEFAULTS.slotMinutes,
@@ -1018,20 +1051,29 @@ function resolveScheduling(config) {
1018
1051
  windowDays: d.windowDays ?? SCHEDULING_DEFAULTS.windowDays,
1019
1052
  summaryTemplate: d.summaryTemplate ?? SCHEDULING_DEFAULTS.summaryTemplate
1020
1053
  };
1021
- const fail = (msg) => {
1022
- throw new Error(`scheduling: ${msg}`);
1023
- };
1024
- if (!(c.slotMinutes >= 15 && c.slotMinutes <= 240)) fail("slotMinutes must be 15\u2013240");
1025
- if (!(c.windowDays >= 1 && c.windowDays <= 62)) fail("windowDays must be 1\u201362 (FreeBusy caps at 62)");
1026
- if (!(c.minNoticeHours >= 0 && c.minNoticeHours <= 336)) fail("minNoticeHours must be 0\u2013336");
1027
- if (!(c.startHour >= 0 && c.startHour < c.endHour && c.endHour <= 24)) fail("require 0 \u2264 startHour < endHour \u2264 24");
1054
+ const errors = {};
1055
+ if (!(c.slotMinutes >= 15 && c.slotMinutes <= 240)) {
1056
+ errors.slotMinutes = "Slot length must be between 15 and 240 minutes.";
1057
+ }
1058
+ if (!(c.windowDays >= 1 && c.windowDays <= 62)) {
1059
+ errors.windowDays = "Booking window must be between 1 and 62 days (the calendar caps look-ahead at 62).";
1060
+ }
1061
+ if (!(c.minNoticeHours >= 0 && c.minNoticeHours <= 336)) {
1062
+ errors.minNoticeHours = "Minimum notice must be between 0 and 336 hours.";
1063
+ }
1064
+ if (!(c.startHour >= 0 && c.startHour < c.endHour && c.endHour <= 24)) {
1065
+ errors.hours = "Hours must satisfy 0 \u2264 start < end \u2264 24.";
1066
+ }
1028
1067
  const days = [...c.days];
1029
- if (!days.length || !days.every((n) => Number.isInteger(n) && n >= 0 && n <= 6)) {
1030
- fail("days must be a non-empty list of weekday integers 0\u20136");
1068
+ if (!days.length) errors.days = "Pick at least one day.";
1069
+ else if (!days.every((n) => Number.isInteger(n) && n >= 0 && n <= 6)) {
1070
+ errors.days = "Days must be weekday numbers, 0 (Sunday) through 6 (Saturday).";
1071
+ }
1072
+ if (typeof c.timezone !== "string" || !isValidTimeZone(c.timezone)) {
1073
+ errors.timezone = `"${String(c.timezone)}" is not a valid IANA timezone (for example "America/Los_Angeles").`;
1031
1074
  }
1032
- if (typeof c.timezone !== "string" || !isValidTimeZone(c.timezone)) fail(`invalid IANA timezone "${c.timezone}"`);
1033
- if (typeof c.summaryTemplate !== "string") fail("summaryTemplate must be a string");
1034
- return { ...c, days };
1075
+ if (typeof c.summaryTemplate !== "string") errors.summaryTemplate = "Calendar summary template must be text.";
1076
+ return Object.keys(errors).length > 0 ? { ok: false, errors } : { ok: true, value: { ...c, days } };
1035
1077
  }
1036
1078
  function slotWindow(now, windowDays) {
1037
1079
  return { from: now, to: now + windowDays * 864e5 };