Return the draft job id as jobId, as the plan API contract specifies. Add a week_plans revision (migration 0013) that every swap and stored draft raises. A draft_week job reads the revision before draft_week runs and stores its week only while the plan still has it; a plan edited while the AI ran is left as it is and the job fails plan_changed, so a draft never erases the household's swap. A confirmed draft now joins only a confirmed queued or running job, so a job asked for without confirm can no longer fail it with confirm_required after the plan was synced. Test simultaneous drafts, a swap during a draft, a confirmed draft while an unconfirmed one runs, impossible calendar dates and start_by across the end of daylight saving time. Maestro task ea48e650-d510-49cc-8485-0f07174ee731. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
132 lines
4.6 KiB
Go
132 lines
4.6 KiB
Go
package server
|
|
|
|
import (
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
|
)
|
|
|
|
// maxPlanBody bounds a POST /plans/{weekStart}/draft or PUT
|
|
// /plans/{weekStart}/days/{date} body.
|
|
const maxPlanBody = 4 << 10
|
|
|
|
// planDraft is the POST /plans/{weekStart}/draft body. confirm is needed
|
|
// to draft over a plan pushed to Calendar.
|
|
type planDraft struct {
|
|
Confirm *bool `json:"confirm"`
|
|
}
|
|
|
|
// planDaySwap is the PUT /plans/{weekStart}/days/{date} body.
|
|
type planDaySwap struct {
|
|
DinnerID *string `json:"dinner_id"`
|
|
}
|
|
|
|
// getPlan answers GET /plans/{weekStart} with the week's plan.
|
|
func getPlan(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.Get(r.Context(), week)
|
|
if err != nil {
|
|
writePlanError(w, r, logger, "read plan", err)
|
|
return
|
|
}
|
|
writeJSON(w, r, logger, http.StatusOK, p)
|
|
}
|
|
}
|
|
|
|
// postPlanDraft enqueues the draft of the week and answers 202 with the
|
|
// jobId that GET /jobs/{id} reports on, without waiting for the job. When
|
|
// a draft of the week is already queued or running, confirmed if this one
|
|
// is, it answers 202 with that job instead.
|
|
func postPlanDraft(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
|
|
}
|
|
body, err := decodePatch[planDraft](http.MaxBytesReader(w, r.Body, maxPlanBody))
|
|
if err != nil {
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
|
return
|
|
}
|
|
d, err := svc.StartDraft(r.Context(), week, body.Confirm != nil && *body.Confirm)
|
|
if err != nil {
|
|
writePlanError(w, r, logger, "start plan draft", err)
|
|
return
|
|
}
|
|
writeJSON(w, r, logger, http.StatusAccepted, d)
|
|
}
|
|
}
|
|
|
|
// putPlanDay swaps the dinner of one day of the week's plan and answers
|
|
// with the plan as swapped.
|
|
func putPlanDay(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
|
|
}
|
|
body, err := decodePatch[planDaySwap](http.MaxBytesReader(w, r.Body, maxPlanBody))
|
|
if err != nil {
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
|
return
|
|
}
|
|
if body.DinnerID == nil {
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "dinner_id: required")
|
|
return
|
|
}
|
|
p, err := svc.Swap(r.Context(), week, chi.URLParam(r, "date"), *body.DinnerID)
|
|
if err != nil {
|
|
writePlanError(w, r, logger, "swap plan day", err)
|
|
return
|
|
}
|
|
writeJSON(w, r, logger, http.StatusOK, p)
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
week, err := plan.ParseWeekStart(chi.URLParam(r, "weekStart"))
|
|
if err != nil {
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_week", err.Error())
|
|
return store.LocalDate{}, false
|
|
}
|
|
return week, true
|
|
}
|
|
|
|
// writePlanError maps a plan.Service error to its API error: a week with
|
|
// no plan or an unknown dinner is 404, a date outside the week is 400, and
|
|
// drafting over a synced plan without confirm, or planning a dinner that
|
|
// is neither a Regular nor in the Quest log, is 409.
|
|
func writePlanError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
|
|
var day *plan.InvalidDayError
|
|
var week *plan.InvalidWeekError
|
|
var notPlannable *plan.DinnerNotPlannableError
|
|
switch {
|
|
case errors.As(err, &day):
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_date", day.Error())
|
|
case errors.As(err, &week):
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_week", week.Error())
|
|
case errors.As(err, ¬Plannable):
|
|
writeError(w, r, logger, http.StatusConflict, "dinner_not_plannable", notPlannable.Error())
|
|
case errors.Is(err, plan.ErrConfirmRequired):
|
|
writeError(w, r, logger, http.StatusConflict, "confirm_required", err.Error())
|
|
case errors.Is(err, plan.ErrNotFound):
|
|
writeError(w, r, logger, http.StatusNotFound, "not_found", "plan not found")
|
|
case errors.Is(err, plan.ErrDinnerNotFound):
|
|
writeError(w, r, logger, http.StatusNotFound, "not_found", "dinner not found")
|
|
default:
|
|
logger.ErrorContext(r.Context(), action, "error", err)
|
|
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
|
}
|
|
}
|