@odla-ai/chapter 0.15.0 → 0.16.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
@@ -242,18 +242,59 @@ interface ChapterConfig {
242
242
  auth?: ChapterAuth;
243
243
  /** odla services (db implied). Default `["db","calendar","o11y"]`. */
244
244
  services?: readonly string[];
245
- /** Apply-time account provisioning: `"invite"` (default) mints a Clerk
246
- * invitation, `"create"` makes the account server-side (so join can say the
247
- * account is ready), `"none"` skips it. Any of these needs a `clerk_secret_key`
248
- * vault secret to act. */
245
+ /** Apply-time account provisioning. **Default `"none"`** it provisions
246
+ * nothing, because the alternatives have an outbound side effect and a site
247
+ * that never made the choice must not be mailing people. `"create"` makes the
248
+ * Clerk account server-side (so join can say the account is ready);
249
+ * `"invite"` **emails the applicant a Clerk invitation**. Both non-default
250
+ * models need a `clerk_secret_key` vault secret to act. Opt in explicitly —
251
+ * leaving this unset provisions no accounts. */
249
252
  account?: AccountModel;
250
253
  /** WHEN lifecycle email fires. Addressing and content live on the group row
251
254
  * (owner-editable at runtime); this is the trigger, which is a build-time
252
255
  * decision. See {@link ChapterSends}. */
253
256
  sends?: ChapterSends;
257
+ /** Site policy for admin operations (approve side effects, refund rules).
258
+ * See {@link ChapterOperations}. */
259
+ operations?: ChapterOperations;
254
260
  }
255
261
  /** Apply-time Clerk account provisioning model. */
256
262
  type AccountModel = "invite" | "create" | "none";
263
+ /** Site policy for admin OPERATIONS: the DECISIONS, where the mechanics stay
264
+ * package-owned. Same model as {@link ChapterSends} — a site declares the rule,
265
+ * chapter enforces it. Distinct from {@link ChapterPolicy}, which is member-facing
266
+ * copy. (The privilege-escalation rules are NOT here: they are package-enforced
267
+ * in `canChangeRole`, gated on `auth.superAdmins`, so a site cannot weaken them.) */
268
+ interface ChapterOperations {
269
+ /** What approving an application does. */
270
+ onApprove?: {
271
+ /** Role to promote the applicant to in Clerk. Defaults to the ladder rung
272
+ * directly below admin (e.g. `"member"`); `false` promotes nobody. */
273
+ promoteTo?: string | false;
274
+ /** Group email template to send on approve. Default `"onboardingInvite"`;
275
+ * `false` sends nothing. */
276
+ send?: string | false;
277
+ };
278
+ /** Refund rules. */
279
+ refund?: {
280
+ /** Application statuses a refund may be issued from. Default: any status. */
281
+ allowedFrom?: readonly string[];
282
+ /** Also cancel the Stripe subscription. Default `true`. */
283
+ cancelSubscription?: boolean;
284
+ };
285
+ }
286
+ /** The fully-resolved {@link ChapterOperations} carried on the {@link Chapter}. */
287
+ interface ResolvedOperations {
288
+ onApprove: {
289
+ promoteTo: string | false;
290
+ send: string | false;
291
+ };
292
+ /** `allowedFrom: null` means "any status". */
293
+ refund: {
294
+ allowedFrom: readonly string[] | null;
295
+ cancelSubscription: boolean;
296
+ };
297
+ }
257
298
  /** When the admin notification fires: on application `submit` (default), on the
258
299
  * first successful `payment`, or `never` (the site drives it itself). */
259
300
  type AdminNotificationTrigger = "submit" | "payment" | "never";
