@odla-ai/chapter 0.4.0 → 0.7.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
@@ -433,6 +433,54 @@ declare function planDelivery(input: {
433
433
  force?: boolean;
434
434
  }): DeliveryDecision;
435
435
 
436
+ /** The mail transport (the worker's Cloudflare SEND_EMAIL binding). */
437
+ interface MailSender {
438
+ send(payload: {
439
+ from: string;
440
+ to: string[];
441
+ subject: string;
442
+ text?: string;
443
+ replyTo?: string;
444
+ }): Promise<{
445
+ messageId: string;
446
+ }>;
447
+ }
448
+ /** Deps for {@link sendTemplated} — the db, the env name (drives the fail-safe),
449
+ * the transport + from (absent ⇒ log-only), and injected clock/id. */
450
+ interface NotifyDeps {
451
+ db: ChapterDb;
452
+ envName: string;
453
+ sender?: MailSender;
454
+ from?: string;
455
+ now: () => number;
456
+ newId: () => string;
457
+ }
458
+ /** One templated send. `dedupeKey` is the exactly-once key. */
459
+ interface NotifyInput {
460
+ group: EmailGroup;
461
+ template: string;
462
+ to: string;
463
+ vars: Record<string, string>;
464
+ dedupeKey: string;
465
+ applicationId?: string;
466
+ /** The admin test route may send a disabled template. */
467
+ force?: boolean;
468
+ }
469
+ /** The outcome. `sent:true` includes the already-sent short-circuit. */
470
+ interface NotifyResult {
471
+ sent: boolean;
472
+ reason?: string;
473
+ }
474
+ /**
475
+ * Send a templated lifecycle email exactly once. Short-circuits on a prior
476
+ * successful send; otherwise plans delivery (dev fail-safe applies), sends via the
477
+ * transport when one is wired and the plan calls for it, and records an emailLog
478
+ * row either way (success keyed for exactly-once, failure unkeyed for retry).
479
+ */
480
+ declare function sendTemplated(deps: NotifyDeps, input: NotifyInput): Promise<NotifyResult>;
481
+ /** Project a `groups` row into the {@link EmailGroup} the email pipeline reads. */
482
+ declare function emailGroupFrom(row: Record<string, unknown>): EmailGroup;
483
+
436
484
  /**
437
485
  * Apply defaults + validate the pipeline config. With no config, the full Silver
438
486
  * & Salt pipeline. With `stages` given but the subsets omitted, the subsets
@@ -619,14 +667,50 @@ interface ProjectionDeps {
619
667
  }
620
668
  /**
621
669
  * Upsert a hub-shared prospect into this chapter's `crm_record` (push
622
- * projection). Resolves an existing person by lowercased `primaryEmail` and
623
- * updates it, else creates one with a `share:${hubRecordId}` mutationId. Returns
624
- * the chapter-side record id. Callers wrap this in `.catch` so a projection
625
- * failure never fails the hub's share request.
670
+ * projection), idempotent by the hub record id and unified by email. Callers wrap
671
+ * this in `.catch` so a projection failure never fails the hub's share request.
626
672
  */
627
673
  declare function projectSharedRecord(deps: ProjectionDeps, person: SharedPerson): Promise<{
628
674
  recordId: string;
629
675
  }>;
