List Google calendars and choose the one to push to (Feature 16)
CI / ci (push) Successful in 3m49s
CI / ci (pull_request) Successful in 3m34s

GET /api/v1/integrations/google/calendars trades the stored refresh token
for an access token, reads every calendarList page and answers
[{id, summary, primary, accessRole}] for writer and owner calendars only.
PUT /api/v1/integrations/google/calendar {calendar_id} validates the id
against that same filtered listing and stores calendar_id and
calendar_name; any other id is 422 unknown_calendar.

Both hold the service's account lock, answer 409 not_connected with
nothing connected, and 502 reconnect_required when Google no longer
accepts the grant, leaving the connection as it is. The gcaltest fake
gains the refresh grant and a pausable calendarList endpoint.

Maestro task 0fe3f1f2-4932-4d10-81dd-74c4f630d215.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 21:15:24 -04:00
co-authored by Claude Opus 5.5
parent f06b57c921
commit 1e3e42e0b1
14 changed files with 1007 additions and 44 deletions
+71
View File
@@ -100,3 +100,74 @@ func deleteGoogle(logger *slog.Logger, svc *gcal.Service) http.HandlerFunc {
}
}
}
// maxGoogleCalendarBody bounds a PUT /integrations/google/calendar body.
const maxGoogleCalendarBody = 4 << 10
// googleCalendarChoice is the PUT /integrations/google/calendar body.
type googleCalendarChoice struct {
CalendarID *string `json:"calendar_id"`
}
// getGoogleCalendars lists the connected account's calendars MealQuest can
// push to, as [{id, summary, primary, accessRole}].
func getGoogleCalendars(logger *slog.Logger, svc *gcal.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
cals, err := svc.Calendars(r.Context())
if err != nil {
writeGoogleCalendarError(w, r, logger, "list Google calendars", err)
return
}
writeJSON(w, r, logger, http.StatusOK, cals)
}
}
// putGoogleCalendar chooses the calendar MealQuest pushes to and answers
// {calendar_id, calendar_name}.
func putGoogleCalendar(logger *slog.Logger, svc *gcal.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := decodePatch[googleCalendarChoice](http.MaxBytesReader(w, r.Body, maxGoogleCalendarBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if body.CalendarID == nil || *body.CalendarID == "" {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "calendar_id: required")
return
}
sel, err := svc.SelectCalendar(r.Context(), *body.CalendarID)
if err != nil {
writeGoogleCalendarError(w, r, logger, "choose Google calendar", err)
return
}
writeJSON(w, r, logger, http.StatusOK, sel)
}
}
// writeGoogleCalendarError maps a calendar listing or choice error to its
// API error: 409 not_connected when nothing is connected, 422
// unknown_calendar for a calendar MealQuest cannot push to, 502
// reconnect_required when Google no longer accepts the stored grant, and 502
// google_failed when Google refused or could not be reached.
func writeGoogleCalendarError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
var calendar *gcal.CalendarError
switch {
case errors.Is(err, gcal.ErrNotConnected):
writeError(w, r, logger, http.StatusConflict, "not_connected", "Google Calendar is not connected")
case errors.Is(err, gcal.ErrUnknownCalendar):
writeError(w, r, logger, http.StatusUnprocessableEntity, "unknown_calendar",
"choose one of your calendars that MealQuest can add dinners to")
case errors.Is(err, gcal.ErrReconnect):
logger.WarnContext(r.Context(), action, "error", err)
writeError(w, r, logger, http.StatusBadGateway, "reconnect_required",
"Google no longer accepts MealQuest's access to your calendar; connect Google Calendar again")
case errors.As(err, &calendar):
logger.WarnContext(r.Context(), action, "error", err)
writeError(w, r, logger, http.StatusBadGateway, "google_failed",
"Google Calendar could not be reached; try again")
default:
logger.ErrorContext(r.Context(), action, "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
}
}