@odla-ai/chapter 0.5.0 → 0.8.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,68 @@ export default {
76
95
  };
77
96
  ```
78
97
 
98
+ ## Adopting into an existing site
99
+
100
+ A real conversion (the site this was extracted from) went from a 2,094-line
101
+ worker to 6 lines and deleted ~2,500 lines. The order that worked:
102
+
103
+ 1. **Config first, assert parity BEFORE deleting anything.** `defineChapter()`
104
+ your site, then diff `chapter.schema` against your existing schema in a test
105
+ and require byte-equality. That single assertion is what makes the deletion
106
+ safe rather than hopeful.
107
+ 2. **Freeze the old schema as a test fixture** (e.g. `test/fixtures/legacy-schema.mjs`)
108
+ and keep asserting against it, so an upstream default change fails a test
109
+ instead of a live `provision`. It is the only durable guard against drift in a
110
+ generated schema.
111
+ 3. **Swap provisioning** — `createChapterIntegration(chapter)`, inert until the
112
+ next provision run. It supplies schema + rules + seeds, so your
113
+ `odla.config.mjs` `db` block goes away entirely.
114
+ 4. **Then the worker**, keeping every bespoke route as a host route
115
+ (`chapterWorker({ chapter, routes })`). Don't hand routes to chapter in the
116
+ same change as the framework swap.
117
+ 5. **Override rather than inherit** wherever local behavior was a decision.
118
+
119
+ ### Behavior deltas to audit
120
+
121
+ These bite silently — a smoke test won't catch them:
122
+
123
+ - **Per-field caps.** `application.defaultMaxLen` is 2000. If your form accepts
124
+ longer input, pass `maxLen` explicitly or the default starts rejecting it.
125
+ - **`services` default** is `["db","calendar","o11y"]`; `smoke` compares config
126
+ against the platform, so set `services` explicitly if you don't run the o11y
127
+ collector.
128
+ - **Which email fires from which route.** `adminNotification` fires from
129
+ `POST /api/applications` (on submit), `prepEmail` from `/api/schedule/book`,
130
+ `paymentConfirmation` from the Stripe webhook. If your policy differs (e.g.
131
+ notify on payment, not submit), override that route.
132
+ - **Account model.** `account: "invite"` (default) mints a Clerk invitation,
133
+ `"create"` makes the account server-side (so join can say the account is
134
+ ready), `"none"` skips it — all need `clerk_secret_key` in the tenant vault. A
135
+ site that wants server-side create must set `account: "create"`; inheriting the
136
+ default silently changes the model.
137
+ - **CRM projection points.** chapter projects the person on application submit
138
+ (`projectApplicant`), not on booking or on webhook status change. If you mirror
139
+ pipeline stage into the CRM, keep those routes.
140
+ - **Route names.** chapter serves `/api/config`, `/api/join-config`, etc. Alias
141
+ legacy names in ~4 lines with a host route in `chapterWorker({ routes })` rather
142
+ than rewriting pages.
143
+
144
+ ### Install + scope notes
145
+
146
+ - **Skip `@odla-ai/auth-clerk` for a worker-only adoption.** The worker never
147
+ imports it (JWTs are verified with `jose` via `ctx.verifyUser`); it is an
148
+ optional peer for `chapter/ui` only. Reach for `@odla-ai/auth-clerk/invitations`
149
+ when you want to send your own branded invitation mail.
150
+ - **The admin surface is intentionally minimal** — `/api/admin/scheduling` +
151
+ `/api/admin/meetings`, plus `peopleSection`. Dashboard, billing, per-person
152
+ comms, approve/refund, etc. are your own host routes via the seam; don't plan
153
+ around them shipping.
154
+
155
+ ### Verify from the types, not this file
156
+
157
+ At several releases a day, prose lags. Treat this README as intent and verify the
158
+ real surface from `dist/*.d.ts` and by grepping the built bundle for route
159
+ strings. One testing gotcha: Clerk session tokens expire in ~60s, so a script
160
+ that mints a JWT then runs a batch of curls must re-mint per batch.
161
+
79
162
  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,18 @@ __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,
36
+ clerkUserRequest: () => clerkUserRequest,
37
37
  createChapterIntegration: () => createChapterIntegration,
38
+ createClerkInvitation: () => createClerkInvitation,
39
+ createClerkUser: () => createClerkUser,
38
40
  defaultCrm: () => defaultCrm,
39
41
  defineChapter: () => defineChapter,
42
+ emailGroupFrom: () => emailGroupFrom,
40
43
  endForSlot: () => endForSlot,
41
44
  findApplicationRef: () => findApplicationRef,
42
45
  firstPaymentPatch: () => firstPaymentPatch,
@@ -44,6 +47,7 @@ __export(index_exports, {
44
47
  introIdempotencyKey: () => introIdempotencyKey,
45
48
  isAdminRole: () => isAdminRole,
46
49
  isAlreadySent: () => isAlreadySent,
50
+ isReconcilable: () => isReconcilable,
47
51
  isSlotAvailable: () => isSlotAvailable,
48
52
  joinConfig: () => joinConfig,
49
53
  meetingCreateRow: () => meetingCreateRow,
@@ -53,7 +57,9 @@ __export(index_exports, {
53
57
  normalizeWebhookEvent: () => normalizeWebhookEvent,
54
58
  paymentsReady: () => paymentsReady,
55
59
  planDelivery: () => planDelivery,
60
+ projectApplicant: () => projectApplicant,
56
61
  projectSharedRecord: () => projectSharedRecord,
62
+ reconcileMeetings: () => reconcileMeetings,
57
63
  refundedPatch: () => refundedPatch,
58
64
  render: () => render,
59
65
  renderSummary: () => renderSummary,
@@ -64,6 +70,7 @@ __export(index_exports, {
64
70
  resolvePipeline: () => resolvePipeline,
65
71
  resolveScheduling: () => resolveScheduling,
66
72
  roleFromClaim: () => roleFromClaim,
73
+ sendTemplated: () => sendTemplated,
67
74
  sharedPersonInput: () => sharedPersonInput,
68
75
  slotWindow: () => slotWindow,
69
76
  stageIndex: () => stageIndex,
@@ -303,7 +310,7 @@ Warmly,
303
310
  ${name}`;
304
311
  return {
305
312
  adminNotification: {
306
- subject: `New application \u2014 {{firstName}} {{lastName}}`,
313
+ subject: `New application: {{firstName}} {{lastName}}`,
307
314
  text: `A new application came in for ${name}.
308
315
 
309
316
  Name: {{firstName}} {{lastName}}
@@ -322,7 +329,7 @@ Your membership payment is confirmed. We'll be in touch to schedule your intro c
322
329
  Looking forward to our call at {{meetingTime}}. {{meetingLink}}${sign}`
323
330
  },
324
331
  onboardingInvite: {
325
- subject: `You're in \u2014 ${name}`,
332
+ subject: `You're in, ${name}`,
326
333
  text: `Hi {{firstName}},
327
334
 
328
335
  Welcome to ${name}. Your member area is here: {{membersUrl}}${sign}`
@@ -547,6 +554,10 @@ function defineChapter(config) {
547
554
  const application = resolveApplication(config.application);
548
555
  const { schema, rules } = chapterDb(mode, auth);
549
556
  const services = config.services ?? ["db", "calendar", "o11y"];
557
+ const account = config.account ?? "invite";
558
+ if (account !== "invite" && account !== "create" && account !== "none") {
559
+ throw new Error(`defineChapter.account: must be "invite", "create", or "none" \u2014 got "${String(account)}"`);
560
+ }
550
561
  const chapter = {
551
562
  config,
552
563
  id: id2,
@@ -559,6 +570,7 @@ function defineChapter(config) {
559
570
  schema,
560
571
  rules,
561
572
  services,
573
+ account,
562
574
  groupSeed: () => mode === "chapter" ? buildGroupSeed(config) : null
563
575
  };
564
576
  if (config.url !== void 0) chapter.url = config.url;
@@ -585,7 +597,7 @@ function createChapterIntegration(chapter, options = {}) {
585
597
  }
586
598
  return {
587
599
  id: "chapter",
588
- title: `Chapter \u2014 ${chapter.name}`,
600
+ title: `Chapter: ${chapter.name}`,
589
601
  npm: "@odla-ai/chapter",
590
602
  schema: {
591
603
  entities: { ...crmDesc.schema.entities, ...chapter.schema.entities },
@@ -633,6 +645,72 @@ function planDelivery(input) {
633
645
  return { deliver: true, transport, to, subject, text, redirected: redirect };
634
646
  }
635
647
 
648
+ // src/notify.ts
649
+ async function sendTemplated(deps, input) {
650
+ const { emailLog: emailLog2 } = await deps.db.query({ emailLog: { $: { where: { dedupeKey: input.dedupeKey } } } });
651
+ const prior = Array.isArray(emailLog2) ? emailLog2 : [];
652
+ if (isAlreadySent(prior)) return { sent: true, reason: "already-sent" };
653
+ const cloudflareReady = Boolean(deps.sender && deps.from);
654
+ const decision = planDelivery({
655
+ envName: deps.envName,
656
+ group: input.group,
657
+ template: input.template,
658
+ to: input.to,
659
+ vars: input.vars,
660
+ cloudflareReady,
661
+ force: input.force
662
+ });
663
+ if (!decision.deliver) return { sent: false, reason: decision.reason };
664
+ let error;
665
+ let messageId;
666
+ if (decision.transport === "cloudflare" && deps.sender && deps.from) {
667
+ try {
668
+ const res = await deps.sender.send({
669
+ from: deps.from,
670
+ to: [decision.to],
671
+ subject: decision.subject,
672
+ text: decision.text,
673
+ replyTo: input.group.replyTo
674
+ });
675
+ messageId = res.messageId;
676
+ } catch (e) {
677
+ error = e instanceof Error ? e.message : String(e);
678
+ }
679
+ }
680
+ const id2 = deps.newId();
681
+ const row = {
682
+ id: id2,
683
+ groupId: input.group.id,
684
+ to: decision.to,
685
+ template: input.template,
686
+ subject: decision.subject,
687
+ body: decision.text,
688
+ transport: decision.transport,
689
+ redirected: decision.redirected,
690
+ dedupeKey: input.dedupeKey,
691
+ sentAt: deps.now(),
692
+ ...input.applicationId ? { applicationId: input.applicationId } : {},
693
+ ...messageId ? { messageId } : {},
694
+ ...error ? { error } : {}
695
+ };
696
+ await deps.db.transact([{ t: "update", ns: "emailLog", id: id2, attrs: row }], error ? void 0 : { mutationId: `email:${input.dedupeKey}` });
697
+ return error ? { sent: false, reason: error } : { sent: true };
698
+ }
699
+ function emailGroupFrom(row) {
700
+ const str = (v) => typeof v === "string" ? v : void 0;
701
+ const templates = row.emailTemplates && typeof row.emailTemplates === "object" ? row.emailTemplates : {};
702
+ return {
703
+ id: String(row.id),
704
+ name: String(row.name ?? ""),
705
+ replyTo: str(row.replyTo) ?? "",
706
+ debugEmail: str(row.debugEmail),
707
+ refundPolicyText: str(row.refundPolicyText),
708
+ commitmentText: str(row.commitmentText),
709
+ normsText: str(row.normsText),
710
+ emailTemplates: templates
711
+ };
712
+ }
713
+
636
714
  // src/payments.ts
637
715
  function parseSigHeader(header) {
638
716
  const parts = {};
@@ -742,21 +820,73 @@ function sharedPersonInput(person) {
742
820
  if (person.linkedin) input.linkedin = person.linkedin;
743
821
  return input;
744
822
  }
745
- async function projectSharedRecord(deps, person) {
746
- const email = person.email.toLowerCase();
747
- const input = sharedPersonInput(person);
823
+ async function upsertPerson(deps, opts) {
824
+ const email = opts.email.toLowerCase();
748
825
  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
- });
826
+ const { crm_record } = await deps.db.query({ crm_record: { $: { where: { type: "person", primaryEmail: email }, limit: 1 } } });
752
827
  const existing = crm_record?.[0];
753
828
  if (existing && typeof existing.id === "string") {
754
- await (0, import_crm3.updateRecord)(crmDeps, { id: existing.id, input });
829
+ await (0, import_crm3.updateRecord)(crmDeps, { id: existing.id, input: opts.input });
755
830
  return { recordId: existing.id };
756
831
  }
757
- const created = await (0, import_crm3.createRecord)(crmDeps, { type: "person", input, mutationId: `share:${person.hubRecordId}` });
832
+ const created = await (0, import_crm3.createRecord)(crmDeps, { type: "person", input: opts.input, mutationId: opts.mutationId });
758
833
  return { recordId: created.id };
759
834
  }
835
+ async function projectSharedRecord(deps, person) {
836
+ return upsertPerson(deps, { email: person.email, input: sharedPersonInput(person), mutationId: `share:${person.hubRecordId}` });
837
+ }
838
+ async function projectApplicant(deps, applicant) {
839
+ const input = sharedPersonInput({
840
+ email: applicant.email,
841
+ firstName: applicant.firstName,
842
+ lastName: applicant.lastName,
843
+ phone: applicant.phone,
844
+ linkedin: applicant.linkedin,
845
+ hubRecordId: applicant.applicationId
846
+ });
847
+ return upsertPerson(deps, { email: applicant.email, input, mutationId: `apply:${applicant.applicationId}` });
848
+ }
849
+
850
+ // src/clerk.ts
851
+ function clerkInviteRequest(input) {
852
+ return {
853
+ path: "/v1/invitations",
854
+ body: {
855
+ email_address: input.email,
856
+ notify: true,
857
+ ...input.redirectUrl ? { redirect_url: input.redirectUrl } : {}
858
+ }
859
+ };
860
+ }
861
+ async function createClerkInvitation(secretKey, input, fetchImpl = fetch) {
862
+ const { path, body } = clerkInviteRequest(input);
863
+ const res = await fetchImpl(`https://api.clerk.com${path}`, {
864
+ method: "POST",
865
+ headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
866
+ body: JSON.stringify(body)
867
+ });
868
+ return { ok: res.ok, status: res.status };
869
+ }
870
+ function clerkUserRequest(input) {
871
+ return {
872
+ path: "/v1/users",
873
+ body: {
874
+ email_address: [input.email],
875
+ skip_password_requirement: true,
876
+ ...input.firstName ? { first_name: input.firstName } : {},
877
+ ...input.lastName ? { last_name: input.lastName } : {}
878
+ }
879
+ };
880
+ }
881
+ async function createClerkUser(secretKey, input, fetchImpl = fetch) {
882
+ const { path, body } = clerkUserRequest(input);
883
+ const res = await fetchImpl(`https://api.clerk.com${path}`, {
884
+ method: "POST",
885
+ headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
886
+ body: JSON.stringify(body)
887
+ });
888
+ return { ok: res.ok, status: res.status };
889
+ }
760
890
 
761
891
  // src/session.ts
762
892
  function applicationSummary(app) {
@@ -822,6 +952,38 @@ function brandTokens(brand) {
822
952
  ` : "";
823
953
  }
824
954
 
955
+ // src/reconcile.ts
956
+ function isReconcilable(meeting, now) {
957
+ return meeting.status === "scheduled" && Boolean(meeting.googleEventId) && (meeting.startAt ?? 0) > now - 36e5;
958
+ }
959
+ function reconcileMeetings(meetings2, events, now) {
960
+ const byEvent = new Map(events.map((e) => [e.eventId, e]));
961
+ const decisions = [];
962
+ for (const m of meetings2) {
963
+ if (!isReconcilable(m, now) || !m.googleEventId) continue;
964
+ const g = byEvent.get(m.googleEventId);
965
+ if (!g || g.status === "cancelled") {
966
+ decisions.push({
967
+ meetingId: m.id,
968
+ applicationId: m.applicationId,
969
+ kind: "cancelled",
970
+ meetingPatch: { status: "cancelled", drift: "none", adoptedFromGoogleAt: now },
971
+ applicationPatch: { meetingAt: 0, meetingLink: "" }
972
+ });
973
+ } else if (g.startAt !== void 0 && g.startAt !== m.startAt) {
974
+ const duration = (m.endAt ?? 0) - (m.startAt ?? 0);
975
+ decisions.push({
976
+ meetingId: m.id,
977
+ applicationId: m.applicationId,
978
+ kind: "moved",
979
+ meetingPatch: { startAt: g.startAt, endAt: g.endAt ?? g.startAt + duration, drift: "none", adoptedFromGoogleAt: now },
980
+ applicationPatch: { meetingAt: g.startAt }
981
+ });
982
+ }
983
+ }
984
+ return decisions;
985
+ }
986
+
825
987
  // src/scheduling.ts
826
988
  var SCHEDULING_DEFAULTS = {
827
989
  slotMinutes: 45,
@@ -868,10 +1030,6 @@ function resolveScheduling(config) {
868
1030
  if (typeof c.summaryTemplate !== "string") fail("summaryTemplate must be a string");
869
1031
  return { ...c, days };
870
1032
  }
871
- var BOOKABLE_STATUSES = ["submitted", "paid_pending_vetting", "call_scheduled"];
872
- function canBookFrom(status) {
873
- return BOOKABLE_STATUSES.includes(status);
874
- }
875
1033
  function slotWindow(now, windowDays) {
876
1034
  return { from: now, to: now + windowDays * 864e5 };
877
1035
  }