@odla-ai/chapter 0.5.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 {
@@ -706,6 +790,45 @@ declare function memberSession(user: SessionUser, opts: {
706
790
  */
707
791
  declare function brandTokens(brand: ChapterBrand | undefined): string;
708
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
+
709
832
  /** A fully-resolved scheduling config (every field present). */
710
833
  interface ResolvedScheduling {
711
834
  slotMinutes: number;
@@ -729,9 +852,6 @@ declare const SCHEDULING_DEFAULTS: ResolvedScheduling;
729
852
  */
730
853
  declare function resolveScheduling(config?: ChapterScheduling): ResolvedScheduling;
731
854
  /** Statuses a member may book/reschedule from (early pipeline only). */
732
- declare const BOOKABLE_STATUSES: readonly string[];
733
- /** Whether an application at `status` may book a call. */
734
- declare function canBookFrom(status: string): boolean;
735
855
  /** The availability window: `[now, now + windowDays]` in epoch ms. */
736
856
  declare function slotWindow(now: number, windowDays: number): {
737
857
  from: number;
@@ -814,4 +934,4 @@ type ApplicationBookingPatch = {
814
934
  * already there (never backward). */
815
935
  declare function applicationBookingUpdate(currentStatus: string, startAt: number, htmlLink?: string | null): ApplicationBookingPatch;
816
936
 
817
- 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, brandTokens, 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 {
@@ -706,6 +790,45 @@ declare function memberSession(user: SessionUser, opts: {
706
790
  */
707
791
  declare function brandTokens(brand: ChapterBrand | undefined): string;
708
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
+
709
832
  /** A fully-resolved scheduling config (every field present). */
710
833
  interface ResolvedScheduling {
711
834
  slotMinutes: number;
@@ -729,9 +852,6 @@ declare const SCHEDULING_DEFAULTS: ResolvedScheduling;
729
852
  */
730
853
  declare function resolveScheduling(config?: ChapterScheduling): ResolvedScheduling;
731
854
  /** Statuses a member may book/reschedule from (early pipeline only). */
732
- declare const BOOKABLE_STATUSES: readonly string[];
733
- /** Whether an application at `status` may book a call. */
734
- declare function canBookFrom(status: string): boolean;
735
855
  /** The availability window: `[now, now + windowDays]` in epoch ms. */
736
856
  declare function slotWindow(now: number, windowDays: number): {
737
857
  from: number;
@@ -814,4 +934,4 @@ type ApplicationBookingPatch = {
814
934
  * already there (never backward). */
815
935
  declare function applicationBookingUpdate(currentStatus: string, startAt: number, htmlLink?: string | null): ApplicationBookingPatch;
816
936
 
817
- 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, brandTokens, 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) {
@@ -745,6 +843,38 @@ function brandTokens(brand) {
745
843
  ` : "";
746
844
  }
747
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
+
748
878
  // src/scheduling.ts
749
879
  var SCHEDULING_DEFAULTS = {
750
880
  slotMinutes: 45,
@@ -791,10 +921,6 @@ function resolveScheduling(config) {
791
921
  if (typeof c.summaryTemplate !== "string") fail("summaryTemplate must be a string");
792
922
  return { ...c, days };
793
923
  }
794
- var BOOKABLE_STATUSES = ["submitted", "paid_pending_vetting", "call_scheduled"];
795
- function canBookFrom(status) {
796
- return BOOKABLE_STATUSES.includes(status);
797
- }
798
924
  function slotWindow(now, windowDays) {
799
925
  return { from: now, to: now + windowDays * 864e5 };
800
926
  }
@@ -841,7 +967,6 @@ function applicationBookingUpdate(currentStatus, startAt, htmlLink) {
841
967
  };
842
968
  }
843
969
  export {
844
- BOOKABLE_STATUSES,
845
970
  SCHEDULING_DEFAULTS,
846
971
  applicationBookingUpdate,
847
972
  applicationSummary,
@@ -850,14 +975,16 @@ export {
850
975
  buildGroupSeed,
851
976
  canApprove,
852
977
  canBook,
853
- canBookFrom,
854
978
  canChangeRole,
855
979
  canTransition,
856
980
  canceledPatch,
857
981
  chapterDb,
982
+ clerkInviteRequest,
858
983
  createChapterIntegration,
984
+ createClerkInvitation,
859
985
  defaultCrm,
860
986
  defineChapter,
987
+ emailGroupFrom,
861
988
  endForSlot,
862
989
  findApplicationRef,
863
990
  firstPaymentPatch,
@@ -865,6 +992,7 @@ export {
865
992
  introIdempotencyKey,
866
993
  isAdminRole,
867
994
  isAlreadySent,
995
+ isReconcilable,
868
996
  isSlotAvailable,
869
997
  joinConfig,
870
998
  meetingCreateRow,
@@ -874,7 +1002,9 @@ export {
874
1002
  normalizeWebhookEvent,
875
1003
  paymentsReady,
876
1004
  planDelivery,
1005
+ projectApplicant,
877
1006
  projectSharedRecord,
1007
+ reconcileMeetings,
878
1008
  refundedPatch,
879
1009
  render,
880
1010
  renderSummary,
@@ -885,6 +1015,7 @@ export {
885
1015
  resolvePipeline,
886
1016
  resolveScheduling,
887
1017
  roleFromClaim,
1018
+ sendTemplated,
888
1019
  sharedPersonInput,
889
1020
  slotWindow,
890
1021
  stageIndex,