@odla-ai/chapter 0.9.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
  }
@@ -708,6 +725,8 @@ interface ClerkResult {
708
725
  ok: boolean;
709
726
  status: number;
710
727
  existed?: boolean;
728
+ /** Set when an `existed` heal also refreshed the account's public_metadata. */
729
+ refreshed?: boolean;
711
730
  }
712
731
  /** Inputs for a Clerk invitation. */
713
732
  interface ClerkInviteInput {
@@ -746,7 +765,9 @@ declare function clerkUserRequest(input: ClerkUserInput): {
746
765
  };
747
766
  /** Create the applicant's Clerk account server-side. A repeat for an email that
748
767
  * already has an account heals to `{ ok: true, existed: true }` — the account
749
- * exists, which is the state apply-time provisioning wanted. */
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`). */
750
771
  declare function createClerkUser(secretKey: string, input: ClerkUserInput, fetchImpl?: typeof fetch): Promise<ClerkResult>;
751
772
 
752
773
  /** An application row, as far as the session cares about it. */
@@ -888,6 +909,22 @@ declare const SCHEDULING_DEFAULTS: ResolvedScheduling;
888
909
  * `windowDays` is capped at 62 because Google FreeBusy is.
889
910
  */
890
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
+ };
891
928
  /** Statuses a member may book/reschedule from (early pipeline only). */
892
929
  /** The availability window: `[now, now + windowDays]` in epoch ms. */
893
930
  declare function slotWindow(now: number, windowDays: number): {
@@ -971,4 +1008,4 @@ type ApplicationBookingPatch = {
971
1008
  * already there (never backward). */
972
1009
  declare function applicationBookingUpdate(currentStatus: string, startAt: number, htmlLink?: string | null): ApplicationBookingPatch;
973
1010
 
974
- 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 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 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
  }
@@ -708,6 +725,8 @@ interface ClerkResult {
708
725
  ok: boolean;
709
726
  status: number;
710
727
  existed?: boolean;
728
+ /** Set when an `existed` heal also refreshed the account's public_metadata. */
729
+ refreshed?: boolean;
711
730
  }
712
731
  /** Inputs for a Clerk invitation. */
713
732
  interface ClerkInviteInput {
@@ -746,7 +765,9 @@ declare function clerkUserRequest(input: ClerkUserInput): {
746
765
  };
747
766
  /** Create the applicant's Clerk account server-side. A repeat for an email that
748
767
  * already has an account heals to `{ ok: true, existed: true }` — the account
749
- * exists, which is the state apply-time provisioning wanted. */
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`). */
750
771
  declare function createClerkUser(secretKey: string, input: ClerkUserInput, fetchImpl?: typeof fetch): Promise<ClerkResult>;
751
772
 
752
773
  /** An application row, as far as the session cares about it. */
@@ -888,6 +909,22 @@ declare const SCHEDULING_DEFAULTS: ResolvedScheduling;
888
909
  * `windowDays` is capped at 62 because Google FreeBusy is.
889
910
  */
890
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
+ };
891
928
  /** Statuses a member may book/reschedule from (early pipeline only). */
892
929
  /** The availability window: `[now, now + windowDays]` in epoch ms. */
893
930
  declare function slotWindow(now: number, windowDays: number): {
@@ -971,4 +1008,4 @@ type ApplicationBookingPatch = {
971
1008
  * already there (never backward). */
972
1009
  declare function applicationBookingUpdate(currentStatus: string, startAt: number, htmlLink?: string | null): ApplicationBookingPatch;
973
1010
 
974
- 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 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 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;
@@ -797,6 +805,20 @@ function clerkUserRequest(input) {
797
805
  }
798
806
  };
799
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
+ }
800
822
  async function createClerkUser(secretKey, input, fetchImpl = fetch) {
801
823
  const { path, body } = clerkUserRequest(input);
802
824
  const res = await fetchImpl(`https://api.clerk.com${path}`, {
@@ -804,7 +826,11 @@ async function createClerkUser(secretKey, input, fetchImpl = fetch) {
804
826
  headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
805
827
  body: JSON.stringify(body)
806
828
  });
807
- return res.ok ? { ok: true, status: res.status } : heal(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 };
808
834
  }
809
835
 
810
836
  // src/session.ts
@@ -923,6 +949,12 @@ function isValidTimeZone(tz) {
923
949
  }
924
950
  }
925
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) {
926
958
  const d = config ?? {};
927
959
  const c = {
928
960
  slotMinutes: d.slotMinutes ?? SCHEDULING_DEFAULTS.slotMinutes,
@@ -934,20 +966,29 @@ function resolveScheduling(config) {
934
966
  windowDays: d.windowDays ?? SCHEDULING_DEFAULTS.windowDays,
935
967
  summaryTemplate: d.summaryTemplate ?? SCHEDULING_DEFAULTS.summaryTemplate
936
968
  };
937
- const fail = (msg) => {
938
- throw new Error(`scheduling: ${msg}`);
939
- };
940
- if (!(c.slotMinutes >= 15 && c.slotMinutes <= 240)) fail("slotMinutes must be 15\u2013240");
941
- if (!(c.windowDays >= 1 && c.windowDays <= 62)) fail("windowDays must be 1\u201362 (FreeBusy caps at 62)");
942
- if (!(c.minNoticeHours >= 0 && c.minNoticeHours <= 336)) fail("minNoticeHours must be 0\u2013336");
943
- 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
+ }
944
982
  const days = [...c.days];
945
- if (!days.length || !days.every((n) => Number.isInteger(n) && n >= 0 && n <= 6)) {
946
- 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").`;
947
989
  }
948
- if (typeof c.timezone !== "string" || !isValidTimeZone(c.timezone)) fail(`invalid IANA timezone "${c.timezone}"`);
949
- if (typeof c.summaryTemplate !== "string") fail("summaryTemplate must be a string");
950
- 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 } };
951
992
  }
952
993
  function slotWindow(now, windowDays) {
953
994
  return { from: now, to: now + windowDays * 864e5 };
@@ -1052,6 +1093,7 @@ export {
1052
1093
  stripeForm,
1053
1094
  submitApplication,
1054
1095
  subscriptionIdempotencyKey,
1096
+ validateScheduling,
1055
1097
  verifyStripeSignature,
1056
1098
  webhookMutationId
1057
1099
  };