Files

225 lines
8.5 KiB
Go
Raw Permalink Normal View History

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")
}