@odla-ai/chapter 0.37.0 → 0.38.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.
@@ -2,7 +2,7 @@ import * as preact from 'preact';
2
2
  import { ComponentChildren, JSX } from 'preact';
3
3
  import { d as MemberContentProjection, e as MemberContentPageProjection, b as ChapterCopy, f as ChapterBrand } from '../../copy-context-Pbs3keLx.js';
4
4
  export { C as ChapterCopyProvider, u as useChapterCopy } from '../../copy-context-Pbs3keLx.js';
5
- import '@odla-ai/crm';
5
+ import { FieldType, FieldConditions } from '@odla-ai/crm';
6
6
 
7
7
  /** A bookable slot: a start instant in epoch milliseconds. */
8
8
  interface Slot {
@@ -302,6 +302,38 @@ interface GiftOffer {
302
302
  /** Provider-free gift offer shape rendered by Chapter and exact CMS previews. */
303
303
  type PublicGiftOffer = Omit<GiftOffer, "stripePriceId" | "target">;
304
304
 
305
+ /** Current public schema version for Chapter flow-field contracts. */
306
+ declare const CHAPTER_FLOW_FIELDS_SCHEMA_VERSION: 1;
307
+ /** Configurable Chapter intake journeys that publish field contracts. */
308
+ type ChapterFieldFlow = "join" | "formation";
309
+ /** Email comparison policy advertised by an email field. */
310
+ type ChapterEmailIdentity = "literal";
311
+ /**
312
+ * One effective field in a Chapter intake journey.
313
+ *
314
+ * IDs, value types, and options come from the follower-declared schema. A hub
315
+ * or CMS may select, order, and configure only those declared fields; it cannot
316
+ * use this presentation document to add storage or widen follower authority.
317
+ */
318
+ interface ChapterFlowField {
319
+ id: string;
320
+ label: string;
321
+ type: FieldType;
322
+ required: boolean;
323
+ order: number;
324
+ maxLength?: number;
325
+ options?: readonly string[];
326
+ conditions?: FieldConditions;
327
+ /** Present only for an email identity field. Aliases such as `+tag` remain significant. */
328
+ emailIdentity?: ChapterEmailIdentity;
329
+ }
330
+ /** Ordered, versioned effective field state for one Chapter journey. */
331
+ interface ChapterFlowFieldContract {
332
+ schemaVersion: typeof CHAPTER_FLOW_FIELDS_SCHEMA_VERSION;
333
+ flow: ChapterFieldFlow;
334
+ fields: readonly ChapterFlowField[];
335
+ }
336
+
305
337
  /** The public join config (the shape `GET /api/join-config` returns). */
306
338
  interface JoinConfig {
307
339
  id: string;
@@ -314,6 +346,8 @@ interface JoinConfig {
314
346
  /** Resolved copy for the packaged join flow. */
315
347
  copy?: ChapterCopy["join"];
316
348
  giftOffers?: readonly PublicGiftOffer[];
349
+ /** Exact ordered field state accepted by the effective server-side join flow. */
350
+ fieldContract?: ChapterFlowFieldContract;
317
351
  }
318
352
  /** Server-validated state used to resume a join journey after a redirect. */
319
353
  type JoinFlowState = {
@@ -199,204 +199,10 @@ async function applicationIdForSubmission(submissionId) {
199
199
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
200
200
  }
201
201
 
202
- // src/admission-grants-contract.ts
203
- var ADMISSION_GRANTS_SCHEMA_VERSION = 1;
204
- var ADMISSION_GRANTS_PATH = "/api/admission-grants";
205
-
206
- // src/signup-control-canonical.ts
207
- var encoder = new TextEncoder();
208
- function canonicalValue(value) {
209
- if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
210
- return JSON.stringify(value);
211
- }
212
- if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
213
- if (typeof value !== "object") throw new TypeError("signup control contains an unsupported value");
214
- const record3 = value;
215
- const entries = Object.keys(record3).sort().flatMap((key) => {
216
- const item = record3[key];
217
- return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue(item)}`];
218
- });
219
- return `{${entries.join(",")}}`;
220
- }
221
- function canonicalSignupControl(value) {
222
- return canonicalValue(value);
223
- }
224
- async function signupControlDigest(document) {
225
- const digest = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalSignupControl(document)));
226
- return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
227
- }
228
-
229
- // src/admission-grants.ts
230
- var ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
231
- var MAX_GRANTS = 2e3;
232
- var MAX_NOTE = 2e3;
233
- var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
234
- var isAdmissionEmail = (value) => EMAIL_RE.test(value);
235
- function normalizeAdmissionEmail(email) {
202
+ // src/email-identity.ts
203
+ function normalizeChapterEmail(email) {
236
204
  return email.trim().toLowerCase();
237
205
  }
238
- function validateAdmissionGrantDocument(input) {
239
- const errors = [];
240
- const root = input && typeof input === "object" ? input : {};
241
- if (root.schemaVersion !== ADMISSION_GRANTS_SCHEMA_VERSION) errors.push("document.schemaVersion must be 1");
242
- const revision = Number(root.revision);
243
- const generatedAt = Number(root.generatedAt);
244
- if (!Number.isSafeInteger(revision) || revision < 1) errors.push("document.revision must be a positive integer");
245
- if (!Number.isFinite(generatedAt) || generatedAt <= 0) errors.push("document.generatedAt must be epoch milliseconds");
246
- const source = root.source && typeof root.source === "object" ? root.source : {};
247
- const target = root.target && typeof root.target === "object" ? root.target : {};
248
- const sourceId = typeof source.appId === "string" ? source.appId : "";
249
- const sourceActor = typeof source.actor === "string" ? source.actor.trim() : "";
250
- const chapterId = typeof target.chapterId === "string" ? target.chapterId : "";
251
- const environment = typeof target.environment === "string" ? target.environment : "";
252
- if (!ID.test(sourceId)) errors.push("document.source.appId must be an identifier");
253
- if (!sourceActor || sourceActor.length > 256) errors.push("document.source.actor must be 1-256 characters");
254
- if (!ID.test(chapterId)) errors.push("document.target.chapterId must be an identifier");
255
- if (!ID.test(environment)) errors.push("document.target.environment must be an identifier");
256
- if (!Array.isArray(root.grants)) errors.push("document.grants must be an array");
257
- const rawGrants = Array.isArray(root.grants) ? root.grants : [];
258
- if (rawGrants.length > MAX_GRANTS) errors.push(`document.grants supports at most ${MAX_GRANTS} entries`);
259
- const grants = [];
260
- const seenIds = /* @__PURE__ */ new Set();
261
- const seenEmails = /* @__PURE__ */ new Set();
262
- for (const [index, raw] of rawGrants.slice(0, MAX_GRANTS).entries()) {
263
- const grant = raw && typeof raw === "object" ? raw : {};
264
- const id = typeof grant.id === "string" ? grant.id : "";
265
- const email = typeof grant.email === "string" ? grant.email.trim() : "";
266
- const normalized = normalizeAdmissionEmail(email);
267
- const decision = grant.decision;
268
- const note = typeof grant.note === "string" ? grant.note.trim() : void 0;
269
- const expiresAt = grant.expiresAt === void 0 ? void 0 : Number(grant.expiresAt);
270
- if (!ID.test(id)) errors.push(`document.grants[${index}].id must be an identifier`);
271
- if (seenIds.has(id)) errors.push(`document.grants repeats id "${id}"`);
272
- if (!isAdmissionEmail(normalized)) errors.push(`document.grants[${index}].email must be valid`);
273
- if (seenEmails.has(normalized)) errors.push(`document.grants repeats email "${normalized}"`);
274
- if (decision !== "waive_interview" && decision !== "require_interview") {
275
- errors.push(`document.grants[${index}].decision is invalid`);
276
- }
277
- if (note && note.length > MAX_NOTE) errors.push(`document.grants[${index}].note is too long`);
278
- if (expiresAt !== void 0 && (!Number.isFinite(expiresAt) || expiresAt <= 0)) {
279
- errors.push(`document.grants[${index}].expiresAt must be epoch milliseconds`);
280
- }
281
- seenIds.add(id);
282
- seenEmails.add(normalized);
283
- grants.push({
284
- id,
285
- email,
286
- decision,
287
- ...note ? { note } : {},
288
- ...expiresAt !== void 0 ? { expiresAt } : {}
289
- });
290
- }
291
- if (errors.length) return { ok: false, errors };
292
- return {
293
- ok: true,
294
- value: {
295
- schemaVersion: ADMISSION_GRANTS_SCHEMA_VERSION,
296
- revision,
297
- generatedAt,
298
- source: { appId: sourceId, actor: sourceActor },
299
- target: { chapterId, environment },
300
- grants
301
- }
302
- };
303
- }
304
- var rows = (value) => Array.isArray(value) ? value : [];
305
- function asGrant(row) {
306
- if (typeof row.id !== "string" || typeof row.emailNormalized !== "string") return null;
307
- if (row.sourceScope !== "local" && row.sourceScope !== "parent") return null;
308
- if (row.decision !== "waive_interview" && row.decision !== "require_interview") return null;
309
- return row;
310
- }
311
- async function findAdmissionGrant(db, targetId, email, now) {
312
- const emailNormalized = normalizeAdmissionEmail(email);
313
- if (!isAdmissionEmail(emailNormalized)) return null;
314
- const result = await db.query({ admissionGrants: { $: { where: { emailNormalized }, limit: 100 } } });
315
- const active = rows(result.admissionGrants).map(asGrant).filter((grant) => Boolean(
316
- grant && grant.targetId === targetId && grant.active === true && !grant.revokedAt && (!grant.expiresAt || grant.expiresAt > now) && !grant.consumedApplicationId
317
- )).sort((a, b) => {
318
- const scope = Number(b.sourceScope === "local") - Number(a.sourceScope === "local");
319
- return scope || b.createdAt - a.createdAt;
320
- });
321
- return active[0] ?? null;
322
- }
323
- async function hasAdmissionRevision(db, sourceId, targetId, environment) {
324
- const where = { sourceId, targetId, environment };
325
- if (db.aggregate) {
326
- return Number((await db.aggregate("admissionGrantRevisions", { count: true }, { where })).count ?? 0) > 0;
327
- }
328
- const found = await db.query({ admissionGrantRevisions: {} });
329
- return rows(found.admissionGrantRevisions).some((row) => row.sourceId === sourceId && row.targetId === targetId && row.environment === environment);
330
- }
331
- async function loadLatestAdmissionRevision(db, sourceId, targetId, environment) {
332
- if (!await hasAdmissionRevision(db, sourceId, targetId, environment)) return null;
333
- const result = await db.query({ admissionGrantRevisions: { $: {
334
- where: { sourceId, targetId, environment },
335
- order: { revision: "desc" },
336
- limit: 1
337
- } } });
338
- const row = rows(result.admissionGrantRevisions).sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0];
339
- return row ? row : null;
340
- }
341
- async function materializeAdmissionGrants(db, envelope, previous, receivedAt) {
342
- const { document, digest } = envelope;
343
- const current = rows((await db.query({ admissionGrants: { $: {
344
- where: { sourceScope: "parent", sourceId: document.source.appId, targetId: document.target.chapterId },
345
- limit: MAX_GRANTS
346
- } } })).admissionGrants);
347
- const incomingIds = new Set(document.grants.map((grant) => `admission:parent:${document.source.appId}:${grant.id}`));
348
- const ops = [];
349
- for (const row of current) {
350
- const id = String(row.id ?? "");
351
- if (id && !incomingIds.has(id)) ops.push({ t: "update", ns: "admissionGrants", id, attrs: {
352
- active: false,
353
- revokedAt: receivedAt,
354
- revokedBy: document.source.actor,
355
- updatedAt: receivedAt,
356
- updatedBy: document.source.actor
357
- } });
358
- }
359
- for (const grant of document.grants) {
360
- const id = `admission:parent:${document.source.appId}:${grant.id}`;
361
- const existing = current.find((row) => row.id === id);
362
- ops.push({ t: "update", ns: "admissionGrants", id, attrs: {
363
- id,
364
- sourceGrantId: grant.id,
365
- sourceScope: "parent",
366
- sourceId: document.source.appId,
367
- targetId: document.target.chapterId,
368
- email: grant.email,
369
- emailNormalized: normalizeAdmissionEmail(grant.email),
370
- decision: grant.decision,
371
- ...grant.note ? { note: grant.note } : {},
372
- ...grant.expiresAt ? { expiresAt: grant.expiresAt } : {},
373
- active: true,
374
- sourceRevision: document.revision,
375
- createdAt: Number(existing?.createdAt ?? receivedAt),
376
- createdBy: String(existing?.createdBy ?? document.source.actor),
377
- updatedAt: receivedAt,
378
- updatedBy: document.source.actor
379
- } });
380
- const retract = [
381
- ...!grant.note && existing?.note !== void 0 ? ["note"] : [],
382
- ...grant.expiresAt === void 0 && existing?.expiresAt !== void 0 ? ["expiresAt"] : []
383
- ];
384
- if (retract.length) ops.push({ t: "retract", ns: "admissionGrants", id, attrs: retract });
385
- }
386
- if (previous?.active) ops.push({ t: "update", ns: "admissionGrantRevisions", id: previous.id, attrs: { active: false } });
387
- const revisionId = `admission-revision:${document.source.appId}:${document.target.environment}:${document.revision}`;
388
- ops.push({ t: "update", ns: "admissionGrantRevisions", id: revisionId, attrs: {
389
- id: revisionId,
390
- sourceId: document.source.appId,
391
- targetId: document.target.chapterId,
392
- environment: document.target.environment,
393
- revision: document.revision,
394
- digest,
395
- active: true,
396
- receivedAt
397
- } });
398
- await db.transact(ops, { mutationId: `admission-grants:${document.source.appId}:${document.target.environment}:${document.revision}:${digest}` });
399
- }
400
206
 
401
207
  // src/member.ts
402
208
  var DEFAULT_REQUIRED = ["firstName", "lastName", "email", "referral", "whoYouAre", "message"];
@@ -449,9 +255,9 @@ function resolveApplication(a) {
449
255
  validateEmail: a?.validateEmail ?? true
450
256
  };
451
257
  }
452
- var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
258
+ var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
453
259
  function isValidEmail(value) {
454
- return typeof value === "string" && EMAIL_RE2.test(value);
260
+ return typeof value === "string" && EMAIL_RE.test(value);
455
261
  }
456
262
  function clampArray(value, max) {
457
263
  if (!Array.isArray(value)) return value;
@@ -486,7 +292,7 @@ async function submitApplication(db, chapter, fields, opts) {
486
292
  const cap = app.maxLen[f] ?? app.defaultMaxLen;
487
293
  if (typeof v === "string" && v.length > cap) return { ok: false, error: `${f} exceeds ${cap} characters` };
488
294
  }
489
- if (app.validateEmail && typeof fields.email === "string" && !isValidEmail(fields.email)) {
295
+ if (app.validateEmail && typeof fields.email === "string" && !isValidEmail(fields.email.trim())) {
490
296
  return { ok: false, error: "email must be a valid email address" };
491
297
  }
492
298
  const acked = hasDisclaimerAck(fields);
@@ -502,7 +308,7 @@ async function submitApplication(db, chapter, fields, opts) {
502
308
  if (fields.focus !== void 0) row.focus = clampArray(fields.focus, app.maxArrayLen);
503
309
  if (opts.groupId) row.groupId = opts.groupId;
504
310
  if (typeof fields.email === "string" && isValidEmail(fields.email.trim())) {
505
- row.emailNormalized = normalizeAdmissionEmail(fields.email);
311
+ row.emailNormalized = normalizeChapterEmail(fields.email);
506
312
  }
507
313
  if (opts.admissionGrant?.decision === "waive_interview") {
508
314
  row.admissionGrantId = opts.admissionGrant.id;
@@ -730,6 +536,281 @@ function applicationBookingUpdate(currentStatus, startAt, htmlLink) {
730
536
  };
731
537
  }
732
538
 
539
+ // src/admission-grants-contract.ts
540
+ var ADMISSION_GRANTS_SCHEMA_VERSION = 1;
541
+ var ADMISSION_GRANTS_PATH = "/api/admission-grants";
542
+
543
+ // src/signup-control-canonical.ts
544
+ var encoder = new TextEncoder();
545
+ function canonicalValue(value) {
546
+ if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
547
+ return JSON.stringify(value);
548
+ }
549
+ if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
550
+ if (typeof value !== "object") throw new TypeError("signup control contains an unsupported value");
551
+ const record3 = value;
552
+ const entries = Object.keys(record3).sort().flatMap((key) => {
553
+ const item = record3[key];
554
+ return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue(item)}`];
555
+ });
556
+ return `{${entries.join(",")}}`;
557
+ }
558
+ function canonicalSignupControl(value) {
559
+ return canonicalValue(value);
560
+ }
561
+ async function signupControlDigest(document) {
562
+ const digest = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalSignupControl(document)));
563
+ return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
564
+ }
565
+
566
+ // src/admission-grants.ts
567
+ var ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
568
+ var MAX_GRANTS = 2e3;
569
+ var MAX_NOTE = 2e3;
570
+ var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
571
+ var isAdmissionEmail = (value) => EMAIL_RE2.test(value);
572
+ function normalizeAdmissionEmail(email) {
573
+ return normalizeChapterEmail(email);
574
+ }
575
+ function validateAdmissionGrantDocument(input) {
576
+ const errors = [];
577
+ const root = input && typeof input === "object" ? input : {};
578
+ if (root.schemaVersion !== ADMISSION_GRANTS_SCHEMA_VERSION) errors.push("document.schemaVersion must be 1");
579
+ const revision = Number(root.revision);
580
+ const generatedAt = Number(root.generatedAt);
581
+ if (!Number.isSafeInteger(revision) || revision < 1) errors.push("document.revision must be a positive integer");
582
+ if (!Number.isFinite(generatedAt) || generatedAt <= 0) errors.push("document.generatedAt must be epoch milliseconds");
583
+ const source = root.source && typeof root.source === "object" ? root.source : {};
584
+ const target = root.target && typeof root.target === "object" ? root.target : {};
585
+ const sourceId = typeof source.appId === "string" ? source.appId : "";
586
+ const sourceActor = typeof source.actor === "string" ? source.actor.trim() : "";
587
+ const chapterId = typeof target.chapterId === "string" ? target.chapterId : "";
588
+ const environment = typeof target.environment === "string" ? target.environment : "";
589
+ if (!ID.test(sourceId)) errors.push("document.source.appId must be an identifier");
590
+ if (!sourceActor || sourceActor.length > 256) errors.push("document.source.actor must be 1-256 characters");
591
+ if (!ID.test(chapterId)) errors.push("document.target.chapterId must be an identifier");
592
+ if (!ID.test(environment)) errors.push("document.target.environment must be an identifier");
593
+ if (!Array.isArray(root.grants)) errors.push("document.grants must be an array");
594
+ const rawGrants = Array.isArray(root.grants) ? root.grants : [];
595
+ if (rawGrants.length > MAX_GRANTS) errors.push(`document.grants supports at most ${MAX_GRANTS} entries`);
596
+ const grants = [];
597
+ const seenIds = /* @__PURE__ */ new Set();
598
+ const seenEmails = /* @__PURE__ */ new Set();
599
+ for (const [index, raw] of rawGrants.slice(0, MAX_GRANTS).entries()) {
600
+ const grant = raw && typeof raw === "object" ? raw : {};
601
+ const id = typeof grant.id === "string" ? grant.id : "";
602
+ const email = typeof grant.email === "string" ? grant.email.trim() : "";
603
+ const normalized = normalizeAdmissionEmail(email);
604
+ const decision = grant.decision;
605
+ const note = typeof grant.note === "string" ? grant.note.trim() : void 0;
606
+ const expiresAt = grant.expiresAt === void 0 ? void 0 : Number(grant.expiresAt);
607
+ if (!ID.test(id)) errors.push(`document.grants[${index}].id must be an identifier`);
608
+ if (seenIds.has(id)) errors.push(`document.grants repeats id "${id}"`);
609
+ if (!isAdmissionEmail(normalized)) errors.push(`document.grants[${index}].email must be valid`);
610
+ if (seenEmails.has(normalized)) errors.push(`document.grants repeats email "${normalized}"`);
611
+ if (decision !== "waive_interview" && decision !== "require_interview") {
612
+ errors.push(`document.grants[${index}].decision is invalid`);
613
+ }
614
+ if (note && note.length > MAX_NOTE) errors.push(`document.grants[${index}].note is too long`);
615
+ if (expiresAt !== void 0 && (!Number.isFinite(expiresAt) || expiresAt <= 0)) {
616
+ errors.push(`document.grants[${index}].expiresAt must be epoch milliseconds`);
617
+ }
618
+ seenIds.add(id);
619
+ seenEmails.add(normalized);
620
+ grants.push({
621
+ id,
622
+ email,
623
+ decision,
624
+ ...note ? { note } : {},
625
+ ...expiresAt !== void 0 ? { expiresAt } : {}
626
+ });
627
+ }
628
+ if (errors.length) return { ok: false, errors };
629
+ return {
630
+ ok: true,
631
+ value: {
632
+ schemaVersion: ADMISSION_GRANTS_SCHEMA_VERSION,
633
+ revision,
634
+ generatedAt,
635
+ source: { appId: sourceId, actor: sourceActor },
636
+ target: { chapterId, environment },
637
+ grants
638
+ }
639
+ };
640
+ }
641
+ var rows = (value) => Array.isArray(value) ? value : [];
642
+ function asGrant(row) {
643
+ if (typeof row.id !== "string" || typeof row.emailNormalized !== "string") return null;
644
+ if (row.sourceScope !== "local" && row.sourceScope !== "parent") return null;
645
+ if (row.decision !== "waive_interview" && row.decision !== "require_interview") return null;
646
+ return row;
647
+ }
648
+ async function findAdmissionGrant(db, targetId, email, now) {
649
+ const emailNormalized = normalizeAdmissionEmail(email);
650
+ if (!isAdmissionEmail(emailNormalized)) return null;
651
+ const result = await db.query({ admissionGrants: { $: { where: { emailNormalized }, limit: 100 } } });
652
+ const active = rows(result.admissionGrants).map(asGrant).filter((grant) => Boolean(
653
+ grant && grant.targetId === targetId && grant.active === true && !grant.revokedAt && (!grant.expiresAt || grant.expiresAt > now) && !grant.consumedApplicationId
654
+ )).sort((a, b) => {
655
+ const scope = Number(b.sourceScope === "local") - Number(a.sourceScope === "local");
656
+ return scope || b.createdAt - a.createdAt;
657
+ });
658
+ return active[0] ?? null;
659
+ }
660
+ async function hasAdmissionRevision(db, sourceId, targetId, environment) {
661
+ const where = { sourceId, targetId, environment };
662
+ if (db.aggregate) {
663
+ return Number((await db.aggregate("admissionGrantRevisions", { count: true }, { where })).count ?? 0) > 0;
664
+ }
665
+ const found = await db.query({ admissionGrantRevisions: {} });
666
+ return rows(found.admissionGrantRevisions).some((row) => row.sourceId === sourceId && row.targetId === targetId && row.environment === environment);
667
+ }
668
+ async function loadLatestAdmissionRevision(db, sourceId, targetId, environment) {
669
+ if (!await hasAdmissionRevision(db, sourceId, targetId, environment)) return null;
670
+ const result = await db.query({ admissionGrantRevisions: { $: {
671
+ where: { sourceId, targetId, environment },
672
+ order: { revision: "desc" },
673
+ limit: 1
674
+ } } });
675
+ const row = rows(result.admissionGrantRevisions).sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0];
676
+ return row ? row : null;
677
+ }
678
+ async function materializeAdmissionGrants(db, envelope, previous, receivedAt) {
679
+ const { document, digest } = envelope;
680
+ const current = rows((await db.query({ admissionGrants: { $: {
681
+ where: { sourceScope: "parent", sourceId: document.source.appId, targetId: document.target.chapterId },
682
+ limit: MAX_GRANTS
683
+ } } })).admissionGrants);
684
+ const incomingIds = new Set(document.grants.map((grant) => `admission:parent:${document.source.appId}:${grant.id}`));
685
+ const ops = [];
686
+ for (const row of current) {
687
+ const id = String(row.id ?? "");
688
+ if (id && !incomingIds.has(id)) ops.push({ t: "update", ns: "admissionGrants", id, attrs: {
689
+ active: false,
690
+ revokedAt: receivedAt,
691
+ revokedBy: document.source.actor,
692
+ updatedAt: receivedAt,
693
+ updatedBy: document.source.actor
694
+ } });
695
+ }
696
+ for (const grant of document.grants) {
697
+ const id = `admission:parent:${document.source.appId}:${grant.id}`;
698
+ const existing = current.find((row) => row.id === id);
699
+ ops.push({ t: "update", ns: "admissionGrants", id, attrs: {
700
+ id,
701
+ sourceGrantId: grant.id,
702
+ sourceScope: "parent",
703
+ sourceId: document.source.appId,
704
+ targetId: document.target.chapterId,
705
+ email: grant.email,
706
+ emailNormalized: normalizeAdmissionEmail(grant.email),
707
+ decision: grant.decision,
708
+ ...grant.note ? { note: grant.note } : {},
709
+ ...grant.expiresAt ? { expiresAt: grant.expiresAt } : {},
710
+ active: true,
711
+ sourceRevision: document.revision,
712
+ createdAt: Number(existing?.createdAt ?? receivedAt),
713
+ createdBy: String(existing?.createdBy ?? document.source.actor),
714
+ updatedAt: receivedAt,
715
+ updatedBy: document.source.actor
716
+ } });
717
+ const retract = [
718
+ ...!grant.note && existing?.note !== void 0 ? ["note"] : [],
719
+ ...grant.expiresAt === void 0 && existing?.expiresAt !== void 0 ? ["expiresAt"] : []
720
+ ];
721
+ if (retract.length) ops.push({ t: "retract", ns: "admissionGrants", id, attrs: retract });
722
+ }
723
+ if (previous?.active) ops.push({ t: "update", ns: "admissionGrantRevisions", id: previous.id, attrs: { active: false } });
724
+ const revisionId = `admission-revision:${document.source.appId}:${document.target.environment}:${document.revision}`;
725
+ ops.push({ t: "update", ns: "admissionGrantRevisions", id: revisionId, attrs: {
726
+ id: revisionId,
727
+ sourceId: document.source.appId,
728
+ targetId: document.target.chapterId,
729
+ environment: document.target.environment,
730
+ revision: document.revision,
731
+ digest,
732
+ active: true,
733
+ receivedAt
734
+ } });
735
+ await db.transact(ops, { mutationId: `admission-grants:${document.source.appId}:${document.target.environment}:${document.revision}:${digest}` });
736
+ }
737
+
738
+ // src/flow-fields.ts
739
+ var CHAPTER_FLOW_FIELDS_SCHEMA_VERSION = 1;
740
+ var APPLICATION_LABELS = {
741
+ firstName: "First name",
742
+ lastName: "Last name",
743
+ email: "Email",
744
+ referral: "Referral source",
745
+ referralName: "Referral name",
746
+ whoYouAre: "Who you are",
747
+ linkedin: "LinkedIn",
748
+ message: "Intro message",
749
+ phone: "Phone",
750
+ state: "State"
751
+ };
752
+ function humanize(id) {
753
+ const text3 = id.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
754
+ return text3 ? `${text3[0].toUpperCase()}${text3.slice(1)}` : id;
755
+ }
756
+ function applicationDefs(chapter) {
757
+ try {
758
+ return chapter.crm.type("person").fields;
759
+ } catch {
760
+ return {};
761
+ }
762
+ }
763
+ function applicationType(id, def) {
764
+ if (def && ["string", "email", "url", "enum"].includes(def.type)) return def.type;
765
+ return id === "email" ? "email" : id === "linkedin" ? "url" : "string";
766
+ }
767
+ function applicationFlowFields(chapter, application = chapter.application) {
768
+ const defs = applicationDefs(chapter);
769
+ return [...application.required, ...application.optional].map((id, order) => {
770
+ const def = defs[id];
771
+ const conditions = application.conditions[id];
772
+ const type = applicationType(id, def);
773
+ return {
774
+ id,
775
+ label: def?.label ?? APPLICATION_LABELS[id] ?? humanize(id),
776
+ type,
777
+ required: application.required.includes(id),
778
+ order,
779
+ maxLength: application.maxLen[id] ?? application.defaultMaxLen,
780
+ ...type === "enum" && def?.options ? { options: [...def.options] } : {},
781
+ ...conditions ? { conditions: { ...conditions } } : {},
782
+ ...type === "email" ? { emailIdentity: "literal" } : {}
783
+ };
784
+ });
785
+ }
786
+ function resolveFormationFlowFields(crm, formation) {
787
+ if (!formation.enabled) return [];
788
+ const def = crm.type(formation.type);
789
+ return [...formation.required, ...formation.optional].map((id, order) => {
790
+ const field = def.fields[id];
791
+ return {
792
+ id,
793
+ label: field.label ?? humanize(id),
794
+ type: field.type,
795
+ required: formation.required.includes(id),
796
+ order,
797
+ maxLength: formation.maxLen[id] ?? formation.defaultMaxLen,
798
+ ...field.options ? { options: [...field.options] } : {},
799
+ ...id === def.emailField || field.type === "email" ? { emailIdentity: "literal" } : {}
800
+ };
801
+ });
802
+ }
803
+ function formationFlowFields(chapter, formation = chapter.formation) {
804
+ return resolveFormationFlowFields(chapter.crm, formation);
805
+ }
806
+ function chapterFlowFieldContract(chapter, flow, application = chapter.application) {
807
+ return {
808
+ schemaVersion: CHAPTER_FLOW_FIELDS_SCHEMA_VERSION,
809
+ flow,
810
+ fields: flow === "join" ? applicationFlowFields(chapter, application) : formationFlowFields(chapter)
811
+ };
812
+ }
813
+
733
814
  // src/gift-offers.ts