@@ -284,10 +325,12 @@ interface Chapter {
284
325
  schema: DbSchema;
285
326
  rules: DbRules;
286
327
  services: readonly string[];
287
- /** Resolved apply-time account provisioning model (default `"invite"`). */
328
+ /** Resolved apply-time account provisioning model (default `"none"`). */
288
329
  account: AccountModel;
289
330
  /** Resolved send policy — when each lifecycle email fires. */
290
331
  sends: ResolvedSends;
332
+ /** Resolved admin-operation policy (approve side effects, refund rules). */
333
+ operations: ResolvedOperations;
291
334
  /** The seed `groups` row derived from config (chapter mode), else `null`. */
292
335
  groupSeed(): Record<string, unknown> | null;
293
336
  }
@@ -781,6 +824,60 @@ declare function projectApplicant(deps: ProjectionDeps, applicant: Applicant): P
781
824
  recordId: string;
782
825
  }>;
783
826
 
827
+ /** The crm `person` input for one application row: the built-in identity/contact
828
+ * fields plus each configured `crmFields` value present on the row. */
829
+ declare function personInputFromApp(chapter: Chapter, app: Record<string, unknown>): Record<string, unknown>;
830
+ /** Upsert the person record for one application (or a synthetic `{ email,
831
+ * firstName }` account row) and mirror its stage, billing snapshot, and Clerk
832
+ * identity. `stage` is the `applications.status` to mirror — omit for
833
+ * account-only rows not in the pipeline. Throws on failure (the backfill route
834
+ * counts; the operational call sites wrap in `.catch`). */
835
+ declare function syncApplicationToCrm(deps: ProjectionDeps & {
836
+ chapter: Chapter;
837
+ }, opts: {
838
+ app: Record<string, unknown>;
839
+ stage?: string;
840
+ }): Promise<string | null>;
841
+ /** Backfill / repair: project every application (newest per person) and every
842
+ * account-only `$users` row into the CRM. Idempotent (safe to re-run). Dev
843
+ * volumes fit one 1000-row page. Returns `{ synced, errors }`. */
844
+ declare function backfillCrm(deps: ProjectionDeps & {
845
+ chapter: Chapter;
846
+ }): Promise<{
847
+ synced: number;
848
+ errors: Array<{
849
+ email: string;
850
+ error: string;
851
+ }>;
852
+ }>;
853
+
854
+ /** Bucket `{ t, v }` points into the last `weeks` weekly buckets ending at `now`
855
+ * (epoch ms), summing `v` per bucket. Returns oldest→newest `{ weekStart, value
856
+ * }`, so a caller renders a sparkline directly. Points outside the window are
857
+ * ignored. */
858
+ declare function bucketSeries(points: Array<{
859
+ t: number;
860
+ v: number;
861
+ }>, now: number, weeks?: number): Array<{
862
+ weekStart: number;
863
+ value: number;
864
+ }>;
865
+ /** Annualized cents for a Stripe subscription: sum each item's
866
+ * `unit_amount * quantity`, ×12 for monthly intervals. A yearly interval is
867
+ * taken as-is. Returns 0 for a shape with no priced items. */
868
+ declare function subAnnualCents(sub: Record<string, unknown>): number;
869
+
870
+ /** The result of a Stripe Backend API call: ok + status + parsed JSON body. */
871
+ type StripeResult = {
872
+ ok: boolean;
873
+ status: number;
874
+ body: Record<string, unknown>;
875
+ };
876
+ /** Call the Stripe Backend API (form-encoded, Bearer sk_). Exposed so the admin
877
+ * billing/dashboard reads and the refund route share one Stripe client instead
878
+ * of each re-deriving the auth + encoding. */
879
+ declare function stripeCall(sk: string, method: "GET" | "POST" | "DELETE", path: string, params?: Record<string, unknown>, idempotencyKey?: string): Promise<StripeResult>;
880
+
784
881
  /** The outcome of a Clerk provisioning call. `existed` marks the heal case: Clerk
785
882
  * rejected it because the account/invitation is already there, which is the end
786
883
  * state we wanted anyway. */
