Add the Rotation check: MarkTried and keep/drop decisions
Per D-021, plan.MarkTried(date) moves every Quest log dinner planned on
a household-local date (any plan status) to tried and opens a pending
rotation_reviews row; a dinner already checked for the date is left as
it is, so it is idempotent and never re-marks a dinner returned to the
Quest log.
GET /api/v1/rotation-reviews lists the pending checks as
{dinner {id, name, photo}, plan_date}. POST
/api/v1/dinners/{id}/rotation-review {decision} applies keep (Regular,
origin kept), drop ("Not for us", archived) or not_made ("We didn't
make it", back to the Quest log), closing the check with decided_at and
the session's updated_by; a second decision is 409 no_pending_review.
A dropped dinner's name joins the passed names: ListDeclinedSuggestionNames
and ListRecentDeclinedNames become ListPassedNames and
ListRecentPassedNames, which also read Rotation checks decided drop, so
the Side quest dedupe and household context never suggest it again.
A static test guards that keep is the only path to Regular: every query
writing dinners.status and every use of the regular status is reviewed.
Maestro: a1887d1c-c405-478c-ac7d-8cb8b40d280d
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
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
|
||||
// 404, and a dinner with no pending check (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.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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user