Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
225 lines
8.5 KiB
Go
225 lines
8.5 KiB
Go
package server
|
|
|
|
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.
|
|
const maxIdeasBody = 16 << 10
|
|
|
|
// ideasFromIngredients is the POST /ideas/from-ingredients body. Both
|
|
// fields are required.
|
|
type ideasFromIngredients struct {
|
|
Ingredients []string `json:"ingredients"`
|
|
QuickOnly *bool `json:"quick_only"`
|
|
}
|
|
|
|
// postIdeasFromIngredients starts finding ideas from the ingredients on
|
|
// hand and answers 202 with the jobId that GET /jobs/{id} reports on,
|
|
// without waiting for the job. An ingredient list the household must
|
|
// correct is 400.
|
|
func postIdeasFromIngredients(logger *slog.Logger, svc *ideas.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
body, err := decodePatch[ideasFromIngredients](http.MaxBytesReader(w, r.Body, maxIdeasBody))
|
|
if err != nil {
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
|
return
|
|
}
|
|
switch {
|
|
case body.Ingredients == nil:
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "ingredients: required")
|
|
return
|
|
case body.QuickOnly == nil:
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "quick_only: required")
|
|
return
|
|
}
|
|
started, err := svc.StartFromIngredients(r.Context(), body.Ingredients, *body.QuickOnly)
|
|
var invalid *ideas.InvalidError
|
|
switch {
|
|
case errors.As(err, &invalid):
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", invalid.Message)
|
|
return
|
|
case err != nil:
|
|
logger.ErrorContext(r.Context(), "start ideas from ingredients", "error", err)
|
|
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
|
return
|
|
}
|
|
writeJSON(w, r, logger, http.StatusAccepted, started)
|
|
}
|
|
}
|
|
|
|
// ideasQuick is the POST /ideas/quick body. Both fields are required:
|
|
// exclude is [] on a first page.
|
|
type ideasQuick struct {
|
|
Kind *string `json:"kind"`
|
|
Exclude []string `json:"exclude"`
|
|
}
|
|
|
|
// postIdeasQuick starts finding a page of Quick dinner ideas of a kind,
|
|
// crockpot or under30, none named in exclude, and answers 202 with the
|
|
// jobId that GET /jobs/{id} reports on, without waiting for the job. A
|
|
// missing or unknown kind, or an exclude list the household must correct,
|
|
// is 400.
|
|
func postIdeasQuick(logger *slog.Logger, svc *ideas.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
body, err := decodePatch[ideasQuick](http.MaxBytesReader(w, r.Body, maxIdeasBody))
|
|
if err != nil {
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
|
return
|
|
}
|
|
switch {
|
|
case body.Kind == nil:
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "kind: required")
|
|
return
|
|
case body.Exclude == nil:
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "exclude: required")
|
|
return
|
|
}
|
|
started, err := svc.StartQuick(r.Context(), *body.Kind, body.Exclude)
|
|
var invalid *ideas.InvalidError
|
|
switch {
|
|
case errors.As(err, &invalid):
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", invalid.Message)
|
|
return
|
|
case err != nil:
|
|
logger.ErrorContext(r.Context(), "start quick ideas", "error", err)
|
|
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
|
return
|
|
}
|
|
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")
|
|
}
|