Skip to content

Refactor calendar event sync to support multi-host pooling#43

Merged
Ceesaxp merged 2 commits into
mainfrom
claude/refine-local-plan-9weBM
May 7, 2026
Merged

Refactor calendar event sync to support multi-host pooling#43
Ceesaxp merged 2 commits into
mainfrom
claude/refine-local-plan-9weBM

Conversation

@Ceesaxp

@Ceesaxp Ceesaxp commented May 6, 2026

Copy link
Copy Markdown
Owner

Summary

This PR introduces a unified calendar event synchronization layer (CalendarEventSyncer) that decouples calendar operations from booking/hosted-event business logic. It enables fan-out of calendar events across multiple host calendars while maintaining per-host tracking, replacing duplicated pooling logic previously embedded in BookingService.

Key Changes

New Components

  • CalendarEventSyncer (calendar_eventsync.go): Unified syncer that handles create/update/delete operations across multiple host calendars with automatic tracking row persistence. Supports both bookings and hosted events via a ScheduledItemKind dispatch mechanism.
  • CalendarEventInput: Provider-agnostic event shape that replaces the previous BookingWithDetails coupling. Callers build this once and hand it to the syncer for multi-host fan-out.
  • Hosted Events infrastructure: New HostedEvent, HostedEventAttendee, and HostedEventCalendarEvent models with corresponding repositories and migrations. Enables host-driven scheduling (inverse of invitee-driven bookings).

Refactored Calendar Service

  • CreateEventForHost() and UpdateEvent() now accept CalendarEventInput instead of BookingWithDetails, removing booking-specific coupling
  • New BuildCalendarEventInputForBooking() method composes the input (including description with agenda, notes, and reschedule link) that booking flows need
  • CreateEventForHost() now returns (eventID, conferenceLink, error) to surface Google Meet links at creation time
  • Extracted calendarEventWriter interface for testability

BookingService Integration

  • Delegates multi-host calendar operations to CalendarEventSyncer instead of inline pooling logic
  • deleteAllCalendarEvents() and updateAllCalendarEvents() now use the syncer for tracked rows, with fallback to legacy single-host path for pre-refactor bookings
  • Simplified error handling: per-host failures are logged but don't abort the batch (preserves prior behavior)

Testing

  • Comprehensive test suite for CalendarEventSyncer with fake calendar writer that records all calls and allows per-host error injection
  • Test fixture infrastructure (makeSyncerHarness, seedFixture) for integration testing with real schema
  • Tests verify: single/multi-host create, per-host failure resilience, conference link propagation, tracking row persistence

Database

  • New migrations for hosted_events, hosted_event_attendees, and hosted_event_calendar_events tables (both PostgreSQL and SQLite)
  • Parallel structure to booking tables, enabling future hosted-event calendar sync

Implementation Details

  • Syncer resolves writable calendars via host default or first-writable fallback when CalendarID is empty
  • Per-host errors are logged but don't prevent other hosts from being synced (matches prior booking behavior)
  • Update operations preserve per-host event IDs and persist replacement IDs returned by providers (CalDAV delete+create path)
  • Tracking store abstraction allows different table backends per ScheduledItemKind without duplicating sync logic

https://claude.ai/code/session_01YWosUoHN5qrc7bTrTv1hzM

claude added 2 commits May 6, 2026 11:46
Phase A of the host-driven event scheduling work (see plan in
/root/.claude/plans/here-is-a-draft-cryptic-crane.md). Pure refactor; no
behavioural change for bookings.

- Introduce CalendarEventInput as a provider-agnostic event shape and
  refactor CreateEventForHost / UpdateEvent / the private Google + CalDAV
  helpers to take it. CreateEvent(*BookingWithDetails) stays as a thin
  booking-side adapter; new BuildCalendarEventInputForBooking composes
  description + reschedule URL + attendees from the booking shape.
- Add the calendarEventWriter interface so the syncer can be tested
  without hitting the network.
