@odla-ai/chapter 0.31.3 → 0.31.4

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
@@ -841,6 +841,14 @@ worker to 6 lines and deleted ~2,500 lines. The order that worked:
841
841
 
842
842
  These bite silently — a smoke test won't catch them:
843
843
 
844
+ - **Application fields generate the application schema.** The resolved
845
+ `application.required` and `application.optional` lists now control both
846
+ submit validation and the `applications` entity emitted by
847
+ `defineChapter()`. Built-in fields retain their types and indexes;
848
+ site-defined fields become string attrs. A field removed from the reference
849
+ form no longer remains accidentally required at `db.transact`, and a field
850
+ listed in both arrays is rejected at definition time. Keep package-owned
851
+ operational attrs such as `status` out of both lists.
844
852
  - **Per-field caps.** `application.defaultMaxLen` is 2000. If your form accepts
845
853
  longer input, pass `maxLen` explicitly or the default starts rejecting it.
846
854
  - **`services` default** is `["db","calendar","o11y"]`; `smoke` compares config
@@ -371,9 +371,9 @@ interface ResolvedPipeline {
371
371
  }
372
372
  /** The application (join form) validation surface — which string fields are
373
373
  * required vs accepted, their max lengths, and the request body cap. Drives
374
- * submit validation + the CRM slot projection; defaults to the reference form.
375
- * The `applications` schema attrs stay fixed; this is
376
- * validation config, not schema generation. */
374
+ * submit validation, the generated `applications` schema, and the CRM slot
375
+ * projection; defaults to the reference form. Built-in fields retain their
376
+ * declared schema types/indexes, and site-defined fields become string attrs. */
377
377
  interface ChapterApplication {
378
378
  required?: readonly string[];
379
379
  optional?: readonly string[];
package/dist/index.cjs CHANGED
@@ -124,6 +124,154 @@ module.exports = __toCommonJS(index_exports);
124
124
  // src/config.ts
125
125
  var import_crm2 = require("@odla-ai/crm");
126
126
 
127
+ // src/member.ts
128
+ var import_crm = require("@odla-ai/crm");
129
+
130
+ // src/application-id.ts
131
+ var APPLICATION_ID_DOMAIN = "odla-ai/chapter/application/v1:";
132
+ async function applicationIdForSubmission(submissionId) {
133
+ const input = new TextEncoder().encode(`${APPLICATION_ID_DOMAIN}${submissionId}`);
134
+ const bytes = new Uint8Array(await crypto.subtle.digest("SHA-256", input)).slice(0, 16);
135
+ bytes[6] = bytes[6] & 15 | 128;
136
+ bytes[8] = bytes[8] & 63 | 128;
137
+ const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
138
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
139
+ }
140
+
141
+ // src/member.ts
142
+ var DEFAULT_REQUIRED = ["firstName", "lastName", "email", "referral", "whoYouAre", "message"];
143
+ var DEFAULT_OPTIONAL = ["referralName", "linkedin", "phone", "state"];
144
+ var SCHEMA_FIELD = /^[A-Za-z_][A-Za-z0-9_-]*$/;
145
+ function resolveApplication(a) {
146
+ const required = a?.required ?? DEFAULT_REQUIRED;
147
+ const optional = a?.optional ?? DEFAULT_OPTIONAL;
148
+ for (const [name, arr] of [["required", required], ["optional", optional]]) {
149
+ if (!Array.isArray(arr) || !arr.every((f) => typeof f === "string" && f !== "")) {
150
+ throw new Error(`defineChapter.application.${name}: must be an array of field-name strings`);
151
+ }
152
+ const unsafe = arr.find((field) => !SCHEMA_FIELD.test(field));
153
+ if (unsafe) {
154
+ throw new Error(`defineChapter.application.${name}: field "${unsafe}" must match ${SCHEMA_FIELD.source}`);
155
+ }
156
+ }
157
+ const duplicate = [...required, ...optional].find((field, index, fields) => fields.indexOf(field) !== index);
158
+ if (duplicate) {
159
+ throw new Error(`defineChapter.application: duplicate field "${duplicate}" across required/optional lists`);
160
+ }
161
+ if (a?.profileFields !== void 0 && (!Array.isArray(a.profileFields) || !a.profileFields.every((f) => typeof f === "string" && f !== ""))) {
162
+ throw new Error("defineChapter.application.profileFields: must be an array of field-name strings");
163
+ }
164
+ if (a?.crmFields !== void 0 && (!Array.isArray(a.crmFields) || !a.crmFields.every((f) => typeof f === "string" && f !== ""))) {
165
+ throw new Error("defineChapter.application.crmFields: must be an array of field-name strings");
166
+ }
167
+ const conditions = a?.conditions ?? {};
168
+ for (const [field, declared] of Object.entries(conditions)) {
169
+ for (const key of ["visibleWhen", "requiredWhen"]) {
170
+ const expression = declared?.[key];
171
+ if (expression === void 0) continue;
172
+ if (typeof expression !== "string" || !expression.trim()) {
173
+ throw new Error(`defineChapter.application.conditions.${field}.${key}: must be a non-empty CEL condition`);
174
+ }
175
+ (0, import_crm.assertFieldCondition)(expression, `defineChapter.application.conditions.${field}.${key}`);
176
+ }
177
+ }
178
+ return {
179
+ required,
180
+ optional,
181
+ conditions,
182
+ maxLen: a?.maxLen ?? {},
183
+ defaultMaxLen: a?.defaultMaxLen ?? 2e3,
184
+ bodyCap: a?.bodyCap ?? 32768,
185
+ requireDisclaimerAck: a?.requireDisclaimerAck ?? true,
186
+ profileFields: a?.profileFields ?? [],
187
+ crmFields: a?.crmFields ?? [],
188
+ maxArrayLen: a?.maxArrayLen ?? 100,
189
+ validateEmail: a?.validateEmail ?? true
190
+ };
191
+ }
192
+ var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
193
+ function isValidEmail(value) {
194
+ return typeof value === "string" && EMAIL_RE.test(value);
195
+ }
196
+ function clampArray(value, max) {
197
+ if (!Array.isArray(value)) return value;
198
+ return value.filter((x) => typeof x === "string" || typeof x === "number" || typeof x === "boolean").slice(0, max);
199
+ }
200
+ function hasDisclaimerAck(fields) {
201
+ return fields.disclaimerAck === true || fields.disclaimerAck === "true";
202
+ }
203
+ var IDENTITY_FIELDS = /* @__PURE__ */ new Set(["email", "firstName", "lastName"]);
204
+ function applicantProfile(chapter, fields) {
205
+ const app = chapter.application;
206
+ const allowed = (f) => app.profileFields.includes(f);
207
+ const profile = {};
208
+ for (const f of [...app.required, ...app.optional]) {
209
+ if (IDENTITY_FIELDS.has(f) || !allowed(f)) continue;
210
+ const v = fields[f];
211
+ if (typeof v === "string" && v.trim() !== "") profile[f] = v.trim();
212
+ }
213
+ if (fields.focus !== void 0 && allowed("focus")) profile.focus = clampArray(fields.focus, app.maxArrayLen);
214
+ return Object.keys(profile).length > 0 ? profile : void 0;
215
+ }
216
+ async function submitApplication(db, chapter, fields, opts) {
217
+ const app = chapter.application;
218
+ const fieldStates = (0, import_crm.resolveFieldStates)(app.conditions ?? {}, fields, app.required);
219
+ for (const [f, state] of Object.entries(fieldStates)) {
220
+ if (!state.required) continue;
221
+ const v = fields[f];
222
+ if (typeof v !== "string" || v.trim() === "") return { ok: false, error: `${f} is required` };
223
+ }
224
+ for (const f of [...app.required, ...app.optional]) {
225
+ const v = fields[f];
226
+ const cap = app.maxLen[f] ?? app.defaultMaxLen;
227
+ if (typeof v === "string" && v.length > cap) return { ok: false, error: `${f} exceeds ${cap} characters` };
228
+ }
229
+ if (app.validateEmail && typeof fields.email === "string" && !isValidEmail(fields.email)) {
230
+ return { ok: false, error: "email must be a valid email address" };
231
+ }
232
+ const acked = hasDisclaimerAck(fields);
233
+ if (app.requireDisclaimerAck && !acked) {
234
+ return { ok: false, error: "disclaimerAck is required" };
235
+ }
236
+ const id2 = opts.submissionId ? await applicationIdForSubmission(opts.submissionId) : opts.newId();
237
+ const row = { id: id2, status: chapter.pipeline.initial, createdAt: opts.now };
238
+ for (const f of [...app.required, ...app.optional]) {
239
+ if (fieldStates[f]?.visible === false) continue;
240
+ if (typeof fields[f] === "string") row[f] = fields[f].trim();
241
+ }
242
+ if (fields.focus !== void 0) row.focus = clampArray(fields.focus, app.maxArrayLen);
243
+ if (opts.groupId) row.groupId = opts.groupId;
244
+ if (typeof fields.tierId === "string" && fields.tierId) {
245
+ if (opts.tierIds && !opts.tierIds.includes(fields.tierId)) {
246
+ return { ok: false, error: "tierId is not an offered tier" };
247
+ }
248
+ row.tierId = fields.tierId;
249
+ }
250
+ if (acked) row.disclaimerAckAt = opts.now;
251
+ const { duplicate } = await db.transact(
252
+ [{ t: "update", ns: "applications", id: id2, attrs: row }],
253
+ opts.submissionId ? { mutationId: `join:${opts.submissionId}` } : void 0
254
+ );
255
+ return { ok: true, id: id2, duplicate, status: chapter.pipeline.initial, disclaimerAckAt: acked ? opts.now : null };
256
+ }
257
+ function joinConfig(group, paymentsReady2, tiers2 = [], conditions = {}) {
258
+ return {
259
+ id: group.id,
260
+ name: group.name,
261
+ standardPriceCents: group.standardPriceCents ?? 0,
262
+ foundingDiscountCents: group.foundingDiscountCents ?? 0,
263
+ tiers: [...tiers2],
264
+ // The browser evaluates the same conditions the server enforces.
265
+ conditions,
266
+ disclaimerText: group.disclaimerText ?? "",
267
+ refundPolicyText: group.refundPolicyText ?? "",
268
+ trustCopy: group.trustCopy ?? "",
269
+ commitmentText: group.commitmentText ?? "",
270
+ normsText: group.normsText ?? "",
271
+ paymentsReady: paymentsReady2
272
+ };
273
+ }
274
+
127
275
  // src/schema.ts
128
276
  function attr(type, flags = {}) {
129
277
  return {
@@ -198,6 +346,47 @@ var applications = {
198
346
  canceled: attr("boolean", { optional: true })
199
347
  }
200
348
  };
349
+ var APPLICATION_INPUT_FIELDS = /* @__PURE__ */ new Set([
350
+ "firstName",
351
+ "lastName",
352
+ "email",
353
+ "referral",
354
+ "referralName",
355
+ "whoYouAre",
356
+ "focus",
357
+ "linkedin",
358
+ "message",
359
+ "phone",
360
+ "state"
361
+ ]);
362
+ function applicationsFor(application) {
363
+ const attrs = {};
364
+ for (const [name, spec] of Object.entries(applications.attrs)) {
365
+ attrs[name] = {
366
+ ...spec,
367
+ optional: APPLICATION_INPUT_FIELDS.has(name) ? true : spec.optional
368
+ };
369
+ }
370
+ const apply = (field, optional) => {
371
+ const existing = Object.hasOwn(attrs, field) ? attrs[field] : void 0;
372
+ if (existing && !APPLICATION_INPUT_FIELDS.has(field)) {
373
+ throw new Error(`defineChapter.application: field "${field}" is reserved by the applications schema`);
374
+ }
375
+ if (existing && existing.type !== "string") {
376
+ throw new Error(`defineChapter.application: field "${field}" is not a configurable string field`);
377
+ }
378
+ const spec = existing ? { ...existing, optional } : attr("string", { optional });
379
+ Object.defineProperty(attrs, field, {
380
+ value: spec,
381
+ enumerable: true,
382
+ configurable: true,
383
+ writable: true
384
+ });
385
+ };
386
+ for (const field of application.required) apply(field, false);
387
+ for (const field of application.optional) apply(field, true);
388
+ return { attrs };
389
+ }
201
390
  var groups = {
202
391
  attrs: {
203
392
  id: id(),
@@ -269,10 +458,10 @@ var emailLog = {
269
458
  sentAt: attr("number", { indexed: true })
270
459
  }
271
460
  };
272
- function chapterDb(mode, auth, includeNetworkNotes = false) {
461
+ function chapterDb(mode, auth, includeNetworkNotes = false, application = resolveApplication(void 0)) {
273
462
  const entities = {};
274
463
  if (mode === "chapter") {
275
- entities.applications = applications;
464
+ entities.applications = applicationsFor(application);
276
465
  entities.groups = groups;
277
466
  entities.tiers = tiers;
278
467
  entities.meetings = meetings;
@@ -535,145 +724,6 @@ function canApprove(status, p) {
535
724
  return p.approvableFrom.includes(status);
536
725
  }
537
726
 
538
- // src/member.ts
539
- var import_crm = require("@odla-ai/crm");
540
-
541
- // src/application-id.ts
542
- var APPLICATION_ID_DOMAIN = "odla-ai/chapter/application/v1:";
543
- async function applicationIdForSubmission(submissionId) {
544
- const input = new TextEncoder().encode(`${APPLICATION_ID_DOMAIN}${submissionId}`);
545
- const bytes = new Uint8Array(await crypto.subtle.digest("SHA-256", input)).slice(0, 16);
546
- bytes[6] = bytes[6] & 15 | 128;
547
- bytes[8] = bytes[8] & 63 | 128;
548
- const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
549
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
550
- }
551
-
552
- // src/member.ts
553
- var DEFAULT_REQUIRED = ["firstName", "lastName", "email", "referral", "whoYouAre", "message"];
554
- var DEFAULT_OPTIONAL = ["referralName", "linkedin", "phone", "state"];
555
- function resolveApplication(a) {
556
- const required = a?.required ?? DEFAULT_REQUIRED;
557
- const optional = a?.optional ?? DEFAULT_OPTIONAL;
558
- for (const [name, arr] of [["required", required], ["optional", optional]]) {
559
- if (!Array.isArray(arr) || !arr.every((f) => typeof f === "string" && f !== "")) {
560
- throw new Error(`defineChapter.application.${name}: must be an array of field-name strings`);
561
- }
562
- }
563
- if (a?.profileFields !== void 0 && (!Array.isArray(a.profileFields) || !a.profileFields.every((f) => typeof f === "string" && f !== ""))) {
564
- throw new Error("defineChapter.application.profileFields: must be an array of field-name strings");
565
- }
566
- if (a?.crmFields !== void 0 && (!Array.isArray(a.crmFields) || !a.crmFields.every((f) => typeof f === "string" && f !== ""))) {
567
- throw new Error("defineChapter.application.crmFields: must be an array of field-name strings");
568
- }
569
- const conditions = a?.conditions ?? {};
570
- for (const [field, declared] of Object.entries(conditions)) {
571
- for (const key of ["visibleWhen", "requiredWhen"]) {
572
- const expression = declared?.[key];
573
- if (expression === void 0) continue;
574
- if (typeof expression !== "string" || !expression.trim()) {
575
- throw new Error(`defineChapter.application.conditions.${field}.${key}: must be a non-empty CEL condition`);
576
- }
577
- (0, import_crm.assertFieldCondition)(expression, `defineChapter.application.conditions.${field}.${key}`);
578
- }
579
- }
580
- return {
581
- required,
582
- optional,
583
- conditions,
584
- maxLen: a?.maxLen ?? {},
585
- defaultMaxLen: a?.defaultMaxLen ?? 2e3,
586
- bodyCap: a?.bodyCap ?? 32768,
587
- requireDisclaimerAck: a?.requireDisclaimerAck ?? true,
588
- profileFields: a?.profileFields ?? [],
589
- crmFields: a?.crmFields ?? [],
590
- maxArrayLen: a?.maxArrayLen ?? 100,
591
- validateEmail: a?.validateEmail ?? true
592
- };
593
- }
594
- var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
595
- function isValidEmail(value) {
596
- return typeof value === "string" && EMAIL_RE.test(value);
597
- }
598
- function clampArray(value, max) {
599
- if (!Array.isArray(value)) return value;
600
- return value.filter((x) => typeof x === "string" || typeof x === "number" || typeof x === "boolean").slice(0, max);
601
- }
602
- function hasDisclaimerAck(fields) {
603
- return fields.disclaimerAck === true || fields.disclaimerAck === "true";
604
- }
605
- var IDENTITY_FIELDS = /* @__PURE__ */ new Set(["email", "firstName", "lastName"]);
606
- function applicantProfile(chapter, fields) {
607
- const app = chapter.application;
608
- const allowed = (f) => app.profileFields.includes(f);
609
- const profile = {};
610
- for (const f of [...app.required, ...app.optional]) {
611
- if (IDENTITY_FIELDS.has(f) || !allowed(f)) continue;
612
- const v = fields[f];
613
- if (typeof v === "string" && v.trim() !== "") profile[f] = v.trim();
614
- }
615
- if (fields.focus !== void 0 && allowed("focus")) profile.focus = clampArray(fields.focus, app.maxArrayLen);
616
- return Object.keys(profile).length > 0 ? profile : void 0;
617
- }
618
- async function submitApplication(db, chapter, fields, opts) {
619
- const app = chapter.application;
620
- const fieldStates = (0, import_crm.resolveFieldStates)(app.conditions ?? {}, fields, app.required);
621
- for (const [f, state] of Object.entries(fieldStates)) {
622
- if (!state.required) continue;
623
- const v = fields[f];
624
- if (typeof v !== "string" || v.trim() === "") return { ok: false, error: `${f} is required` };
625
- }
626
- for (const f of [...app.required, ...app.optional]) {
627
- const v = fields[f];
628
- const cap = app.maxLen[f] ?? app.defaultMaxLen;
629
- if (typeof v === "string" && v.length > cap) return { ok: false, error: `${f} exceeds ${cap} characters` };
630
- }
631
- if (app.validateEmail && typeof fields.email === "string" && !isValidEmail(fields.email)) {
632
- return { ok: false, error: "email must be a valid email address" };
633
- }
634
- const acked = hasDisclaimerAck(fields);
635
- if (app.requireDisclaimerAck && !acked) {
636
- return { ok: false, error: "disclaimerAck is required" };
637
- }
638
- const id2 = opts.submissionId ? await applicationIdForSubmission(opts.submissionId) : opts.newId();
639
- const row = { id: id2, status: chapter.pipeline.initial, createdAt: opts.now };
640
- for (const f of [...app.required, ...app.optional]) {
641
- if (fieldStates[f]?.visible === false) continue;
642
- if (typeof fields[f] === "string") row[f] = fields[f].trim();
643
- }
644
- if (fields.focus !== void 0) row.focus = clampArray(fields.focus, app.maxArrayLen);
645
- if (opts.groupId) row.groupId = opts.groupId;
646
- if (typeof fields.tierId === "string" && fields.tierId) {
647
- if (opts.tierIds && !opts.tierIds.includes(fields.tierId)) {
648
- return { ok: false, error: "tierId is not an offered tier" };
649
- }
650
- row.tierId = fields.tierId;
651
- }
652
- if (acked) row.disclaimerAckAt = opts.now;
653
- const { duplicate } = await db.transact(
654
- [{ t: "update", ns: "applications", id: id2, attrs: row }],
655
- opts.submissionId ? { mutationId: `join:${opts.submissionId}` } : void 0
656
- );
657
- return { ok: true, id: id2, duplicate, status: chapter.pipeline.initial, disclaimerAckAt: acked ? opts.now : null };
658
- }
659
- function joinConfig(group, paymentsReady2, tiers2 = [], conditions = {}) {
660
- return {
661
- id: group.id,
662
- name: group.name,
663
- standardPriceCents: group.standardPriceCents ?? 0,
664
- foundingDiscountCents: group.foundingDiscountCents ?? 0,
665
- tiers: [...tiers2],
666
- // The browser evaluates the same conditions the server enforces.
667
- conditions,
668
- disclaimerText: group.disclaimerText ?? "",
669
- refundPolicyText: group.refundPolicyText ?? "",
670
- trustCopy: group.trustCopy ?? "",
671
- commitmentText: group.commitmentText ?? "",
672
- normsText: group.normsText ?? "",
673
- paymentsReady: paymentsReady2
674
- };
675
- }
676
-
677
727
  // src/copy-defaults-admin.ts
678
728
  var DEFAULT_ADMIN_COPY = {
679
729
  auth: {
@@ -1222,7 +1272,7 @@ function defineChapter(config) {
1222
1272
  const copy = resolveChapterCopy(config.copy);
1223
1273
  const network = resolveNetwork(config, crm);
1224
1274
  const formation = resolveLeaderFormation(config.formation, crm);
1225
- const { schema, rules } = chapterDb(mode, auth, network.targets.length > 0);
1275
+ const { schema, rules } = chapterDb(mode, auth, network.targets.length > 0, application);
1226
1276
  const services = config.services ?? ["db", "calendar", "o11y"];
1227
1277
  const account = config.account ?? "none";
1228
1278
  if (account !== "invite" && account !== "create" && account !== "none") {