Add idea actions: add to Quest log and cook tonight (Feature 18)
CI / ci (push) Successful in 5m42s
CI / ci (pull_request) Successful in 5m44s

POST /api/v1/ideas/{jobId}/items/{i}/quest-log adds idea i of a
succeeded ideas job to the Quest log as a dinner grounded in the idea's
own recipe, with origin ingredients or quick, or answers the dinner it
repeats as it is (the Regular dedupe rule), so repeating the call is
idempotent. POST .../cook-tonight does the same, then puts the dinner on
today (household timezone) of this week's plan through plan.Service.Swap;
with no plan for the week it answers 409 no_plan and changes nothing.

Maestro task 54dcd487-1597-4a91-8493-97f3a7635e2d.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 20:58:20 -04:00
co-authored by Claude Opus 5.5
parent 659d082178
commit e93c9c63f6
8 changed files with 785 additions and 6 deletions
+129
View File
@@ -4,8 +4,13 @@ import (
"errors"
"log/slog"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
)
// maxIdeasBody bounds a POST /ideas/from-ingredients or /ideas/quick body.
@@ -93,3 +98,127 @@ func postIdeasQuick(logger *slog.Logger, svc *ideas.Service) http.HandlerFunc {
writeJSON(w, r, logger, http.StatusAccepted, started)
}
}
// chosenIdeaOrFail returns the idea {i} of ideas job {jobId}, or answers
// 400 invalid_request when i is not an index (0, 1, ...), 404 not_found
// for an idea no succeeded ideas job shows, or 409 ideas_not_ready while
// the job is still finding ideas.
func chosenIdeaOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger, svc *ideas.Service) (ideas.Chosen, bool) {
raw := chi.URLParam(r, "i")
i, err := strconv.Atoi(raw)
if err != nil || i < 0 || strconv.Itoa(i) != raw {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "i: must be an idea's index, 0 or more")
return ideas.Chosen{}, false
}
idea, err := svc.Idea(r.Context(), chi.URLParam(r, "jobId"), i)
switch {
case errors.Is(err, ideas.ErrIdeaNotFound):
writeError(w, r, logger, http.StatusNotFound, "not_found", "idea not found")
return ideas.Chosen{}, false
case errors.Is(err, ideas.ErrIdeasNotReady):
writeError(w, r, logger, http.StatusConflict, "ideas_not_ready", err.Error())
return ideas.Chosen{}, false
case err != nil:
logger.ErrorContext(r.Context(), "read idea", "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return ideas.Chosen{}, false
}
return idea, true
}
// postIdeaQuestLog adds idea {i} of ideas job {jobId} to the Quest log,
// recording the session's actor as updated_by, as a dinner grounded in
// the recipe the idea was grounded in, with origin ingredients or quick.
// It answers 201 with the dinner made, or 200 with the dinner the idea
// repeats (see dinners.Service.AddToQuestLog), so asking again answers the
// same dinner. A repeated dinner awaiting the household's choice is 409
// duplicate_dinner.
func postIdeaQuestLog(logger *slog.Logger, ideasSvc *ideas.Service, dinnerSvc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actor, ok := actorOrFail(w, r, logger)
if !ok {
return
}
idea, ok := chosenIdeaOrFail(w, r, logger, ideasSvc)
if !ok {
return
}
d, created, err := dinnerSvc.AddToQuestLog(r.Context(), dinners.NewQuestLog(idea), actor.Subject)
if err != nil {
writeDinnerError(w, r, logger, "add idea to Quest log", err)
return
}
status := http.StatusOK
if created {
status = http.StatusCreated
}
writeJSON(w, r, logger, status, d)
}
}
// cookedTonight is the POST /ideas/{jobId}/items/{i}/cook-tonight answer:
// the idea's dinner and the week's plan with it tonight.
type cookedTonight struct {
Dinner dinners.Dinner `json:"dinner"`
Plan plan.Plan `json:"plan"`
}
// postIdeaCookTonight plans idea {i} of ideas job {jobId} tonight: today,
// in the household's zone, of the plan of today's week. The idea's dinner
// is added or found as postIdeaQuestLog does, then swapped onto today
// through plan.Service.Swap, exactly as a swap on the Draft board, so the
// plan is marked edited and a synced plan becomes changed. It answers 200
// with the dinner and the plan as swapped; asking again plans the same
// dinner again.
//
// When today's week has no plan it answers 409 no_plan and changes
// nothing: no dinner is added and no plan is made, since a plan holds all
// seven days; the household plans the week first. The week is always
// today's own, so no other day is ever written. A dinner already on
// another day of the week is 409 dinner_already_planned, after the dinner
// is added or found.
func postIdeaCookTonight(logger *slog.Logger, ideasSvc *ideas.Service, dinnerSvc *dinners.Service, planSvc *plan.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actor, ok := actorOrFail(w, r, logger)
if !ok {
return
}
idea, ok := chosenIdeaOrFail(w, r, logger, ideasSvc)
if !ok {
return
}
week, today, err := planSvc.Tonight(r.Context())
if errors.Is(err, plan.ErrNotFound) {
writeNoPlan(w, r, logger)
return
}
if err != nil {
writePlanError(w, r, logger, "find tonight's plan", err)
return
}
d, _, err := dinnerSvc.AddToQuestLog(r.Context(), dinners.NewQuestLog(idea), actor.Subject)
if err != nil {
writeDinnerError(w, r, logger, "add idea to cook tonight", err)
return
}
p, err := planSvc.Swap(r.Context(), week, today.String(), d.ID)
if errors.Is(err, plan.ErrNotFound) {
// Plans are never deleted, so the plan Tonight found is still
// there; this is said loudly rather than answered as success.
logger.ErrorContext(r.Context(), "plan of tonight's week gone before the swap", "week", week.String())
writeNoPlan(w, r, logger)
return
}
if err != nil {
writePlanError(w, r, logger, "cook idea tonight", err)
return
}
writeJSON(w, r, logger, http.StatusOK, cookedTonight{Dinner: d, Plan: p})
}
}
// writeNoPlan answers 409 no_plan: today's week has no plan to put
// tonight's dinner on, so the household plans the week first.
func writeNoPlan(w http.ResponseWriter, r *http.Request, logger *slog.Logger) {
writeError(w, r, logger, http.StatusConflict, "no_plan", "this week has no plan yet; plan this week first")
}