Task 36c40130-a69a-46ed-a047-2cdc5ee2d2e0 (Feature 23, task 1 of 2). A backfill_dinner_photos job grounds, by name with the existing recipes.Grounder.Ground, every unarchived dinner whose grounding failed and that has no photo and no pasted recipe_url. Ground already tries the ranked candidates best first and keeps the first whose photo caches and whose ingredients normalize, reusing stored recipes and photos. Success records the recipe and photo as grounded; a failure leaves the dinner failed with the new grounding_error for the fix-recipe flow, without stopping its siblings. Writes are guarded on status, photo and grounding_revision, so grounded, manual, pending, renamed and archived dinners are never touched. POST /api/v1/settings/photo-backfill starts a full pass and answers 202 with the job id; the kind is service-only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
291 lines
11 KiB
Go
291 lines
11 KiB
Go
package server
|
|
|
|
import (
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
|
)
|
|
|
|
// maxDinnerBody bounds a POST or PATCH /dinners request body.
|
|
const maxDinnerBody = 16 << 10
|
|
|
|
// maxGroundBody bounds a POST /dinners/{id}/ground body, which may carry a
|
|
// manual recipe of dinners.MaxManualLines lines of each kind.
|
|
const maxGroundBody = 512 << 10
|
|
|
|
// dinnerCreate is the POST /dinners body: a name, with or without a
|
|
// recipe_url.
|
|
type dinnerCreate struct {
|
|
Name *string `json:"name"`
|
|
RecipeURL *string `json:"recipe_url"`
|
|
}
|
|
|
|
// dinnerPatch is the PATCH /dinners/{id} body. Every field is optional;
|
|
// unknown fields and null values are refused.
|
|
type dinnerPatch struct {
|
|
Name *string `json:"name"`
|
|
Method *string `json:"method"`
|
|
MinutesTotal *int `json:"minutes_total"`
|
|
Difficulty *string `json:"difficulty"`
|
|
}
|
|
|
|
type dinnerList struct {
|
|
Dinners []dinners.Dinner `json:"dinners"`
|
|
}
|
|
|
|
// dinnerGround is the POST /dinners/{id}/ground body: exactly one of
|
|
// candidate_id, recipe_url and manual.
|
|
type dinnerGround struct {
|
|
CandidateID *string `json:"candidate_id"`
|
|
RecipeURL *string `json:"recipe_url"`
|
|
Manual *dinnerManualRecipe `json:"manual"`
|
|
}
|
|
|
|
type dinnerManualRecipe struct {
|
|
Ingredients []string `json:"ingredients"`
|
|
Steps []string `json:"steps"`
|
|
Minutes *int `json:"minutes"`
|
|
Method *string `json:"method"`
|
|
}
|
|
|
|
type candidateList struct {
|
|
Candidates []dinners.Candidate `json:"candidates"`
|
|
}
|
|
|
|
// getDinners answers GET /dinners?status=regular|quest_log.
|
|
func getDinners(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
list, err := svc.List(r.Context(), r.URL.Query().Get("status"))
|
|
if err != nil {
|
|
writeDinnerError(w, r, logger, "list dinners", err)
|
|
return
|
|
}
|
|
writeJSON(w, r, logger, http.StatusOK, dinnerList{Dinners: list})
|
|
}
|
|
}
|
|
|
|
// getDinner answers GET /dinners/{id} with the dinner, its recipe and the
|
|
// reason it was suggested. An archived dinner is 404, like an unknown one,
|
|
// not a 200 with its status: it has no detail to show. source never has the
|
|
// recipe page's link, and source.site_name is null unless the page itself
|
|
// named its site — a name that could be a host, the link's or a redirect's,
|
|
// is withheld (dinners.Source) — so the detail screen shows "Recipe from
|
|
// {site}" only when site_name is not null.
|
|
func getDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
d, err := svc.Get(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
writeDinnerError(w, r, logger, "get dinner", err)
|
|
return
|
|
}
|
|
writeJSON(w, r, logger, http.StatusOK, d)
|
|
}
|
|
}
|
|
|
|
// postDinner adds a Regular, recording the session's actor as updated_by,
|
|
// and answers 201 with it and its grounding_job_id without waiting for the
|
|
// job. When the job cannot be enqueued it still answers 201, with the dinner
|
|
// grounding_status failed and grounding_job_id null.
|
|
func postDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
actor, ok := actorOrFail(w, r, logger)
|
|
if !ok {
|
|
return
|
|
}
|
|
body, err := decodePatch[dinnerCreate](http.MaxBytesReader(w, r.Body, maxDinnerBody))
|
|
if err != nil {
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
|
return
|
|
}
|
|
d, err := svc.CreateRegular(r.Context(), dinners.NewRegular(body), actor.Subject)
|
|
if err != nil {
|
|
writeDinnerError(w, r, logger, "create dinner", err)
|
|
return
|
|
}
|
|
writeJSON(w, r, logger, http.StatusCreated, d)
|
|
}
|
|
}
|
|
|
|
// postPhotoBackfill starts a photo backfill over every dinner without a
|
|
// photo and answers 202 with its job id, which GET /jobs/{id} reports on,
|
|
// without waiting for the job.
|
|
func postPhotoBackfill(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
id, err := svc.StartPhotoBackfill(r.Context())
|
|
if err != nil {
|
|
writeDinnerError(w, r, logger, "start photo backfill", err)
|
|
return
|
|
}
|
|
writeJSON(w, r, logger, http.StatusAccepted, jobCreated{ID: id})
|
|
}
|
|
}
|
|
|
|
// patchDinner edits a dinner, recording the session's actor as updated_by.
|
|
func patchDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
actor, ok := actorOrFail(w, r, logger)
|
|
if !ok {
|
|
return
|
|
}
|
|
patch, err := decodePatch[dinnerPatch](http.MaxBytesReader(w, r.Body, maxDinnerBody))
|
|
if err != nil {
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
|
return
|
|
}
|
|
d, err := svc.Update(r.Context(), chi.URLParam(r, "id"), dinners.Patch(patch), actor.Subject)
|
|
if err != nil {
|
|
writeDinnerError(w, r, logger, "update dinner", err)
|
|
return
|
|
}
|
|
writeJSON(w, r, logger, http.StatusOK, d)
|
|
}
|
|
}
|
|
|
|
// getDinnerCandidates answers GET /dinners/{id}/recipe-candidates with up
|
|
// to dinners.MaxCandidates recipe pages for the dinner's name.
|
|
func getDinnerCandidates(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
list, err := svc.Candidates(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
writeDinnerError(w, r, logger, "list recipe candidates", err)
|
|
return
|
|
}
|
|
writeJSON(w, r, logger, http.StatusOK, candidateList{Candidates: list})
|
|
}
|
|
}
|
|
|
|
// postDinnerGround grounds a dinner by hand in a candidate, a pasted link
|
|
// or a recipe typed in, recording the session's actor as updated_by.
|
|
func postDinnerGround(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
actor, ok := actorOrFail(w, r, logger)
|
|
if !ok {
|
|
return
|
|
}
|
|
body, err := decodePatch[dinnerGround](http.MaxBytesReader(w, r.Body, maxGroundBody))
|
|
if err != nil {
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
|
return
|
|
}
|
|
fix := dinners.Fix{CandidateID: body.CandidateID, RecipeURL: body.RecipeURL}
|
|
if body.Manual != nil {
|
|
m := dinners.ManualRecipe(*body.Manual)
|
|
fix.Manual = &m
|
|
}
|
|
d, err := svc.Ground(r.Context(), chi.URLParam(r, "id"), fix, actor.Subject)
|
|
if err != nil {
|
|
writeDinnerError(w, r, logger, "ground dinner", err)
|
|
return
|
|
}
|
|
writeJSON(w, r, logger, http.StatusOK, d)
|
|
}
|
|
}
|
|
|
|
// deleteDinner archives a dinner; the row is never deleted.
|
|
func deleteDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
actor, ok := actorOrFail(w, r, logger)
|
|
if !ok {
|
|
return
|
|
}
|
|
d, err := svc.Archive(r.Context(), chi.URLParam(r, "id"), actor.Subject)
|
|
if err != nil {
|
|
writeDinnerError(w, r, logger, "archive dinner", err)
|
|
return
|
|
}
|
|
writeJSON(w, r, logger, http.StatusOK, d)
|
|
}
|
|
}
|
|
|
|
// postDinnerFavorite toggles a dinner's is_favorite.
|
|
func postDinnerFavorite(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
actor, ok := actorOrFail(w, r, logger)
|
|
if !ok {
|
|
return
|
|
}
|
|
d, err := svc.ToggleFavorite(r.Context(), chi.URLParam(r, "id"), actor.Subject)
|
|
if err != nil {
|
|
writeDinnerError(w, r, logger, "toggle dinner favorite", err)
|
|
return
|
|
}
|
|
writeJSON(w, r, logger, http.StatusOK, d)
|
|
}
|
|
}
|
|
|
|
// actorOrFail returns the session's actor, or answers 500 when the request
|
|
// has none, which behind requireSession is a wiring bug.
|
|
func actorOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger) (auth.Actor, bool) {
|
|
actor, ok := auth.ActorFrom(r.Context())
|
|
if !ok {
|
|
logger.ErrorContext(r.Context(), r.Method+" "+r.URL.Path+" without a session")
|
|
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
|
}
|
|
return actor, ok
|
|
}
|
|
|
|
// writeDinnerError maps a dinners.Service error to its API error. A page
|
|
// that cannot ground the dinner is 422 ungroundable; recipe search not set
|
|
// up is 409 not_configured; a search or model failure is 502; grounding by
|
|
// hand that runs past dinners.GroundTimeout is 504 ground_timeout; grounding
|
|
// an archived dinner is 409 dinner_archived and one changed while grounding
|
|
// 409 ground_conflict. Any other grounding failure, by candidate, link or
|
|
// manual recipe, wraps dinners.ErrGroundFailed and is 500, logged by reason
|
|
// and dinner id only, as is any other failure finding candidates, which
|
|
// wraps dinners.ErrCandidatesFailed.
|
|
func writeDinnerError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
|
|
var invalid *dinners.ValidationError
|
|
var dup *dinners.DuplicateError
|
|
var ungroundable *recipes.ErrUngroundable
|
|
switch {
|
|
case errors.As(err, &ungroundable):
|
|
// The reasons quote dependency errors, so they are neither returned
|
|
// nor logged.
|
|
writeError(w, r, logger, http.StatusUnprocessableEntity, "ungroundable", "no usable recipe was found at that link")
|
|
case errors.Is(err, recipes.ErrSearchNotConfigured):
|
|
writeError(w, r, logger, http.StatusConflict, "not_configured", "recipe search is not configured")
|
|
case errors.Is(err, recipes.ErrSearchFailed):
|
|
// The error may quote the search provider's response, so only the
|
|
// reason is logged.
|
|
logger.WarnContext(r.Context(), action, "reason", "search_failed")
|
|
writeError(w, r, logger, http.StatusBadGateway, "search_failed", "recipe search failed")
|
|
case errors.Is(err, recipes.ErrNormalizeFailed):
|
|
logger.WarnContext(r.Context(), action, "reason", "normalize_recipe failed")
|
|
writeError(w, r, logger, http.StatusBadGateway, "ai_failed", "the AI provider failed to read the recipe")
|
|
case errors.Is(err, dinners.ErrGroundFailed):
|
|
// The error may quote the pasted link, which can carry a personal
|
|
// token, and dependency errors, so only the reason and dinner are
|
|
// logged.
|
|
logger.ErrorContext(r.Context(), action, "reason", "ground_failed", "dinner_id", chi.URLParam(r, "id"))
|
|
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
|
case errors.Is(err, dinners.ErrCandidatesFailed):
|
|
// The error may quote a candidate's URL, which can carry a
|
|
// personal token, and dependency errors, so only the reason and
|
|
// dinner are logged.
|
|
logger.ErrorContext(r.Context(), action, "reason", "candidates_failed", "dinner_id", chi.URLParam(r, "id"))
|
|
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
|
case errors.Is(err, dinners.ErrArchived):
|
|
writeError(w, r, logger, http.StatusConflict, "dinner_archived", "the dinner is archived")
|
|
case errors.Is(err, dinners.ErrGroundConflict):
|
|
writeError(w, r, logger, http.StatusConflict, "ground_conflict", "the dinner changed while its recipe was grounding; reload and try again")
|
|
case errors.Is(err, dinners.ErrGroundTimeout):
|
|
logger.WarnContext(r.Context(), action, "reason", "ground_timeout")
|
|
writeError(w, r, logger, http.StatusGatewayTimeout, "ground_timeout", "grounding the recipe took too long; try again")
|
|
case errors.As(err, &invalid):
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_dinner", invalid.Error())
|
|
case errors.As(err, &dup):
|
|
writeError(w, r, logger, http.StatusConflict, "duplicate_dinner", dup.Error())
|
|
case errors.Is(err, dinners.ErrNotFound):
|
|
writeError(w, r, logger, http.StatusNotFound, "not_found", "dinner not found")
|
|
default:
|
|
logger.ErrorContext(r.Context(), action, "error", err)
|
|
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
|
}
|
|
}
|