Push the week's plan to Google Calendar (Feature 16)
POST /api/v1/plans/{weekStart}/push-calendar writes one event per plan
day to the chosen calendar: "Dinner: {name}" from the household dinner
time for 60 minutes in the household zone, with the dinner name,
"Start by {time}" for crock-pot dinners and the
{MEALQUEST_PUBLIC_URL}/dinner/{id} link. Event ids are the lowercase
base32hex SHA-256 of "mealquest|{weekStart}|{date}"; each event is
inserted, and a 409 updates it instead.
gcal.Service.PushEvents does the Calendar writes under the account lock,
with the same redaction as listing. plan.Service.Push records a push only
when every event was written: the plan becomes synced with
calendar_synced_at and each day stores its event id. A partial failure
records nothing; a plan edited mid-push is 409 plan_changed.
A swap, or a redraft to another dinner, clears the day's
calendar_event_id, so the next push writes just those days; a push with
none pending writes every day again.
No calendar chosen is 409 no_calendar; a deleted or read-only chosen
calendar is 409 calendar_unavailable; a refused grant is 502
reconnect_required, leaving the connection as it is.
Maestro task 5885d2b1-cf06-44ff-abf3-242d7763c3c9.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
@@ -93,6 +94,58 @@ func putPlanDay(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// postPlanPush pushes the week's plan to the chosen Google calendar
|
||||
// (Push to Calendar) and answers with the plan as synced.
|
||||
func postPlanPush(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
week, ok := weekStartOrFail(w, r, logger)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
p, err := svc.Push(r.Context(), week)
|
||||
if err != nil {
|
||||
writePlanPushError(w, r, logger, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, p)
|
||||
}
|
||||
}
|
||||
|
||||
// writePlanPushError maps a Push to Calendar error to its API error: 409
|
||||
// not_connected when Google Calendar is not connected, no_calendar when no
|
||||
// calendar is chosen, calendar_unavailable when the chosen calendar was
|
||||
// deleted or is no longer writable, and plan_changed when the plan was
|
||||
// edited during the push; 502 reconnect_required when Google no longer
|
||||
// accepts the grant, and google_failed when Google refused or could not be
|
||||
// reached. A failed push leaves the plan's status as it was. Any other
|
||||
// error is the plan's, as writePlanError maps it.
|
||||
func writePlanPushError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, 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.ErrNoCalendar):
|
||||
writeError(w, r, logger, http.StatusConflict, "no_calendar",
|
||||
"choose the calendar to push dinners to in Settings, then push again")
|
||||
case errors.Is(err, gcal.ErrCalendarUnavailable):
|
||||
logger.WarnContext(r.Context(), "push plan to Calendar", "error", err)
|
||||
writeError(w, r, logger, http.StatusConflict, "calendar_unavailable",
|
||||
"the chosen calendar was deleted or MealQuest can no longer add dinners to it; choose another calendar in Settings")
|
||||
case errors.Is(err, plan.ErrPlanChanged):
|
||||
writeError(w, r, logger, http.StatusConflict, "plan_changed", err.Error())
|
||||
case errors.Is(err, gcal.ErrReconnect):
|
||||
logger.WarnContext(r.Context(), "push plan to Calendar", "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(), "push plan to Calendar", "error", err)
|
||||
writeError(w, r, logger, http.StatusBadGateway, "google_failed",
|
||||
"Google Calendar could not be reached; push again")
|
||||
default:
|
||||
writePlanError(w, r, logger, "push plan to Calendar", err)
|
||||
}
|
||||
}
|
||||
|
||||
// weekStartOrFail parses the {weekStart} path parameter, or answers 400
|
||||
// when it is not a Monday date.
|
||||
func weekStartOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger) (store.LocalDate, bool) {
|
||||
|
||||
Reference in New Issue
Block a user