676
+ /** An arriving applicant, as far as the CRM projection cares. */
677
+ interface Applicant {
678
+ applicationId: string;
679
+ email: string;
680
+ firstName?: string;
681
+ lastName?: string;
682
+ phone?: string;
683
+ linkedin?: string;
684
+ }
685
+ /**
686
+ * Project an arriving applicant into this chapter's `crm_record`, so a new
687
+ * application shows up in the CRM immediately, unified by email with any prior
688
+ * record. Idempotent per application (`apply:${applicationId}`). Best-effort at
689
+ * the call site — a projection failure never fails the application.
690
+ */
691
+ declare function projectApplicant(deps: ProjectionDeps, applicant: Applicant): Promise<{
692
+ recordId: string;
693
+ }>;
694
+
695
+ /** Inputs for a Clerk invitation. */
696
+ interface ClerkInviteInput {
697
+ email: string;
698
+ /** Where the accept-invitation link lands (usually the member area). */
699
+ redirectUrl?: string;
700
+ }
701
+ /** Build the Clerk Backend API invitation request (path + JSON body). Pure, so
702
+ * the wire shape is testable without a network call. */
703
+ declare function clerkInviteRequest(input: ClerkInviteInput): {
704
+ path: string;
705
+ body: Record<string, unknown>;
706
+ };
707
+ /** POST the invitation to the Clerk Backend API. `ignore_existing` isn't set, so
708
+ * a repeat invite for an already-invited/known email returns non-ok — the caller
709
+ * swallows that (idempotent-enough for a best-effort apply-time invite). */
710
+ declare function createClerkInvitation(secretKey: string, input: ClerkInviteInput, fetchImpl?: typeof fetch): Promise<{
711
+ ok: boolean;
712
+ status: number;
713
+ }>;
630
714
 
631
715
  /** An application row, as far as the session cares about it. */