@@ -1165,4 +1262,4 @@ type ApplicationBookingPatch = {
1165
1262
  * already there (never backward). */
1166
1263
  declare function applicationBookingUpdate(currentStatus: string, startAt: number, htmlLink?: string | null): ApplicationBookingPatch;
1167
1264
 
1168
- 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 ClerkUserRecord, type DbOp, type DbRules, type DbSchema, type DeliveryDecision, type EmailGroup, type EmailTemplate, type EmailTemplateRow, type Entity, type ExistingMeeting, type GuardResult, type IntegrationDescriptor, type IntegrationProvision, type IntegrationSecret, type IntegrationSetting, type IntegrationSync, 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 SecretMode, 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, clerkGetUser, clerkGetUserByEmail, clerkIntegration, clerkInviteRequest, clerkListUsers, clerkSetRole, 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 };
1265
+ 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 ChapterOperations, type ChapterPipeline, type ChapterPolicy, type ChapterPrices, type ChapterScheduling, type ChapterSends, type ClerkInviteInput, type ClerkResult, type ClerkUserInput, type ClerkUserRecord, type DbOp, type DbRules, type DbSchema, type DeliveryDecision, type EmailGroup, type EmailTemplate, type EmailTemplateRow, type Entity, type ExistingMeeting, type GuardResult, type IntegrationDescriptor, type IntegrationProvision, type IntegrationSecret, type IntegrationSetting, type IntegrationSync, 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 ResolvedOperations, type ResolvedPipeline, type ResolvedScheduling, type ResolvedSends, type RoleChangeContext, type Rule, SCHEDULING_DEFAULTS, type SchedulingErrors, type SecretMode, type SecretStore, type SessionUser, type SharedPerson, type StripeEvent, type StripeResult, type SubmitResult, type WebhookEvent, applicantProfile, applicationBookingUpdate, applicationSummary, backfillCrm, bookingDecision, brandTokens, bucketSeries, buildGroupSeed, canApprove, canBook, canChangeRole, canTransition, canceledPatch, chapterDb, clampArray, clerkGetUser, clerkGetUserByEmail, clerkIntegration, clerkInviteRequest, clerkListUsers, clerkSetRole, 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, personInputFromApp, planDelivery, projectApplicant, projectSharedRecord, reconcileMeetings, refundedPatch, render, renderSummary, renderTemplateBody, renewalPatch, resolveApplication, resolveAuth, resolvePipeline, resolveScheduling, roleFromClaim, sendTemplated, sharedPersonInput, slotWindow, stageIndex, stripeCall, stripeForm, subAnnualCents, submitApplication, subscriptionIdempotencyKey, syncApplicationToCrm, validateScheduling, verifyStripeSignature, webhookMutationId };
package/dist/index.d.ts CHANGED
@@ -242,18 +242,59 @@ interface ChapterConfig {
242
242
  auth?: ChapterAuth;
243
243
  /** odla services (db implied). Default `["db","calendar","o11y"]`. */
244
244
  services?: readonly string[];
245
- /** Apply-time account provisioning: `"invite"` (default) mints a Clerk
246
- * invitation, `"create"` makes the account server-side (so join can say the
247
- * account is ready), `"none"` skips it. Any of these needs a `clerk_secret_key`
248
- * vault secret to act. */
245
+ /** Apply-time account provisioning. **Default `"none"`** it provisions
246
+ * nothing, because the alternatives have an outbound side effect and a site
247
+ * that never made the choice must not be mailing people. `"create"` makes the
248
+ * Clerk account server-side (so join can say the account is ready);
249
+ * `"invite"` **emails the applicant a Clerk invitation**. Both non-default
250
+ * models need a `clerk_secret_key` vault secret to act. Opt in explicitly —
251
+ * leaving this unset provisions no accounts. */
249
252
  account?: AccountModel;
250
253
  /** WHEN lifecycle email fires. Addressing and content live on the group row
251
254
  * (owner-editable at runtime); this is the trigger, which is a build-time
252
255
  * decision. See {@link ChapterSends}. */
253
256
  sends?: ChapterSends;
257
+ /** Site policy for admin operations (approve side effects, refund rules).
258
+ * See {@link ChapterOperations}. */
259
+ operations?: ChapterOperations;
254
260
  }
