@odla-ai/chapter 0.11.0 → 0.12.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,12 +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
159
+ - **What lands on the Clerk account (and its `public_metadata` is
160
+ client-readable).** Both models write `public_metadata` as
161
+ `{ applicationId, profile }`. By default `profile` is every configured
161
162
  `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.
163
+ (so *not* email/firstName/lastName), plus `focus` **which means free-text or
164
+ third-party fields like `message` or `referral` are exposed to the browser
165
+ unless you curate.** Set `application: { profileFields: ["phone", "state",
166
+ "focus", ...] }` to an allowlist and everything else stays db-only. Array fields
167
+ (`focus`) are clamped to `maxArrayLen` (default 100) and non-primitive elements
168
+ dropped, so a client can't post an unbounded array into metadata.
169
+ `applicantProfile(chapter, fields)` is exported to assert the exact shape in a
170
+ test before deleting a local override. (Default is back-compat today; expected
171
+ to tighten at 1.0.)
172
+ - **Email + input validation.** The field literally named `email` is checked
173
+ against a permissive email shape (400 on `"notanemail"`, so it fails cleanly
174
+ here rather than at the downstream Clerk create) — set
175
+ `application: { validateEmail: false }` to opt out; `isValidEmail` is exported.
176
+ A valid application is never newly rejected.
177
+ - **CRM enrichment.** `projectApplicant` writes the base identity/contact person.
178
+ To carry more of the application into the CRM, list `application: { crmFields:
179
+ [...] }` — each must be a field on your crm person type, else that field is
180
+ dropped and the base person still projects (it is never lost). Stage mirroring,
181
+ billing snapshots and Clerk-identity linking stay yours as host routes.
165
182
  - **Disclaimer acknowledgement.** A truthy `disclaimerAck` on the submit body
166
183
  (boolean or the string a plain form posts) stamps `disclaimerAckAt` from the
167
184
  *server* clock; no ack leaves the attr absent, and a client-supplied
@@ -219,6 +236,11 @@ These bite silently — a smoke test won't catch them:
219
236
  per-person comms, people list, role changes, approve and refund are out of
220
237
  scope** — they are yours as host routes via the seam. Don't plan around them
221
238
  shipping.
239
+ - **`onboardingInvite` is a template with no built-in caller.** It is seeded into
240
+ the group's email settings (so you can edit its copy) but chapter never fires
241
+ it, because the moment it would fire — approval — is a host route (approval is
242
+ out of scope, above). Send it from your own approve handler via `sendTemplated`;
243
+ the seed existing does not mean the built-ins send it.
222
244
 
223
245
  ### Verify from the types, not this file
224
246
 
package/dist/index.cjs CHANGED
@@ -33,6 +33,7 @@ __export(index_exports, {
33
33
  canTransition: () => canTransition,
34
34
  canceledPatch: () => canceledPatch,
35
35
  chapterDb: () => chapterDb,
36
+ clampArray: () => clampArray,
36
37
  clerkInviteRequest: () => clerkInviteRequest,
37
38
  clerkUserRequest: () => clerkUserRequest,
38
39
  createChapterIntegration: () => createChapterIntegration,
@@ -51,6 +52,7 @@ __export(index_exports, {
51
52
  isAlreadySent: () => isAlreadySent,
52
53
  isReconcilable: () => isReconcilable,
53
54
  isSlotAvailable: () => isSlotAvailable,
55
+ isValidEmail: () => isValidEmail,
54
56
  joinConfig: () => joinConfig,
55
57
  meetingCreateRow: () => meetingCreateRow,
56
58
  meetingRescheduleUpdate: () => meetingRescheduleUpdate,
@@ -482,27 +484,47 @@ function resolveApplication(a) {
482
484
  throw new Error(`defineChapter.application.${name}: must be an array of field-name strings`);
483
485
  }
484
486
  }
487
+ if (a?.profileFields !== void 0 && (!Array.isArray(a.profileFields) || !a.profileFields.every((f) => typeof f === "string" && f !== ""))) {
488
+ throw new Error("defineChapter.application.profileFields: must be an array of field-name strings");
489
+ }
490
+ if (a?.crmFields !== void 0 && (!Array.isArray(a.crmFields) || !a.crmFields.every((f) => typeof f === "string" && f !== ""))) {
491
+ throw new Error("defineChapter.application.crmFields: must be an array of field-name strings");
492
+ }
485
493
  return {
486
494
  required,
487
495
  optional,
488
496
  maxLen: a?.maxLen ?? {},
489
497
  defaultMaxLen: a?.defaultMaxLen ?? 2e3,
490
498
  bodyCap: a?.bodyCap ?? 32768,
491
- requireDisclaimerAck: a?.requireDisclaimerAck ?? false
499
+ requireDisclaimerAck: a?.requireDisclaimerAck ?? false,
500
+ profileFields: a?.profileFields ?? null,
501
+ crmFields: a?.crmFields ?? [],
502
+ maxArrayLen: a?.maxArrayLen ?? 100,
503
+ validateEmail: a?.validateEmail ?? true
492
504
  };
493
505
  }
506
+ var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
507
+ function isValidEmail(value) {
508
+ return typeof value === "string" && EMAIL_RE.test(value);
509
+ }
510
+ function clampArray(value, max) {
511
+ if (!Array.isArray(value)) return value;
512
+ return value.filter((x) => typeof x === "string" || typeof x === "number" || typeof x === "boolean").slice(0, max);
513
+ }
494
514
  function hasDisclaimerAck(fields) {
495
515
  return fields.disclaimerAck === true || fields.disclaimerAck === "true";
496
516
  }
497
517
  var IDENTITY_FIELDS = /* @__PURE__ */ new Set(["email", "firstName", "lastName"]);
498
518
  function applicantProfile(chapter, fields) {
519
+ const app = chapter.application;
520
+ const allowed = (f) => app.profileFields === null || app.profileFields.includes(f);
499
521
  const profile = {};
500
- for (const f of [...chapter.application.required, ...chapter.application.optional]) {
501
- if (IDENTITY_FIELDS.has(f)) continue;
522
+ for (const f of [...app.required, ...app.optional]) {
523
+ if (IDENTITY_FIELDS.has(f) || !allowed(f)) continue;
502
524
  const v = fields[f];
503
525
  if (typeof v === "string" && v.trim() !== "") profile[f] = v.trim();
504
526
  }
505
- if (fields.focus !== void 0) profile.focus = fields.focus;
527
+ if (fields.focus !== void 0 && allowed("focus")) profile.focus = clampArray(fields.focus, app.maxArrayLen);
506
528
  return Object.keys(profile).length > 0 ? profile : void 0;
507
529
  }
508
530
  async function submitApplication(db, chapter, fields, opts) {
@@ -516,6 +538,9 @@ async function submitApplication(db, chapter, fields, opts) {
516
538
  const cap = app.maxLen[f] ?? app.defaultMaxLen;
517
539
  if (typeof v === "string" && v.length > cap) return { ok: false, error: `${f} exceeds ${cap} characters` };
518
540
  }
541
+ if (app.validateEmail && typeof fields.email === "string" && !isValidEmail(fields.email)) {
542
+ return { ok: false, error: "email must be a valid email address" };
543
+ }
519
544
  const acked = hasDisclaimerAck(fields);
520
545
  if (app.requireDisclaimerAck && !acked) {
521
546
  return { ok: false, error: "disclaimerAck is required" };
@@ -525,7 +550,7 @@ async function submitApplication(db, chapter, fields, opts) {
525
550
  for (const f of [...app.required, ...app.optional]) {
526
551
  if (typeof fields[f] === "string") row[f] = fields[f].trim();
527
552
  }
528
- if (fields.focus !== void 0) row.focus = fields.focus;
553
+ if (fields.focus !== void 0) row.focus = clampArray(fields.focus, app.maxArrayLen);
529
554
  if (opts.groupId) row.groupId = opts.groupId;
530
555
  if (acked) row.disclaimerAckAt = opts.now;
531
556
  const { duplicate } = await db.transact(
@@ -867,7 +892,7 @@ async function projectSharedRecord(deps, person) {
867
892
  return upsertPerson(deps, { email: person.email, input: sharedPersonInput(person), mutationId: `share:${person.hubRecordId}` });
868
893
  }
869
894
  async function projectApplicant(deps, applicant) {
870
- const input = sharedPersonInput({
895
+ const base = sharedPersonInput({
871
896
  email: applicant.email,
872
897
  firstName: applicant.firstName,
873
898
  lastName: applicant.lastName,
@@ -875,7 +900,14 @@ async function projectApplicant(deps, applicant) {
875
900
  linkedin: applicant.linkedin,
876
901
  hubRecordId: applicant.applicationId
877
902
  });
878
- return upsertPerson(deps, { email: applicant.email, input, mutationId: `apply:${applicant.applicationId}` });
903
+ const mutationId = `apply:${applicant.applicationId}`;
904
+ const extra = applicant.extra ?? {};
905
+ if (Object.keys(extra).length === 0) return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
906
+ try {
907
+ return await upsertPerson(deps, { email: applicant.email, input: { ...base, ...extra }, mutationId });
908
+ } catch {
909
+ return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
910
+ }
879
911
  }
880
912
 
881
913
  // src/clerk.ts