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>
86 lines
3.0 KiB
Go
86 lines
3.0 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
|
|
// 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")
|
|
}
|
|
}
|