R-01: a Rotation check decided drop now records the dinner's name at the decision in rotation_reviews.name (migration 0014, a table rebuild as 0012 did for suggestions; checks dropped before it take their dinner's name). ListPassedNames and ListRecentPassedNames read that name instead of the mutable dinners.name, so renaming the archived dinner no longer lets the dropped dish back into a Side quest batch. A CHECK keeps a name on drops only. R-02: deciding on a dinner that has never had a Rotation check is now ErrReviewNotFound, 404 not_found; a dinner whose checks are all decided, such as a second decision, stays 409 no_pending_review. Tests: TestLaterBatchNeverResuggestsRenamedDrop (drop, rename, next batch still refuses the original name), TestMigrateRotationReviewName (0013 to 0014 upgrade and constraints), TestPostRotationReviewNeverChecked (404), and TestDecideReview/TestDecideReviewRefused updated. Maestro: a1887d1c-c405-478c-ac7d-8cb8b40d280d Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
88 lines
3.2 KiB
Go
88 lines
3.2 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"
|
|
)
|
|
|
|
// maxRotationReviewBody bounds a POST /dinners/{id}/rotation-review body.
|
|
const maxRotationReviewBody = 1 << 10
|
|
|
|
// rotationDecision is the POST /dinners/{id}/rotation-review body:
|
|
// keep, drop ("Not for us") or not_made ("We didn't make it").
|
|
type rotationDecision struct {
|
|
Decision *string `json:"decision"`
|
|
}
|
|
|
|
type rotationReviewList struct {
|
|
Reviews []plan.Review `json:"reviews"`
|
|
}
|
|
|
|
// getRotationReviews answers GET /rotation-reviews with the pending
|
|
// Rotation checks, oldest plan date first.
|
|
func getRotationReviews(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
list, err := svc.PendingReviews(r.Context())
|
|
if err != nil {
|
|
writeRotationError(w, r, logger, "list Rotation checks", err)
|
|
return
|
|
}
|
|
writeJSON(w, r, logger, http.StatusOK, rotationReviewList{Reviews: list})
|
|
}
|
|
}
|
|
|
|
// postRotationReview applies the household's answer to "Keep it in
|
|
// rotation?" for a tried dinner, recording the session's actor as
|
|
// updated_by, and answers with the Rotation check as decided.
|
|
func postRotationReview(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
actor, ok := actorOrFail(w, r, logger)
|
|
if !ok {
|
|
return
|
|
}
|
|
body, err := decodePatch[rotationDecision](http.MaxBytesReader(w, r.Body, maxRotationReviewBody))
|
|
if err != nil {
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
|
return
|
|
}
|
|
if body.Decision == nil {
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "decision: required")
|
|
return
|
|
}
|
|
d, err := svc.DecideReview(r.Context(), chi.URLParam(r, "id"), *body.Decision, actor.Subject)
|
|
if err != nil {
|
|
writeRotationError(w, r, logger, "decide Rotation check", err)
|
|
return
|
|
}
|
|
writeJSON(w, r, logger, http.StatusOK, d)
|
|
}
|
|
}
|
|
|
|
// writeRotationError maps a Rotation check error to its API error: a
|
|
// decision other than keep, drop or not_made is 400, an unknown dinner or
|
|
// a dinner never checked 404, and a dinner whose checks are all decided
|
|
// (such as a second decision) or no longer tried 409.
|
|
func writeRotationError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
|
|
var invalid *plan.InvalidDecisionError
|
|
switch {
|
|
case errors.As(err, &invalid):
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_decision", invalid.Error())
|
|
case errors.Is(err, plan.ErrDinnerNotFound):
|
|
writeError(w, r, logger, http.StatusNotFound, "not_found", "dinner not found")
|
|
case errors.Is(err, plan.ErrReviewNotFound):
|
|
writeError(w, r, logger, http.StatusNotFound, "not_found", "rotation check not found")
|
|
case errors.Is(err, plan.ErrNoPendingReview):
|
|
writeError(w, r, logger, http.StatusConflict, "no_pending_review", err.Error())
|
|
case errors.Is(err, plan.ErrDinnerNotTried):
|
|
writeError(w, r, logger, http.StatusConflict, "dinner_not_tried", err.Error())
|
|
default:
|
|
logger.ErrorContext(r.Context(), action, "error", err)
|
|
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
|
}
|
|
}
|