@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/README.md +63 -28
- package/dist/index.cjs +169 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +136 -8
- package/dist/index.d.ts +136 -8
- package/dist/index.js +169 -14
- package/dist/index.js.map +1 -1
- package/dist/ui/index.d.ts +54 -3
- package/dist/ui/index.js +188 -93
- package/dist/ui/index.js.map +1 -1
- package/dist/worker/index.cjs +344 -17
- package/dist/worker/index.cjs.map +1 -1
- package/dist/worker/index.d.cts +60 -6
- package/dist/worker/index.d.ts +60 -6
- package/dist/worker/index.js +344 -17
- package/dist/worker/index.js.map +1 -1
- package/package.json +1 -1
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)
|
|
623
|
-
*
|
|
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,
|
|
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)
|
|
623
|
-
*
|
|
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,
|
|
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
|
|
669
|
-
const email =
|
|
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:
|
|
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,
|