Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -761,6 +761,31 @@ as the desired state:

---

### Reminders after external Google Calendar cancellation

Before sending a reminder, the worker checks the primary host's recorded calendar
and event ID. Google `status=cancelled` suppresses that job even when the local
booking remains confirmed. An active event permits delivery; lookup failures
(including unavailable credentials, 404/410, rate limits and malformed responses)
use the job's normal retry policy and never authorize sending. After retries are
exhausted the job is failed. The lookup has a 10-second deadline, and local booking
status is checked again afterwards to catch cancellation during the request.

This is a reminder guard, not inbound booking synchronization: it does not change
booking status, free the slot, refund a payment or send cancellation notifications.
Secondary hosts' calendar copies do not decide cancellation. Bookings without a
recorded primary-host event and providers without cancellation-read support retain
the existing reminder behavior. Only Google currently implements this check.
Saving or clearing Google credentials replaces only the Google provider using a
fresh calendar-service snapshot; Microsoft/CalDAV registrations and an existing
non-Google primary provider survive without a restart. In-flight operations keep
the previous snapshot. Clearing Google credentials still leaves Google reminders
unverifiable, so they follow the same retry/failure policy rather than sending
without a cancellation check.

The recorded calendar rescues destination changes within an account; changing to
another account can still make the event inaccessible and fail its reminder job.

## 13. Webhooks & background worker

- `internal/worker`: polls the `jobs` table **every 5s** (batch ≤10). Job types:
Expand Down
23 changes: 23 additions & 0 deletions internal/calendar/calendar.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"context"
"database/sql"
"errors"
"fmt"
"sort"
"time"

Expand Down Expand Up @@ -434,3 +435,25 @@ func (s *Service) CancelEvent(ctx context.Context, userID, calendarID, eventID,
}
return nil
}

// EventCancellationReader is an optional provider capability. Only a positively
// identified cancellation returns true; access and lookup failures are errors.
type EventCancellationReader interface {
EventCancelled(context.Context, string, string, string) (bool, error)
}

// EventCancelled checks the provider recorded at creation, using the same routing
// as updates and cancellations. Providers without this capability keep existing behavior.
func (s *Service) EventCancelled(ctx context.Context, userID, calendarID, eventID, provider string) (bool, error) {
if provider != "" && s.providers[provider] == nil {
Comment thread
harley marked this conversation as resolved.
return false, fmt.Errorf("calendar: recorded event provider unavailable")
}
pr := s.providerForEvent(ctx, userID, eventID, provider)
if pr == nil {
return false, fmt.Errorf("calendar: event provider unavailable")
}
if reader, ok := pr.(EventCancellationReader); ok {
return reader.EventCancelled(ctx, userID, calendarID, eventID)
}
return false, nil
}
45 changes: 45 additions & 0 deletions internal/calendar/provider_event_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,48 @@ func TestProviderForEvent_prefersStampedProvider(t *testing.T) {
}
}
}

type cancellationProvider struct {
recordProvider
cancelled bool
calls int
}

func (p *cancellationProvider) EventCancelled(context.Context, string, string, string) (bool, error) {
p.calls++
return p.cancelled, nil
}

func TestEventCancelledUsesStoredProvider(t *testing.T) {
database, err := db.Open("sqlite://" + t.TempDir() + "/test.db")
if err != nil {
t.Fatal(err)
}
defer database.Close()
s := NewService(database)
google := &cancellationProvider{recordProvider: recordProvider{name: "google"}, cancelled: true}
microsoft := &cancellationProvider{recordProvider: recordProvider{name: "microsoft"}}
s.Register(google)
s.Register(microsoft)
cancelled, err := s.EventCancelled(context.Background(), "host", "original", "event", "google")
if err != nil || !cancelled || google.calls != 1 || microsoft.calls != 0 {
t.Fatalf("cancelled=%v err=%v calls=%d/%d", cancelled, err, google.calls, microsoft.calls)
}
if _, err := s.EventCancelled(context.Background(), "host", "original", "event", "missing-provider"); err == nil {
t.Fatal("missing stamped provider must not fall through")
}
}

func TestEventCancelledUnsupportedProvider(t *testing.T) {
database, err := db.Open("sqlite://" + t.TempDir() + "/test.db")
if err != nil {
t.Fatal(err)
}
defer database.Close()
s := NewService(database)
s.Register(&recordProvider{name: "microsoft"})
cancelled, err := s.EventCancelled(context.Background(), "host", "calendar", "event", "microsoft")
if err != nil || cancelled {
t.Fatalf("unsupported provider got %v, %v", cancelled, err)
}
}
43 changes: 43 additions & 0 deletions internal/gcal/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,3 +231,46 @@ func (c *Client) CancelEvent(ctx context.Context, userID, calendarID, eventID st
}
return nil
}