- New CalendarEventSyncer.Create / Update / Delete fans out across all
  host targets and persists tracking rows in booking_calendar_events.
  The Update path explicitly persists any replacement event_id returned
  by the calendar layer (CalDAV delete+create or fall-through to create
  when the prior ID was empty), so subsequent updates and deletes target
  the correct provider event.
- BookingService loses its three duplicate fan-out helpers
  (createPooledHostCalendarEvents, updateAllCalendarEvents,
  deleteAllCalendarEvents) and now goes through the syncer. The legacy
  pre-pooled-host fallback (booking.CalendarEventID with
  template.CalendarID) is preserved for old rows.
- Regression tests cover one-host create, pooled-host fan-out, per-host
  failure isolation, the CalDAV replacement-id and prior-id-empty
  persistence invariants, delete fan-out, and Google Meet conference
  link surfacing.

https://claude.ai/code/session_01YWosUoHN5qrc7bTrTv1hzM
Phase B of the host-driven event scheduling work. DB-only — no service or
handler wiring yet.

- Migration 014 adds hosted_events, hosted_event_attendees, and
  hosted_event_calendar_events for both postgres (root migrations/) and
  sqlite (migrations/sqlite/). status enum is intentionally narrow for v1
  (scheduled | cancelled).
- Models HostedEvent, HostedEventAttendee, HostedEventCalendarEvent, and
  HostedEventStatus mirror the booking shapes' SQLiteTime / db tag /
  json tag style.
- HostedEventRepository: Create, GetByID, Update, ListByHost,
  GetByHostIDAndTimeRange (with excludeEventID for self-collision
  filtering on edits), GetUpcomingForReminders, MarkReminderSent.
- HostedEventAttendeeRepository.ReplaceForEvent: atomic delete-and-insert
  in a transaction. Source of truth for the attendee diff is whatever
  this method commits.
- HostedEventCalendarEventRepository: Create / GetByHostedEventID /
  Update / DeleteByHostedEventID — same shape as
  BookingCalendarEventRepository.
- Register the hostedEventTrackingStore on CalendarEventSyncer so the
  ItemKindHostedEvent dispatch is now wired (was a stub in Phase A).
- Round-trip tests for the new repos under sqlite.

Note on placeholders: q(driver, sql) does plain regex substitution of $N
to ?, so under sqlite the bind order follows the order placeholders
appear in the query — not their numeric value. Out-of-order $1/$3/$2
silently mis-binds. New time-range methods order placeholders strictly
ascending and document this constraint inline.

