@odla-ai/chapter 0.8.0 → 0.10.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
@@ -215,9 +215,24 @@ interface ChapterConfig {
215
215
  * account is ready), `"none"` skips it. Any of these needs a `clerk_secret_key`
216
216
  * vault secret to act. */
217
217
  account?: AccountModel;
218
+ /** WHEN lifecycle email fires. Addressing and content live on the group row
219
+ * (owner-editable at runtime); this is the trigger, which is a build-time
220
+ * decision. See {@link ChapterSends}. */
221
+ sends?: ChapterSends;
218
222
  }
219
223
  /** Apply-time Clerk account provisioning model. */
220
224
  type AccountModel = "invite" | "create" | "none";
225
+ /** When the admin notification fires: on application `submit` (default), on the
226
+ * first successful `payment`, or `never` (the site drives it itself). */
227
+ type AdminNotificationTrigger = "submit" | "payment" | "never";
228
+ /** Send-policy config: the trigger for each lifecycle email chapter owns. */
229
+ interface ChapterSends {
230
+ adminNotification?: AdminNotificationTrigger;
231
+ }
232
+ /** Resolved send policy (every trigger present). */
233
+ interface ResolvedSends {
234
+ adminNotification: AdminNotificationTrigger;
235
+ }
221
236
  /** The resolved engine `defineChapter()` returns. */
222
237
  interface Chapter {
223
238
  config: ChapterConfig;
@@ -239,6 +254,8 @@ interface Chapter {
239
254
  services: readonly string[];
240
255
  /** Resolved apply-time account provisioning model (default `"invite"`). */
241
256
  account: AccountModel;
257
+ /** Resolved send policy — when each lifecycle email fires. */
258
+ sends: ResolvedSends;
242
259
  /** The seed `groups` row derived from config (chapter mode), else `null`. */
243
260
  groupSeed(): Record<string, unknown> | null;
244
261
  }
@@ -701,11 +718,24 @@ declare function projectApplicant(deps: ProjectionDeps, applicant: Applicant): P
701
718
  recordId: string;
702
719
  }>;
703
720
 
721
+ /** The outcome of a Clerk provisioning call. `existed` marks the heal case: Clerk
722
+ * rejected it because the account/invitation is already there, which is the end
723
+ * state we wanted anyway. */
724
+ interface ClerkResult {
725
+ ok: boolean;
726
+ status: number;
727
+ existed?: boolean;
728
+ /** Set when an `existed` heal also refreshed the account's public_metadata. */
729
+ refreshed?: boolean;
730
+ }
704
731
  /** Inputs for a Clerk invitation. */
705
732
  interface ClerkInviteInput {
706
733
  email: string;
707
734
  /** Where the accept-invitation link lands (usually the member area). */
708
735
  redirectUrl?: string;
736
+ /** Written to the invitation's `public_metadata`, so the accepted account
737
+ * carries your own profile fields. */
738
+ publicMetadata?: Record<string, unknown>;
709
739
  }
710
740
  /** Build the Clerk Backend API invitation request (path + JSON body). Pure, so
711
741
  * the wire shape is testable without a network call. */
@@ -713,18 +743,18 @@ declare function clerkInviteRequest(input: ClerkInviteInput): {
713
743
  path: string;
714
744
  body: Record<string, unknown>;
715
745
  };
716
- /** POST the invitation to the Clerk Backend API. `ignore_existing` isn't set, so
717
- * a repeat invite for an already-invited/known email returns non-ok the caller
718
- * swallows that (idempotent-enough for a best-effort apply-time invite). */
719
- declare function createClerkInvitation(secretKey: string, input: ClerkInviteInput, fetchImpl?: typeof fetch): Promise<{
720
- ok: boolean;
721
- status: number;
722
- }>;
746
+ /** POST the invitation to the Clerk Backend API. A repeat invite for an
747
+ * already-invited email heals to `{ ok: true, existed: true }` rather than
748
+ * reporting a failure the caller would have to special-case. */
749
+ declare function createClerkInvitation(secretKey: string, input: ClerkInviteInput, fetchImpl?: typeof fetch): Promise<ClerkResult>;
723
750
  /** Inputs for a server-side Clerk user create. */