255
261
  /** Apply-time Clerk account provisioning model. */
256
262
  type AccountModel = "invite" | "create" | "none";
263
+ /** Site policy for admin OPERATIONS: the DECISIONS, where the mechanics stay
264
+ * package-owned. Same model as {@link ChapterSends} — a site declares the rule,
265
+ * chapter enforces it. Distinct from {@link ChapterPolicy}, which is member-facing
266
+ * copy. (The privilege-escalation rules are NOT here: they are package-enforced
267
+ * in `canChangeRole`, gated on `auth.superAdmins`, so a site cannot weaken them.) */
268
+ interface ChapterOperations {
269
+ /** What approving an application does. */
270
+ onApprove?: {
271
+ /** Role to promote the applicant to in Clerk. Defaults to the ladder rung
272
+ * directly below admin (e.g. `"member"`); `false` promotes nobody. */
273
+ promoteTo?: string | false;
274
+ /** Group email template to send on approve. Default `"onboardingInvite"`;
275
+ * `false` sends nothing. */
276
+ send?: string | false;
277
+ };
278
+ /** Refund rules. */
279
+ refund?: {
280
+ /** Application statuses a refund may be issued from. Default: any status. */
281
+ allowedFrom?: readonly string[];
282
+ /** Also cancel the Stripe subscription. Default `true`. */
283
+ cancelSubscription?: boolean;
284
+ };
285
+ }
286
+ /** The fully-resolved {@link ChapterOperations} carried on the {@link Chapter}. */
287
+ interface ResolvedOperations {
288
+ onApprove: {
289
+ promoteTo: string | false;
290
+ send: string | false;
291
+ };
292
+ /** `allowedFrom: null` means "any status". */
293
+ refund: {
294
+ allowedFrom: readonly string[] | null;
295
+ cancelSubscription: boolean;
296
+ };
297
+ }
257
298
  /** When the admin notification fires: on application `submit` (default), on the
258
299
  * first successful `payment`, or `never` (the site drives it itself). */
259
300
  type AdminNotificationTrigger = "submit" | "payment" | "never";
@@ -284,10 +325,12 @@ interface Chapter {
284
325
  schema: DbSchema;
285
326
  rules: DbRules;
286
327
  services: readonly string[];
287
- /** Resolved apply-time account provisioning model (default `"invite"`). */
328
+ /** Resolved apply-time account provisioning model (default `"none"`). */
288
329
  account: AccountModel;
289
330
  /** Resolved send policy — when each lifecycle email fires. */
290
331
  sends: ResolvedSends;
332
+ /** Resolved admin-operation policy (approve side effects, refund rules). */
333
+ operations: ResolvedOperations;
291
334
  /** The seed `groups` row derived from config (chapter mode), else `null`. */
292
335
  groupSeed(): Record<string, unknown> | null;
293
336
  }
@@ -781,6 +824,60 @@ declare function projectApplicant(deps: ProjectionDeps, applicant: Applicant): P
781
824
  recordId: string;
782
825
  }>;
783
826
 
