Files
MealQuest/internal/server/dinners.go
T
dev_agentandClaude Opus 5.5 9ba9b1d089
CI / ci (push) Successful in 2m11s
CI / ci (pull_request) Successful in 2m12s
Add the dinners API for Regulars
POST /api/v1/dinners adds a Regular from a name or a recipe link
(status and origin regular, grounding_status pending), refusing a name
harness.Similar to an unarchived dinner, or a known link, with 409.
GET /dinners?status=regular|quest_log lists dinners with photo URLs and
grounding status; PATCH /dinners/{id} edits name, method, minutes and
difficulty; DELETE archives the row; POST /dinners/{id}/favorite toggles
is_favorite. Every write records the session subject as updated_by.

Migration 0008 adds dinners.recipe_url so a pasted link is kept until
grounding resolves it.

Maestro task 28d3cb4b-83d1-40e8-991f-d88f0f64d270.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:46:33 -04:00

149 lines
4.8 KiB
Go

package server
import (
"errors"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
)
// maxDinnerBody bounds a POST or PATCH /dinners request body.
const maxDinnerBody = 16 << 10
// dinnerCreate is the POST /dinners body: a name or a recipe_url.
type dinnerCreate struct {
Name *string `json:"name"`
RecipeURL *string `json:"recipe_url"`
}
// dinnerPatch is the PATCH /dinners/{id} body. Every field is optional;
// unknown fields and null values are refused.
type dinnerPatch struct {
Name *string `json:"name"`
Method *string `json:"method"`
MinutesTotal *int `json:"minutes_total"`
Difficulty *string `json:"difficulty"`
}
type dinnerList struct {
Dinners []dinners.Dinner `json:"dinners"`
}
// getDinners answers GET /dinners?status=regular|quest_log.
func getDinners(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
list, err := svc.List(r.Context(), r.URL.Query().Get("status"))
if err != nil {
writeDinnerError(w, r, logger, "list dinners", err)
return
}
writeJSON(w, r, logger, http.StatusOK, dinnerList{Dinners: list})
}
}
// postDinner adds a Regular, recording the session's actor as updated_by.
func postDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actor, ok := actorOrFail(w, r, logger)
if !ok {
return
}
body, err := decodePatch[dinnerCreate](http.MaxBytesReader(w, r.Body, maxDinnerBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
d, err := svc.CreateRegular(r.Context(), dinners.NewRegular(body), actor.Subject)
if err != nil {
writeDinnerError(w, r, logger, "create dinner", err)
return
}
writeJSON(w, r, logger, http.StatusCreated, d)
}
}
// patchDinner edits a dinner, recording the session's actor as updated_by.
func patchDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actor, ok := actorOrFail(w, r, logger)
if !ok {
return
}
patch, err := decodePatch[dinnerPatch](http.MaxBytesReader(w, r.Body, maxDinnerBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
d, err := svc.Update(r.Context(), chi.URLParam(r, "id"), dinners.Patch(patch), actor.Subject)
if err != nil {
writeDinnerError(w, r, logger, "update dinner", err)
return
}
writeJSON(w, r, logger, http.StatusOK, d)
}
}
// deleteDinner archives a dinner; the row is never deleted.
func deleteDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actor, ok := actorOrFail(w, r, logger)
if !ok {
return
}
d, err := svc.Archive(r.Context(), chi.URLParam(r, "id"), actor.Subject)
if err != nil {
writeDinnerError(w, r, logger, "archive dinner", err)
return
}
writeJSON(w, r, logger, http.StatusOK, d)
}
}
// postDinnerFavorite toggles a dinner's is_favorite.
func postDinnerFavorite(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actor, ok := actorOrFail(w, r, logger)
if !ok {
return
}
d, err := svc.ToggleFavorite(r.Context(), chi.URLParam(r, "id"), actor.Subject)
if err != nil {
writeDinnerError(w, r, logger, "toggle dinner favorite", err)
return
}
writeJSON(w, r, logger, http.StatusOK, d)
}
}
// actorOrFail returns the session's actor, or answers 500 when the request
// has none, which behind requireSession is a wiring bug.
func actorOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger) (auth.Actor, bool) {
actor, ok := auth.ActorFrom(r.Context())
if !ok {
logger.ErrorContext(r.Context(), r.Method+" "+r.URL.Path+" without a session")
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
}
return actor, ok
}
// writeDinnerError maps a dinners.Service error to its API error.
func writeDinnerError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
var invalid *dinners.ValidationError
var dup *dinners.DuplicateError
switch {
case errors.As(err, &invalid):
writeError(w, r, logger, http.StatusBadRequest, "invalid_dinner", invalid.Error())
case errors.As(err, &dup):
writeError(w, r, logger, http.StatusConflict, "duplicate_dinner", dup.Error())
case errors.Is(err, dinners.ErrNotFound):
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")
}
}