724
751
  interface ClerkUserInput {
725
752
  email: string;
726
753
  firstName?: string;
727
754
  lastName?: string;
755
+ /** Written to the user's `public_metadata` — the site's own profile fields
756
+ * (role, tier, whatever the member area reads). */
757
+ publicMetadata?: Record<string, unknown>;
728
758
  }
729
759
  /** Build the Clerk Backend API user-create request. The account is created
730
760
  * passwordless (the member signs in via the site's Clerk flow), so join step 3
@@ -733,12 +763,12 @@ declare function clerkUserRequest(input: ClerkUserInput): {
733
763
  path: string;
734
764
  body: Record<string, unknown>;
735
765
  };
736
- /** Create the applicant's Clerk account server-side. A repeat for a known email
737
- * returns non-ok; the caller swallows it (best-effort at apply time). */
738
- declare function createClerkUser(secretKey: string, input: ClerkUserInput, fetchImpl?: typeof fetch): Promise<{
739
- ok: boolean;
740
- status: number;
741
- }>;
766
+ /** Create the applicant's Clerk account server-side. A repeat for an email that
767
+ * already has an account heals to `{ ok: true, existed: true }` — the account
768
+ * exists, which is the state apply-time provisioning wanted — and, when
769
+ * `publicMetadata` was supplied, refreshes it on the existing account so a
770
+ * re-application repairs a previously missed create (`refreshed: true`). */
771
+ declare function createClerkUser(secretKey: string, input: ClerkUserInput, fetchImpl?: typeof fetch): Promise<ClerkResult>;
742
772
 
743
773
  /** An application row, as far as the session cares about it. */