632
716
  interface ApplicationRecord {
@@ -698,6 +782,53 @@ declare function memberSession(user: SessionUser, opts: {
698
782
  superAdmin: boolean;
699
783
  }): MemberSession;
700
784
 
785
+ /**
786
+ * Build the `:root { … }` CSS that maps a chapter's brand onto the design tokens
787
+ * the UI reads: each `palette` entry becomes a custom property, and `fonts`
788
+ * (display/body/numeral) map to `--ui-font-display` / `--ui-font-sans` /
789
+ * `--ui-font-numeral`. Returns "" when there is nothing to theme.
790
+ */
791
+ declare function brandTokens(brand: ChapterBrand | undefined): string;
792
+
793
+ /** A `meetings` row, as far as reconciliation cares. */
794
+ interface MeetingForReconcile {
795
+ id: string;
796
+ applicationId: string;
797
+ googleEventId?: string | null;
798
+ status: string;
799
+ startAt?: number | null;
800
+ endAt?: number | null;
801
+ }
802
+ /** One live calendar event (a subset of @odla-ai/calendar's Booking). */
803
+ interface LiveEvent {
804
+ eventId: string;
805
+ status?: string;
806
+ startAt?: number;
807
+ endAt?: number;
808
+ }
809
+ /** An adopt decision: mirror a Google move/cancel onto our rows. */
810
+ interface ReconcileDecision {
811
+ meetingId: string;
812
+ applicationId: string;
813
+ kind: "cancelled" | "moved";
814
+ /** Attrs to write onto the `meetings` row. */
815
+ meetingPatch: Record<string, unknown>;
816
+ /** Attrs to write onto the `applications` row (the projection). */
817
+ applicationPatch: Record<string, unknown>;
818
+ }
819
+ /** Meetings still worth reconciling: a scheduled booking with a Google event that
820
+ * starts in the future (or within the last hour, to catch a just-passed edit). */
821
+ declare function isReconcilable(meeting: MeetingForReconcile, now: number): boolean;
822
+ /**
823
+ * Diff canonical `meetings` against the live calendar events and return the adopt
824
+ * decisions — only for meetings that actually changed (a still-matching meeting
825
+ * is omitted). A meeting whose event vanished or is `cancelled` in Google is
826
+ * adopted as cancelled (application `meetingAt` zeroed — 0 means "was booked,
827
+ * then cancelled"); a meeting whose event moved adopts the new window (duration
828
+ * preserved when the event omits `endAt`).
829
+ */
830
+ declare function reconcileMeetings(meetings: readonly MeetingForReconcile[], events: readonly LiveEvent[], now: number): ReconcileDecision[];
831
+
701
832
  /** A fully-resolved scheduling config (every field present). */
702
833
  interface ResolvedScheduling {
703
834
  slotMinutes: number;
@@ -721,9 +852,6 @@ declare const SCHEDULING_DEFAULTS: ResolvedScheduling;
721
852
  */
722
853
  declare function resolveScheduling(config?: ChapterScheduling): ResolvedScheduling;
723
854
  /** Statuses a member may book/reschedule from (early pipeline only). */
724
- declare const BOOKABLE_STATUSES: readonly string[];
725
- /** Whether an application at `status` may book a call. */
726
- declare function canBookFrom(status: string): boolean;
727
855
  /** The availability window: `[now, now + windowDays]` in epoch ms. */
728
856
  declare function slotWindow(now: number, windowDays: number): {
729
857
  from: number;
@@ -806,4 +934,4 @@ type ApplicationBookingPatch = {
806
934
  * already there (never backward). */
807
935
  declare function applicationBookingUpdate(currentStatus: string, startAt: number, htmlLink?: string | null): ApplicationBookingPatch;
808
936
 
809
- export { type ApplicationBookingPatch, type ApplicationRecord, type ApplicationSummary, type Attr, type AttrType, BOOKABLE_STATUSES, 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 DbOp, type DbRules, type DbSchema, type DeliveryDecision, type EmailGroup, type EmailTemplate, type EmailTemplateRow, type Entity, type ExistingMeeting, type GuardResult, type JoinConfigGroup, type MeetingRecord, type MeetingReschedulePatch, type MemberApplication, type MemberSession, type NewMeetingRow, type PaymentsGroup, type ProjectionDeps, 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, buildGroupSeed, canApprove, canBook, canBookFrom, canChangeRole, canTransition, canceledPatch, chapterDb, createChapterIntegration, defaultCrm, defineChapter, endForSlot, findApplicationRef, firstPaymentPatch, getVaultSecret, introIdempotencyKey, isAdminRole, isAlreadySent, isSlotAvailable, joinConfig, meetingCreateRow, meetingRescheduleUpdate, memberApplication, memberSession, normalizeWebhookEvent, paymentsReady, planDelivery, projectSharedRecord, refundedPatch, render, renderSummary, renderTemplateBody, renewalPatch, resolveApplication, resolveAuth, resolvePipeline, resolveScheduling, roleFromClaim, sharedPersonInput, slotWindow, stageIndex, stripeForm, submitApplication, subscriptionIdempotencyKey, verifyStripeSignature, webhookMutationId };
937
+ export { 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 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, createChapterIntegration, createClerkInvitation, 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 };
package/dist/index.d.ts CHANGED
@@ -433,6 +433,54 @@ declare function planDelivery(input: {
433
433
  force?: boolean;
434
434
  }): DeliveryDecision;
435
435
 
436
+ /** The mail transport (the worker's Cloudflare SEND_EMAIL binding). */
437
+ interface MailSender {
438
+ send(payload: {
439
+ from: string;
440
+ to: string[];
441
+ subject: string;
442
+ text?: string;
443
+ replyTo?: string;
444
+ }): Promise<{
445
+ messageId: string;
446
+ }>;
447
+ }
448
+ /** Deps for {@link sendTemplated} — the db, the env name (drives the fail-safe),
449
+ * the transport + from (absent ⇒ log-only), and injected clock/id. */
450
+ interface NotifyDeps {
451
+ db: ChapterDb;
452
+ envName: string;
453
+ sender?: MailSender;
454
+ from?: string;
455
+ now: () => number;
456
+ newId: () => string;
457
+ }
458
+ /** One templated send. `dedupeKey` is the exactly-once key. */
459
+ interface NotifyInput {
460
+ group: EmailGroup;
461
+ template: string;
462
+ to: string;
463
+ vars: Record<string, string>;
464
+ dedupeKey: string;
465
+ applicationId?: string;
466
+ /** The admin test route may send a disabled template. */
467
+ force?: boolean;
468
+ }
469
+ /** The outcome. `sent:true` includes the already-sent short-circuit. */
470
+ interface NotifyResult {
471
+ sent: boolean;
472
+ reason?: string;
473
+ }
474
+ /**
475
+ * Send a templated lifecycle email exactly once. Short-circuits on a prior
476
+ * successful send; otherwise plans delivery (dev fail-safe applies), sends via the
477
+ * transport when one is wired and the plan calls for it, and records an emailLog
478
+ * row either way (success keyed for exactly-once, failure unkeyed for retry).
479
+ */
480
+ declare function sendTemplated(deps: NotifyDeps, input: NotifyInput): Promise<NotifyResult>;
481
+ /** Project a `groups` row into the {@link EmailGroup} the email pipeline reads. */
482
+ declare function emailGroupFrom(row: Record<string, unknown>): EmailGroup;
483
+
436
484
  /**
437
485
  * Apply defaults + validate the pipeline config. With no config, the full Silver
438
486
  * & Salt pipeline. With `stages` given but the subsets omitted, the subsets
@@ -619,14 +667,50 @@ interface ProjectionDeps {
619
667
  }
620
668
  /**
621
669
  * Upsert a hub-shared prospect into this chapter's `crm_record` (push
622
- * projection). Resolves an existing person by lowercased `primaryEmail` and
623
- * updates it, else creates one with a `share:${hubRecordId}` mutationId. Returns
624
- * the chapter-side record id. Callers wrap this in `.catch` so a projection
625
- * failure never fails the hub's share request.
670
+ * projection), idempotent by the hub record id and unified by email. Callers wrap
671
+ * this in `.catch` so a projection failure never fails the hub's share request.
626
672
  */
627
673
  declare function projectSharedRecord(deps: ProjectionDeps, person: SharedPerson): Promise<{
628
674
  recordId: string;
629
675
  }>;
676
+ /** An arriving applicant, as far as the CRM projection cares. */
677
+ interface Applicant {
678
+ applicationId: string;
679
+ email: string;
680
+ firstName?: string;
681
+ lastName?: string;
682
+ phone?: string;
683
+ linkedin?: string;
684
+ }
685
+ /**
686
+ * Project an arriving applicant into this chapter's `crm_record`, so a new
687
+ * application shows up in the CRM immediately, unified by email with any prior
688
+ * record. Idempotent per application (`apply:${applicationId}`). Best-effort at
689
+ * the call site — a projection failure never fails the application.
690
+ */
691
+ declare function projectApplicant(deps: ProjectionDeps, applicant: Applicant): Promise<{
692
+ recordId: string;
693
+ }>;
694
+
695
+ /** Inputs for a Clerk invitation. */
696
+ interface ClerkInviteInput {
697
+ email: string;
698
+ /** Where the accept-invitation link lands (usually the member area). */
699
+ redirectUrl?: string;
700
+ }
701
+ /** Build the Clerk Backend API invitation request (path + JSON body). Pure, so
702
+ * the wire shape is testable without a network call. */
703
+ declare function clerkInviteRequest(input: ClerkInviteInput): {
704
+ path: string;
705
+ body: Record<string, unknown>;
706
+ };
707
+ /** POST the invitation to the Clerk Backend API. `ignore_existing` isn't set, so
708
+ * a repeat invite for an already-invited/known email returns non-ok — the caller
709
+ * swallows that (idempotent-enough for a best-effort apply-time invite). */
710
+ declare function createClerkInvitation(secretKey: string, input: ClerkInviteInput, fetchImpl?: typeof fetch): Promise<{
711
+ ok: boolean;
712
+ status: number;
713
+ }>;
630
714
 
631
715
  /** An application row, as far as the session cares about it. */
632
716
  interface ApplicationRecord {
@@ -698,6 +782,53 @@ declare function memberSession(user: SessionUser, opts: {
698
782
  superAdmin: boolean;
699
783
  }): MemberSession;
700
784
 
785
+ /**
786
+ * Build the `:root { … }` CSS that maps a chapter's brand onto the design tokens
787
+ * the UI reads: each `palette` entry becomes a custom property, and `fonts`
788
+ * (display/body/numeral) map to `--ui-font-display` / `--ui-font-sans` /
789
+ * `--ui-font-numeral`. Returns "" when there is nothing to theme.
790
+ */
791
+ declare function brandTokens(brand: ChapterBrand | undefined): string;
792
+
793
+ /** A `meetings` row, as far as reconciliation cares. */
794
+ interface MeetingForReconcile {
795
+ id: string;
796
+ applicationId: string;
797
+ googleEventId?: string | null;
798
+ status: string;
799
+ startAt?: number | null;
800
+ endAt?: number | null;
801
+ }
802
+ /** One live calendar event (a subset of @odla-ai/calendar's Booking). */
803
+ interface LiveEvent {
804
+ eventId: string;
805
+ status?: string;
806
+ startAt?: number;
807
+ endAt?: number;
808
+ }
809
+ /** An adopt decision: mirror a Google move/cancel onto our rows. */
810
+ interface ReconcileDecision {
811
+ meetingId: string;
812
+ applicationId: string;
813
+ kind: "cancelled" | "moved";
814
+ /** Attrs to write onto the `meetings` row. */
815
+ meetingPatch: Record<string, unknown>;
816
+ /** Attrs to write onto the `applications` row (the projection). */
817
+ applicationPatch: Record<string, unknown>;
818
+ }
819
+ /** Meetings still worth reconciling: a scheduled booking with a Google event that
820
+ * starts in the future (or within the last hour, to catch a just-passed edit). */
821
+ declare function isReconcilable(meeting: MeetingForReconcile, now: number): boolean;
822
+ /**
823
+ * Diff canonical `meetings` against the live calendar events and return the adopt
824
+ * decisions — only for meetings that actually changed (a still-matching meeting
825
+ * is omitted). A meeting whose event vanished or is `cancelled` in Google is
826
+ * adopted as cancelled (application `meetingAt` zeroed — 0 means "was booked,
827
+ * then cancelled"); a meeting whose event moved adopts the new window (duration
828
+ * preserved when the event omits `endAt`).
829
+ */
830
+ declare function reconcileMeetings(meetings: readonly MeetingForReconcile[], events: readonly LiveEvent[], now: number): ReconcileDecision[];
831
+
701
832
  /** A fully-resolved scheduling config (every field present). */
702
833
  interface ResolvedScheduling {
703
834
  slotMinutes: number;
@@ -721,9 +852,6 @@ declare const SCHEDULING_DEFAULTS: ResolvedScheduling;
721
852
  */
722
853
  declare function resolveScheduling(config?: ChapterScheduling): ResolvedScheduling;
723
854
  /** Statuses a member may book/reschedule from (early pipeline only). */
724
- declare const BOOKABLE_STATUSES: readonly string[];
725
- /** Whether an application at `status` may book a call. */
726
- declare function canBookFrom(status: string): boolean;
727
855
  /** The availability window: `[now, now + windowDays]` in epoch ms. */
728
856
  declare function slotWindow(now: number, windowDays: number): {
729
857
  from: number;
@@ -806,4 +934,4 @@ type ApplicationBookingPatch = {
806
934
  * already there (never backward). */
807
935
  declare function applicationBookingUpdate(currentStatus: string, startAt: number, htmlLink?: string | null): ApplicationBookingPatch;
808
936
 
809
- export { type ApplicationBookingPatch, type ApplicationRecord, type ApplicationSummary, type Attr, type AttrType, BOOKABLE_STATUSES, 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 DbOp, type DbRules, type DbSchema, type DeliveryDecision, type EmailGroup, type EmailTemplate, type EmailTemplateRow, type Entity, type ExistingMeeting, type GuardResult, type JoinConfigGroup, type MeetingRecord, type MeetingReschedulePatch, type MemberApplication, type MemberSession, type NewMeetingRow, type PaymentsGroup, type ProjectionDeps, 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, buildGroupSeed, canApprove, canBook, canBookFrom, canChangeRole, canTransition, canceledPatch, chapterDb, createChapterIntegration, defaultCrm, defineChapter, endForSlot, findApplicationRef, firstPaymentPatch, getVaultSecret, introIdempotencyKey, isAdminRole, isAlreadySent, isSlotAvailable, joinConfig, meetingCreateRow, meetingRescheduleUpdate, memberApplication, memberSession, normalizeWebhookEvent, paymentsReady, planDelivery, projectSharedRecord, refundedPatch, render, renderSummary, renderTemplateBody, renewalPatch, resolveApplication, resolveAuth, resolvePipeline, resolveScheduling, roleFromClaim, sharedPersonInput, slotWindow, stageIndex, stripeForm, submitApplication, subscriptionIdempotencyKey, verifyStripeSignature, webhookMutationId };
937
+ export { 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 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, createChapterIntegration, createClerkInvitation, 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 };
package/dist/index.js CHANGED
@@ -556,6 +556,72 @@ function planDelivery(input) {
556
556
  return { deliver: true, transport, to, subject, text, redirected: redirect };
557
557
  }
558
558
 
559
+ // src/notify.ts
560
+ async function sendTemplated(deps, input) {
561
+ const { emailLog: emailLog2 } = await deps.db.query({ emailLog: { $: { where: { dedupeKey: input.dedupeKey } } } });
562
+ const prior = Array.isArray(emailLog2) ? emailLog2 : [];
563
+ if (isAlreadySent(prior)) return { sent: true, reason: "already-sent" };
564
+ const cloudflareReady = Boolean(deps.sender && deps.from);
565
+ const decision = planDelivery({
566
+ envName: deps.envName,
567
+ group: input.group,
568
+ template: input.template,
569
+ to: input.to,
570
+ vars: input.vars,
571
+ cloudflareReady,
572
+ force: input.force
573
+ });
574
+ if (!decision.deliver) return { sent: false, reason: decision.reason };
575
+ let error;
576
+ let messageId;
577
+ if (decision.transport === "cloudflare" && deps.sender && deps.from) {
578
+ try {
579
+ const res = await deps.sender.send({
580
+ from: deps.from,
581
+ to: [decision.to],
582
+ subject: decision.subject,
583
+ text: decision.text,
584
+ replyTo: input.group.replyTo
585
+ });
586
+ messageId = res.messageId;
587
+ } catch (e) {
588
+ error = e instanceof Error ? e.message : String(e);
589
+ }
590
+ }
591
+ const id2 = deps.newId();
592
+ const row = {
593
+ id: id2,
594
+ groupId: input.group.id,
595
+ to: decision.to,
596
+ template: input.template,
597
+ subject: decision.subject,
598
+ body: decision.text,
599
+ transport: decision.transport,
600
+ redirected: decision.redirected,
601
+ dedupeKey: input.dedupeKey,
602
+ sentAt: deps.now(),
603
+ ...input.applicationId ? { applicationId: input.applicationId } : {},
604
+ ...messageId ? { messageId } : {},
605
+ ...error ? { error } : {}
606
+ };
607
+ await deps.db.transact([{ t: "update", ns: "emailLog", id: id2, attrs: row }], error ? void 0 : { mutationId: `email:${input.dedupeKey}` });
608
+ return error ? { sent: false, reason: error } : { sent: true };
609
+ }
610
+ function emailGroupFrom(row) {
611
+ const str = (v) => typeof v === "string" ? v : void 0;
612
+ const templates = row.emailTemplates && typeof row.emailTemplates === "object" ? row.emailTemplates : {};
613
+ return {
614
+ id: String(row.id),
615
+ name: String(row.name ?? ""),
616
+ replyTo: str(row.replyTo) ?? "",
617
+ debugEmail: str(row.debugEmail),
618
+ refundPolicyText: str(row.refundPolicyText),
619
+ commitmentText: str(row.commitmentText),
620
+ normsText: str(row.normsText),
621
+ emailTemplates: templates
622
+ };
623
+ }
624
+
559
625
  // src/payments.ts
560
626
  function parseSigHeader(header) {
561
627
  const parts = {};
@@ -665,21 +731,53 @@ function sharedPersonInput(person) {
665
731
  if (person.linkedin) input.linkedin = person.linkedin;
666
732
  return input;
667
733
  }
668
- async function projectSharedRecord(deps, person) {
669
- const email = person.email.toLowerCase();
670
- const input = sharedPersonInput(person);
734
+ async function upsertPerson(deps, opts) {
735
+ const email = opts.email.toLowerCase();
671
736
  const crmDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
672
- const { crm_record } = await deps.db.query({
673
- crm_record: { $: { where: { type: "person", primaryEmail: email }, limit: 1 } }
674
- });
737
+ const { crm_record } = await deps.db.query({ crm_record: { $: { where: { type: "person", primaryEmail: email }, limit: 1 } } });
675
738
  const existing = crm_record?.[0];
676
739
  if (existing && typeof existing.id === "string") {
677
- await updateRecord(crmDeps, { id: existing.id, input });
740
+ await updateRecord(crmDeps, { id: existing.id, input: opts.input });
678
741
  return { recordId: existing.id };
679
742
  }
680
- const created = await createRecord(crmDeps, { type: "person", input, mutationId: `share:${person.hubRecordId}` });
743
+ const created = await createRecord(crmDeps, { type: "person", input: opts.input, mutationId: opts.mutationId });
681
744
  return { recordId: created.id };
682
745
  }
746
+ async function projectSharedRecord(deps, person) {
747
+ return upsertPerson(deps, { email: person.email, input: sharedPersonInput(person), mutationId: `share:${person.hubRecordId}` });
748
+ }
749
+ async function projectApplicant(deps, applicant) {
750
+ const input = sharedPersonInput({
751
+ email: applicant.email,
752
+ firstName: applicant.firstName,
753
+ lastName: applicant.lastName,
754
+ phone: applicant.phone,
755
+ linkedin: applicant.linkedin,
756
+ hubRecordId: applicant.applicationId
757
+ });
758
+ return upsertPerson(deps, { email: applicant.email, input, mutationId: `apply:${applicant.applicationId}` });
759
+ }
760
+
761
+ // src/clerk.ts
762
+ function clerkInviteRequest(input) {
763
+ return {
764
+ path: "/v1/invitations",
765
+ body: {
766
+ email_address: input.email,
767
+ notify: true,
768
+ ...input.redirectUrl ? { redirect_url: input.redirectUrl } : {}
769
+ }
770
+ };
771
+ }
772
+ async function createClerkInvitation(secretKey, input, fetchImpl = fetch) {
773
+ const { path, body } = clerkInviteRequest(input);
774
+ const res = await fetchImpl(`https://api.clerk.com${path}`, {
775
+ method: "POST",
776
+ headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
777
+ body: JSON.stringify(body)
778
+ });
779
+ return { ok: res.ok, status: res.status };
780
+ }
683
781
 
684
782
  // src/session.ts
685
783
  function applicationSummary(app) {
@@ -722,6 +820,61 @@ function memberSession(user, opts) {
722
820
  };
723
821
  }
724
822
 
823
+ // src/brand.ts
824
+ function paletteVar(key) {
825
+ return key.startsWith("--") ? key : `--${key}`;
826
+ }
827
+ function cleanValue(value) {
828
+ return value.replace(/[<>{};]/g, "").trim();
829
+ }
830
+ function brandTokens(brand) {
831
+ if (!brand) return "";
832
+ const decls = [];
833
+ for (const [key, value] of Object.entries(brand.palette ?? {})) {
834
+ if (typeof value === "string" && value.trim()) decls.push(`${paletteVar(key)}: ${cleanValue(value)};`);
835
+ }
836
+ const fonts = brand.fonts;
837
+ if (fonts?.display) decls.push(`--ui-font-display: ${cleanValue(fonts.display)};`);
838
+ if (fonts?.body) decls.push(`--ui-font-sans: ${cleanValue(fonts.body)};`);
839
+ if (fonts?.numeral) decls.push(`--ui-font-numeral: ${cleanValue(fonts.numeral)};`);
840
+ return decls.length ? `:root {
841
+ ${decls.join("\n ")}
842
+ }
843
+ ` : "";
844
+ }
845
+
846
+ // src/reconcile.ts
847
+ function isReconcilable(meeting, now) {
848
+ return meeting.status === "scheduled" && Boolean(meeting.googleEventId) && (meeting.startAt ?? 0) > now - 36e5;
849
+ }
850
+ function reconcileMeetings(meetings2, events, now) {
851
+ const byEvent = new Map(events.map((e) => [e.eventId, e]));
852
+ const decisions = [];
853
+ for (const m of meetings2) {
854
+ if (!isReconcilable(m, now) || !m.googleEventId) continue;
855
+ const g = byEvent.get(m.googleEventId);
856
+ if (!g || g.status === "cancelled") {
857
+ decisions.push({
858
+ meetingId: m.id,
859
+ applicationId: m.applicationId,
860
+ kind: "cancelled",
861
+ meetingPatch: { status: "cancelled", drift: "none", adoptedFromGoogleAt: now },
862
+ applicationPatch: { meetingAt: 0, meetingLink: "" }
863
+ });
864
+ } else if (g.startAt !== void 0 && g.startAt !== m.startAt) {
865
+ const duration = (m.endAt ?? 0) - (m.startAt ?? 0);
866
+ decisions.push({
867
+ meetingId: m.id,
868
+ applicationId: m.applicationId,
869
+ kind: "moved",
870
+ meetingPatch: { startAt: g.startAt, endAt: g.endAt ?? g.startAt + duration, drift: "none", adoptedFromGoogleAt: now },
871
+ applicationPatch: { meetingAt: g.startAt }
872
+ });
873
+ }
874
+ }
875
+ return decisions;
876
+ }
877
+
725
878
  // src/scheduling.ts
726
879
  var SCHEDULING_DEFAULTS = {
727
880
  slotMinutes: 45,
@@ -768,10 +921,6 @@ function resolveScheduling(config) {
768
921
  if (typeof c.summaryTemplate !== "string") fail("summaryTemplate must be a string");
769
922
  return { ...c, days };
770
923
  }
771
- var BOOKABLE_STATUSES = ["submitted", "paid_pending_vetting", "call_scheduled"];
772
- function canBookFrom(status) {
773
- return BOOKABLE_STATUSES.includes(status);
774
- }
775
924
  function slotWindow(now, windowDays) {
776
925
  return { from: now, to: now + windowDays * 864e5 };
777
926
  }
@@ -818,22 +967,24 @@ function applicationBookingUpdate(currentStatus, startAt, htmlLink) {
818
967
  };
819
968
  }
820
969
  export {
821
- BOOKABLE_STATUSES,
822
970
  SCHEDULING_DEFAULTS,
823
971
  applicationBookingUpdate,
824
972
  applicationSummary,
825
973
  bookingDecision,
974
+ brandTokens,
826
975
  buildGroupSeed,
827
976
  canApprove,
828
977
  canBook,
829
- canBookFrom,
830
978
  canChangeRole,
831
979
  canTransition,
832
980
  canceledPatch,
833
981
  chapterDb,
982
+ clerkInviteRequest,
834
983
  createChapterIntegration,
984
+ createClerkInvitation,
835
985
  defaultCrm,
836
986
  defineChapter,
987
+ emailGroupFrom,
837
988
  endForSlot,
838
989
  findApplicationRef,
839
990
  firstPaymentPatch,
@@ -841,6 +992,7 @@ export {
841
992
  introIdempotencyKey,
842
993
  isAdminRole,
843
994
  isAlreadySent,
995
+ isReconcilable,
844
996
  isSlotAvailable,
845
997
  joinConfig,
846
998
  meetingCreateRow,
@@ -850,7 +1002,9 @@ export {
850
1002
  normalizeWebhookEvent,
851
1003
  paymentsReady,
852
1004
  planDelivery,
1005
+ projectApplicant,
853
1006
  projectSharedRecord,
1007
+ reconcileMeetings,
854
1008
  refundedPatch,
855
1009
  render,
856
1010
  renderSummary,
@@ -861,6 +1015,7 @@ export {
861
1015
  resolvePipeline,
862
1016
  resolveScheduling,
863
1017
  roleFromClaim,
1018
+ sendTemplated,
864
1019
  sharedPersonInput,
865
1020
  slotWindow,
866
1021
  stageIndex,