@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 CHANGED
@@ -1,9 +1,10 @@
1
1
  # @odla-ai/chapter
2
2
 
3
- A foundation for **membership sites**. One `defineChapter({...})` config
4
- stands up a full member site — join/apply, Stripe membership, Google booking, a
5
- member area, an admin console, and a CRM or an **admin-only hub**, on odla-db +
6
- Clerk + [@odla-ai/crm](https://odla.ai/docs/packages/crm) + calendar + email.
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
- > **Early access (pre-1.0).** Hub mode the admin console, CRM, and provisioning
16
- > is complete. The public member surface (join/apply, Stripe membership, Google
17
- > booking, member area) is being ported release by release.
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 (a directory/registry over the same CRM).
24
- Same worker shell, auth, CRM, chrome, and provisioning the mode gates the
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` today: Clerk-JWT verify, the odla-db
28
- admins-allowlist gate, the mounted `@odla-ai/crm` routes, and the static-asset
29
- fallback (the member/join/Stripe/booking routes are being added). A site's
30
- `src/worker.ts` is ~3 lines. Observability is a host concern — wrap it with
31
- `withObservability` from `@odla-ai/o11y` in your entry if you run it. Everything
32
- brand-specific (prices, policy copy, email templates, scheduling) is read at
33
- runtime from a single odla-db `groups` row, never hardcoded.
34
- - **Provisioning is declarative.** `createChapterIntegration(chapter, opts)` is a
35
- CLI-consumable descriptor that composes the crm namespaces + the chapter
36
- namespaces (`applications`, `groups`, `meetings`, `emailLog`, `admins`) + a
37
- guarded group-row seed. Drop it in `odla.config.mjs` `integrations: [...]`.
38
- - **The admin UI.** `./ui` ships `<ChapterAdmin>` the console shell (config
39
- fetch, themed Clerk sign-in, the allowlist gate, and a TopBar that renders the
40
- sections you pass), authored against React and rendered as Preact via
41
- `preact/compat` in the reference sites. The member islands (join, members,
42
- slot-picker) and brand-token theming are on the roadmap.
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: { /* --ui-* overrides */ }, wordmark: "Example Chapter" },
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 projectSharedRecord(deps, person) {
746
- const email = person.email.toLowerCase();
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: `share:${person.hubRecordId}` });
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
  }