@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/README.md +63 -28
- package/dist/index.cjs +145 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +128 -8
- package/dist/index.d.ts +128 -8
- package/dist/index.js +145 -14
- package/dist/index.js.map +1 -1
- package/dist/ui/index.d.ts +5 -1
- package/dist/ui/index.js +3 -2
- 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,7 +20,6 @@ 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,
|
|
@@ -29,14 +28,16 @@ __export(index_exports, {
|
|
|
29
28
|
buildGroupSeed: () => buildGroupSeed,
|
|
30
29
|
canApprove: () => canApprove,
|
|
31
30
|
canBook: () => canBook,
|
|
32
|
-
canBookFrom: () => canBookFrom,
|
|
33
31
|
canChangeRole: () => canChangeRole,
|
|
34
32
|
canTransition: () => canTransition,
|
|
35
33
|
canceledPatch: () => canceledPatch,
|
|
36
34
|
chapterDb: () => chapterDb,
|
|
35
|
+
clerkInviteRequest: () => clerkInviteRequest,
|
|
37
36
|
createChapterIntegration: () => createChapterIntegration,
|
|
37
|
+
createClerkInvitation: () => createClerkInvitation,
|
|
38
38
|
defaultCrm: () => defaultCrm,
|
|
39
39
|
defineChapter: () => defineChapter,
|
|
40
|
+
emailGroupFrom: () => emailGroupFrom,
|
|
40
41
|
endForSlot: () => endForSlot,
|
|
41
42
|
findApplicationRef: () => findApplicationRef,
|
|
42
43
|
firstPaymentPatch: () => firstPaymentPatch,
|
|
@@ -44,6 +45,7 @@ __export(index_exports, {
|
|
|
44
45
|
introIdempotencyKey: () => introIdempotencyKey,
|
|
45
46
|
isAdminRole: () => isAdminRole,
|
|
46
47
|
isAlreadySent: () => isAlreadySent,
|
|
48
|
+
isReconcilable: () => isReconcilable,
|
|
47
49
|
isSlotAvailable: () => isSlotAvailable,
|
|
48
50
|
joinConfig: () => joinConfig,
|
|
49
51
|
meetingCreateRow: () => meetingCreateRow,
|
|
@@ -53,7 +55,9 @@ __export(index_exports, {
|
|
|
53
55
|
normalizeWebhookEvent: () => normalizeWebhookEvent,
|
|
54
56
|
paymentsReady: () => paymentsReady,
|
|
55
57
|
planDelivery: () => planDelivery,
|
|
58
|
+
projectApplicant: () => projectApplicant,
|
|
56
59
|
projectSharedRecord: () => projectSharedRecord,
|
|
60
|
+
reconcileMeetings: () => reconcileMeetings,
|
|
57
61
|
refundedPatch: () => refundedPatch,
|
|
58
62
|
render: () => render,
|
|
59
63
|
renderSummary: () => renderSummary,
|
|
@@ -64,6 +68,7 @@ __export(index_exports, {
|
|
|
64
68
|
resolvePipeline: () => resolvePipeline,
|
|
65
69
|
resolveScheduling: () => resolveScheduling,
|
|
66
70
|
roleFromClaim: () => roleFromClaim,
|
|
71
|
+
sendTemplated: () => sendTemplated,
|
|
67
72
|
sharedPersonInput: () => sharedPersonInput,
|
|
68
73
|
slotWindow: () => slotWindow,
|
|
69
74
|
stageIndex: () => stageIndex,
|
|
@@ -633,6 +638,72 @@ function planDelivery(input) {
|
|
|
633
638
|
return { deliver: true, transport, to, subject, text, redirected: redirect };
|
|
634
639
|
}
|
|
635
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
|
+
|
|
636
707
|
// src/payments.ts
|
|
637
708
|
function parseSigHeader(header) {
|
|
638
709
|
const parts = {};
|
|
@@ -742,21 +813,53 @@ function sharedPersonInput(person) {
|
|
|
742
813
|
if (person.linkedin) input.linkedin = person.linkedin;
|
|
743
814
|
return input;
|
|
744
815
|
}
|
|
745
|
-
async function
|
|
746
|
-
const email =
|
|
747
|
-
const input = sharedPersonInput(person);
|
|
816
|
+
async function upsertPerson(deps, opts) {
|
|
817
|
+
const email = opts.email.toLowerCase();
|
|
748
818
|
const crmDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
|
|
749
|
-
const { crm_record } = await deps.db.query({
|
|
750
|
-
crm_record: { $: { where: { type: "person", primaryEmail: email }, limit: 1 } }
|
|
751
|
-
});
|
|
819
|
+
const { crm_record } = await deps.db.query({ crm_record: { $: { where: { type: "person", primaryEmail: email }, limit: 1 } } });
|
|
752
820
|
const existing = crm_record?.[0];
|
|
753
821
|
if (existing && typeof existing.id === "string") {
|
|
754
|
-
await (0, import_crm3.updateRecord)(crmDeps, { id: existing.id, input });
|
|
822
|
+
await (0, import_crm3.updateRecord)(crmDeps, { id: existing.id, input: opts.input });
|
|
755
823
|
return { recordId: existing.id };
|
|
756
824
|
}
|
|
757
|
-
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 });
|
|
758
826
|
return { recordId: created.id };
|
|
759
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
|
+
}
|
|
760
863
|
|
|
761
864
|
// src/session.ts
|
|
762
865
|
function applicationSummary(app) {
|
|
@@ -822,6 +925,38 @@ function brandTokens(brand) {
|
|
|
822
925
|
` : "";
|
|
823
926
|
}
|
|
824
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
|
+
|
|
825
960
|
// src/scheduling.ts
|
|
826
961
|
var SCHEDULING_DEFAULTS = {
|
|
827
962
|
slotMinutes: 45,
|
|
@@ -868,10 +1003,6 @@ function resolveScheduling(config) {
|
|
|
868
1003
|
if (typeof c.summaryTemplate !== "string") fail("summaryTemplate must be a string");
|
|
869
1004
|
return { ...c, days };
|
|
870
1005
|
}
|
|
871
|
-
var BOOKABLE_STATUSES = ["submitted", "paid_pending_vetting", "call_scheduled"];
|
|
872
|
-
function canBookFrom(status) {
|
|
873
|
-
return BOOKABLE_STATUSES.includes(status);
|
|
874
|
-
}
|
|
875
1006
|
function slotWindow(now, windowDays) {
|
|
876
1007
|
return { from: now, to: now + windowDays * 864e5 };
|
|
877
1008
|
}
|