@odla-ai/chapter 0.10.0 → 0.11.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
@@ -156,6 +156,29 @@ These bite silently — a smoke test won't catch them:
156
156
  ready), `"none"` skips it — all need `clerk_secret_key` in the tenant vault. A
157
157
  site that wants server-side create must set `account: "create"`; inheriting the
158
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.
169
+
170
+ Unlike most data loss this is **unreconstructible** — you cannot later
171
+ determine whether someone checked a box — so the submit response always echoes
172
+ `disclaimerAckAt` (a number, or `null` when nothing was recorded). Assert it in
173
+ one integration test and a join page that quietly stops posting the flag can't
174
+ go unnoticed.
175
+
176
+ **If the disclaimer is a compliance record, set
177
+ `application: { requireDisclaimerAck: true }`** and a submit with no ack is a
178
+ 400 instead of a row with no consent. It defaults to `false` so an upgrade
179
+ never starts rejecting traffic; enabling it fails *deterministically* on your
180
+ first test submit, not intermittently in production. Expect this to default to
181
+ `true` at 1.0.
159
182
  - **CRM projection points.** chapter projects the person on application submit
160
183
  (`projectApplicant`), not on booking or on webhook status change. If you mirror
161
184
  pipeline stage into the CRM, keep those routes.
@@ -165,6 +188,13 @@ These bite silently — a smoke test won't catch them:
165
188
 
166
189
  ### Install + scope notes
167
190
 
191
+ - **Your first admin is seeded in odla Studio, by hand — and the email must be
192
+ lowercased.** Neither `admins` nor `superAdmins` is ever written by a worker
193
+ route or a provisioning seed; that is deliberate, so nothing running in the app
194
+ (or injected into a page) can grant admin. The gate looks the email up
195
+ lowercased, so a row saved as `Ada@Example.com` matches nothing and the account
196
+ silently isn't an admin — with no error to tell you. Save it lowercase, then
197
+ confirm by signing in, not by looking at the row.
168
198
  - **`@odla-ai/auth-clerk` is not a chapter peer.** It is deliberately absent from
169
199
  this package's manifest, because only one entry imports it: the worker verifies
170
200
  JWTs with `jose` via `ctx.verifyUser`, and `@odla-ai/chapter/ui/member` is
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,
@@ -44,6 +45,7 @@ __export(index_exports, {
44
45
  findApplicationRef: () => findApplicationRef,
45
46
  firstPaymentPatch: () => firstPaymentPatch,
46
47
  getVaultSecret: () => getVaultSecret,
48
+ hasDisclaimerAck: () => hasDisclaimerAck,
47
49
  introIdempotencyKey: () => introIdempotencyKey,
48
50
  isAdminRole: () => isAdminRole,
49
51
  isAlreadySent: () => isAlreadySent,
@@ -485,9 +487,24 @@ function resolveApplication(a) {
485
487
  optional,
486
488
  maxLen: a?.maxLen ?? {},
487
489
  defaultMaxLen: a?.defaultMaxLen ?? 2e3,
488
- bodyCap: a?.bodyCap ?? 32768
490
+ bodyCap: a?.bodyCap ?? 32768,
491
+ requireDisclaimerAck: a?.requireDisclaimerAck ?? false
489
492
  };
490
493
  }
494
+ function hasDisclaimerAck(fields) {
495
+ return fields.disclaimerAck === true || fields.disclaimerAck === "true";
496
+ }
497
+ var IDENTITY_FIELDS = /* @__PURE__ */ new Set(["email", "firstName", "lastName"]);
498
+ function applicantProfile(chapter, fields) {
499
+ const profile = {};
500
+ for (const f of [...chapter.application.required, ...chapter.application.optional]) {
501
+ if (IDENTITY_FIELDS.has(f)) continue;
502
+ const v = fields[f];
503
+ if (typeof v === "string" && v.trim() !== "") profile[f] = v.trim();
504
+ }
505
+ if (fields.focus !== void 0) profile.focus = fields.focus;
506
+ return Object.keys(profile).length > 0 ? profile : void 0;
507
+ }
491
508
  async function submitApplication(db, chapter, fields, opts) {
492
509
  const app = chapter.application;
493
510
  for (const f of app.required) {
@@ -499,6 +516,10 @@ async function submitApplication(db, chapter, fields, opts) {
499
516
  const cap = app.maxLen[f] ?? app.defaultMaxLen;
500
517
  if (typeof v === "string" && v.length > cap) return { ok: false, error: `${f} exceeds ${cap} characters` };
501
518
  }
519
+ const acked = hasDisclaimerAck(fields);
520
+ if (app.requireDisclaimerAck && !acked) {
521
+ return { ok: false, error: "disclaimerAck is required" };
522
+ }
502
523
  const id2 = opts.newId();
503
524
  const row = { id: id2, status: chapter.pipeline.initial, createdAt: opts.now };
504
525
  for (const f of [...app.required, ...app.optional]) {
@@ -506,11 +527,12 @@ async function submitApplication(db, chapter, fields, opts) {
506
527
  }
507
528
  if (fields.focus !== void 0) row.focus = fields.focus;
508
529
  if (opts.groupId) row.groupId = opts.groupId;
530
+ if (acked) row.disclaimerAckAt = opts.now;
509
531
  const { duplicate } = await db.transact(
510
532
  [{ t: "update", ns: "applications", id: id2, attrs: row }],
511
533
  opts.submissionId ? { mutationId: `join:${opts.submissionId}` } : void 0
512
534
  );
513
- return { ok: true, id: id2, duplicate, status: chapter.pipeline.initial };
535
+ return { ok: true, id: id2, duplicate, status: chapter.pipeline.initial, disclaimerAckAt: acked ? opts.now : null };
514
536
  }
515
537
  function joinConfig(group, paymentsReady2) {
516
538
  return {