@odla-ai/chapter 0.10.1 → 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/dist/index.d.cts CHANGED
@@ -175,6 +175,32 @@ interface ChapterApplication {
175
175
  defaultMaxLen?: number;
176
176
  /** Max JSON request body in bytes. Default 32768. */
177
177
  bodyCap?: number;
178
+ /** Reject a submit that carries no truthy `disclaimerAck` (400), instead of
179
+ * writing a row with no consent record. Default `false` for back-compat —
180
+ * but turn it on if the disclaimer is a compliance record: a missing ack is
181
+ * otherwise silent, permanent and unreconstructible. Failure is deterministic
182
+ * and surfaces on the first test submit, not intermittently in production. */
183
+ requireDisclaimerAck?: boolean;
184
+ /** Allowlist of fields that reach the Clerk account's client-readable
185
+ * `public_metadata.profile`. Default (unset) projects every non-identity
186
+ * configured field — convenient, but it also exposes free-text and
187
+ * third-party fields (`message`, `referral`). Set this to a curated list
188
+ * (e.g. `["phone", "state", "focus"]`) to keep confidential fields db-only.
189
+ * Expected to become required-in-spirit at 1.0. */
190
+ profileFields?: readonly string[];
191
+ /** Extra application fields carried into the one-way CRM projection, on top of
192
+ * the built-in identity/contact set. Each MUST be declared on your crm person
193
+ * type or the enrichment is dropped (the base person still projects). Default
194
+ * none. */
195
+ crmFields?: readonly string[];
196
+ /** Cap on the element count of array-valued fields (e.g. `focus`), so a client
197
+ * cannot post a 10k-element array into a row or into Clerk metadata.
198
+ * Non-primitive elements are dropped. Default 100. */
199
+ maxArrayLen?: number;
200
+ /** Validate that a field literally named `email` looks like an email address,
201
+ * returning a 400 rather than accepting input the downstream Clerk create will
202
+ * reject anyway. Default `true`; a valid application is never newly rejected. */
203
+ validateEmail?: boolean;
178
204
  }
179
205
  /** The fully-resolved application config carried on the {@link Chapter}. */