https://claude.ai/code/session_01YWosUoHN5qrc7bTrTv1hzM
@Ceesaxp
Ceesaxp merged commit 365a6a3 into main May 7, 2026
3 checks passed
@Ceesaxp
Ceesaxp deleted the claude/refine-local-plan-9weBM branch May 7, 2026 13:41
Ceesaxp added a commit that referenced this pull request May 7, 2026
… F) (#46)

* refactor(calendar): extract calendar fan-out into CalendarEventSyncer

Phase A of the host-driven event scheduling work (see plan in
/root/.claude/plans/here-is-a-draft-cryptic-crane.md). Pure refactor; no
behavioural change for bookings.

- Introduce CalendarEventInput as a provider-agnostic event shape and
  refactor CreateEventForHost / UpdateEvent / the private Google + CalDAV
  helpers to take it. CreateEvent(*BookingWithDetails) stays as a thin
  booking-side adapter; new BuildCalendarEventInputForBooking composes
  description + reschedule URL + attendees from the booking shape.
- Add the calendarEventWriter interface so the syncer can be tested
  without hitting the network.
- New CalendarEventSyncer.Create / Update / Delete fans out across all
  host targets and persists tracking rows in booking_calendar_events.
  The Update path explicitly persists any replacement event_id returned
  by the calendar layer (CalDAV delete+create or fall-through to create
  when the prior ID was empty), so subsequent updates and deletes target
  the correct provider event.
- BookingService loses its three duplicate fan-out helpers
  (createPooledHostCalendarEvents, updateAllCalendarEvents,
  deleteAllCalendarEvents) and now goes through the syncer. The legacy
  pre-pooled-host fallback (booking.CalendarEventID with
  template.CalendarID) is preserved for old rows.
- Regression tests cover one-host create, pooled-host fan-out, per-host
  failure isolation, the CalDAV replacement-id and prior-id-empty
  persistence invariants, delete fan-out, and Google Meet conference
  link surfacing.

https://claude.ai/code/session_01YWosUoHN5qrc7bTrTv1hzM

* feat(hosted-events): schema, models, and repositories

Phase B of the host-driven event scheduling work. DB-only — no service or
handler wiring yet.

- Migration 014 adds hosted_events, hosted_event_attendees, and
  hosted_event_calendar_events for both postgres (root migrations/) and
  sqlite (migrations/sqlite/). status enum is intentionally narrow for v1
  (scheduled | cancelled).
- Models HostedEvent, HostedEventAttendee, HostedEventCalendarEvent, and
  HostedEventStatus mirror the booking shapes' SQLiteTime / db tag /
  json tag style.
- HostedEventRepository: Create, GetByID, Update, ListByHost,
  GetByHostIDAndTimeRange (with excludeEventID for self-collision
  filtering on edits), GetUpcomingForReminders, MarkReminderSent.
- HostedEventAttendeeRepository.ReplaceForEvent: atomic delete-and-insert
  in a transaction. Source of truth for the attendee diff is whatever
  this method commits.
- HostedEventCalendarEventRepository: Create / GetByHostedEventID /
  Update / DeleteByHostedEventID — same shape as
  BookingCalendarEventRepository.
- Register the hostedEventTrackingStore on CalendarEventSyncer so the
  ItemKindHostedEvent dispatch is now wired (was a stub in Phase A).
- Round-trip tests for the new repos under sqlite.

Note on placeholders: q(driver, sql) does plain regex substitution of $N
to ?, so under sqlite the bind order follows the order placeholders
appear in the query — not their numeric value. Out-of-order $1/$3/$2
silently mis-binds. New time-range methods order placeholders strictly
ascending and document this constraint inline.

https://claude.ai/code/session_01YWosUoHN5qrc7bTrTv1hzM

* feat(hosted-events): HostedEventService + email + reminder integration

Phase C + F of the host-driven event scheduling work, building on PR #43's
CalendarEventSyncer + repos. No UI yet — that's the next PR.

Service (internal/services/hosted_event.go):
- HostedEventService with Create / Update / Cancel / Get / List / Archive /
  Unarchive / RetryCalendarEvent / DetectBusyConflicts.
- Calendar resolution: explicit input → template → host default → first
  writable (the syncer's resolveWritableCalendarID handles the last fallback).
- Conferencing-link write happens twice: once after CreateMeetingForHostedEvent
  (Zoom path) and again after the syncer surfaces a Google Meet link from the
  calendar create response — matches the booking flow's coupling.
- ErrConferencingReauthRequired on create logs and continues; the event still
  persists. On Update it surfaces (parity with booking.go:425).
- Update flow computes field + attendee diffs (lowercased email keys),
  resets reminder_sent when Start changes, and only upserts contacts for
  newly added attendees so retained attendees don't get meeting_count
  re-incremented on a benign edit.
- Email diff: removed → SendHostedEventCancelledForAttendee, added →
  SendHostedEventInvited, retained (when material field changed) →
  SendHostedEventUpdated.
- DetectBusyConflicts merges three sources (provider busy, confirmed
  bookings, scheduled hosted events) with excludeEventID for self-edit.
- Service depends on a narrow hostedEventEmailSender interface so tests
  can inject a spy without dialling SMTP.

Conferencing (internal/services/conferencing.go):
- Extracted createZoomMeetingRaw(ctx, hostID, topic, start, durationMin) so
  both bookings and hosted events share the Zoom API call.
- New CreateMeetingForHostedEvent(ctx, hostID, title, start, duration,
  locationType, customLocation). CreateMeeting(details) keeps its existing
  signature and now delegates to createZoomMeetingRaw internally.

Contacts (internal/services/contact.go):
- New UpsertFromHostedEventAttendee. Callers invoke this only for newly
  added attendees so meeting_count and last_met don't drift on edits.

Email (internal/services/email.go):
- SendHostedEventInvited / Updated / Cancelled / CancelledForAttendee /
  Reminder, plus generateICSForHostedEvent. Inline-Go-string pattern
  matching SendBookingConfirmed at email.go:184. Cancellation paths emit
  METHOD:CANCEL ICS so calendar clients remove the event.

Reminder (internal/services/reminder.go):
- checkAndSendReminders now splits into processBookingReminders +
  processHostedEventReminders so a partial failure in one source doesn't
  abort the other.

Tests:
- internal/services/hosted_event_test.go: 13 cases covering persistence,
  calendar resolution priority, ErrConferencingReauthRequired handling,
  Google Meet link surfacing through the syncer, invited-email fan-out,
  contact upsert, attendee-diff email routing (added/removed/retained),
  reminder_sent reset on Start change, no-op-update sends no email,
  Update.PatchesAllTrackedCalendarEvents, retained-attendee contact
  not re-upserted, Cancel deletes tracking rows + cancellation emails,
  DetectBusyConflicts excludes self when editing.
- internal/services/reminder_test.go: end-to-end pass through
  processHostedEventReminders flips reminder_sent and prevents re-fire.

go vet clean. All previously-passing tests still pass. Build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Ceesaxp added a commit that referenced this pull request May 7, 2026
… + E) (#47)

* refactor(calendar): extract calendar fan-out into CalendarEventSyncer

Phase A of the host-driven event scheduling work (see plan in
/root/.claude/plans/here-is-a-draft-cryptic-crane.md). Pure refactor; no
behavioural change for bookings.

- Introduce CalendarEventInput as a provider-agnostic event shape and
  refactor CreateEventForHost / UpdateEvent / the private Google + CalDAV
  helpers to take it. CreateEvent(*BookingWithDetails) stays as a thin
  booking-side adapter; new BuildCalendarEventInputForBooking composes
  description + reschedule URL + attendees from the booking shape.
- Add the calendarEventWriter interface so the syncer can be tested
  without hitting the network.
- New CalendarEventSyncer.Create / Update / Delete fans out across all
  host targets and persists tracking rows in booking_calendar_events.
  The Update path explicitly persists any replacement event_id returned
  by the calendar layer (CalDAV delete+create or fall-through to create
  when the prior ID was empty), so subsequent updates and deletes target
  the correct provider event.
- BookingService loses its three duplicate fan-out helpers
  (createPooledHostCalendarEvents, updateAllCalendarEvents,
  deleteAllCalendarEvents) and now goes through the syncer. The legacy
  pre-pooled-host fallback (booking.CalendarEventID with
  template.CalendarID) is preserved for old rows.
- Regression tests cover one-host create, pooled-host fan-out, per-host
  failure isolation, the CalDAV replacement-id and prior-id-empty
  persistence invariants, delete fan-out, and Google Meet conference
  link surfacing.

https://claude.ai/code/session_01YWosUoHN5qrc7bTrTv1hzM

* feat(hosted-events): schema, models, and repositories

Phase B of the host-driven event scheduling work. DB-only — no service or
handler wiring yet.

- Migration 014 adds hosted_events, hosted_event_attendees, and
  hosted_event_calendar_events for both postgres (root migrations/) and
  sqlite (migrations/sqlite/). status enum is intentionally narrow for v1
  (scheduled | cancelled).
- Models HostedEvent, HostedEventAttendee, HostedEventCalendarEvent, and
  HostedEventStatus mirror the booking shapes' SQLiteTime / db tag /
  json tag style.
- HostedEventRepository: Create, GetByID, Update, ListByHost,
  GetByHostIDAndTimeRange (with excludeEventID for self-collision
  filtering on edits), GetUpcomingForReminders, MarkReminderSent.
- HostedEventAttendeeRepository.ReplaceForEvent: atomic delete-and-insert
  in a transaction. Source of truth for the attendee diff is whatever
  this method commits.
- HostedEventCalendarEventRepository: Create / GetByHostedEventID /
  Update / DeleteByHostedEventID — same shape as
  BookingCalendarEventRepository.
- Register the hostedEventTrackingStore on CalendarEventSyncer so the
  ItemKindHostedEvent dispatch is now wired (was a stub in Phase A).
- Round-trip tests for the new repos under sqlite.

Note on placeholders: q(driver, sql) does plain regex substitution of $N
to ?, so under sqlite the bind order follows the order placeholders
appear in the query — not their numeric value. Out-of-order $1/$3/$2
silently mis-binds. New time-range methods order placeholders strictly
ascending and document this constraint inline.

https://claude.ai/code/session_01YWosUoHN5qrc7bTrTv1hzM

* feat(hosted-events): HostedEventService + email + reminder integration

Phase C + F of the host-driven event scheduling work, building on PR #43's
CalendarEventSyncer + repos. No UI yet — that's the next PR.

Service (internal/services/hosted_event.go):
- HostedEventService with Create / Update / Cancel / Get / List / Archive /
  Unarchive / RetryCalendarEvent / DetectBusyConflicts.
- Calendar resolution: explicit input → template → host default → first
  writable (the syncer's resolveWritableCalendarID handles the last fallback).
- Conferencing-link write happens twice: once after CreateMeetingForHostedEvent
  (Zoom path) and again after the syncer surfaces a Google Meet link from the
  calendar create response — matches the booking flow's coupling.
- ErrConferencingReauthRequired on create logs and continues; the event still
  persists. On Update it surfaces (parity with booking.go:425).
- Update flow computes field + attendee diffs (lowercased email keys),
  resets reminder_sent when Start changes, and only upserts contacts for
  newly added attendees so retained attendees don't get meeting_count
  re-incremented on a benign edit.
- Email diff: removed → SendHostedEventCancelledForAttendee, added →
  SendHostedEventInvited, retained (when material field changed) →
  SendHostedEventUpdated.
- DetectBusyConflicts merges three sources (provider busy, confirmed
  bookings, scheduled hosted events) with excludeEventID for self-edit.
- Service depends on a narrow hostedEventEmailSender interface so tests
  can inject a spy without dialling SMTP.

Conferencing (internal/services/conferencing.go):
- Extracted createZoomMeetingRaw(ctx, hostID, topic, start, durationMin) so
  both bookings and hosted events share the Zoom API call.
- New CreateMeetingForHostedEvent(ctx, hostID, title, start, duration,
  locationType, customLocation). CreateMeeting(details) keeps its existing
  signature and now delegates to createZoomMeetingRaw internally.

Contacts (internal/services/contact.go):
- New UpsertFromHostedEventAttendee. Callers invoke this only for newly
  added attendees so meeting_count and last_met don't drift on edits.

Email (internal/services/email.go):
- SendHostedEventInvited / Updated / Cancelled / CancelledForAttendee /
  Reminder, plus generateICSForHostedEvent. Inline-Go-string pattern
  matching SendBookingConfirmed at email.go:184. Cancellation paths emit
  METHOD:CANCEL ICS so calendar clients remove the event.

Reminder (internal/services/reminder.go):
- checkAndSendReminders now splits into processBookingReminders +
  processHostedEventReminders so a partial failure in one source doesn't
  abort the other.

Tests:
- internal/services/hosted_event_test.go: 13 cases covering persistence,
  calendar resolution priority, ErrConferencingReauthRequired handling,
  Google Meet link surfacing through the syncer, invited-email fan-out,
  contact upsert, attendee-diff email routing (added/removed/retained),
  reminder_sent reset on Start change, no-op-update sends no email,
  Update.PatchesAllTrackedCalendarEvents, retained-attendee contact
  not re-upserted, Cancel deletes tracking rows + cancellation emails,
  DetectBusyConflicts excludes self when editing.
- internal/services/reminder_test.go: end-to-end pass through
  processHostedEventReminders flips reminder_sent and prevents re-fire.

go vet clean. All previously-passing tests still pass. Build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(hosted-events): dashboard handler, routes, and UI templates (Phase D + E)

Phase D + E of host-driven event scheduling. Builds on the
HostedEventService introduced in #44 and exposes it through 12 dashboard
routes plus 6 HTML templates. Sidebar gets a "Schedule Event" item between
Templates and Bookings.

## Routes (cmd/server/main.go)

- GET  /dashboard/events                       — list (?archived=true to include)
- GET  /dashboard/events/new                   — create form (?template=ID prefills)
- POST /dashboard/events                       — create
- GET  /dashboard/events/check-conflicts       — HTMX partial; busy-time merge
- GET  /dashboard/events/attendee-search       — HTMX partial; contact autocomplete
- GET  /dashboard/events/{id}/details          — modal partial
- GET  /dashboard/events/{id}/edit             — full edit form
- POST /dashboard/events/{id}/edit             — update
- POST /dashboard/events/{id}/cancel           — cancel
- POST /dashboard/events/{id}/archive          — archive
- POST /dashboard/events/{id}/unarchive        — unarchive
- POST /dashboard/events/{id}/retry-calendar   — retry calendar event creation

The HTMX partial routes use the /events/check-conflicts and
/events/attendee-search paths so they don't shadow {id}.

## Handler (internal/handlers/dashboard_events.go)

DashboardEventsHandler wired on the Handlers struct alongside Dashboard.
Form parsing uses parallel-indexed repeated form fields
(attendee_email/_name/_contact_id), matching the existing pattern used by
the templates handler. parseDateTimeInTZ combines the form's date + time +
timezone fields into a UTC time.Time before handing to the service. Update
flow uses pointer fields on UpdateHostedEventInput so the form can express
"unchanged" vs "set to empty".

## Templates (Phase E)

All six new templates reuse the existing primitives — `.section`,
`.section-header`, `.form-input`, `.form-select`, `.form-textarea`,
`.form-label`, `.form-hint`, `.btn .btn-primary/.btn-secondary/.btn-outline`,
`.btn-sm`, `.badge`, `.table-container/.table`, `.duration-chips`,
`.radio-cards`, `.modal-overlay/.modal-content/.modal-header/.modal-body`,
`.empty-state`, `.alert/.alert-warning/.alert-error`. No new CSS files; the
attendee chip styling is a small inline block confined to the form template.

- templates/pages/dashboard_events.html              — list + filter view
- templates/pages/dashboard_event_form.html          — create + edit form
- templates/partials/events_table_partial.html       — table body
- templates/partials/event_attendee_picker_results.html — autocomplete results
- templates/partials/event_conflict_warning.html     — soft conflict banner
- templates/partials/dashboard_event_detail_partial.html — modal detail

The form has 7 sections (Template prefill (new only) → Basics → When →
Location → Calendar → Attendees) with HTMX-driven conflict warnings and
contact autocomplete. Modal pattern matches the booking flow's recent
fetch-based approach (#41 pattern).

## Tests

internal/handlers/dashboard_events_test.go — 9 cases:

- TestEventAttendeePickerResults_RendersContacts
- TestEventAttendeePickerResults_EmptyShowsFreeFormHint
- TestEventAttendeePickerResults_EmptyAndNoQuery_RendersNothing
- TestEventConflictWarning_RendersConflicts (asserts pluralisation + soft-warning copy)
- TestEventConflictWarning_NoConflicts_RendersEmpty
- TestEventsTablePartial_RendersScheduledAndCancelled (status-routed badges + actions)
- TestEventForm_NewMode_RendersTitleAndAttendeePicker
- TestEventForm_EditMode_PrefillsAndCarriesExcludeEventID (zoom + 45-min + exclude_event_id)
- TestEventForm_FormErrorRendersHumanCopy (at_least_one_attendee → human copy)

Pattern matches internal/handlers/template_test.go: load real template
files, render to bytes.Buffer, assert strings. Tests render the {{define
"content"}} blocks directly to avoid the layout-loading dependency.

`make test`, `go vet ./...`, `go build ./cmd/server` all clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants