GET /api/v1/shopping/{weekStart} reads the week's Supply run with its
week label, counts and groups by plan day; POST
/api/v1/shopping/{weekStart}/generate makes or remakes it through
shopping.Generate; PATCH /api/v1/shopping/items/{id} sets an item's
needed flag and answers with the list's new counts, 409 archived once
the list is in the Supply archive and 404 for an unknown item.
A successful Push to Calendar now makes the pushed week's Supply run.
Generation runs after the push is recorded and never fails or undoes
it: the 200 answer reports supply_run generated, archived or failed,
and a failure is logged as an error.
Maestro task d076480c-6c59-4c57-9023-6941badb4520.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
199 lines
6.3 KiB
Go
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")
|
|
}
|
|
}
|