// EventCancelled reads the stored event before reminder delivery. A missing or
// inaccessible event is ambiguous (e.g. the destination account changed), so only
// an explicit cancelled status suppresses delivery; all other failures retry.
func (c *Client) EventCancelled(ctx context.Context, userID, calendarID, eventID string) (bool, error) {
hc, calID, err := c.DestinationClient(ctx, userID)
if err != nil {
return false, err
}
if hc == nil {
return false, fmt.Errorf("gcal: event status: no destination connection")
}
if calendarID != "" {
calID = calendarID
}
apiURL := c.apiBase + "/calendars/" + url.PathEscape(calID) + "/events/" + url.PathEscape(eventID) + "?fields=status"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
if err != nil {
return false, fmt.Errorf("gcal: event status request: %w", err)
}
resp, err := hc.Do(req)
if err != nil {
return false, fmt.Errorf("gcal: event status: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false, fmt.Errorf("gcal: event status HTTP %d", resp.StatusCode)
}
var event struct {
Status string `json:"status"`
}
if err := json.NewDecoder(resp.Body).Decode(&event); err != nil {
return false, fmt.Errorf("gcal: decode event status: %w", err)
}
switch event.Status {
case "cancelled":
return true, nil
case "confirmed", "tentative":
return false, nil
default:
return false, fmt.Errorf("gcal: unknown event status %q", event.Status)
}
}
45 changes: 45 additions & 0 deletions internal/gcal/events_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -328,3 +328,48 @@ func TestUpdateEvent_emptyEventID_noOp(t *testing.T) {
t.Errorf("UpdateEvent(\"\") = %v; want nil", err)
}
}

func TestEventCancelled(t *testing.T) {
for _, tc := range []struct {
name, body string
code int
cancelled, wantErr bool
}{
{"cancelled", `{"status":"cancelled"}`, 200, true, false},
{"confirmed", `{"status":"confirmed"}`, 200, false, false},
{"tentative", `{"status":"tentative"}`, 200, false, false},
{"missing", `{}`, 404, false, true},
{"gone", `{}`, 410, false, true},
{"forbidden", `{}`, 403, false, true},
{"rate_limit", `{}`, 429, false, true},
{"server_error", `{}`, 500, false, true},
{"invalid_json", `{`, 200, false, true},
{"empty_status", `{}`, 200, false, true},
} {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/calendars/original@example.com/events/event-id" || r.URL.Query().Get("fields") != "status" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL)
}
w.WriteHeader(tc.code)
_, _ = w.Write([]byte(tc.body))
}))
defer srv.Close()
c := newTestClient(t)
c.apiBase = srv.URL
saveDestinationConnection(t, c, "user-1", "new-destination")
got, err := c.EventCancelled(context.Background(), "user-1", "original@example.com", "event-id")
if got != tc.cancelled || (err != nil) != tc.wantErr {
t.Fatalf("got (%v, %v), want cancelled=%v error=%v", got, err, tc.cancelled, tc.wantErr)
}
})
}
}

func TestEventCancelledDisconnected(t *testing.T) {
c := newTestClient(t)
got, err := c.EventCancelled(context.Background(), "unknown", "primary", "event-id")
if got || err == nil {
t.Fatalf("got (%v, %v); want lookup error", got, err)
}
}
31 changes: 31 additions & 0 deletions internal/handler/calendar_reload.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package handler

import "github.com/calnode/calnode/internal/calendar"

// setGoogleCalendar replaces only Google's provider. Publish a fresh service so
// in-flight operations can finish on the previous snapshot without map races.
func (h *Handler) setGoogleCalendar(google calendar.Provider) {
h.calMu.Lock()
defer h.calMu.Unlock()
next := calendar.NewService(h.db)
if h.cal != nil {
if primary := h.cal.Primary(); primary != nil && primary.Name() != "google" {
next.Register(primary)
} else if google != nil {
next.Register(google)
}
for _, name := range h.cal.ProviderNames() {
if name != "google" && next.Provider(name) == nil {
next.Register(h.cal.Provider(name))
}
}
}
if google != nil && next.Provider("google") == nil {
next.Register(google)
}
if next.Any() {
h.cal = next
} else {
h.cal = nil
}
}
67 changes: 67 additions & 0 deletions internal/handler/calendar_reload_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package handler

import (
"github.com/calnode/calnode/internal/calendar"
"testing"
)

type reloadProvider struct {
calendar.Provider
name string
}

func (p *reloadProvider) Name() string { return p.name }

func TestSetGoogleCalendarPreservesPrimaryAndSnapshot(t *testing.T) {
for _, primary := range []string{"google", "microsoft"} {
for _, clear := range []bool{false, true} {
t.Run(primary+map[bool]string{false: "/replace", true: "/clear"}[clear], func(t *testing.T) {
h := &Handler{}
old := calendar.NewService(nil)
old.Register(&reloadProvider{name: primary})
other := "google"
if primary == "google" {
other = "microsoft"
}
old.Register(&reloadProvider{name: other})
h.SetCalendar(old)
replacement := &reloadProvider{name: "google"}
var provider calendar.Provider = replacement
if clear {
provider = nil
}
h.setGoogleCalendar(provider)
current := h.getCal()
wantPrimary := primary
if clear && primary == "google" {
wantPrimary = "microsoft"
}
if current.Primary().Name() != wantPrimary {
t.Fatalf("primary=%s, want %s", current.Primary().Name(), wantPrimary)
}
if current.Provider("microsoft") != old.Provider("microsoft") {
t.Fatal("non-Google provider replaced")
}
if clear {
if current.Provider("google") != nil {
t.Fatal("Google not removed")
}
} else if current.Provider("google") != replacement {
t.Fatal("Google not replaced")
}
if old.Provider("google") == nil || len(old.ProviderNames()) != 2 {
t.Fatal("old snapshot mutated")
}
})
}
}
h := &Handler{}
h.setGoogleCalendar(&reloadProvider{name: "google"})
if h.getCal() == nil {
t.Fatal("first Google configuration not installed")
}
h.setGoogleCalendar(nil)
if h.getCal() != nil {
t.Fatal("Google-only removal did not clear service")
}
}
Loading
Loading