Files
MealQuest/internal/server/shopping.go
T

199 lines
6.3 KiB
Go

package server
import (
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// maxShoppingItemBody bounds a PATCH /shopping/items/{id} body.
const maxShoppingItemBody = 1 << 10
// supplyRun is a Supply run as the API shows it. label is the week label,
// which stands in for title until the AI writes one.
type supplyRun struct {
WeekStart string `json:"weekStart"`
Label string `json:"label"`
Title *string `json:"title"`
Status string `json:"status"`
SentToChatAt *string `json:"sent_to_chat_at"`
shopping.Counts
Groups []supplyRunGroup `json:"groups"`
}
// supplyRunGroup is the items of one plan day's dinner. day is the plan
// day's weekday, such as "Monday".
type supplyRunGroup struct {
Dinner supplyRunDinner `json:"dinner"`
Date string `json:"date"`
Day string `json:"day"`
Items []supplyRunItem `json:"items"`
}
type supplyRunDinner struct {
ID string `json:"id"`
Name string `json:"name"`
}
// supplyRunItem is one ingredient to buy; qty is null when the recipe
// gives none, and needed is false once it is marked "have it".
type supplyRunItem struct {
ID string `json:"id"`
Name string `json:"name"`
Qty *string `json:"qty"`
Needed bool `json:"needed"`
}
// generatedSupplyRun is the POST /shopping/{weekStart}/generate answer:
// the Supply run as made, and the plan days with nothing to shop for.
type generatedSupplyRun struct {
supplyRun
Skipped []shopping.SkippedDay `json:"skipped"`
}
// shoppingItemPatch is the PATCH /shopping/items/{id} body.
type shoppingItemPatch struct {
Needed *bool `json:"needed"`
}
// toggledItem is the PATCH /shopping/items/{id} answer: the item's flag
// and its Supply run's counts after the change.
type toggledItem struct {
ID string `json:"id"`
Needed bool `json:"needed"`
shopping.Counts
}
// getSupplyRun answers GET /shopping/{weekStart} with the week's Supply
// run.
func getSupplyRun(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
week, ok := weekStartOrFail(w, r, logger)
if !ok {
return
}
l, err := svc.Get(r.Context(), week)
if err != nil {
writeShoppingError(w, r, logger, "read Supply run", err)
return
}
out, err := supplyRunOf(week, l)
if err != nil {
writeShoppingError(w, r, logger, "read Supply run", err)
return
}
writeJSON(w, r, logger, http.StatusOK, out)
}
}
// postSupplyRunGenerate makes, or remakes, the week's Supply run from its
// plan and answers with it and the plan days it skipped.
func postSupplyRunGenerate(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
week, ok := weekStartOrFail(w, r, logger)
if !ok {
return
}
g, err := svc.Generate(r.Context(), week)
if err != nil {
writeShoppingError(w, r, logger, "generate Supply run", err)
return
}
out, err := supplyRunOf(week, g.List)
if err != nil {
writeShoppingError(w, r, logger, "generate Supply run", err)
return
}
skipped := g.Skipped
if skipped == nil {
skipped = []shopping.SkippedDay{}
}
writeJSON(w, r, logger, http.StatusOK, generatedSupplyRun{supplyRun: out, Skipped: skipped})
}
}
// patchShoppingItem marks a Supply run item needed or "have it" and
// answers with its Supply run's new counts.
func patchShoppingItem(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := decodePatch[shoppingItemPatch](http.MaxBytesReader(w, r.Body, maxShoppingItemBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if body.Needed == nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "needed: required")
return
}
id := chi.URLParam(r, "id")
c, err := svc.SetNeeded(r.Context(), id, *body.Needed)
if err != nil {
writeShoppingError(w, r, logger, "mark Supply run item", err)
return
}
writeJSON(w, r, logger, http.StatusOK, toggledItem{ID: id, Needed: *body.Needed, Counts: c})
}
}
// supplyRunOf is l, the Supply run of the week starting week, as the API
// shows it.
func supplyRunOf(week store.LocalDate, l shopping.List) (supplyRun, error) {
label, err := household.WeekLabel(week)
if err != nil {
return supplyRun{}, err
}
groups := make([]supplyRunGroup, len(l.Groups))
for i, g := range l.Groups {
date, err := time.Parse(time.DateOnly, g.Date)
if err != nil {
return supplyRun{}, fmt.Errorf("group %s date: %w", g.ID, err)
}
items := make([]supplyRunItem, len(g.Items))
for j, it := range g.Items {
items[j] = supplyRunItem{ID: it.ID, Name: it.Name, Qty: it.Qty, Needed: it.Needed}
}
groups[i] = supplyRunGroup{
Dinner: supplyRunDinner{ID: g.DinnerID, Name: g.DinnerName},
Date: g.Date,
Day: date.Weekday().String(),
Items: items,
}
}
return supplyRun{
WeekStart: l.WeekStart,
Label: label,
Title: l.Title,
Status: l.Status,
SentToChatAt: l.SentToChatAt,
Counts: l.Counts(),
Groups: groups,
}, nil
}
// writeShoppingError maps a shopping.Service error to its API error: a
// week with no Supply run or no plan, or an unknown item, is 404, and a
// change to a Supply run in the Supply archive is 409 archived.
func writeShoppingError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
switch {
case errors.Is(err, shopping.ErrListNotFound):
writeError(w, r, logger, http.StatusNotFound, "not_found", "Supply run not found")
case errors.Is(err, shopping.ErrItemNotFound):
writeError(w, r, logger, http.StatusNotFound, "not_found", "Supply run item not found")
case errors.Is(err, shopping.ErrPlanNotFound):
writeError(w, r, logger, http.StatusNotFound, "not_found", "plan not found")
case errors.Is(err, shopping.ErrFrozen):
writeError(w, r, logger, http.StatusConflict, "archived", err.Error())
default:
logger.ErrorContext(r.Context(), action, "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
}
}