734
815
  var GIFT_OFFER_CATALOG_VERSION = 1;
735
816
  var MAX_GIFT_OFFERS = 20;
@@ -1735,7 +1816,7 @@ async function notifyAdminOfApplication(db, env, chapterId, applicationId, field
1735
1816
  // src/worker-routes.ts
1736
1817
  async function memberSessionApplication(db, chapterId, email) {
1737
1818
  let apps = (await db.query({ applications: { $: {
1738
- where: { emailNormalized: normalizeAdmissionEmail(email) },
1819
+ where: { emailNormalized: normalizeChapterEmail(email) },
1739
1820
  limit: 20
1740
1821
  } } })).applications;
1741
1822
  if (!apps?.length) apps = (await db.query({ applications: { $: { where: { email }, limit: 20 } } })).applications;
@@ -1834,6 +1915,7 @@ var handleMember = async (req, url, env, ctx) => {
1834
1915
  const application = controlledApplication(chapter.application, activeControl?.document);
1835
1916
  return json({
1836
1917
  ...joinConfig(group, paymentsReady, joinConfigTiers(offered), application.conditions),
1918
+ fieldContract: chapterFlowFieldContract(chapter, "join", application),
1837
1919
  copy: { ...chapter.copy.join, ...activeControl?.document.application?.copy ?? {} },
1838
1920
  ...activeControl ? { signupControl: {
1839
1921
  revision: activeControl.revision,
@@ -3077,7 +3159,7 @@ var handleAdminAdmissionGrants = async (req, url, env, ctx) => {
3077
3159
  return json({ error: "invalid JSON body" }, 400);
3078
3160
  }
3079
3161
  const email = typeof body.email === "string" ? body.email.trim() : "";
3080
- const emailNormalized = normalizeAdmissionEmail(email);
3162
+ const emailNormalized = normalizeChapterEmail(email);
3081
3163
  if (!isValidEmail(emailNormalized)) return json({ error: "email must be valid" }, 400);
3082
3164
  const decision = body.decision ?? "waive_interview";
3083
3165
  if (decision !== "waive_interview" && decision !== "require_interview") {
@@ -3579,7 +3661,7 @@ async function rotateGiftClaim(db, claim, now) {
3579
3661
  async function redeemGiftClaim(db, claim, beneficiary, now) {
3580
3662
  if (claim.status !== "pending" || Number(claim.codeExpiresAt) <= now) return null;
3581
3663
  const chapterId = String(claim.chapterId);
3582
- const emailNormalized = normalizeAdmissionEmail(beneficiary.email);
3664
+ const emailNormalized = normalizeChapterEmail(beneficiary.email);
3583
3665
  const target = claim.target;
3584
3666
  const ops = [];
3585
3667
  let result;
@@ -3876,7 +3958,7 @@ async function startSubscription(req, env, ctx) {
3876
3958
  offer,
3877
3959
  purchaserType: "application",
3878
3960
  purchaserEmail: String(app.email ?? ""),
3879
- purchaserEmailNormalized: normalizeAdmissionEmail(String(app.email ?? "")),
3961
+ purchaserEmailNormalized: normalizeChapterEmail(String(app.email ?? "")),
3880
3962
  purchaserApplicationId: applicationId,
3881
3963
  stripeSecret: secretKey,
3882
3964
  publishableKey: group.stripePublishableKey,
@@ -4025,7 +4107,7 @@ var handleGifts = async (req, url, env, ctx) => {
4025
4107
  if (!user) return json({ error: "unauthorized" }, 401);
4026
4108
  const control2 = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
4027
4109
  const found = await db.query({ giftClaims: { $: { where: {
4028
- purchaserEmailNormalized: normalizeAdmissionEmail(user.email),
4110
+ purchaserEmailNormalized: normalizeChapterEmail(user.email),
4029
4111
  chapterId: ctx.chapter.id
4030
4112
  }, limit: 200 } } });
4031
4113
  const group = first3((await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups);
@@ -4052,7 +4134,7 @@ var handleGifts = async (req, url, env, ctx) => {
4052
4134
  const group = first3((await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups);
4053
4135
  const stripeSecret = await getVaultSecret(db, "stripe_secret_key");
4054
4136
  if (!group || !stripeSecret || typeof group.stripePublishableKey !== "string") return json({ error: "payments not configured" }, 503);
4055
- const emailNormalized = normalizeAdmissionEmail(user.email);
4137
+ const emailNormalized = normalizeChapterEmail(user.email);
4056
4138
  const applications = rows2((await db.query({ applications: { $: { where: { emailNormalized }, limit: 20 } } })).applications);
4057
4139
  const app = applications.sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0];
4058
4140
  try {
@@ -4094,7 +4176,7 @@ var handleGifts = async (req, url, env, ctx) => {
4094
4176
  return invalid();
4095
4177
  }
4096
4178
  }
4097
- const owns = user ? claim.purchaserEmailNormalized === normalizeAdmissionEmail(user.email) : claim.purchaserApplicationId === applicationId;
4179
+ const owns = user ? claim.purchaserEmailNormalized === normalizeChapterEmail(user.email) : claim.purchaserApplicationId === applicationId;
4098
4180
  if (!owns) return invalid();
4099
4181
  const rotated = await rotateGiftClaim(db, claim, Date.now());
4100
4182
  return rotated ? json(rotated) : invalid();
@@ -5618,31 +5700,17 @@ var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
5618
5700
 
5619
5701
  // src/worker-routes-formation.ts
5620
5702
  var import_crm9 = require("@odla-ai/crm");
5621
-
5622
- // src/formation.ts
5623
- function formationFields(crm, formation) {
5624
- const def = crm.type(formation.type);
5625
- return [...formation.required, ...formation.optional].map((id) => {
5626
- const field = def.fields[id];
5627
- return {
5628
- id,
5629
- label: field.label ?? id,
5630
- type: field.type,
5631
- required: formation.required.includes(id),
5632
- ...field.options ? { options: field.options } : {}
5633
- };
5634
- });
5635
- }
5636
-
5637
- // src/worker-routes-formation.ts
5638
5703
  var SUBMISSION = /^[A-Za-z0-9_-]{8,128}$/;
5639
5704
  var handleFormation = async (req, url, env, ctx) => {
5640
5705
  const formation = ctx.chapter.formation;
5641
5706
  if (!formation.enabled) return null;
5642
5707
  if (req.method === "GET" && url.pathname === "/api/formation/config") {
5708
+ const fieldContract = chapterFlowFieldContract(ctx.chapter, "formation");
5643
5709
  return json({
5644
5710
  type: formation.type,
5645
- fields: formationFields(ctx.chapter.crm, formation)
5711
+ fieldContract,
5712
+ // Backward compatibility for hosts that consumed the original array.
5713
+ fields: fieldContract.fields
5646
5714
  });
5647
5715
  }
5648
5716
  if (req.method !== "POST" || url.pathname !== "/api/formation/applications") return null;