@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 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.** `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
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 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
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 it, so
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** `/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.
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
- return res.ok ? { ok: true, status: res.status } : heal(res.status);
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 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");
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 || !days.every((n) => Number.isInteger(n) && n >= 0 && n <= 6)) {
1030
- fail("days must be a non-empty list of weekday integers 0\u20136");
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.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 };
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 };