827
+ /** The crm `person` input for one application row: the built-in identity/contact
828
+ * fields plus each configured `crmFields` value present on the row. */
829
+ declare function personInputFromApp(chapter: Chapter, app: Record<string, unknown>): Record<string, unknown>;
830
+ /** Upsert the person record for one application (or a synthetic `{ email,
831
+ * firstName }` account row) and mirror its stage, billing snapshot, and Clerk
832
+ * identity. `stage` is the `applications.status` to mirror — omit for
833
+ * account-only rows not in the pipeline. Throws on failure (the backfill route
834
+ * counts; the operational call sites wrap in `.catch`). */
835
+ declare function syncApplicationToCrm(deps: ProjectionDeps & {
836
+ chapter: Chapter;
837
+ }, opts: {
838
+ app: Record<string, unknown>;
839
+ stage?: string;
840
+ }): Promise<string | null>;
841
+ /** Backfill / repair: project every application (newest per person) and every
842
+ * account-only `$users` row into the CRM. Idempotent (safe to re-run). Dev
843
+ * volumes fit one 1000-row page. Returns `{ synced, errors }`. */
844
+ declare function backfillCrm(deps: ProjectionDeps & {
845
+ chapter: Chapter;
846
+ }): Promise<{
847
+ synced: number;
848
+ errors: Array<{
849
+ email: string;
850
+ error: string;
851
+ }>;
852
+ }>;
853
+
854
+ /** Bucket `{ t, v }` points into the last `weeks` weekly buckets ending at `now`
855
+ * (epoch ms), summing `v` per bucket. Returns oldest→newest `{ weekStart, value
856
+ * }`, so a caller renders a sparkline directly. Points outside the window are
857
+ * ignored. */
858
+ declare function bucketSeries(points: Array<{
859
+ t: number;
860
+ v: number;
861
+ }>, now: number, weeks?: number): Array<{
862
+ weekStart: number;
863
+ value: number;
864
+ }>;
865
+ /** Annualized cents for a Stripe subscription: sum each item's
866
+ * `unit_amount * quantity`, ×12 for monthly intervals. A yearly interval is
867
+ * taken as-is. Returns 0 for a shape with no priced items. */
868
+ declare function subAnnualCents(sub: Record<string, unknown>): number;
869
+
870
+ /** The result of a Stripe Backend API call: ok + status + parsed JSON body. */
871
+ type StripeResult = {
872
+ ok: boolean;
873
+ status: number;
874
+ body: Record<string, unknown>;
875
+ };
876
+ /** Call the Stripe Backend API (form-encoded, Bearer sk_). Exposed so the admin
877
+ * billing/dashboard reads and the refund route share one Stripe client instead
878
+ * of each re-deriving the auth + encoding. */
879
+ declare function stripeCall(sk: string, method: "GET" | "POST" | "DELETE", path: string, params?: Record<string, unknown>, idempotencyKey?: string): Promise<StripeResult>;
880
+
784
881
  /** The outcome of a Clerk provisioning call. `existed` marks the heal case: Clerk
785
882
  * rejected it because the account/invitation is already there, which is the end
786
883
  * state we wanted anyway. */
@@ -1165,4 +1262,4 @@ type ApplicationBookingPatch = {
1165
1262
  * already there (never backward). */
1166
1263
  declare function applicationBookingUpdate(currentStatus: string, startAt: number, htmlLink?: string | null): ApplicationBookingPatch;
1167
1264
 
1168
- 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 ClerkUserRecord, type DbOp, type DbRules, type DbSchema, type DeliveryDecision, type EmailGroup, type EmailTemplate, type EmailTemplateRow, type Entity, type ExistingMeeting, type GuardResult, type IntegrationDescriptor, type IntegrationProvision, type IntegrationSecret, type IntegrationSetting, type IntegrationSync, 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 SecretMode, 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, clerkGetUser, clerkGetUserByEmail, clerkIntegration, clerkInviteRequest, clerkListUsers, clerkSetRole, 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 };
1265
+ 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 ChapterOperations, type ChapterPipeline, type ChapterPolicy, type ChapterPrices, type ChapterScheduling, type ChapterSends, type ClerkInviteInput, type ClerkResult, type ClerkUserInput, type ClerkUserRecord, type DbOp, type DbRules, type DbSchema, type DeliveryDecision, type EmailGroup, type EmailTemplate, type EmailTemplateRow, type Entity, type ExistingMeeting, type GuardResult, type IntegrationDescriptor, type IntegrationProvision, type IntegrationSecret, type IntegrationSetting, type IntegrationSync, 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 ResolvedOperations, type ResolvedPipeline, type ResolvedScheduling, type ResolvedSends, type RoleChangeContext, type Rule, SCHEDULING_DEFAULTS, type SchedulingErrors, type SecretMode, type SecretStore, type SessionUser, type SharedPerson, type StripeEvent, type StripeResult, type SubmitResult, type WebhookEvent, applicantProfile, applicationBookingUpdate, applicationSummary, backfillCrm, bookingDecision, brandTokens, bucketSeries, buildGroupSeed, canApprove, canBook, canChangeRole, canTransition, canceledPatch, chapterDb, clampArray, clerkGetUser, clerkGetUserByEmail, clerkIntegration, clerkInviteRequest, clerkListUsers, clerkSetRole, 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, personInputFromApp, planDelivery, projectApplicant, projectSharedRecord, reconcileMeetings, refundedPatch, render, renderSummary, renderTemplateBody, renewalPatch, resolveApplication, resolveAuth, resolvePipeline, resolveScheduling, roleFromClaim, sendTemplated, sharedPersonInput, slotWindow, stageIndex, stripeCall, stripeForm, subAnnualCents, submitApplication, subscriptionIdempotencyKey, syncApplicationToCrm, validateScheduling, verifyStripeSignature, webhookMutationId };
package/dist/index.js CHANGED
@@ -537,6 +537,27 @@ function defineChapter(config) {
537
537
  );