180
206
  interface ResolvedApplication {
@@ -183,6 +209,12 @@ interface ResolvedApplication {
183
209
  maxLen: Record<string, number>;
184
210
  defaultMaxLen: number;
185
211
  bodyCap: number;
212
+ requireDisclaimerAck: boolean;
213
+ /** Resolved Clerk-metadata allowlist; `null` means "all non-identity fields". */
214
+ profileFields: readonly string[] | null;
215
+ crmFields: readonly string[];
216
+ maxArrayLen: number;
217
+ validateEmail: boolean;
186
218
  }
187
219
  /** The `defineChapter()` config a site fills in. */
188
220
  interface ChapterConfig {
@@ -625,20 +657,35 @@ declare function canceledPatch(): {
625
657
 
626
658
  /** Apply defaults + validate the application config. Throws at import on bad shape. */
627
659
  declare function resolveApplication(a: ChapterApplication | undefined): ResolvedApplication;
660
+ /** Whether a string looks like an email address (see {@link EMAIL_RE}). Exported
661
+ * so a site building its own submit path applies the same rule chapter does. */
662
+ declare function isValidEmail(value: unknown): boolean;
663
+ /** Bound an array-valued field: drop non-primitive elements and cap the length,
664
+ * so a client cannot post an unbounded array. Non-arrays pass through unchanged. */
665
+ declare function clampArray(value: unknown, max: number): unknown;
666
+ /** Whether a submit body carries a genuine disclaimer acknowledgement. Accepts
667
+ * the boolean an API client sends and the string a plain HTML form posts. */
668
+ declare function hasDisclaimerAck(fields: Record<string, unknown>): boolean;
628
669
  /**
629
- * The applicant profile written to the Clerk account's `public_metadata.profile`:
630
- * every configured application field Clerk does not already carry natively, plus
631
- * `focus`. Derived from `application.required`/`optional`, so a site's own field
632
- * names project without this package knowing them. Pure; returns `undefined` when
633
- * there is nothing to write.
670
+ * The applicant profile written to the Clerk account's client-readable
671
+ * `public_metadata.profile`. Projects each configured non-identity field, plus
672
+ * `focus` (clamped) but ONLY those in `application.profileFields` when that
673
+ * allowlist is set, so a site keeps confidential fields (`message`, `referral`)
674
+ * db-only. Derived from config, so a site's own field names project without this
675
+ * package knowing them. Pure; returns `undefined` when there is nothing to write.
634
676
  */
635
677
  declare function applicantProfile(chapter: Chapter, fields: Record<string, unknown>): Record<string, unknown> | undefined;
636
- /** A validated submission, or a 400-worthy validation error the route returns. */
678
+ /** A validated submission, or a 400-worthy validation error the route returns.
679
+ * `disclaimerAckAt` reports what THIS request recorded — a number when consent
680
+ * was stamped, `null` when none was supplied. It is always present so a missing
681
+ * consent record is visible in the response rather than silently absent from a
682
+ * row nobody reads until an audit. */
637
683
  type SubmitResult = {
638
684
  ok: true;
639
685
  id: string;
640
686
  duplicate: boolean;
641
687
  status: string;
688
+ disclaimerAckAt: number | null;
642
689
  } | {
643
690
  ok: false;
644
691
  error: string;
@@ -707,7 +754,9 @@ interface ProjectionDeps {
707
754
  declare function projectSharedRecord(deps: ProjectionDeps, person: SharedPerson): Promise<{
708
755
  recordId: string;
709
756
  }>;
710
- /** An arriving applicant, as far as the CRM projection cares. */
757
+ /** An arriving applicant, as far as the CRM projection cares. `extra` carries the
758
+ * site-configured `crmFields` (values from the application), merged on top of the
759
+ * built-in identity/contact set. */
711
760
  interface Applicant {
712
761
  applicationId: string;
713
762
  email: string;
@@ -715,12 +764,18 @@ interface Applicant {
715
764
  lastName?: string;
716
765
  phone?: string;
717
766
  linkedin?: string;
767
+ extra?: Record<string, unknown>;
718
768
  }
719
769
  /**
720
770
  * Project an arriving applicant into this chapter's `crm_record`, so a new
721
771
  * application shows up in the CRM immediately, unified by email with any prior
722
772
  * record. Idempotent per application (`apply:${applicationId}`). Best-effort at
723
773
  * the call site — a projection failure never fails the application.
774
+ *
775
+ * `extra` fields (a site's `crmFields`) are merged on top of the base person. If
776
+ * an extra field is not on the crm person type, crm validation throws — so the
777
+ * projection retries with the base person alone, ensuring a misconfigured
778
+ * enrichment never silently drops the applicant from the CRM entirely.
724
779
  */
725
780
  declare function projectApplicant(deps: ProjectionDeps, applicant: Applicant): Promise<{
726
781
  recordId: string;
@@ -1016,4 +1071,4 @@ type ApplicationBookingPatch = {
1016
1071
  * already there (never backward). */
1017
1072
  declare function applicationBookingUpdate(currentStatus: string, startAt: number, htmlLink?: string | null): ApplicationBookingPatch;
1018
1073
 
1019
- export { type AccountModel, type AdminNotificationTrigger, type Applicant, type ApplicationBookingPatch, type ApplicationRecord, type ApplicationSummary, type Attr, type AttrType, type Chapter, type ChapterApplication, type ChapterAuth, type ChapterBrand, type ChapterConfig, type ChapterDb, type ChapterEmails, type ChapterIntegrationDescriptor, type ChapterIntegrationOptions, type ChapterMode, type ChapterPipeline, type ChapterPolicy, type ChapterPrices, type ChapterScheduling, type ChapterSends, type ClerkInviteInput, type ClerkResult, type ClerkUserInput, type DbOp, type DbRules, type DbSchema, type DeliveryDecision, type EmailGroup, type EmailTemplate, type EmailTemplateRow, type Entity, type ExistingMeeting, type GuardResult, type JoinConfigGroup, type LiveEvent, type MailSender, type MeetingForReconcile, type MeetingRecord, type MeetingReschedulePatch, type MemberApplication, type MemberSession, type NewMeetingRow, type NotifyDeps, type NotifyInput, type NotifyResult, type PaymentsGroup, type ProjectionDeps, type ReconcileDecision, type ResolvedApplication, type ResolvedAuth, type ResolvedPipeline, type ResolvedScheduling, type ResolvedSends, type RoleChangeContext, type Rule, SCHEDULING_DEFAULTS, type SchedulingErrors, type SecretStore, type SessionUser, type SharedPerson, type StripeEvent, type SubmitResult, type WebhookEvent, applicantProfile, applicationBookingUpdate, applicationSummary, bookingDecision, brandTokens, buildGroupSeed, canApprove, canBook, canChangeRole, canTransition, canceledPatch, chapterDb, clerkInviteRequest, clerkUserRequest, createChapterIntegration, createClerkInvitation, createClerkUser, defaultCrm, defineChapter, emailGroupFrom, endForSlot, findApplicationRef, firstPaymentPatch, getVaultSecret, introIdempotencyKey, isAdminRole, isAlreadySent, isReconcilable, isSlotAvailable, joinConfig, meetingCreateRow, meetingRescheduleUpdate, memberApplication, memberSession, normalizeWebhookEvent, paymentsReady, planDelivery, projectApplicant, projectSharedRecord, reconcileMeetings, refundedPatch, render, renderSummary, renderTemplateBody, renewalPatch, resolveApplication, resolveAuth, resolvePipeline, resolveScheduling, roleFromClaim, sendTemplated, sharedPersonInput, slotWindow, stageIndex, stripeForm, submitApplication, subscriptionIdempotencyKey, validateScheduling, verifyStripeSignature, webhookMutationId };
1074
+ export { type AccountModel, type AdminNotificationTrigger, type Applicant, type ApplicationBookingPatch, type ApplicationRecord, type ApplicationSummary, type Attr, type AttrType, type Chapter, type ChapterApplication, type ChapterAuth, type ChapterBrand, type ChapterConfig, type ChapterDb, type ChapterEmails, type ChapterIntegrationDescriptor, type ChapterIntegrationOptions, type ChapterMode, type ChapterPipeline, type ChapterPolicy, type ChapterPrices, type ChapterScheduling, type ChapterSends, type ClerkInviteInput, type ClerkResult, type ClerkUserInput, type DbOp, type DbRules, type DbSchema, type DeliveryDecision, type EmailGroup, type EmailTemplate, type EmailTemplateRow, type Entity, type ExistingMeeting, type GuardResult, type JoinConfigGroup, type LiveEvent, type MailSender, type MeetingForReconcile, type MeetingRecord, type MeetingReschedulePatch, type MemberApplication, type MemberSession, type NewMeetingRow, type NotifyDeps, type NotifyInput, type NotifyResult, type PaymentsGroup, type ProjectionDeps, type ReconcileDecision, type ResolvedApplication, type ResolvedAuth, type ResolvedPipeline, type ResolvedScheduling, type ResolvedSends, type RoleChangeContext, type Rule, SCHEDULING_DEFAULTS, type SchedulingErrors, type SecretStore, type SessionUser, type SharedPerson, type StripeEvent, type SubmitResult, type WebhookEvent, applicantProfile, applicationBookingUpdate, applicationSummary, bookingDecision, brandTokens, buildGroupSeed, canApprove, canBook, canChangeRole, canTransition, canceledPatch, chapterDb, clampArray, clerkInviteRequest, clerkUserRequest, createChapterIntegration, createClerkInvitation, createClerkUser, defaultCrm, defineChapter, emailGroupFrom, endForSlot, findApplicationRef, firstPaymentPatch, getVaultSecret, hasDisclaimerAck, introIdempotencyKey, isAdminRole, isAlreadySent, isReconcilable, isSlotAvailable, isValidEmail, joinConfig, meetingCreateRow, meetingRescheduleUpdate, memberApplication, memberSession, normalizeWebhookEvent, paymentsReady, planDelivery, projectApplicant, projectSharedRecord, reconcileMeetings, refundedPatch, render, renderSummary, renderTemplateBody, renewalPatch, resolveApplication, resolveAuth, resolvePipeline, resolveScheduling, roleFromClaim, sendTemplated, sharedPersonInput, slotWindow, stageIndex, stripeForm, submitApplication, subscriptionIdempotencyKey, validateScheduling, verifyStripeSignature, webhookMutationId };
package/dist/index.d.ts CHANGED
@@ -175,6 +175,32 @@ interface ChapterApplication {
175
175
  defaultMaxLen?: number;
176
176
  /** Max JSON request body in bytes. Default 32768. */
177
177
  bodyCap?: number;
178
+ /** Reject a submit that carries no truthy `disclaimerAck` (400), instead of
179
+ * writing a row with no consent record. Default `false` for back-compat —
180
+ * but turn it on if the disclaimer is a compliance record: a missing ack is
181
+ * otherwise silent, permanent and unreconstructible. Failure is deterministic
182
+ * and surfaces on the first test submit, not intermittently in production. */
183
+ requireDisclaimerAck?: boolean;
184
+ /** Allowlist of fields that reach the Clerk account's client-readable
185
+ * `public_metadata.profile`. Default (unset) projects every non-identity
186
+ * configured field — convenient, but it also exposes free-text and
187
+ * third-party fields (`message`, `referral`). Set this to a curated list
188
+ * (e.g. `["phone", "state", "focus"]`) to keep confidential fields db-only.
189
+ * Expected to become required-in-spirit at 1.0. */
190
+ profileFields?: readonly string[];
191
+ /** Extra application fields carried into the one-way CRM projection, on top of
192
+ * the built-in identity/contact set. Each MUST be declared on your crm person
193
+ * type or the enrichment is dropped (the base person still projects). Default
194
+ * none. */
195
+ crmFields?: readonly string[];
196
+ /** Cap on the element count of array-valued fields (e.g. `focus`), so a client
197
+ * cannot post a 10k-element array into a row or into Clerk metadata.
198
+ * Non-primitive elements are dropped. Default 100. */
199
+ maxArrayLen?: number;
200
+ /** Validate that a field literally named `email` looks like an email address,
201
+ * returning a 400 rather than accepting input the downstream Clerk create will
202
+ * reject anyway. Default `true`; a valid application is never newly rejected. */
203
+ validateEmail?: boolean;
178
204
  }
179
205
  /** The fully-resolved application config carried on the {@link Chapter}. */
180
206
  interface ResolvedApplication {
@@ -183,6 +209,12 @@ interface ResolvedApplication {
183
209
  maxLen: Record<string, number>;
184
210
  defaultMaxLen: number;
185
211
  bodyCap: number;
212
+ requireDisclaimerAck: boolean;
213
+ /** Resolved Clerk-metadata allowlist; `null` means "all non-identity fields". */
214
+ profileFields: readonly string[] | null;
215
+ crmFields: readonly string[];
216
+ maxArrayLen: number;
217
+ validateEmail: boolean;
186
218
  }
187
219
  /** The `defineChapter()` config a site fills in. */
188
220
  interface ChapterConfig {
@@ -625,20 +657,35 @@ declare function canceledPatch(): {
625
657
 
626
658
  /** Apply defaults + validate the application config. Throws at import on bad shape. */
627
659
  declare function resolveApplication(a: ChapterApplication | undefined): ResolvedApplication;
660
+ /** Whether a string looks like an email address (see {@link EMAIL_RE}). Exported
661
+ * so a site building its own submit path applies the same rule chapter does. */
662
+ declare function isValidEmail(value: unknown): boolean;
663
+ /** Bound an array-valued field: drop non-primitive elements and cap the length,
664
+ * so a client cannot post an unbounded array. Non-arrays pass through unchanged. */
665
+ declare function clampArray(value: unknown, max: number): unknown;
666
+ /** Whether a submit body carries a genuine disclaimer acknowledgement. Accepts
667
+ * the boolean an API client sends and the string a plain HTML form posts. */
668
+ declare function hasDisclaimerAck(fields: Record<string, unknown>): boolean;
628
669
  /**
629
- * The applicant profile written to the Clerk account's `public_metadata.profile`:
630
- * every configured application field Clerk does not already carry natively, plus
631
- * `focus`. Derived from `application.required`/`optional`, so a site's own field
632
- * names project without this package knowing them. Pure; returns `undefined` when
633
- * there is nothing to write.
670
+ * The applicant profile written to the Clerk account's client-readable
671
+ * `public_metadata.profile`. Projects each configured non-identity field, plus
672
+ * `focus` (clamped) but ONLY those in `application.profileFields` when that
673
+ * allowlist is set, so a site keeps confidential fields (`message`, `referral`)
674
+ * db-only. Derived from config, so a site's own field names project without this
675
+ * package knowing them. Pure; returns `undefined` when there is nothing to write.
634
676
  */
635
677
  declare function applicantProfile(chapter: Chapter, fields: Record<string, unknown>): Record<string, unknown> | undefined;
636
- /** A validated submission, or a 400-worthy validation error the route returns. */
678
+ /** A validated submission, or a 400-worthy validation error the route returns.
679
+ * `disclaimerAckAt` reports what THIS request recorded — a number when consent
680
+ * was stamped, `null` when none was supplied. It is always present so a missing
681
+ * consent record is visible in the response rather than silently absent from a
682
+ * row nobody reads until an audit. */
637
683
  type SubmitResult = {
638
684
  ok: true;
639
685
  id: string;
640
686
  duplicate: boolean;
641
687
  status: string;
688
+ disclaimerAckAt: number | null;
642
689
  } | {
643
690
  ok: false;
644
691
  error: string;
@@ -707,7 +754,9 @@ interface ProjectionDeps {
707
754
  declare function projectSharedRecord(deps: ProjectionDeps, person: SharedPerson): Promise<{
708
755
  recordId: string;
709
756
  }>;
710
- /** An arriving applicant, as far as the CRM projection cares. */
757
+ /** An arriving applicant, as far as the CRM projection cares. `extra` carries the
758
+ * site-configured `crmFields` (values from the application), merged on top of the
759
+ * built-in identity/contact set. */
711
760
  interface Applicant {
712
761
  applicationId: string;
713
762
  email: string;
@@ -715,12 +764,18 @@ interface Applicant {
715
764
  lastName?: string;
716
765
  phone?: string;
717
766
  linkedin?: string;
767
+ extra?: Record<string, unknown>;
718
768
  }
719
769
  /**
720
770
  * Project an arriving applicant into this chapter's `crm_record`, so a new
721
771
  * application shows up in the CRM immediately, unified by email with any prior
722
772
  * record. Idempotent per application (`apply:${applicationId}`). Best-effort at
723
773
  * the call site — a projection failure never fails the application.
774
+ *
775
+ * `extra` fields (a site's `crmFields`) are merged on top of the base person. If
776
+ * an extra field is not on the crm person type, crm validation throws — so the
777
+ * projection retries with the base person alone, ensuring a misconfigured
778
+ * enrichment never silently drops the applicant from the CRM entirely.
724
779
  */
725
780
  declare function projectApplicant(deps: ProjectionDeps, applicant: Applicant): Promise<{
726
781
  recordId: string;
@@ -1016,4 +1071,4 @@ type ApplicationBookingPatch = {
1016
1071
  * already there (never backward). */
1017
1072
  declare function applicationBookingUpdate(currentStatus: string, startAt: number, htmlLink?: string | null): ApplicationBookingPatch;
1018
1073
 
1019
- export { type AccountModel, type AdminNotificationTrigger, type Applicant, type ApplicationBookingPatch, type ApplicationRecord, type ApplicationSummary, type Attr, type AttrType, type Chapter, type ChapterApplication, type ChapterAuth, type ChapterBrand, type ChapterConfig, type ChapterDb, type ChapterEmails, type ChapterIntegrationDescriptor, type ChapterIntegrationOptions, type ChapterMode, type ChapterPipeline, type ChapterPolicy, type ChapterPrices, type ChapterScheduling, type ChapterSends, type ClerkInviteInput, type ClerkResult, type ClerkUserInput, type DbOp, type DbRules, type DbSchema, type DeliveryDecision, type EmailGroup, type EmailTemplate, type EmailTemplateRow, type Entity, type ExistingMeeting, type GuardResult, type JoinConfigGroup, type LiveEvent, type MailSender, type MeetingForReconcile, type MeetingRecord, type MeetingReschedulePatch, type MemberApplication, type MemberSession, type NewMeetingRow, type NotifyDeps, type NotifyInput, type NotifyResult, type PaymentsGroup, type ProjectionDeps, type ReconcileDecision, type ResolvedApplication, type ResolvedAuth, type ResolvedPipeline, type ResolvedScheduling, type ResolvedSends, type RoleChangeContext, type Rule, SCHEDULING_DEFAULTS, type SchedulingErrors, type SecretStore, type SessionUser, type SharedPerson, type StripeEvent, type SubmitResult, type WebhookEvent, applicantProfile, applicationBookingUpdate, applicationSummary, bookingDecision, brandTokens, buildGroupSeed, canApprove, canBook, canChangeRole, canTransition, canceledPatch, chapterDb, clerkInviteRequest, clerkUserRequest, createChapterIntegration, createClerkInvitation, createClerkUser, defaultCrm, defineChapter, emailGroupFrom, endForSlot, findApplicationRef, firstPaymentPatch, getVaultSecret, introIdempotencyKey, isAdminRole, isAlreadySent, isReconcilable, isSlotAvailable, joinConfig, meetingCreateRow, meetingRescheduleUpdate, memberApplication, memberSession, normalizeWebhookEvent, paymentsReady, planDelivery, projectApplicant, projectSharedRecord, reconcileMeetings, refundedPatch, render, renderSummary, renderTemplateBody, renewalPatch, resolveApplication, resolveAuth, resolvePipeline, resolveScheduling, roleFromClaim, sendTemplated, sharedPersonInput, slotWindow, stageIndex, stripeForm, submitApplication, subscriptionIdempotencyKey, validateScheduling, verifyStripeSignature, webhookMutationId };
1074
+ export { type AccountModel, type AdminNotificationTrigger, type Applicant, type ApplicationBookingPatch, type ApplicationRecord, type ApplicationSummary, type Attr, type AttrType, type Chapter, type ChapterApplication, type ChapterAuth, type ChapterBrand, type ChapterConfig, type ChapterDb, type ChapterEmails, type ChapterIntegrationDescriptor, type ChapterIntegrationOptions, type ChapterMode, type ChapterPipeline, type ChapterPolicy, type ChapterPrices, type ChapterScheduling, type ChapterSends, type ClerkInviteInput, type ClerkResult, type ClerkUserInput, type DbOp, type DbRules, type DbSchema, type DeliveryDecision, type EmailGroup, type EmailTemplate, type EmailTemplateRow, type Entity, type ExistingMeeting, type GuardResult, type JoinConfigGroup, type LiveEvent, type MailSender, type MeetingForReconcile, type MeetingRecord, type MeetingReschedulePatch, type MemberApplication, type MemberSession, type NewMeetingRow, type NotifyDeps, type NotifyInput, type NotifyResult, type PaymentsGroup, type ProjectionDeps, type ReconcileDecision, type ResolvedApplication, type ResolvedAuth, type ResolvedPipeline, type ResolvedScheduling, type ResolvedSends, type RoleChangeContext, type Rule, SCHEDULING_DEFAULTS, type SchedulingErrors, type SecretStore, type SessionUser, type SharedPerson, type StripeEvent, type SubmitResult, type WebhookEvent, applicantProfile, applicationBookingUpdate, applicationSummary, bookingDecision, brandTokens, buildGroupSeed, canApprove, canBook, canChangeRole, canTransition, canceledPatch, chapterDb, clampArray, clerkInviteRequest, clerkUserRequest, createChapterIntegration, createClerkInvitation, createClerkUser, defaultCrm, defineChapter, emailGroupFrom, endForSlot, findApplicationRef, firstPaymentPatch, getVaultSecret, hasDisclaimerAck, introIdempotencyKey, isAdminRole, isAlreadySent, isReconcilable, isSlotAvailable, isValidEmail, joinConfig, meetingCreateRow, meetingRescheduleUpdate, memberApplication, memberSession, normalizeWebhookEvent, paymentsReady, planDelivery, projectApplicant, projectSharedRecord, reconcileMeetings, refundedPatch, render, renderSummary, renderTemplateBody, renewalPatch, resolveApplication, resolveAuth, resolvePipeline, resolveScheduling, roleFromClaim, sendTemplated, sharedPersonInput, slotWindow, stageIndex, stripeForm, submitApplication, subscriptionIdempotencyKey, validateScheduling, verifyStripeSignature, webhookMutationId };
package/dist/index.js CHANGED
@@ -395,23 +395,47 @@ function resolveApplication(a) {
395
395
  throw new Error(`defineChapter.application.${name}: must be an array of field-name strings`);
396
396
  }
397
397
  }
398
+ if (a?.profileFields !== void 0 && (!Array.isArray(a.profileFields) || !a.profileFields.every((f) => typeof f === "string" && f !== ""))) {
399
+ throw new Error("defineChapter.application.profileFields: must be an array of field-name strings");
400
+ }
401
+ if (a?.crmFields !== void 0 && (!Array.isArray(a.crmFields) || !a.crmFields.every((f) => typeof f === "string" && f !== ""))) {
402
+ throw new Error("defineChapter.application.crmFields: must be an array of field-name strings");
403
+ }
398
404
  return {
399
405
  required,
400
406
  optional,
401
407
  maxLen: a?.maxLen ?? {},
402
408
  defaultMaxLen: a?.defaultMaxLen ?? 2e3,
403
- bodyCap: a?.bodyCap ?? 32768
409
+ bodyCap: a?.bodyCap ?? 32768,
410
+ requireDisclaimerAck: a?.requireDisclaimerAck ?? false,
411
+ profileFields: a?.profileFields ?? null,
412
+ crmFields: a?.crmFields ?? [],
413
+ maxArrayLen: a?.maxArrayLen ?? 100,
414
+ validateEmail: a?.validateEmail ?? true
404
415
  };
405
416
  }
417
+ var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
418
+ function isValidEmail(value) {
419
+ return typeof value === "string" && EMAIL_RE.test(value);
420
+ }
421
+ function clampArray(value, max) {
422
+ if (!Array.isArray(value)) return value;
423
+ return value.filter((x) => typeof x === "string" || typeof x === "number" || typeof x === "boolean").slice(0, max);
424
+ }
425
+ function hasDisclaimerAck(fields) {
426
+ return fields.disclaimerAck === true || fields.disclaimerAck === "true";
427
+ }
406
428
  var IDENTITY_FIELDS = /* @__PURE__ */ new Set(["email", "firstName", "lastName"]);
407
429
  function applicantProfile(chapter, fields) {
430
+ const app = chapter.application;
431
+ const allowed = (f) => app.profileFields === null || app.profileFields.includes(f);
408
432
  const profile = {};
409
- for (const f of [...chapter.application.required, ...chapter.application.optional]) {
410
- if (IDENTITY_FIELDS.has(f)) continue;
433
+ for (const f of [...app.required, ...app.optional]) {
434
+ if (IDENTITY_FIELDS.has(f) || !allowed(f)) continue;
411
435
  const v = fields[f];
412
436
  if (typeof v === "string" && v.trim() !== "") profile[f] = v.trim();
413
437
  }
414
- if (fields.focus !== void 0) profile.focus = fields.focus;
438
+ if (fields.focus !== void 0 && allowed("focus")) profile.focus = clampArray(fields.focus, app.maxArrayLen);
415
439
  return Object.keys(profile).length > 0 ? profile : void 0;
416
440
  }
417
441
  async function submitApplication(db, chapter, fields, opts) {
@@ -425,19 +449,26 @@ async function submitApplication(db, chapter, fields, opts) {
425
449
  const cap = app.maxLen[f] ?? app.defaultMaxLen;
426
450
  if (typeof v === "string" && v.length > cap) return { ok: false, error: `${f} exceeds ${cap} characters` };
427
451
  }
452
+ if (app.validateEmail && typeof fields.email === "string" && !isValidEmail(fields.email)) {
453
+ return { ok: false, error: "email must be a valid email address" };
454
+ }
455
+ const acked = hasDisclaimerAck(fields);
456
+ if (app.requireDisclaimerAck && !acked) {
457
+ return { ok: false, error: "disclaimerAck is required" };
458
+ }
428
459
  const id2 = opts.newId();
429
460
  const row = { id: id2, status: chapter.pipeline.initial, createdAt: opts.now };
430
461
  for (const f of [...app.required, ...app.optional]) {
431
462
  if (typeof fields[f] === "string") row[f] = fields[f].trim();
432
463
  }
433
- if (fields.focus !== void 0) row.focus = fields.focus;
464
+ if (fields.focus !== void 0) row.focus = clampArray(fields.focus, app.maxArrayLen);
434
465
  if (opts.groupId) row.groupId = opts.groupId;
435
- if (fields.disclaimerAck === true || fields.disclaimerAck === "true") row.disclaimerAckAt = opts.now;
466
+ if (acked) row.disclaimerAckAt = opts.now;
436
467
  const { duplicate } = await db.transact(
437
468
  [{ t: "update", ns: "applications", id: id2, attrs: row }],
438
469
  opts.submissionId ? { mutationId: `join:${opts.submissionId}` } : void 0
439
470
  );
440
- return { ok: true, id: id2, duplicate, status: chapter.pipeline.initial };
471
+ return { ok: true, id: id2, duplicate, status: chapter.pipeline.initial, disclaimerAckAt: acked ? opts.now : null };
441
472
  }
442
473
  function joinConfig(group, paymentsReady2) {
443
474
  return {
@@ -772,7 +803,7 @@ async function projectSharedRecord(deps, person) {
772
803
  return upsertPerson(deps, { email: person.email, input: sharedPersonInput(person), mutationId: `share:${person.hubRecordId}` });
773
804
  }
774
805
  async function projectApplicant(deps, applicant) {
775
- const input = sharedPersonInput({
806
+ const base = sharedPersonInput({
776
807
  email: applicant.email,
777
808
  firstName: applicant.firstName,
778
809
  lastName: applicant.lastName,
@@ -780,7 +811,14 @@ async function projectApplicant(deps, applicant) {
780
811
  linkedin: applicant.linkedin,
781
812
  hubRecordId: applicant.applicationId
782
813
  });
783
- return upsertPerson(deps, { email: applicant.email, input, mutationId: `apply:${applicant.applicationId}` });
814
+ const mutationId = `apply:${applicant.applicationId}`;
815
+ const extra = applicant.extra ?? {};
816
+ if (Object.keys(extra).length === 0) return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
817
+ try {
818
+ return await upsertPerson(deps, { email: applicant.email, input: { ...base, ...extra }, mutationId });
819
+ } catch {
820
+ return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
821
+ }
784
822
  }
785
823
 
786
824
  // src/clerk.ts
@@ -1061,6 +1099,7 @@ export {
1061
1099
  canTransition,
1062
1100
  canceledPatch,
1063
1101
  chapterDb,
1102
+ clampArray,
1064
1103
  clerkInviteRequest,
1065
1104
  clerkUserRequest,
1066
1105
  createChapterIntegration,
@@ -1073,11 +1112,13 @@ export {
1073
1112
  findApplicationRef,
1074
1113
  firstPaymentPatch,
1075
1114
  getVaultSecret,
1115
+ hasDisclaimerAck,
1076
1116
  introIdempotencyKey,
1077
1117
  isAdminRole,
1078
1118
  isAlreadySent,
1079
1119
  isReconcilable,
1080
1120
  isSlotAvailable,
1121
+ isValidEmail,
1081
1122
  joinConfig,
1082
1123
  meetingCreateRow,
1083
1124
  meetingRescheduleUpdate,