shopping.Freeze(weekStart) archives a week's Supply run once its week has
ended in the household's time zone, setting status archived and frozen_at;
it is idempotent, and a week not yet ended (ErrWeekNotEnded) or with no
list (ErrListNotFound) is an explicit error. A list still untitled is
titled first with archive_title and the same conditional write the
title_supply_run job uses; a failed title is logged and the list is
frozen untitled, keeping its week label.
GET /api/v1/shopping/archive?by=week|month|year returns the Supply runs
up to the household's current week, newest first, grouped by the
Monday's ISO week, month or year, each post with its week label, title or
null, dinner, item and on-hand counts, dinner names and current flag. Any
other by is 400 invalid_request. Frozen lists stay readable through
GET /shopping/{weekStart} and reject item PATCHes with 409 archived.
Maestro task 7ac36526-cb51-4dc7-bbad-6ebbee481942.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
263 lines
8.4 KiB
Go
263 lines
8.4 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})
|
|
}
|
|
}
|
|
|
|
// supplyArchive is the GET /shopping/archive answer: the Supply archive's
|
|
// periods, newest first, as grouped by by.
|
|
type supplyArchive struct {
|
|
By string `json:"by"`
|
|
Periods []archivePeriod `json:"periods"`
|
|
}
|
|
|
|
// archivePeriod is one heading of the Supply archive and its posts,
|
|
// newest first.
|
|
type archivePeriod struct {
|
|
Key string `json:"key"`
|
|
Heading string `json:"heading"`
|
|
Posts []archivePost `json:"posts"`
|
|
}
|
|
|
|
// archivePost is one week's Supply run in the Supply archive. label is
|
|
// the week label, which stands in for title while it is null, and
|
|
// onHandCount counts the items marked "have it".
|
|
type archivePost struct {
|
|
WeekStart string `json:"weekStart"`
|
|
Label string `json:"label"`
|
|
Title *string `json:"title"`
|
|
DinnerCount int `json:"dinnerCount"`
|
|
ItemCount int `json:"itemCount"`
|
|
OnHandCount int `json:"onHandCount"`
|
|
Dinners []string `json:"dinners"`
|
|
Current bool `json:"current"`
|
|
}
|
|
|
|
// getSupplyArchive answers GET /shopping/archive?by=week|month|year with
|
|
// the Supply archive grouped by that period.
|
|
func getSupplyArchive(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
by := r.URL.Query().Get("by")
|
|
periods, err := svc.Archive(r.Context(), by)
|
|
if errors.Is(err, shopping.ErrInvalidPeriod) {
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", shopping.ErrInvalidPeriod.Error())
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeShoppingError(w, r, logger, "read Supply archive", err)
|
|
return
|
|
}
|
|
out := supplyArchive{By: by, Periods: make([]archivePeriod, len(periods))}
|
|
for i, p := range periods {
|
|
posts := make([]archivePost, len(p.Posts))
|
|
for j, post := range p.Posts {
|
|
posts[j] = archivePost{
|
|
WeekStart: post.WeekStart,
|
|
Label: post.Label,
|
|
Title: post.Title,
|
|
DinnerCount: len(post.Dinners),
|
|
ItemCount: post.Items,
|
|
OnHandCount: post.OnHand,
|
|
Dinners: post.Dinners,
|
|
Current: post.Current,
|
|
}
|
|
}
|
|
out.Periods[i] = archivePeriod{Key: p.Key, Heading: p.Heading, Posts: posts}
|
|
}
|
|
writeJSON(w, r, logger, http.StatusOK, out)
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|