538
538
  }
539
539
  const sends = { adminNotification };
540
+ const onApproveCfg = config.operations?.onApprove ?? {};
541
+ const belowAdmin = auth.ladder[auth.ladder.length - 2] ?? auth.adminRole;
542
+ const promoteTo = onApproveCfg.promoteTo === void 0 ? belowAdmin : onApproveCfg.promoteTo;
543
+ if (promoteTo !== false && !auth.ladder.includes(promoteTo)) {
544
+ throw new Error(
545
+ `defineChapter.operations.onApprove.promoteTo: "${String(promoteTo)}" is not in the auth ladder (${auth.ladder.join(", ")})`
546
+ );
547
+ }
548
+ const refundCfg = config.operations?.refund ?? {};
549
+ if (refundCfg.allowedFrom !== void 0) {
550
+ const unknown = refundCfg.allowedFrom.filter((s) => !pipeline.stages.includes(s));
551
+ if (unknown.length > 0) {
552
+ throw new Error(
553
+ `defineChapter.operations.refund.allowedFrom: ${unknown.map((s) => `"${s}"`).join(", ")} not in the pipeline (${pipeline.stages.join(", ")})`
554
+ );
555
+ }
556
+ }
557
+ const operations = {
558
+ onApprove: { promoteTo, send: onApproveCfg.send === void 0 ? "onboardingInvite" : onApproveCfg.send },
559
+ refund: { allowedFrom: refundCfg.allowedFrom ?? null, cancelSubscription: refundCfg.cancelSubscription ?? true }
560
+ };
540
561
  const chapter = {
541
562
  config,
542
563
  id: id2,
@@ -551,6 +572,7 @@ function defineChapter(config) {
551
572
  services,
552
573
  account,
553
574
  sends,
575
+ operations,
554
576
  groupSeed: () => mode === "chapter" ? buildGroupSeed(config) : null
555
577
  };
556
578
  if (config.url !== void 0) chapter.url = config.url;
@@ -677,16 +699,16 @@ async function sendTemplated(deps, input) {
677
699
  return error ? { sent: false, reason: error } : { sent: true };
678
700
  }
679
701
  function emailGroupFrom(row) {
680
- const str = (v) => typeof v === "string" ? v : void 0;
702
+ const str2 = (v) => typeof v === "string" ? v : void 0;
681
703
  const templates = row.emailTemplates && typeof row.emailTemplates === "object" ? row.emailTemplates : {};
682
704
  return {
683
705
  id: String(row.id),
684
706
  name: String(row.name ?? ""),
685
- replyTo: str(row.replyTo) ?? "",
686
- debugEmail: str(row.debugEmail),
687
- refundPolicyText: str(row.refundPolicyText),
688
- commitmentText: str(row.commitmentText),
689
- normsText: str(row.normsText),
707
+ replyTo: str2(row.replyTo) ?? "",
708
+ debugEmail: str2(row.debugEmail),
709
+ refundPolicyText: str2(row.refundPolicyText),
710
+ commitmentText: str2(row.commitmentText),
711
+ normsText: str2(row.normsText),
690
712
  emailTemplates: templates
691
713
  };
692
714
  }
@@ -834,6 +856,126 @@ async function projectApplicant(deps, applicant) {
834
856
  }
835
857
  }
