Skip to content

feat: Google Calendar as the events source (cutover from Craft CMS) - #15

Open
JoeKarow wants to merge 46 commits into
mainfrom
feat/gcal
Open

JoeKarow wants to merge 46 commits into
mainfrom
feat/gcal

Conversation

@JoeKarow

@JoeKarow JoeKarow commented Sep 13, 2026

Copy link
Copy Markdown
Member

Linked Issue

Closes #14

Description

Event announcements now read from the Virtual Coffee Google Calendar instead of Craft CMS, and the CMS source is gone.

Google Calendar adapter. src/google/calendar.ts is the one CalendarPort (listEvents / getEvent / watch / stopChannel); service-account JWT-bearer auth via jose in src/google/auth.ts. EVENT_SOURCE defaults to google; the EventSource registry stays as the seam.

Typed provider clients (docs/adr/0012). Every Google and Zoom REST call goes through createApiClient (src/http/client.ts) — openapi-fetch typed by src/generated/*.d.ts, so paths, params, bodies and responses are checked at compile time. The types come from vendored specs in specs/ (Google Calendar v3 via APIs.guru's OpenAPI conversion, Zoom Meetings from Zoom's API Hub, two hand-written fragments for the OAuth token endpoints), pruned to the operations we call by Redocly at generation time; pnpm gen:api-types regenerates offline, pnpm specs:update refreshes the vendored files. Non-2xx answers are ApiError (src/http/error.ts) with the same messages as before; parsing stays tolerant (ADR 0001/0002).

Event model (docs/adr/0001, amended by docs/adr/0002). Join Link = the event's location (video conferenceData fallback); descriptions are Markdown rendered with slackify-markdown. ReminderEvent.join is a discriminated union (zoom / url / place / none); the Zoom host key (extendedProperties.private.hostCode) exists only on the zoom kind, is shown only in the event-admin mirror, and is never logged. A Zoom Join Link with no host key is rejected at derivation — dropped from the listing, alerted to #bot-log, every other event proceeds.

Cancellations / reschedules via push. New route POST /google/notify → new CalendarSync Durable Object (migration v2): owns the events/watch channel (7-day TTL, self-renewing via alarm), keeps an event_snapshot table, diffs the announced Mon–Sun week against it via the pure diff.ts, posts standout notices to announcements / events / event-admin, and re-queues the starting-soon pair. The daily cron bootstraps/heals the watch.

Admin surfaces. src/bots/admin/{actions,slash,panel}.ts: one AdminAction union behind runAdminAction (workspace-admin gate and error handling inside); the slash command, panel buttons and modals are adapters. Slash gains watch status|start|stop and welcome [<@user>]; the panel gains Calendar Watch buttons.

Config / secrets / deps. New vars GOOGLE_CALENDAR_ID, EVENT_SOURCE; new secrets GOOGLE_SERVICE_ACCOUNT_KEY, GOOGLE_WATCH_TOKEN. Removed: CMS_TOKEN, CMS_GRAPHQL_URL, graphql, graphql-request, the local html-to-mrkdwn converter, and the Zoom scopes meeting:read:meeting:admin / user:read:user:admin / user:read:list_users:admin. Added: openapi-fetch (runtime), openapi-typescript + @redocly/openapi-core (dev). .gitattributes marks src/generated/ generated and the downloaded specs vendored; Prettier, ESLint and CodeRabbit skip them. Cron strings unchanged; the weekly window is now anchored to the ISO Mon–Sun week.

Structural refactors (each its own commit, behaviour-preserving unless noted):

  • Seams: src/events.ts shared event model (a1f3ee0); CalendarPort injected into CalendarSync with a fake (48e3c4f); InviteLinkPort + swappable room-channel port on CoworkingRoom, coworking-do.test.ts on fakes only (8e3e13b); pure diffSnapshot (11205da)
  • Reminders: starting-soon.ts owns the pair, lead time and sweep (5be07a8); JoinInfo union + reject-at-derivation, ADR 0002 (6a70384)
  • Admin module (83c58fd, e761f11); lazy() wrapper in src/slack/app.ts so escaped lazy rejections alert #bot-log (7b95bab, behaviour change); src/zoom/webhook.ts with the full route-test matrix (f87811d)
  • Typed clients: vendored specs + generator (ae6c427), openapi-fetch clients and ApiError (46dd0a7), ADR 0012 (00114c0), CodeRabbit filters (f7c28b6)
  • Docs: CLAUDE.md rewritten for agents, detail moved into ADRs 0009–0011 (56c0221); this PR template (da2a299); ADR 0001 notes virtualcoffee.io ADR 0014 reconciled (7c2297c)
  • pnpm fix-calendar [--apply] (1b449cb): the one-off calendar migration below, run locally under plain node with a self-minted write-scoped token; the Worker stays read-only

Rollout checklist (manual, before/after merge):

  • virtualcoffee.io ADR 0014 / PR #1579 reconciled (calendar is workspace-readable, not public; host code is an Event field written by /admin/events)
  • pnpm fix-calendar --apply — set location from the old joinLink property where they differ (Morning/Afternoon Crowd), converted the HTML descriptions to Markdown, deleted joinLink on every series (applied 2026-09-17: 9 patches; re-run is all no-ops)
  • wrangler secret put GOOGLE_SERVICE_ACCOUNT_KEY and GOOGLE_WATCH_TOKEN in prod; wrangler secret delete CMS_TOKEN
  • PUBLIC_BASE_URL host domain-verified in Google Cloud Console (push delivery requires it); then "Start watch" from the admin panel and live-test a cancel/move
  • Zoom S2S app: drop the three scopes listed above (only meeting:write:invite_links:admin is used)

Verification. pnpm check (Prettier, ESLint, tsc, knip) and pnpm test green — 32 files / 344 tests on workerd via Miniflare; wrangler deploy --dry-run bundles. pnpm gen:api-types is idempotent. Live read-only run of the Google source against the real calendar: all 10 upcoming events are Zoom events with a 6-digit host key mapped.

Methodology

Why Google Calendar. The Craft CMS + Solspace Calendar backend is being retired; the community's events already live on the Google Calendar, so the bot reads the system of record directly instead of a copy. The EventSource seam was kept so a future source is a registry entry, not a rewrite.

Why the host key is on the calendar (and the calendar is private). #14 originally had the bot fetch the host key from Zoom at send time. Zoom removed host_key from every API response in 2022 (verified live under every relevant granular scope; https://devforum.zoom.us/t/get-a-users-host-key-via-api/79004), so the only place it can live is the calendar's private hostCode property — which means the calendar can no longer be public. Recorded in docs/adr/0001. virtualcoffee.io ADR 0014 (Virtual-Coffee/virtualcoffee.io#1579) has since been reconciled with this: the Host Code is an Event field, the calendar is workspace-readable rather than public, and the site's /admin/events is the only writer of hostCode.

Why reject-at-derivation instead of fail-the-run (docs/adr/0002). The original rule threw from reconcileStartingSoon, so one calendar entry missing a host code black-holed the daily run, the weekly summary and every push-driven sync. Making the state unrepresentable in the model and dropping the one bad event with a #bot-log alert keeps everything else flowing; the trade-off (a Zoom event that loses its host code mid-week vanishes silently for members until fixed) is documented.

Why push notifications rather than polling. Cancellations and moves need to reach the channels well before the scheduled "Starting Soon" pair fires; a daily poll can't do that. The watch channel is cheap, self-renewing, and the DO serialises notifications per calendar.

Why generated wire types (docs/adr/0012). Seven hand-written response interfaces had nothing checking them against the vendors. googleapis is out (Node internals on workerd) and openapi-typescript can't read Google's discovery document, so the Calendar spec is APIs.guru's OpenAPI conversion; neither vendor publishes a spec for its OAuth token endpoint, so those two are hand-written fragments. Zoom's Meetings document is 1.2 MB for one endpoint we call, hence pruning by operationId — done by Redocly's decorators rather than our own $ref walker. Compile-time only: no runtime schema validation, so the tolerant parsing ADR 0001/0002 require is untouched.

Why the refactor pass. An architecture review of the branch found the same seams missing that the calendar work had just added elsewhere (ports with injected fakes, pure diff logic, one place per rule). Landing them on this branch keeps the calendar code and its tests in the shape the rest of the worker now follows, before it merges.

Code of Conduct

By submitting this pull request, you agree to follow our Code of Conduct

Summary by CodeRabbit

  • New Features

    • Event announcements now use Google Calendar, supporting Zoom links, other URLs, physical locations, and Markdown descriptions.
    • Calendar changes automatically update announcements, including cancellation and rescheduling notices.
    • Added calendar watch controls and status information to the admin panel and command.
    • Added starting-soon announcements with public and event-admin versions.
    • Zoom webhooks now support coworking presence updates and endpoint validation.
  • Bug Fixes

    • Zoom events without host keys are skipped and reported without interrupting other announcements.
    • Slack handler failures now generate bot-log alerts.
  • Documentation

    • Updated setup, configuration, architecture, and event-model documentation for the Google Calendar workflow.

JoeKarow added 10 commits June 11, 2026 15:01
Introduce a Google Calendar adapter alongside the CMS source via an
EventSource registry. getEventSource resolves an explicit name (from
/vc-bot-admin daily|weekly [source]), then env.EVENT_SOURCE, then the
"cms" default, throwing on an unknown name. Google events use
service-account JWT-bearer auth (jose) with module-level credential and
token caching. SendResult now carries the source name, surfaced in the
admin replies.

Adds the GOOGLE_SERVICE_ACCOUNT_KEY secret, GOOGLE_CALENDAR_ID and
EVENT_SOURCE config vars, docs, and tests.
# Conflicts:
#	CLAUDE.md
#	README.md
#	worker-configuration.d.ts
The Google Calendar source reads joinLink/hostCode (and slackChannelId) from extendedProperties.private, falling back to the legacy shared keys. private keeps the Zoom host code hidden from public calendar subscribers while the managing service account can still read it.
reminderRange("weekly") now runs from the current ISO week's Monday (Luxon startOf("week")) to the next Monday, instead of a rolling 7-day window anchored at the run time. Shared by the weekly summary and the calendar change-notices so both cover the same announced week regardless of run day.
Registering push (watch) channels and patching event properties needs the full calendar scope, not calendar.readonly.
…ver to Google

Adds a CalendarSync Durable Object that owns a Calendar events/watch channel (7-day TTL, self-renewing via alarm) and a snapshot of the announced week. POST /google/notify verifies the X-Goog-Channel-Token, gates on EVENT_SOURCE=google, and kicks the DO, which diffs the live week against the snapshot and posts cancellation/reschedule notices (only for still-upcoming events) to the announcements, events, and event-admin channels, then reconciles the scheduled Starting Soon queue. The watch address is derived from PUBLIC_BASE_URL; the daily cron bootstraps/heals the watch; the admin panel gains watch status/start/stop buttons.

Flips EVENT_SOURCE to "google" and pins the cms source in the CMS-specific reminder/admin tests so they keep exercising that adapter.
Resolve CLAUDE.md (keep both sources + the Quartz-weekday warning), regenerate the lockfile, and
move the branch's cancellation/reschedule builders onto the shared 3-arg dateToken.
…ons (#14)

Google Calendar is the system of record (virtualcoffee.io ADR 0014; docs/adr/0001 here):

- Join Link = the event's location (video conferenceData as fallback); extendedProperties is
  never read. The 'private' properties were readable by any API reader of the public calendar.
- The host key is not an event field. reconcileStartingSoon parses the meeting id from the Join
  Link and resolves it from Zoom at send time (GET /meetings/{id} -> host_id -> GET /users/{id}
  -> host_key; S2S apps can't use /users/me), cached per run. A Zoom failure fails the run.
- Descriptions are Markdown, rendered with slackify-markdown; the local html-to-mrkdwn goes.
- The CMS source, CMS_TOKEN/CMS_GRAPHQL_URL, graphql + graphql-request are removed; google is
  the only registered EventSource and the EVENT_SOURCE default.
- ReminderEvent drops zoomHostCode and slackChannelId.
- fetch-recorder answers the Zoom meeting/user GETs and Google Calendar; the reminders/admin
  suites use it instead of hand-rolled spies.

Closes #14
A live check showed the S2S app is on granular scopes; Zoom rejects the user hop with
'does not contain scopes:[user:read:user:admin, user:read:user]', so the classic names were
misleading.
…okup

Zoom removed host_key from every API response in 2022, so the send-time
lookup added in #14 cannot work under any scope. The events calendar is
now private, so the key goes back to extendedProperties.private.hostCode
and the Google source maps it to ReminderEvent.hostKey. A Zoom Join Link
without one fails the run. src/zoom/host-key.ts shrinks to join-link.ts
(parseZoomMeetingId only). Docs and ADR 0001 rewritten; the conflict
with virtualcoffee.io ADR 0014 is flagged on PR #1579.

Refs #14
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 13, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
vc-bots f7c28b6 Sep 17 2026, 08:26 PM

@coderabbitai

coderabbitai Bot commented Sep 13, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

📝 Walkthrough

Walkthrough

The event-announcement system moves from CMS GraphQL to Google Calendar. It adds service-account authentication, Join Link classification, Calendar watch synchronization, shared admin actions, Zoom webhook extraction, and related tests and documentation.

Changes

Google Calendar event pipeline

Layer / File(s) Summary
Event contracts and Calendar adapter
src/events.ts, src/google/*, src/bots/reminders/source.ts, src/env.ts
The system defines typed Join Link variants and reads timed events from Google Calendar. Zoom links require a host key. Invalid events are skipped and reported.
Reminder and Slack rendering
src/bots/reminders/*, package.json
Reminders use Google as the registered source, Monday-based weekly windows, Markdown conversion, starting-soon scheduling, and cancellation or reschedule messages.
Calendar watch synchronization
src/bots/calendar-sync/*, src/router.ts, wrangler.jsonc
A Durable Object manages watch registration, renewal, snapshots, push notification validation, event diffs, and Slack notices.
Admin and provider integration
src/bots/admin/*, src/zoom/*, src/bots/coworking/durable-object.ts, src/slack/app.ts
Admin actions are shared by slash commands and the panel. Watch controls are added. Zoom webhook handling, invite-link ports, and lazy Slack error reporting are separated into dedicated modules.
Documentation and validation
README.md, CLAUDE.md, CONTEXT.md, docs/adr/*, test/*, vitest.config.ts
Documentation records the new event model and configuration. Tests cover Calendar, synchronization, administration, Zoom, Slack, and adapter behavior.

Sequence Diagram(s)

sequenceDiagram
  participant SlackAdmin
  participant AdminActions
  participant CalendarSync
  participant GoogleCalendar
  participant Slack
  SlackAdmin->>AdminActions: submit reminder or watch action
  AdminActions->>CalendarSync: ensureWatch(), stopWatch(), or watchStatus()
  CalendarSync->>GoogleCalendar: register or stop watch
  AdminActions->>Slack: send result response
  GoogleCalendar->>CalendarSync: push notification
  CalendarSync->>GoogleCalendar: fetch current event window
  CalendarSync->>Slack: post event notices and reconcile scheduled messages
Loading

Priority: ➖ Normal

Merge Risk: 🟡 Moderate · up to 7c229

Calendar changes can be missed after seeding, token rotation, initialization failure, or partial Slack delivery. These synchronization and permission issues should be fixed before merge.

🚥 Pre-merge checks | ✅ 2 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning Issue #14 requires location as the join link and removal of extendedProperties reads. The PR maps location with conference-data fallback, but src/google/calendar.ts still reads `extendedProper… Remove extendedProperties reads and the host key from the event model. Fetch the Zoom host key at send time for the event-admin mirror. Update fixtures and tests for these rules. Provide reviewable evidence of the two required calendar lo…
Out of Scope Changes check ⚠️ Warning Issue #14 explicitly excludes push notifications and the CalendarSync Durable Object. The PR adds src/bots/calendar-sync/durable-object.ts, snapshot diffing, Google watch registration and renewal,… Remove the push-notification, CalendarSync, Google watch, watch-admin, migration, and related test changes from this PR, or move them to a separate PR linked to an issue that requires them.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: migrating the event source from Craft CMS to Google Calendar.
Full details: Linked Issues check

Explanation

Issue #14 requires location as the join link and removal of extendedProperties reads. The PR maps location with conference-data fallback, but src/google/calendar.ts still reads extendedProperties.private.hostCode. Issue #14 also requires no host key on ReminderEvent and a Zoom host-key lookup at send time. The PR keeps hostKey in JoinInfo and reminder flows, and the event-admin mirror uses that calendar property instead of a Zoom lookup. The PR summary does not establish the required Morning Crowd and Afternoon Crowd calendar data correction.

Resolution

Remove extendedProperties reads and the host key from the event model. Fetch the Zoom host key at send time for the event-admin mirror. Update fixtures and tests for these rules. Provide reviewable evidence of the two required calendar location corrections, or mark that data task incomplete before cutover.

Full details: Out of Scope Changes check

Explanation

Issue #14 explicitly excludes push notifications and the CalendarSync Durable Object. The PR adds src/bots/calendar-sync/durable-object.ts, snapshot diffing, Google watch registration and renewal, /google/notify, Durable Object migrations, watch admin actions, and related tests. These changes implement a separate notification and watch lifecycle, not the field mapping required by #14.

  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/gcal

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

coderabbitai[bot]

This comment was marked as resolved.

…mments

The secret was in .dev.vars.example and Env but not the README's secrets list
or deploy block. The env.ts / oauth.ts / wrangler.jsonc comments still
described the Zoom host-key lookup that b5a2221 removed (the key is the
calendar's private hostCode).
"name in SOURCES" accepted inherited names ("daily toString" would call
Object.prototype.toString as a source factory).
…eData

A whitespace-only location suppressed the video entry-point fallback and
rendered as a Location line. Trim it, and use the trimmed value.
The regex matched lookalikes (notzoom.us/j/...), which would then demand a
host key and fail the whole reminder run. Parse the URL and accept only
zoom.us and its subdomains.
reconcileStartingSoon cleared Slack's scheduled messages first, so a later
Zoom event with no hostCode threw after the schedule was gone and partly
rebuilt. Run the check over every upcoming event before the first mutation.
…ure-safe

- ensureWatch creates and persists the replacement channel before stopping
  the old one, so a failed renewal leaves the live channel and its row intact.
- alarm() re-arms itself (1h) after a failed renewal instead of silently
  ending automatic renewal; stopWatch's deleteAlarm is what ends retries.
- stopChannel reports stopped/gone/failed (404/410 = gone = fine). stopWatch
  keeps the row + alarm and throws on a real failure so the admin panel
  doesn't report a watch as stopped while Google still pushes from it.
- New notify(channelId) gate in front of processNotification drops pushes
  whose X-Goog-Channel-ID isn't the stored channel; the router calls it.
- processNotification commits the snapshot before delivering, isolates each
  Slack post and the reconcile in try/catch, and throws one aggregate error
  at the end (-> #bot-log via the router). A partial failure now loses and
  alerts on a notice rather than re-posting it to every channel next push.
@JoeKarow JoeKarow added the coderabbit-review Toggle CodeRabbit to review a PR label Sep 16, 2026
The source-agnostic event model now lives outside the reminders bot so
adapters under src/google can depend on it without importing from
src/bots. reminders/source.ts re-exports both types, so importers are
unchanged.
All Google Calendar HTTP now lives in src/google/calendar.ts
(createGoogleCalendarPort): listEvents / getEvent / watch / stopChannel,
with the wire-to-ReminderEvent mapping in one place and the access token
cached per adapter instance. auth.ts keeps only the pure JWT-bearer
exchange (fetchGoogleAccessToken); the module-level cache and
resetGoogleTokenCacheForTests are gone.

The reminders Google source is the adapter's listEvents; the CalendarSync
DO takes the port as an injected field and no longer knows CALENDAR_BASE,
bearer headers, wire shapes, or how to re-derive startsAt. getEvent
returns a live / cancelled / all-day lookup instead of a raw event.

Tests: test/helpers/calendar-fake.ts is the second adapter at the seam;
calendar-sync-do and router-gcal run against it instead of a fetch spy.
reminders-google.test.ts becomes google-calendar.test.ts (the adapter
suite). One shared RSA keygen (test/helpers/google-key.ts, Web Crypto)
replaces three copies plus the node:crypto one in vitest.config.ts.

CONTEXT.md gains Calendar and Calendar watch.
Move the per-event "Starting Soon" pair (public + event-admin mirror,
its two Block Kit builders, reconcileStartingSoon, and the scheduled-
message sweep) out of reminders/index.ts and reminders/blocks.ts into
a new src/bots/reminders/starting-soon.ts. Behaviour is unchanged.

Their tests moved with them into test/starting-soon.test.ts, unedited
apart from import paths and de-duplicated fixture names.
…without a host key at derivation

ReminderEvent.join is now a JoinInfo union (zoom | url | place | none); only
zoom carries hostKey, so a Zoom link without one is unrepresentable. The Google
adapter enforces the rule: toReminderEvent is total (event | skipped | invalid),
listEvents drops invalid events with a #bot-log alert and lets the rest through,
and getEvent reports { kind: "invalid" } (CalendarSync posts no notice for it).
reconcileStartingSoon no longer pre-validates or fails the run. docs/adr/0002.
…nd panel become adapters

src/bots/admin.ts and admin-panel.ts each ran the same operations (reminder, welcome DM,
App Home publish, co-working announce, calendar watch) with their own gate, try/catch and
reply strings, and panel.ts imported the gate from admin.ts (a cycle). Move both under
src/bots/admin/ and put the operations behind one AdminAction / AdminResult union in
actions.ts: runAdminAction gates, runs, and catches once; adminReplyText owns every reply
line. slash.ts and panel.ts parse their own payload into an action and deliver the result
(the panel keeps its replace-vs-dismiss matrix). Modal-opening buttons and the slash's
non-action replies gate via the exported guardAdmin, so users.info is still called once per
request.

No reply text changes. The panel's per-handler log events (admin.panel.*_failed,
admin.panel.denied) collapse into admin.failed / admin.denied. EASTERN is exported from
reminders/source.ts instead of duplicated. New test/admin-actions.test.ts drives the actions
directly, including the previously untested watch status/start/stop.
The panel could already manage the Calendar watch and send the welcome to another member;
the slash command now reaches the same actions. `welcome` accepts an escaped mention
(`<@U…|name>`, `<@U…>`) or a bare user id, matched against the rest of the line so a display
name with spaces still parses; anything else gets a usage reply. Another-member sends reuse
the panel's "Sent the welcome message to <@U>." line.
slack-edge hands handler.lazy(request) straight to ctx.waitUntil with no
try/catch, so a lazy handler that rejects after the ACK is silent. Of the 16
lazy handlers only handleJoinClick alerted #bot-log; the admin adapters'
respondEphemeral / views.open / deleteOriginal calls and the team_join /
app_home_opened handlers had no last resort.

createSlackApp now wraps every lazy arg in lazy(label, fn), which logs
slack.lazy_failed and notifyBotLogs it. Handlers keep their own catches.
`.action`'s lazy slot is a union of two handler types, so those registrations
name the request type (ActionRequest) explicitly rather than let inference
collapse payload to never.
Lift the snapshot diff out of CalendarSync.processNotification into
src/bots/calendar-sync/diff.ts: departedUpcoming names the departed ids whose
announced start is still upcoming (the ones that need a getEvent lookup), and
diffSnapshot turns prior/current/lookups into notices + counts + invalid
entries. No I/O, no `this`; the DO fetches the lookups into a Map and logs
calendar_sync.event_invalid per invalid entry, then commits/delivers/reconciles
exactly as before. Notice order is unchanged: departed (snapshot order) then
in-window reschedules (live order).

The seven rule cases move to test/calendar-sync-diff.test.ts as plain-map
tests (plus notice-order, new-id and missing-lookup cases); the DO suite keeps
the I/O around them: the end-to-end cancellation smoke, the zero-getEvent past
case, reconcile, calendar failure, and commit-before-deliver.
Move handleZoomWebhook (and its safeJson) out of the router into its own
module; the router now only dispatches. The DO call stays awaited on purpose
(webhook ordering per meeting) — documented at the call site and in CLAUDE.md.

Tests: test/helpers/signing.ts replaces the three hand-rolled v0 signers;
test/router-zoom.test.ts becomes test/zoom-webhook.test.ts and covers the
signature/handshake/filter/dispatch/bot-log paths end to end.
The DO's two outward edges — the Zoom invite-link mint and the room
message's Slack port — become swappable private fields, mirroring
CalendarSync's CalendarPort. createZoomInviteLinkPort (S2S token +
createInviteLink) is the real InviteLinkPort; the DO no longer imports
oauth.ts or createInviteLink directly.

The DO suite now runs on in-memory fakes (invite-link-fake.ts,
installRoomChannelFake) with fetch forbidden; the two wire-level
invite-link cases move to test/zoom-invite-links.test.ts (renamed from
zoom-invite-link-log.test.ts) as adapter tests.
@JoeKarow

This comment was marked as resolved.

@coderabbitai

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

…ger matches

After GOOGLE_WATCH_TOKEN rotates, Google keeps sending the token the
channel was registered with, so the router rejects every push — yet
ensureWatch took the healthy fast path and watchStatus reported active
until expiry approached. Both now also require the stored token to match
the current one; a mismatch falls through to the (re)create branch.
…ced week rolled over

Two problems with the old seeding: a first seed that failed after the
channel row was persisted was never retried (later ensureWatch calls took
the healthy fast path), and the daily cron reseeded unconditionally, so a
Calendar change whose push was still in flight at cron time got absorbed
into the baseline and the later push diffed to nothing.

ensureWatch now owns the baseline on both branches: the snapshot is tagged
with the weekly rangeStart it was taken for (KV key snapshot_range_start),
and seed() runs only when that tag is absent or belongs to a previous
week. runReminders no longer calls seed().
…efault

getEventSource defaulted EVENT_SOURCE to "google" but the router and the
daily-cron watch bootstrap compared the raw var, so the three could drift.
All three now go through activeSourceName(env).
@JoeKarow

This comment was marked as resolved.

@coderabbitai

This comment was marked as resolved.

@coderabbitai

This comment was marked as resolved.

JoeKarow and others added 8 commits September 17, 2026 13:29
Brings in #24 (DO event queue, ADR 0003), #26 (co-working deepening,
ADRs 0004–0007) and #27 (ESLint/Prettier/knip + CI).

Resolutions:
- Zoom route: main's ACK-then-`waitUntil` dispatch (ADR 0003) ported into
  the branch's `src/zoom/webhook.ts`; the router passes `ctx` through.
- CoworkingRoom: main's version, with the branch's swappable
  `InviteLinkPort` / `roomMessage` fields re-applied.
- Admin: main's `sendWelcomeDm` / `publishHomeTab` helpers wired into the
  branch's `AdminAction` union (`src/bots/admin/`); `src/bots/admin.ts`,
  `app-home.ts`, `html-to-mrkdwn.ts` and the CMS test stay deleted.
- coworking-do tests: main's new cases (correlation edge cases,
  participant_uuid, retire failure, schema migration, event serialization)
  ported to the fake ports; the fake channel port gained a `hold` gate so
  the serialization suite can park the DO mid-post/update.
- CLAUDE.md / README: both sides' prose merged; ADR pointers kept.
- Lint/format applied across the branch's files so `pnpm check` passes.
CLAUDE.md drops from 231 to 114 lines: commands the environment can't state,
the cross-cutting invariants, and one paragraph per area ending in a conditional
pointer. Detail it used to restate moves into new ADRs (0009 RoomMessage owns
the channel message, 0010 one AdminAction union, 0011 one Calendar adapter
behind CalendarPort) and amendments to 0004/0005/0006/0008. CONTEXT.md entries
trimmed to definitions; .coderabbit.yaml path_instructions dropped in favour of
its knowledge_base; docs/agents/domain.md example ADR no longer collides with
the real 0007. Adds the missing POST /google/notify route to the map.

Co-Authored-By: Claude <noreply@anthropic.com>
…wn cutover

`pnpm fix-calendar [--apply]` (scripts/fix-calendar.ts) runs under plain node with a
self-minted write-scoped service-account token — the Worker stays on the read-only
scope. For every live series master and standalone event it sets `location` from the
retired `private.joinLink` where they differ (the Morning/Afternoon Crowd series),
converts an HTML description to Markdown, and deletes `joinLink`. Instance exceptions and
Zoom Join Links without a hostCode are reported, not patched. Dry-run by default.

scripts/html-to-markdown.ts is the pure converter, sized to the calendar's actual
corpus (<p>, <br>, <a>, <b>/<strong>, <i>/<em>, lists, entities) and refusing any other
tag so an event is skipped rather than guessed at; test/html-to-markdown.test.ts covers
it. eslint gains a scripts/** override (syntax-only, console allowed); .dev.vars.example
single-quotes the key because mise and `node --env-file` strip the JSON's own quotes.

Co-Authored-By: Claude <noreply@anthropic.com>
One block per event, wrapped -/+ description lines instead of one-line JSON, identical
conversions printed once and referenced after, no-ops and warnings grouped at the end.

Co-Authored-By: Claude <noreply@anthropic.com>
…erate wire types

`pnpm specs:update` (scripts/update-specs.ts) vendors APIs.guru's OpenAPI 3 conversion
of Google Calendar v3 (openapi-typescript can't read the discovery document) and Zoom's
API Hub document for Meetings into specs/. `pnpm gen:api-types` (scripts/gen-api-types.ts)
prunes each to the operations the Worker calls — Redocly's `filter-in` on an `operationId`
allow-list plus `removeUnusedComponents`, and a sweep for the path items Google's
path-level parameters keep alive — then runs openapi-typescript into src/generated/
(Google 67 KB / 4 paths, Zoom 6 KB / 1 path; `defaultNonNullable` off so vendor defaults
don't become required fields). Neither vendor publishes a document for its OAuth token
endpoint, so specs/ also carries two hand-written fragments for those.

Generated files are linguist-generated and skipped by Prettier and ESLint; the downloaded
specs are linguist-vendored. scripts/** may import node:* (the workerd rule is for src/).
@redocly/openapi-core is pinned to the 1.x range openapi-typescript declares — 2.x changes
the `config.styleguide` surface — so the two bump together.

Co-Authored-By: Claude <noreply@anthropic.com>
…l; ApiError

All seven raw `fetch` sites — the Google token exchange, the four Calendar operations, the
Zoom S2S token and invite_links — now go through `createApiClient<paths>` (src/http/client.ts),
openapi-fetch typed by the generated wire types, so paths, params, bodies and 2xx responses
are checked at compile time. The factory looks `globalThis.fetch` up per call (openapi-fetch
captures it at creation, which would bypass a `fetch` the tests stub later) and takes an
optional bearer resolver so token caching stays where it was. Non-2xx answers throw
`ApiError` (src/http/error.ts) carrying provider, status and body; messages keep the
`"<what failed>: <status> <body>"` shape the tests pin.

Parsing stays tolerant (ADR 0001/0002): `GoogleCalendarEvent` is the spec's `Event` with
`id` required, narrowed at the boundary — an id-less body is malformed, like a watch
response without `expiration`. The Zoom token exchange gains the shape guard it lacked.
The Slack response_url post never reads a body and stays raw.

Tests: google-auth reads the form body from the `Request` the client now sends; a
google-calendar fixture drops a cast the real schema makes unnecessary.

Co-Authored-By: Claude <noreply@anthropic.com>
Records why the types are generated from vendored specs pruned by Redocly at gen time,
why the OAuth token endpoints are hand-written fragments, why @redocly/openapi-core is
called directly rather than @redocly/cli or a redocly.yaml, what stays raw, and the
compile-time-only stance. ADR 0011 gets the amendment line; CLAUDE.md gains the regen
commands and a "Provider HTTP" area pointing at the factory and the allow-list.

Co-Authored-By: Claude <noreply@anthropic.com>
Same set .gitattributes marks generated/vendored; the hand-written OAuth fragments stay reviewable.

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

coderabbit-review Toggle CodeRabbit to review a PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Google Calendar source: read the Join Link from location, get the host key from Zoom, drop extendedProperties

1 participant