@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/README.md
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# @odla-ai/chapter
|
|
2
2
|
|
|
3
|
-
A foundation for **membership sites**. One `defineChapter({...})` config
|
|
4
|
-
|
|
5
|
-
member area
|
|
6
|
-
Clerk + [@odla-ai/crm](https://odla.ai/docs/packages/crm) + calendar +
|
|
3
|
+
A foundation for **membership sites**. One `defineChapter({...})` config stands up
|
|
4
|
+
a full public member site — join/apply → Stripe membership → Google booking →
|
|
5
|
+
member area — plus an admin console and a CRM, or an **admin-only hub**, on
|
|
6
|
+
odla-db + Clerk + [@odla-ai/crm](https://odla.ai/docs/packages/crm) + calendar +
|
|
7
|
+
email.
|
|
7
8
|
|
|
8
9
|
```sh
|
|
9
10
|
npm i @odla-ai/chapter
|
|
@@ -12,34 +13,53 @@ npm i @odla-ai/chapter
|
|
|
12
13
|
> **Agentic experiment.** Built and maintained by AI agents from bounded runbooks
|
|
13
14
|
> with human review. Review the documented guarantees before relying on it.
|
|
14
15
|
|
|
15
|
-
> **
|
|
16
|
-
>
|
|
17
|
-
>
|
|
16
|
+
> **Pre-1.0.** The member surface and the operational worker (auth, join, Stripe,
|
|
17
|
+
> booking, email, CRM projection, admin operational routes) ship. The admin *UI*
|
|
18
|
+
> is a growing section catalog: `peopleSection` and the availability editor today,
|
|
19
|
+
> more sections landing per release.
|
|
18
20
|
|
|
19
21
|
## The shape
|
|
20
22
|
|
|
21
23
|
- **One config, two profiles.** `defineChapter()` validates at import and returns
|
|
22
24
|
a resolved engine. `mode: "chapter"` is the full public member site; `mode:
|
|
23
|
-
"hub"` is admin-only and CRM-focused
|
|
24
|
-
|
|
25
|
-
member/join/payment route surface.
|
|
25
|
+
"hub"` is admin-only and CRM-focused. The mode gates the member/join/payment
|
|
26
|
+
route surface; everything else (auth, CRM, chrome, provisioning) is shared.
|
|
26
27
|
- **The worker is the package.** `chapterWorker({ chapter })` is the whole
|
|
27
|
-
Cloudflare `ExportedHandler
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
`
|
|
42
|
-
|
|
28
|
+
Cloudflare `ExportedHandler`. It serves `/api/health`, `/api/config`,
|
|
29
|
+
`/api/me`, `/api/crm/*`, `/api/network/shared`; chapter mode adds the public
|
|
30
|
+
member surface (`/api/join-config`, `/api/applications`,
|
|
31
|
+
`/api/schedule/{slots,book}`, `/api/payments/subscription`,
|
|
32
|
+
`/api/webhooks/stripe`) and the admin operational routes (`/api/admin/*`), then
|
|
33
|
+
falls back to static assets. Your `src/worker.ts` is ~3 lines. Observability is
|
|
34
|
+
a host concern — wrap it with `withObservability` from `@odla-ai/o11y`.
|
|
35
|
+
Everything brand-specific (prices, policy copy, email templates, scheduling
|
|
36
|
+
rules) is read at runtime from a single odla-db `groups` row, never hardcoded.
|
|
37
|
+
- **A route seam, not a black box.** `chapterWorker({ chapter, routes })` runs
|
|
38
|
+
your handlers *before* the built-ins (add routes, or override/alias a path),
|
|
39
|
+
each receiving the same context the built-ins get. The worker entry also exports
|
|
40
|
+
`createWorkerContext` + the `WorkerContext`/`Route` types, so a wrapping worker
|
|
41
|
+
reuses chapter's JWT verify, db client, and role resolution
|
|
42
|
+
(`verifyUser`/`makeDb`/`roleFor`/`isAdmin`) instead of duplicating them.
|
|
43
|
+
- **Source-aware auth.** A JWT role-claim ladder (`provisional → member → admin`)
|
|
44
|
+
*or* an odla-db `admins` allowlist, plus a read-only `superAdmins` tier —
|
|
45
|
+
selected by `auth.source`, defaulting per mode. Escalation guards
|
|
46
|
+
(`canChangeRole`) are package-enforced.
|
|
47
|
+
- **Correctness is packaged, not per-site.** Exactly-once email
|
|
48
|
+
(`sendTemplated`/`isAlreadySent`), a non-prod delivery fail-safe
|
|
49
|
+
(`planDelivery`), status-never-backwards (`canTransition`), Stripe webhook
|
|
50
|
+
integrity (`verifyStripeSignature`) with the webhook as the authoritative writer
|
|
51
|
+
of paid/refunded, one-subscription-per-application idempotency, meetings-as-
|
|
52
|
+
canonical booking (a rebooking *reschedules* the event, preserving the Meet
|
|
53
|
+
link), Google-edit adoption (`reconcileMeetings`), and a one-way CRM projection.
|
|
54
|
+
- **Provisioning is declarative.** `createChapterIntegration(chapter)` composes
|
|
55
|
+
the crm namespaces + the chapter namespaces (`applications`, `groups`,
|
|
56
|
+
`meetings`, `emailLog`, plus the auth tables) + a guarded group-row seed. Drop
|
|
57
|
+
it in `odla.config.mjs` `integrations: [...]`.
|
|
58
|
+
- **The UI kit (`./ui`).** Member islands — `JoinIsland` (form → payment →
|
|
59
|
+
booking), `MembersArea`, `Rescheduler`, `SlotPicker`, `PaymentStep`; the admin
|
|
60
|
+
console `ChapterAdmin` + the section catalog (`peopleSection`); and brand tokens
|
|
61
|
+
(`brandTokens`/`<BrandStyle>`) that re-skin the whole UI from `brand`. Authored
|
|
62
|
+
against React, rendered as Preact via `preact/compat` in the reference sites.
|
|
43
63
|
|
|
44
64
|
## Quick start
|
|
45
65
|
|
|
@@ -50,9 +70,8 @@ import { defineChapter } from "@odla-ai/chapter";
|
|
|
50
70
|
export const chapter = defineChapter({
|
|
51
71
|
id: "example-chapter",
|
|
52
72
|
name: "Example Chapter",
|
|
53
|
-
url: "example.com",
|
|
54
73
|
mode: "chapter",
|
|
55
|
-
brand: { palette: {
|
|
74
|
+
brand: { palette: { "--ui-accent": "#2f6f4f" }, fonts: { display: "GT Sectra" } },
|
|
56
75
|
prices: { standardCents: 100000, foundingDiscountCents: 10000 },
|
|
57
76
|
emails: { notificationEmail: "hello@example.com" },
|
|
58
77
|
});
|
|
@@ -76,4 +95,20 @@ export default {
|
|
|
76
95
|
};
|
|
77
96
|
```
|
|
78
97
|
|
|
98
|
+
## Adopting into an existing site
|
|
99
|
+
|
|
100
|
+
1. **Schema + provisioning (safe, verifiable).** Replace a hand-rolled
|
|
101
|
+
`schema.mjs` / `rules.mjs` / group seed / provisioner with `defineChapter` +
|
|
102
|
+
`createChapterIntegration`. The emitted schema is directly comparable — diff
|
|
103
|
+
`chapter.schema` against your namespaces and expect byte-equality — so this is
|
|
104
|
+
a no-behavior-change deletion of hand-maintained code that also installs the
|
|
105
|
+
deny-all `crm_*` rules a hand-rolled provisioner tends to skip.
|
|
106
|
+
2. **The worker.** Either use `chapterWorker({ chapter })` directly, or keep your
|
|
107
|
+
bespoke routes and pass them via `chapterWorker({ chapter, routes })` — they
|
|
108
|
+
run first and reuse chapter's auth through the shared context, so you never
|
|
109
|
+
verify a JWT twice. Path renames your frontend depends on are your own alias
|
|
110
|
+
routes in that array.
|
|
111
|
+
|
|
112
|
+
Read `dist/*.d.ts` for the authoritative surface.
|
|
113
|
+
|
|
79
114
|
MIT © odla
|
package/dist/index.cjs
CHANGED
|
@@ -20,22 +20,24 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/index.ts
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
|
-
BOOKABLE_STATUSES: () => BOOKABLE_STATUSES,
|
|
24
23
|
SCHEDULING_DEFAULTS: () => SCHEDULING_DEFAULTS,
|
|
25
24
|
applicationBookingUpdate: () => applicationBookingUpdate,
|
|
26
25
|
applicationSummary: () => applicationSummary,
|
|
27
26
|
bookingDecision: () => bookingDecision,
|
|
27
|
+
brandTokens: () => brandTokens,
|
|
28
28
|
buildGroupSeed: () => buildGroupSeed,
|
|
29
29
|
canApprove: () => canApprove,
|
|
30
30
|
canBook: () => canBook,
|
|
31
|
-
canBookFrom: () => canBookFrom,
|
|
32
31
|
canChangeRole: () => canChangeRole,
|
|
33
32
|
canTransition: () => canTransition,
|
|
34
33
|
canceledPatch: () => canceledPatch,
|
|
35
34
|
chapterDb: () => chapterDb,
|
|
35
|
+
clerkInviteRequest: () => clerkInviteRequest,
|
|
36
36
|
createChapterIntegration: () => createChapterIntegration,
|
|
37
|
+
createClerkInvitation: () => createClerkInvitation,
|
|
37
38
|
defaultCrm: () => defaultCrm,
|
|
38
39
|
defineChapter: () => defineChapter,
|
|
40
|
+
emailGroupFrom: () => emailGroupFrom,
|
|
39
41
|
endForSlot: () => endForSlot,
|
|
40
42
|
findApplicationRef: () => findApplicationRef,
|
|
41
43
|
firstPaymentPatch: () => firstPaymentPatch,
|
|
@@ -43,6 +45,7 @@ __export(index_exports, {
|
|
|
43
45
|
introIdempotencyKey: () => introIdempotencyKey,
|
|
44
46
|
isAdminRole: () => isAdminRole,
|
|
45
47
|
isAlreadySent: () => isAlreadySent,
|
|
48
|
+
isReconcilable: () => isReconcilable,
|
|
46
49
|
isSlotAvailable: () => isSlotAvailable,
|
|
47
50
|
joinConfig: () => joinConfig,
|
|
48
51
|
meetingCreateRow: () => meetingCreateRow,
|
|
@@ -52,7 +55,9 @@ __export(index_exports, {
|
|
|
52
55
|
normalizeWebhookEvent: () => normalizeWebhookEvent,
|
|
53
56
|
paymentsReady: () => paymentsReady,
|
|
54
57
|
planDelivery: () => planDelivery,
|
|
58
|
+
projectApplicant: () => projectApplicant,
|
|
55
59
|
projectSharedRecord: () => projectSharedRecord,
|
|
60
|
+
reconcileMeetings: () => reconcileMeetings,
|
|
56
61
|
refundedPatch: () => refundedPatch,
|
|
57
62
|
render: () => render,
|
|
58
63
|
renderSummary: () => renderSummary,
|
|
@@ -63,6 +68,7 @@ __export(index_exports, {
|
|
|
63
68
|
resolvePipeline: () => resolvePipeline,
|
|
64
69
|
resolveScheduling: () => resolveScheduling,
|
|
65
70
|
roleFromClaim: () => roleFromClaim,
|
|
71
|
+
sendTemplated: () => sendTemplated,
|
|
66
72
|
sharedPersonInput: () => sharedPersonInput,
|
|
67
73
|
slotWindow: () => slotWindow,
|
|
68
74
|
stageIndex: () => stageIndex,
|
|
@@ -632,6 +638,72 @@ function planDelivery(input) {
|
|
|
632
638
|
return { deliver: true, transport, to, subject, text, redirected: redirect };
|
|
633
639
|
}
|
|
634
640
|
|
|
641
|
+
// src/notify.ts
|
|
642
|
+
async function sendTemplated(deps, input) {
|
|
643
|
+
const { emailLog: emailLog2 } = await deps.db.query({ emailLog: { $: { where: { dedupeKey: input.dedupeKey } } } });
|
|
644
|
+
const prior = Array.isArray(emailLog2) ? emailLog2 : [];
|
|
645
|
+
if (isAlreadySent(prior)) return { sent: true, reason: "already-sent" };
|
|
646
|
+
const cloudflareReady = Boolean(deps.sender && deps.from);
|
|
647
|
+
const decision = planDelivery({
|
|
648
|
+
envName: deps.envName,
|
|
649
|
+
group: input.group,
|
|
650
|
+
template: input.template,
|
|
651
|
+
to: input.to,
|
|
652
|
+
vars: input.vars,
|
|
653
|
+
cloudflareReady,
|
|
654
|
+
force: input.force
|
|
655
|
+
});
|
|
656
|
+
if (!decision.deliver) return { sent: false, reason: decision.reason };
|
|
657
|
+
let error;
|
|
658
|
+
let messageId;
|
|
659
|
+
if (decision.transport === "cloudflare" && deps.sender && deps.from) {
|
|
660
|
+
try {
|
|
661
|
+
const res = await deps.sender.send({
|
|
662
|
+
from: deps.from,
|
|
663
|
+
to: [decision.to],
|
|
664
|
+
subject: decision.subject,
|
|
665
|
+
text: decision.text,
|
|
666
|
+
replyTo: input.group.replyTo
|
|
667
|
+
});
|
|
668
|
+
messageId = res.messageId;
|
|
669
|
+
} catch (e) {
|
|
670
|
+
error = e instanceof Error ? e.message : String(e);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
const id2 = deps.newId();
|
|
674
|
+
const row = {
|
|
675
|
+
id: id2,
|
|
676
|
+
groupId: input.group.id,
|
|
677
|
+
to: decision.to,
|
|
678
|
+
template: input.template,
|
|
679
|
+
subject: decision.subject,
|
|
680
|
+
body: decision.text,
|
|
681
|
+
transport: decision.transport,
|
|
682
|
+
redirected: decision.redirected,
|
|
683
|
+
dedupeKey: input.dedupeKey,
|
|
684
|
+
sentAt: deps.now(),
|
|
685
|
+
...input.applicationId ? { applicationId: input.applicationId } : {},
|
|
686
|
+
...messageId ? { messageId } : {},
|
|
687
|
+
...error ? { error } : {}
|
|
688
|
+
};
|
|
689
|
+
await deps.db.transact([{ t: "update", ns: "emailLog", id: id2, attrs: row }], error ? void 0 : { mutationId: `email:${input.dedupeKey}` });
|
|
690
|
+
return error ? { sent: false, reason: error } : { sent: true };
|
|
691
|
+
}
|
|
692
|
+
function emailGroupFrom(row) {
|
|
693
|
+
const str = (v) => typeof v === "string" ? v : void 0;
|
|
694
|
+
const templates = row.emailTemplates && typeof row.emailTemplates === "object" ? row.emailTemplates : {};
|
|
695
|
+
return {
|
|
696
|
+
id: String(row.id),
|
|
697
|
+
name: String(row.name ?? ""),
|
|
698
|
+
replyTo: str(row.replyTo) ?? "",
|
|
699
|
+
debugEmail: str(row.debugEmail),
|
|
700
|
+
refundPolicyText: str(row.refundPolicyText),
|
|
701
|
+
commitmentText: str(row.commitmentText),
|
|
702
|
+
normsText: str(row.normsText),
|
|
703
|
+
emailTemplates: templates
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
|
|
635
707
|
// src/payments.ts
|
|
636
708
|
function parseSigHeader(header) {
|
|
637
709
|
const parts = {};
|
|
@@ -741,21 +813,53 @@ function sharedPersonInput(person) {
|
|
|
741
813
|
if (person.linkedin) input.linkedin = person.linkedin;
|
|
742
814
|
return input;
|
|
743
815
|
}
|
|
744
|
-
async function
|
|
745
|
-
const email =
|
|
746
|
-
const input = sharedPersonInput(person);
|
|
816
|
+
async function upsertPerson(deps, opts) {
|
|
817
|
+
const email = opts.email.toLowerCase();
|
|
747
818
|
const crmDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
|
|
748
|
-
const { crm_record } = await deps.db.query({
|
|
749
|
-
crm_record: { $: { where: { type: "person", primaryEmail: email }, limit: 1 } }
|
|
750
|
-
});
|
|
819
|
+
const { crm_record } = await deps.db.query({ crm_record: { $: { where: { type: "person", primaryEmail: email }, limit: 1 } } });
|
|
751
820
|
const existing = crm_record?.[0];
|
|
752
821
|
if (existing && typeof existing.id === "string") {
|
|
753
|
-
await (0, import_crm3.updateRecord)(crmDeps, { id: existing.id, input });
|
|
822
|
+
await (0, import_crm3.updateRecord)(crmDeps, { id: existing.id, input: opts.input });
|
|
754
823
|
return { recordId: existing.id };
|
|
755
824
|
}
|
|
756
|
-
const created = await (0, import_crm3.createRecord)(crmDeps, { type: "person", input, mutationId:
|
|
825
|
+
const created = await (0, import_crm3.createRecord)(crmDeps, { type: "person", input: opts.input, mutationId: opts.mutationId });
|
|
757
826
|
return { recordId: created.id };
|
|
758
827
|
}
|
|
828
|
+
async function projectSharedRecord(deps, person) {
|
|
829
|
+
return upsertPerson(deps, { email: person.email, input: sharedPersonInput(person), mutationId: `share:${person.hubRecordId}` });
|
|
830
|
+
}
|
|
831
|
+
async function projectApplicant(deps, applicant) {
|
|
832
|
+
const input = sharedPersonInput({
|
|
833
|
+
email: applicant.email,
|
|
834
|
+
firstName: applicant.firstName,
|
|
835
|
+
lastName: applicant.lastName,
|
|
836
|
+
phone: applicant.phone,
|
|
837
|
+
linkedin: applicant.linkedin,
|
|
838
|
+
hubRecordId: applicant.applicationId
|
|
839
|
+
});
|
|
840
|
+
return upsertPerson(deps, { email: applicant.email, input, mutationId: `apply:${applicant.applicationId}` });
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// src/clerk.ts
|
|
844
|
+
function clerkInviteRequest(input) {
|
|
845
|
+
return {
|
|
846
|
+
path: "/v1/invitations",
|
|
847
|
+
body: {
|
|
848
|
+
email_address: input.email,
|
|
849
|
+
notify: true,
|
|
850
|
+
...input.redirectUrl ? { redirect_url: input.redirectUrl } : {}
|
|
851
|
+
}
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
async function createClerkInvitation(secretKey, input, fetchImpl = fetch) {
|
|
855
|
+
const { path, body } = clerkInviteRequest(input);
|
|
856
|
+
const res = await fetchImpl(`https://api.clerk.com${path}`, {
|
|
857
|
+
method: "POST",
|
|
858
|
+
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
859
|
+
body: JSON.stringify(body)
|
|
860
|
+
});
|
|
861
|
+
return { ok: res.ok, status: res.status };
|
|
862
|
+
}
|
|
759
863
|
|
|
760
864
|
// src/session.ts
|
|
761
865
|
function applicationSummary(app) {
|
|
@@ -798,6 +902,61 @@ function memberSession(user, opts) {
|
|
|
798
902
|
};
|
|
799
903
|
}
|
|
800
904
|
|
|
905
|
+
// src/brand.ts
|
|
906
|
+
function paletteVar(key) {
|
|
907
|
+
return key.startsWith("--") ? key : `--${key}`;
|
|
908
|
+
}
|
|
909
|
+
function cleanValue(value) {
|
|
910
|
+
return value.replace(/[<>{};]/g, "").trim();
|
|
911
|
+
}
|
|
912
|
+
function brandTokens(brand) {
|
|
913
|
+
if (!brand) return "";
|
|
914
|
+
const decls = [];
|
|
915
|
+
for (const [key, value] of Object.entries(brand.palette ?? {})) {
|
|
916
|
+
if (typeof value === "string" && value.trim()) decls.push(`${paletteVar(key)}: ${cleanValue(value)};`);
|
|
917
|
+
}
|
|
918
|
+
const fonts = brand.fonts;
|
|
919
|
+
if (fonts?.display) decls.push(`--ui-font-display: ${cleanValue(fonts.display)};`);
|
|
920
|
+
if (fonts?.body) decls.push(`--ui-font-sans: ${cleanValue(fonts.body)};`);
|
|
921
|
+
if (fonts?.numeral) decls.push(`--ui-font-numeral: ${cleanValue(fonts.numeral)};`);
|
|
922
|
+
return decls.length ? `:root {
|
|
923
|
+
${decls.join("\n ")}
|
|
924
|
+
}
|
|
925
|
+
` : "";
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// src/reconcile.ts
|
|
929
|
+
function isReconcilable(meeting, now) {
|
|
930
|
+
return meeting.status === "scheduled" && Boolean(meeting.googleEventId) && (meeting.startAt ?? 0) > now - 36e5;
|
|
931
|
+
}
|
|
932
|
+
function reconcileMeetings(meetings2, events, now) {
|
|
933
|
+
const byEvent = new Map(events.map((e) => [e.eventId, e]));
|
|
934
|
+
const decisions = [];
|
|
935
|
+
for (const m of meetings2) {
|
|
936
|
+
if (!isReconcilable(m, now) || !m.googleEventId) continue;
|
|
937
|
+
const g = byEvent.get(m.googleEventId);
|
|
938
|
+
if (!g || g.status === "cancelled") {
|
|
939
|
+
decisions.push({
|
|
940
|
+
meetingId: m.id,
|
|
941
|
+
applicationId: m.applicationId,
|
|
942
|
+
kind: "cancelled",
|
|
943
|
+
meetingPatch: { status: "cancelled", drift: "none", adoptedFromGoogleAt: now },
|
|
944
|
+
applicationPatch: { meetingAt: 0, meetingLink: "" }
|
|
945
|
+
});
|
|
946
|
+
} else if (g.startAt !== void 0 && g.startAt !== m.startAt) {
|
|
947
|
+
const duration = (m.endAt ?? 0) - (m.startAt ?? 0);
|
|
948
|
+
decisions.push({
|
|
949
|
+
meetingId: m.id,
|
|
950
|
+
applicationId: m.applicationId,
|
|
951
|
+
kind: "moved",
|
|
952
|
+
meetingPatch: { startAt: g.startAt, endAt: g.endAt ?? g.startAt + duration, drift: "none", adoptedFromGoogleAt: now },
|
|
953
|
+
applicationPatch: { meetingAt: g.startAt }
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
return decisions;
|
|
958
|
+
}
|
|
959
|
+
|
|
801
960
|
// src/scheduling.ts
|
|
802
961
|
var SCHEDULING_DEFAULTS = {
|
|
803
962
|
slotMinutes: 45,
|
|
@@ -844,10 +1003,6 @@ function resolveScheduling(config) {
|
|
|
844
1003
|
if (typeof c.summaryTemplate !== "string") fail("summaryTemplate must be a string");
|
|
845
1004
|
return { ...c, days };
|
|
846
1005
|
}
|
|
847
|
-
var BOOKABLE_STATUSES = ["submitted", "paid_pending_vetting", "call_scheduled"];
|
|
848
|
-
function canBookFrom(status) {
|
|
849
|
-
return BOOKABLE_STATUSES.includes(status);
|
|
850
|
-
}
|
|
851
1006
|
function slotWindow(now, windowDays) {
|
|
852
1007
|
return { from: now, to: now + windowDays * 864e5 };
|
|
853
1008
|
}
|