836
858
 
859
+ // src/crm-sync.ts
860
+ import { createRecord as createRecord2, updateRecord as updateRecord2, setStage, linkIdentity } from "@odla-ai/crm";
861
+ var str = (v) => typeof v === "string" ? v : v == null ? "" : String(v);
862
+ function personInputFromApp(chapter, app) {
863
+ const input = sharedPersonInput({
864
+ email: str(app.email),
865
+ firstName: str(app.firstName) || void 0,
866
+ lastName: str(app.lastName) || void 0,
867
+ phone: str(app.phone) || void 0,
868
+ linkedin: str(app.linkedin) || void 0,
869
+ hubRecordId: str(app.id)
870
+ });
871
+ for (const f of chapter.application.crmFields) {
872
+ if (app[f] !== void 0) input[f] = app[f];
873
+ }
874
+ if (app.id !== void 0) input.applicationId = str(app.id);
875
+ return input;
876
+ }
877
+ function billingColumns(app) {
878
+ const status = str(app.status);
879
+ const paid = Boolean(app.stripeSubscriptionId) && status !== "refunded";
880
+ const billingStatus = status === "refunded" ? "refunded" : app.canceled === true ? "canceled" : paid ? "active" : "none";
881
+ const cols = { billingStatus };
882
+ if (app.stripeCustomerId) cols.stripeCustomerId = str(app.stripeCustomerId);
883
+ if (app.stripeSubscriptionId) cols.subscriptionId = str(app.stripeSubscriptionId);
884
+ if (typeof app.renewalAt === "number") cols.renewalAt = app.renewalAt;
885
+ return cols;
886
+ }
887
+ async function syncApplicationToCrm(deps, opts) {
888
+ const emailKey = str(opts.app.email).toLowerCase();
889
+ if (!emailKey) return null;
890
+ const crmDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
891
+ const input = personInputFromApp(deps.chapter, opts.app);
892
+ const { crm_record } = await deps.db.query({
893
+ crm_record: { $: { where: { type: "person", primaryEmail: emailKey }, limit: 1 } }
894
+ });
895
+ const existing = crm_record?.[0] ?? null;
896
+ const stage = opts.stage || void 0;
897
+ let recordId;
898
+ if (existing && typeof existing.id === "string") {
899
+ recordId = existing.id;
900
+ await updateRecord2(crmDeps, { id: recordId, input });
901
+ } else {
902
+ const created = await createRecord2(crmDeps, { type: "person", input, ...stage ? { stage } : {} });
903
+ recordId = created.id;
904
+ }
905
+ if (existing && stage && existing.stage !== stage) {
906
+ await setStage(crmDeps, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${stage}` }).catch(() => void 0);
907
+ }
908
+ await deps.db.transact([{ t: "update", ns: "crm_record", id: recordId, attrs: billingColumns(opts.app) }]);
909
+ await linkIdentity(crmDeps, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
910
+ return recordId;
911
+ }
912
+ async function backfillCrm(deps) {
913
+ const [appsRes, usersRes] = await Promise.all([
914
+ deps.db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 1e3 } } }),
915
+ deps.db.query({ $users: { $: { limit: 1e3 } } })
916
+ ]);
917
+ const seen = /* @__PURE__ */ new Set();
918
+ let synced = 0;
919
+ const errors = [];
920
+ const run = async (app, stage) => {
921
+ const key = str(app.email).toLowerCase();
922
+ if (!key || seen.has(key)) return;
923
+ seen.add(key);
924
+ try {
925
+ await syncApplicationToCrm(deps, { app, stage });
926
+ synced += 1;
927
+ } catch (err) {
928
+ errors.push({ email: key, error: err instanceof Error ? err.message : String(err) });
929
+ }
930
+ };
931
+ for (const a of appsRes.applications ?? []) await run(a, str(a.status));
932
+ for (const u of usersRes.$users ?? []) {
933
+ if (u.deleted === true) continue;
934
+ await run({ email: u.email, firstName: str(u.name) });
935
+ }
936
+ return { synced, errors };
937
+ }
938
+
939
+ // src/series.ts
940
+ function bucketSeries(points, now, weeks = 12) {
941
+ const WEEK = 7 * 864e5;
942
+ const end = now;
943
+ const start = end - weeks * WEEK;
944
+ const buckets = Array.from({ length: weeks }, (_, i) => ({ weekStart: start + i * WEEK, value: 0 }));
945
+ for (const p of points) {
946
+ if (!Number.isFinite(p.t) || p.t < start || p.t > end) continue;
947
+ const idx = Math.min(weeks - 1, Math.floor((p.t - start) / WEEK));
948
+ const bucket = buckets[idx];
949
+ if (bucket) bucket.value += Number.isFinite(p.v) ? p.v : 0;
950
+ }
951
+ return buckets;
952
+ }
953
+ function subAnnualCents(sub) {
954
+ const items = sub.items?.data ?? [];
955
+ let cents = 0;
956
+ for (const it of items) {
957
+ const price = it.price ?? {};
958
+ const per = (price.unit_amount ?? 0) * (it.quantity ?? 1);
959
+ cents += price.recurring?.interval === "month" ? per * 12 : per;
960
+ }
961
+ return cents;
962
+ }
963
+
964
+ // src/payments-stripe.ts
965
+ async function stripeCall(sk, method, path, params, idempotencyKey) {
966
+ const qs = method === "GET" && params ? `?${stripeForm(params)}` : "";
967
+ const headers = { authorization: `Bearer ${sk}` };
968
+ if (idempotencyKey) headers["idempotency-key"] = idempotencyKey;
969
+ const init = { method, headers };
970
+ if (method === "POST" && params) {
971
+ headers["content-type"] = "application/x-www-form-urlencoded";
972
+ init.body = stripeForm(params);
973
+ }
974
+ const res = await fetch(`https://api.stripe.com${path}${qs}`, init);
975
+ const body = await res.json().catch(() => ({}));
976
+ return { ok: res.ok, status: res.status, body };
977
+ }
978
+
837
979
  // src/clerk.ts
838
980
  var heal = (status) => status === 422 ? { ok: true, status, existed: true } : { ok: false, status };
839
981
  function clerkInviteRequest(input) {
@@ -1216,8 +1358,10 @@ export {
1216
1358
  applicantProfile,
1217
1359
  applicationBookingUpdate,
1218
1360
  applicationSummary,
1361
+ backfillCrm,
1219
1362
  bookingDecision,
1220
1363
  brandTokens,
1364
+ bucketSeries,
1221
1365
  buildGroupSeed,
1222
1366
  canApprove,
1223
1367
  canBook,
@@ -1257,6 +1401,7 @@ export {
1257
1401
  memberSession,
1258
1402
  normalizeWebhookEvent,
1259
1403
  paymentsReady,
1404
+ personInputFromApp,
1260
1405
  planDelivery,
1261
1406
  projectApplicant,
1262
1407
  projectSharedRecord,
@@ -1275,9 +1420,12 @@ export {
1275
1420
  sharedPersonInput,
1276
1421
  slotWindow,
1277
1422
  stageIndex,
1423
+ stripeCall,
1278
1424
  stripeForm,
1425
+ subAnnualCents,
1279
1426
  submitApplication,
1280
1427
  subscriptionIdempotencyKey,
1428
+ syncApplicationToCrm,
1281
1429
  validateScheduling,
1282
1430
  verifyStripeSignature,
1283
1431
  webhookMutationId