744
774
  interface ApplicationRecord {
@@ -879,6 +909,22 @@ declare const SCHEDULING_DEFAULTS: ResolvedScheduling;
879
909
  * `windowDays` is capped at 62 because Google FreeBusy is.
880
910
  */
881
911
  declare function resolveScheduling(config?: ChapterScheduling): ResolvedScheduling;
912
+ /** Validation messages keyed by form field. */
913
+ type SchedulingErrors = Record<string, string>;
914
+ /**
915
+ * Validate a scheduling config field by field, collecting owner-readable messages
916
+ * rather than throwing on the first problem. An admin settings form needs to say
917
+ * *which* field is wrong and why — "pick at least one day" beats a stack trace —
918
+ * so the admin route returns these directly. {@link resolveScheduling} is the
919
+ * throwing wrapper for internal/config-time use.
920
+ */
921
+ declare function validateScheduling(config?: ChapterScheduling): {
922
+ ok: true;
923
+ value: ResolvedScheduling;
924
+ } | {
925
+ ok: false;
926
+ errors: SchedulingErrors;
927
+ };
882
928
  /** Statuses a member may book/reschedule from (early pipeline only). */
883
929
  /** The availability window: `[now, now + windowDays]` in epoch ms. */
884
930
  declare function slotWindow(now: number, windowDays: number): {
@@ -962,4 +1008,4 @@ type ApplicationBookingPatch = {
962
1008
  * already there (never backward). */
963
1009
  declare function applicationBookingUpdate(currentStatus: string, startAt: number, htmlLink?: string | null): ApplicationBookingPatch;
964
1010
 
965
- export { type AccountModel, 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 ClerkInviteInput, 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 RoleChangeContext, type Rule, SCHEDULING_DEFAULTS, type SecretStore, type SessionUser, type SharedPerson, type StripeEvent, type SubmitResult, type WebhookEvent, 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, verifyStripeSignature, webhookMutationId };
1011
+ 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, 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 };
package/dist/index.d.ts CHANGED
@@ -215,9 +215,24 @@ interface ChapterConfig {
215
215
  * account is ready), `"none"` skips it. Any of these needs a `clerk_secret_key`
216
216
  * vault secret to act. */
217
217
  account?: AccountModel;
218
+ /** WHEN lifecycle email fires. Addressing and content live on the group row
219
+ * (owner-editable at runtime); this is the trigger, which is a build-time
220
+ * decision. See {@link ChapterSends}. */
221
+ sends?: ChapterSends;
218
222
  }
219
223
  /** Apply-time Clerk account provisioning model. */
220
224
  type AccountModel = "invite" | "create" | "none";
225
+ /** When the admin notification fires: on application `submit` (default), on the
226
+ * first successful `payment`, or `never` (the site drives it itself). */
227
+ type AdminNotificationTrigger = "submit" | "payment" | "never";
228
+ /** Send-policy config: the trigger for each lifecycle email chapter owns. */
229
+ interface ChapterSends {
230
+ adminNotification?: AdminNotificationTrigger;
231
+ }
232
+ /** Resolved send policy (every trigger present). */
233
+ interface ResolvedSends {
234
+ adminNotification: AdminNotificationTrigger;
235
+ }
221
236
  /** The resolved engine `defineChapter()` returns. */
222
237
  interface Chapter {
223
238
  config: ChapterConfig;
@@ -239,6 +254,8 @@ interface Chapter {
239
254
  services: readonly string[];
240
255
  /** Resolved apply-time account provisioning model (default `"invite"`). */
241
256
  account: AccountModel;
257
+ /** Resolved send policy — when each lifecycle email fires. */
258
+ sends: ResolvedSends;
242
259
  /** The seed `groups` row derived from config (chapter mode), else `null`. */
243
260
  groupSeed(): Record<string, unknown> | null;
244
261
  }
@@ -701,11 +718,24 @@ declare function projectApplicant(deps: ProjectionDeps, applicant: Applicant): P
701
718
  recordId: string;
702
719
  }>;
703
720
 
721
+ /** The outcome of a Clerk provisioning call. `existed` marks the heal case: Clerk
722
+ * rejected it because the account/invitation is already there, which is the end
723
+ * state we wanted anyway. */
724
+ interface ClerkResult {
725
+ ok: boolean;
726
+ status: number;
727
+ existed?: boolean;
728
+ /** Set when an `existed` heal also refreshed the account's public_metadata. */
729
+ refreshed?: boolean;
730
+ }
704
731
  /** Inputs for a Clerk invitation. */
705
732
  interface ClerkInviteInput {
706
733
  email: string;
707
734
  /** Where the accept-invitation link lands (usually the member area). */
708
735
  redirectUrl?: string;
736
+ /** Written to the invitation's `public_metadata`, so the accepted account
737
+ * carries your own profile fields. */
738
+ publicMetadata?: Record<string, unknown>;
709
739
  }
710
740
  /** Build the Clerk Backend API invitation request (path + JSON body). Pure, so
711
741
  * the wire shape is testable without a network call. */
@@ -713,18 +743,18 @@ declare function clerkInviteRequest(input: ClerkInviteInput): {
713
743
  path: string;
714
744
  body: Record<string, unknown>;
715
745
  };
716
- /** POST the invitation to the Clerk Backend API. `ignore_existing` isn't set, so
717
- * a repeat invite for an already-invited/known email returns non-ok the caller
718
- * swallows that (idempotent-enough for a best-effort apply-time invite). */
719
- declare function createClerkInvitation(secretKey: string, input: ClerkInviteInput, fetchImpl?: typeof fetch): Promise<{
720
- ok: boolean;
721
- status: number;
722
- }>;
746
+ /** POST the invitation to the Clerk Backend API. A repeat invite for an
747
+ * already-invited email heals to `{ ok: true, existed: true }` rather than
748
+ * reporting a failure the caller would have to special-case. */
749
+ declare function createClerkInvitation(secretKey: string, input: ClerkInviteInput, fetchImpl?: typeof fetch): Promise<ClerkResult>;
723
750
  /** Inputs for a server-side Clerk user create. */
724
751
  interface ClerkUserInput {
725
752
  email: string;
726
753
  firstName?: string;
727
754
  lastName?: string;
755
+ /** Written to the user's `public_metadata` — the site's own profile fields
756
+ * (role, tier, whatever the member area reads). */
757
+ publicMetadata?: Record<string, unknown>;
728
758
  }
729
759
  /** Build the Clerk Backend API user-create request. The account is created
730
760
  * passwordless (the member signs in via the site's Clerk flow), so join step 3
@@ -733,12 +763,12 @@ declare function clerkUserRequest(input: ClerkUserInput): {
733
763
  path: string;
734
764
  body: Record<string, unknown>;
735
765
  };
736
- /** Create the applicant's Clerk account server-side. A repeat for a known email
737
- * returns non-ok; the caller swallows it (best-effort at apply time). */
738
- declare function createClerkUser(secretKey: string, input: ClerkUserInput, fetchImpl?: typeof fetch): Promise<{
739
- ok: boolean;
740
- status: number;
741
- }>;
766
+ /** Create the applicant's Clerk account server-side. A repeat for an email that
767
+ * already has an account heals to `{ ok: true, existed: true }` — the account
768
+ * exists, which is the state apply-time provisioning wanted — and, when
769
+ * `publicMetadata` was supplied, refreshes it on the existing account so a
770
+ * re-application repairs a previously missed create (`refreshed: true`). */
771
+ declare function createClerkUser(secretKey: string, input: ClerkUserInput, fetchImpl?: typeof fetch): Promise<ClerkResult>;
742
772
 
743
773
  /** An application row, as far as the session cares about it. */
744
774
  interface ApplicationRecord {
@@ -879,6 +909,22 @@ declare const SCHEDULING_DEFAULTS: ResolvedScheduling;
879
909
  * `windowDays` is capped at 62 because Google FreeBusy is.
880
910
  */
881
911
  declare function resolveScheduling(config?: ChapterScheduling): ResolvedScheduling;
912
+ /** Validation messages keyed by form field. */
913
+ type SchedulingErrors = Record<string, string>;
914
+ /**
915
+ * Validate a scheduling config field by field, collecting owner-readable messages
916
+ * rather than throwing on the first problem. An admin settings form needs to say
917
+ * *which* field is wrong and why — "pick at least one day" beats a stack trace —
918
+ * so the admin route returns these directly. {@link resolveScheduling} is the
919
+ * throwing wrapper for internal/config-time use.
920
+ */
921
+ declare function validateScheduling(config?: ChapterScheduling): {
922
+ ok: true;
923
+ value: ResolvedScheduling;
924
+ } | {
925
+ ok: false;
926
+ errors: SchedulingErrors;
927
+ };
882
928
  /** Statuses a member may book/reschedule from (early pipeline only). */
883
929
  /** The availability window: `[now, now + windowDays]` in epoch ms. */
884
930
  declare function slotWindow(now: number, windowDays: number): {
@@ -962,4 +1008,4 @@ type ApplicationBookingPatch = {
962
1008
  * already there (never backward). */
963
1009
  declare function applicationBookingUpdate(currentStatus: string, startAt: number, htmlLink?: string | null): ApplicationBookingPatch;
964
1010
 
965
- export { type AccountModel, 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 ClerkInviteInput, 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 RoleChangeContext, type Rule, SCHEDULING_DEFAULTS, type SecretStore, type SessionUser, type SharedPerson, type StripeEvent, type SubmitResult, type WebhookEvent, 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, verifyStripeSignature, webhookMutationId };
1011
+ 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, 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 };
package/dist/index.js CHANGED
@@ -474,6 +474,13 @@ function defineChapter(config) {
474
474
  if (account !== "invite" && account !== "create" && account !== "none") {
475
475
  throw new Error(`defineChapter.account: must be "invite", "create", or "none" \u2014 got "${String(account)}"`);
476
476
  }
477
+ const adminNotification = config.sends?.adminNotification ?? "submit";
478
+ if (adminNotification !== "submit" && adminNotification !== "payment" && adminNotification !== "never") {
479
+ throw new Error(
480
+ `defineChapter.sends.adminNotification: must be "submit", "payment", or "never" \u2014 got "${String(adminNotification)}"`
481
+ );
482
+ }
483
+ const sends = { adminNotification };
477
484
  const chapter = {
478
485
  config,
479
486
  id: id2,
@@ -487,6 +494,7 @@ function defineChapter(config) {
487
494
  rules,
488
495
  services,
489
496
  account,
497
+ sends,
490
498
  groupSeed: () => mode === "chapter" ? buildGroupSeed(config) : null
491
499
  };
492
500
  if (config.url !== void 0) chapter.url = config.url;
@@ -764,13 +772,15 @@ async function projectApplicant(deps, applicant) {
764
772
  }
765
773
 
766
774
  // src/clerk.ts
775
+ var heal = (status) => status === 422 ? { ok: true, status, existed: true } : { ok: false, status };
767
776
  function clerkInviteRequest(input) {
768
777
  return {
769
778
  path: "/v1/invitations",
770
779
  body: {
771
780
  email_address: input.email,
772
781
  notify: true,
773
- ...input.redirectUrl ? { redirect_url: input.redirectUrl } : {}
782
+ ...input.redirectUrl ? { redirect_url: input.redirectUrl } : {},
783
+ ...input.publicMetadata ? { public_metadata: input.publicMetadata } : {}
774
784
  }
775
785
  };
776
786
  }
@@ -781,7 +791,7 @@ async function createClerkInvitation(secretKey, input, fetchImpl = fetch) {
781
791
  headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
782
792
  body: JSON.stringify(body)
783
793
  });
784
- return { ok: res.ok, status: res.status };
794
+ return res.ok ? { ok: true, status: res.status } : heal(res.status);
785
795
  }
786
796
  function clerkUserRequest(input) {
787
797
  return {
@@ -790,10 +800,25 @@ function clerkUserRequest(input) {
790
800
  email_address: [input.email],
791
801
  skip_password_requirement: true,
792
802
  ...input.firstName ? { first_name: input.firstName } : {},
793
- ...input.lastName ? { last_name: input.lastName } : {}
803
+ ...input.lastName ? { last_name: input.lastName } : {},
804
+ ...input.publicMetadata ? { public_metadata: input.publicMetadata } : {}
794
805
  }
795
806
  };
796
807
  }
808
+ async function refreshUserMetadata(secretKey, email, publicMetadata, fetchImpl) {
809
+ const auth = { authorization: `Bearer ${secretKey}` };
810
+ const found = await fetchImpl(`https://api.clerk.com/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, { headers: auth });
811
+ if (!found.ok) return false;
812
+ const users = await found.json().catch(() => null);
813
+ const id2 = Array.isArray(users) && typeof users[0]?.id === "string" ? users[0].id : void 0;
814
+ if (!id2) return false;
815
+ const patched = await fetchImpl(`https://api.clerk.com/v1/users/${id2}/metadata`, {
816
+ method: "PATCH",
817
+ headers: { ...auth, "content-type": "application/json" },
818
+ body: JSON.stringify({ public_metadata: publicMetadata })
819
+ });
820
+ return patched.ok;
821
+ }
797
822
  async function createClerkUser(secretKey, input, fetchImpl = fetch) {
798
823
  const { path, body } = clerkUserRequest(input);
799
824
  const res = await fetchImpl(`https://api.clerk.com${path}`, {
@@ -801,7 +826,11 @@ async function createClerkUser(secretKey, input, fetchImpl = fetch) {
801
826
  headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
802
827
  body: JSON.stringify(body)
803
828
  });
804
- return { ok: res.ok, status: res.status };
829
+ if (res.ok) return { ok: true, status: res.status };
830
+ const healed = heal(res.status);
831
+ if (!healed.existed || !input.publicMetadata) return healed;
832
+ const refreshed = await refreshUserMetadata(secretKey, input.email, input.publicMetadata, fetchImpl).catch(() => false);
833
+ return { ...healed, refreshed };
805
834
  }
806
835
 
807
836
  // src/session.ts
@@ -920,6 +949,12 @@ function isValidTimeZone(tz) {
920
949
  }
921
950
  }
922
951
  function resolveScheduling(config) {
952
+ const result = validateScheduling(config);
953
+ if (result.ok) return result.value;
954
+ const detail = Object.entries(result.errors).map(([field, message]) => `${field}: ${message}`).join(" ");
955
+ throw new Error(`scheduling: ${detail}`);
956
+ }
957
+ function validateScheduling(config) {
923
958
  const d = config ?? {};
924
959
  const c = {
925
960
  slotMinutes: d.slotMinutes ?? SCHEDULING_DEFAULTS.slotMinutes,
@@ -931,20 +966,29 @@ function resolveScheduling(config) {
931
966
  windowDays: d.windowDays ?? SCHEDULING_DEFAULTS.windowDays,
932
967
  summaryTemplate: d.summaryTemplate ?? SCHEDULING_DEFAULTS.summaryTemplate
933
968
  };
934
- const fail = (msg) => {
935
- throw new Error(`scheduling: ${msg}`);
936
- };
937
- if (!(c.slotMinutes >= 15 && c.slotMinutes <= 240)) fail("slotMinutes must be 15\u2013240");
938
- if (!(c.windowDays >= 1 && c.windowDays <= 62)) fail("windowDays must be 1\u201362 (FreeBusy caps at 62)");
939
- if (!(c.minNoticeHours >= 0 && c.minNoticeHours <= 336)) fail("minNoticeHours must be 0\u2013336");
940
- if (!(c.startHour >= 0 && c.startHour < c.endHour && c.endHour <= 24)) fail("require 0 \u2264 startHour < endHour \u2264 24");
969
+ const errors = {};
970
+ if (!(c.slotMinutes >= 15 && c.slotMinutes <= 240)) {
971
+ errors.slotMinutes = "Slot length must be between 15 and 240 minutes.";
972
+ }
973
+ if (!(c.windowDays >= 1 && c.windowDays <= 62)) {
974
+ errors.windowDays = "Booking window must be between 1 and 62 days (the calendar caps look-ahead at 62).";
975
+ }
976
+ if (!(c.minNoticeHours >= 0 && c.minNoticeHours <= 336)) {
977
+ errors.minNoticeHours = "Minimum notice must be between 0 and 336 hours.";
978
+ }
979
+ if (!(c.startHour >= 0 && c.startHour < c.endHour && c.endHour <= 24)) {
980
+ errors.hours = "Hours must satisfy 0 \u2264 start < end \u2264 24.";
981
+ }
941
982
  const days = [...c.days];
942
- if (!days.length || !days.every((n) => Number.isInteger(n) && n >= 0 && n <= 6)) {
943
- fail("days must be a non-empty list of weekday integers 0\u20136");
983
+ if (!days.length) errors.days = "Pick at least one day.";
984
+ else if (!days.every((n) => Number.isInteger(n) && n >= 0 && n <= 6)) {
985
+ errors.days = "Days must be weekday numbers, 0 (Sunday) through 6 (Saturday).";
986
+ }
987
+ if (typeof c.timezone !== "string" || !isValidTimeZone(c.timezone)) {
988
+ errors.timezone = `"${String(c.timezone)}" is not a valid IANA timezone (for example "America/Los_Angeles").`;
944
989
  }
945
- if (typeof c.timezone !== "string" || !isValidTimeZone(c.timezone)) fail(`invalid IANA timezone "${c.timezone}"`);
946
- if (typeof c.summaryTemplate !== "string") fail("summaryTemplate must be a string");
947
- return { ...c, days };
990
+ if (typeof c.summaryTemplate !== "string") errors.summaryTemplate = "Calendar summary template must be text.";
991
+ return Object.keys(errors).length > 0 ? { ok: false, errors } : { ok: true, value: { ...c, days } };
948
992
  }
949
993
  function slotWindow(now, windowDays) {
950
994
  return { from: now, to: now + windowDays * 864e5 };
@@ -1049,6 +1093,7 @@ export {
1049
1093
  stripeForm,
1050
1094
  submitApplication,
1051
1095
  subscriptionIdempotencyKey,
1096
+ validateScheduling,
1052
1097
  verifyStripeSignature,
1053
1098
  webhookMutationId
1054
1099
  };