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>
732 lines
25 KiB
Go
732 lines
25 KiB
Go
// Package dinners keeps the household's dinners: adding Regulars by name or
|
|
// recipe link, listing Regulars and the Quest log, editing, archiving and
|
|
// favoriting, and grounding each dinner in a recipe. Names are deduplicated with the harness's dinner-name rules,
|
|
// so the Regulars the household types and the Side quests the AI suggests
|
|
// are compared the same way.
|
|
package dinners
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"math"
|
|
"net/url"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
|
)
|
|
|
|
// Dinner statuses (the dinners.status column).
|
|
const (
|
|
StatusSuggested = "suggested"
|
|
StatusQuestLog = "quest_log"
|
|
StatusTried = "tried"
|
|
StatusRegular = "regular"
|
|
StatusArchived = "archived"
|
|
)
|
|
|
|
// Limits on what a member may enter.
|
|
const (
|
|
MaxNameChars = 120
|
|
MaxRecipeURL = 2048
|
|
MaxMinutes = 1440 // a day; a crock-pot dinner fits
|
|
photoListWidth = 400 // the stored photo size lists show
|
|
)
|
|
|
|
// Methods and Difficulties are the values the dinners table allows.
|
|
var (
|
|
Methods = []string{"stovetop", "oven", "crockpot", "grill", "other"}
|
|
Difficulties = []string{"easy", "medium", "hard"}
|
|
)
|
|
|
|
// listable are the statuses List answers for.
|
|
var listable = []string{StatusRegular, StatusQuestLog}
|
|
|
|
// ErrNotFound is returned for an unknown dinner id.
|
|
var ErrNotFound = errors.New("dinner not found")
|
|
|
|
// Dinner is a dinner as the API shows it. It has no recipe_url: a pasted
|
|
// link may carry a personal token, so it stays in the database for
|
|
// grounding and never leaves the server.
|
|
type Dinner struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
Origin string `json:"origin"`
|
|
Method *string `json:"method"`
|
|
MinutesTotal *int64 `json:"minutes_total"`
|
|
Difficulty *string `json:"difficulty"`
|
|
Cuisine *string `json:"cuisine"`
|
|
IsFavorite bool `json:"is_favorite"`
|
|
RecipeID *string `json:"recipe_id"`
|
|
PhotoURL *string `json:"photo_url"`
|
|
GroundingStatus string `json:"grounding_status"`
|
|
// GroundingError is why grounding failed, one of the Reason codes;
|
|
// null unless GroundingStatus is failed.
|
|
GroundingError *string `json:"grounding_error"`
|
|
UpdatedBy *string `json:"updated_by"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
// NewRegular is a Regular to add: its Name, which the household always
|
|
// gives, and optionally the RecipeURL to ground it in. The name is never
|
|
// taken from the link: a pasted link may carry a personal token anywhere in
|
|
// it, and the name is shown in every dinner response.
|
|
type NewRegular struct {
|
|
Name *string
|
|
RecipeURL *string
|
|
}
|
|
|
|
// Patch edits a dinner. Nil fields are left as they are.
|
|
type Patch struct {
|
|
Name *string
|
|
Method *string
|
|
MinutesTotal *int
|
|
Difficulty *string
|
|
}
|
|
|
|
// Problem is one invalid field, named by its JSON key.
|
|
type Problem struct {
|
|
Field string
|
|
Reason string
|
|
}
|
|
|
|
// ValidationError lists every invalid field of a request.
|
|
type ValidationError struct {
|
|
Problems []Problem
|
|
}
|
|
|
|
func (e *ValidationError) Error() string {
|
|
parts := make([]string, len(e.Problems))
|
|
for i, p := range e.Problems {
|
|
parts[i] = p.Field + ": " + p.Reason
|
|
}
|
|
return "invalid dinner: " + strings.Join(parts, "; ")
|
|
}
|
|
|
|
func invalid(field, reason string) *ValidationError {
|
|
return &ValidationError{Problems: []Problem{{Field: field, Reason: reason}}}
|
|
}
|
|
|
|
// DuplicateError is returned when a new or renamed dinner repeats an
|
|
// unarchived one: its name is harness.Similar, or it has the same recipe
|
|
// link.
|
|
type DuplicateError struct {
|
|
Existing Dinner
|
|
}
|
|
|
|
func (e *DuplicateError) Error() string {
|
|
return fmt.Sprintf("%q is already a dinner (%s)", e.Existing.Name, e.Existing.Status)
|
|
}
|
|
|
|
// Service reads and changes dinners.
|
|
type Service struct {
|
|
st *store.Store
|
|
grounder *recipes.Grounder
|
|
logger *slog.Logger
|
|
groundJob *jobs.Kind[GroundJob]
|
|
// backfillJob enqueues KindBackfillPhotos jobs.
|
|
backfillJob *jobs.Kind[BackfillJob]
|
|
// wake is called after every committed change to a dinner's method or
|
|
// minutes, which can move its crock-pot start reminder.
|
|
wake func()
|
|
|
|
// groundingsMu guards groundings: the KindGroundDinner jobs running,
|
|
// by dinner id, so archiving or renaming a dinner can stop them.
|
|
groundingsMu sync.Mutex
|
|
groundings map[string]map[*runningGrounding]struct{}
|
|
|
|
// offerKey keys the candidate ids Candidates offers, so they are
|
|
// opaque and stable for this process.
|
|
offerKey []byte
|
|
// offersMu guards offers and offerSearches. offers holds, by dinner
|
|
// id, the candidates last offered by Candidates until they expire, so
|
|
// a candidate id is resolved here and its URL never leaves the server.
|
|
// offerSearches holds the Candidates searches running, by dinner id,
|
|
// so clearing a dinner's offers keeps them from storing stale ones.
|
|
offersMu sync.Mutex
|
|
offers map[string]*offerSet
|
|
offerSearches map[string]map[*offerSearch]struct{}
|
|
}
|
|
|
|
// NewService returns a service over st that grounds dinners with grounder,
|
|
// calls wake (the scheduler's Wake) after every committed change to a
|
|
// dinner's method or minutes, and registers the KindGroundDinner and
|
|
// KindBackfillPhotos jobs on runner, which must not have started yet.
|
|
func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner, wake func(), logger *slog.Logger) (*Service, error) {
|
|
switch {
|
|
case st == nil:
|
|
return nil, errors.New("dinners: nil store")
|
|
case grounder == nil:
|
|
return nil, errors.New("dinners: nil grounder")
|
|
case runner == nil:
|
|
return nil, errors.New("dinners: nil job runner")
|
|
case wake == nil:
|
|
return nil, errors.New("dinners: nil wake")
|
|
case logger == nil:
|
|
return nil, errors.New("dinners: nil logger")
|
|
}
|
|
s := &Service{st: st, grounder: grounder, wake: wake, logger: logger, groundings: make(map[string]map[*runningGrounding]struct{}), offerKey: make([]byte, 32), offers: make(map[string]*offerSet), offerSearches: make(map[string]map[*offerSearch]struct{})}
|
|
rand.Read(s.offerKey) // never fails: it crashes the program instead
|
|
kind, err := jobs.Register(runner, KindGroundDinner, s.runGroundJob, Reasons...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.groundJob = kind
|
|
backfill, err := jobs.Register(runner, KindBackfillPhotos, s.runBackfillJob)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := backfill.ServiceOnly(); err != nil {
|
|
return nil, err
|
|
}
|
|
s.backfillJob = backfill
|
|
return s, nil
|
|
}
|
|
|
|
// Created is a new Regular with the id of the job grounding it, which is
|
|
// nil when the job could not be enqueued and the dinner's grounding is
|
|
// failed instead.
|
|
type Created struct {
|
|
Dinner
|
|
GroundingJobID *string `json:"grounding_job_id"`
|
|
}
|
|
|
|
// CreateRegular adds a Regular named r.Name, keeping r.RecipeURL for
|
|
// grounding when given, with status and origin regular and grounding_status
|
|
// pending, recording updatedBy, and enqueues the KindGroundDinner job that grounds it. It returns without
|
|
// waiting for the job. A name repeating an unarchived dinner is a
|
|
// *DuplicateError. When the job cannot be enqueued, the stored dinner is
|
|
// returned with grounding_status failed (ReasonGroundingError) and no job
|
|
// id, so the household can fix its recipe by hand.
|
|
func (s *Service) CreateRegular(ctx context.Context, r NewRegular, updatedBy string) (Created, error) {
|
|
d, err := s.createRegular(ctx, r, updatedBy)
|
|
if err != nil {
|
|
return Created{}, err
|
|
}
|
|
jobID, err := s.enqueueGrounding(ctx, d.ID, d.GroundingRevision)
|
|
if err != nil {
|
|
return Created{}, err
|
|
}
|
|
if jobID == nil {
|
|
row, err := db.New(s.st.Reader).GetDinner(context.WithoutCancel(ctx), d.ID)
|
|
if err != nil {
|
|
return Created{}, fmt.Errorf("dinners: read %s: %w", d.ID, err)
|
|
}
|
|
return Created{Dinner: fromRow(row)}, nil
|
|
}
|
|
return Created{Dinner: fromRow(d), GroundingJobID: jobID}, nil
|
|
}
|
|
|
|
// enqueueGrounding enqueues the KindGroundDinner job for pending dinner id
|
|
// at grounding_revision revision and returns its id. When the job cannot be
|
|
// enqueued, the dinner's grounding is failed with ReasonGroundingError, so
|
|
// it is not left pending with no job, and the id is nil.
|
|
func (s *Service) enqueueGrounding(ctx context.Context, id string, revision int64) (*string, error) {
|
|
jobID, err := s.groundJob.Enqueue(ctx, GroundJob{DinnerID: id, Revision: revision})
|
|
if err == nil {
|
|
return &jobID, nil
|
|
}
|
|
// The error is a database error, never a dependency's text.
|
|
s.logger.ErrorContext(ctx, "dinner grounding not enqueued", "dinner_id", id, "error", err)
|
|
if _, err := db.New(s.st.Writer).FailPendingDinnerGrounding(context.WithoutCancel(ctx), db.FailPendingDinnerGroundingParams{
|
|
GroundingError: sql.NullString{String: ReasonGroundingError, Valid: true},
|
|
UpdatedAt: store.Now().String(),
|
|
ID: id,
|
|
GroundingRevision: revision,
|
|
}); err != nil {
|
|
return nil, fmt.Errorf("dinners: record failed grounding of %s: %w", id, err)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy string) (db.Dinner, error) {
|
|
if updatedBy == "" {
|
|
return db.Dinner{}, errors.New("dinners: create: no member to record as updated_by")
|
|
}
|
|
if r.Name == nil {
|
|
return db.Dinner{}, invalid("name", "give the dinner's name, with or without a recipe_url")
|
|
}
|
|
name, err := checkName(*r.Name)
|
|
if err != nil {
|
|
return db.Dinner{}, err
|
|
}
|
|
var recipeURL sql.NullString
|
|
if r.RecipeURL != nil {
|
|
u, err := checkRecipeURL(*r.RecipeURL)
|
|
if err != nil {
|
|
return db.Dinner{}, err
|
|
}
|
|
recipeURL = sql.NullString{String: u.String(), Valid: true}
|
|
}
|
|
|
|
var row db.Dinner
|
|
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
|
q := db.New(tx)
|
|
if err := checkUnique(ctx, q, "", name, recipeURL); err != nil {
|
|
return err
|
|
}
|
|
now := store.Now().String()
|
|
var err error
|
|
row, err = q.CreateDinner(ctx, db.CreateDinnerParams{
|
|
ID: store.NewID(),
|
|
Name: name,
|
|
NormalizedName: harness.Normalize(name),
|
|
Status: StatusRegular,
|
|
Origin: "regular",
|
|
RecipeUrl: recipeURL,
|
|
GroundingStatus: "pending",
|
|
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("dinners: create: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return db.Dinner{}, err
|
|
}
|
|
return row, nil
|
|
}
|
|
|
|
// IdeaOrigins are the origins of a dinner added from an idea: ingredients
|
|
// for Cook from what I have, quick for Quick dinners.
|
|
var IdeaOrigins = []string{"ingredients", "quick"}
|
|
|
|
// NewQuestLog is a dinner to add to the Quest log from an idea: its name,
|
|
// the recipe the idea was already grounded in, and its origin, one of
|
|
// IdeaOrigins.
|
|
type NewQuestLog struct {
|
|
Name string
|
|
RecipeID string
|
|
Origin string
|
|
}
|
|
|
|
// AddToQuestLog adds n to the Quest log, recording updatedBy: a dinner
|
|
// with status quest_log and origin n.Origin, grounded in recipe n.RecipeID
|
|
// (its method, minutes, difficulty and photo), and returns it with
|
|
// created true. When an unarchived dinner already repeats it, by the same
|
|
// rule that refuses a duplicate Regular (a harness.Similar name, or the
|
|
// recipe's link), that dinner is returned as it is, with created false,
|
|
// so adding the same idea again never makes a second dinner: a Regular
|
|
// stays a Regular and a Quest log dinner stays in the Quest log. A
|
|
// repeated dinner still awaiting the household's choice (a suggested Side
|
|
// quest, or one tried and awaiting its Rotation check) is a
|
|
// *DuplicateError and changes nothing: adding it would make that choice
|
|
// for them. An unknown recipe is an error.
|
|
func (s *Service) AddToQuestLog(ctx context.Context, n NewQuestLog, updatedBy string) (d Dinner, created bool, err error) {
|
|
if updatedBy == "" {
|
|
return Dinner{}, false, errors.New("dinners: add to Quest log: no member to record as updated_by")
|
|
}
|
|
if !slices.Contains(IdeaOrigins, n.Origin) {
|
|
return Dinner{}, false, fmt.Errorf("dinners: add to Quest log: origin %q is not one of %s", n.Origin, strings.Join(IdeaOrigins, ", "))
|
|
}
|
|
name, err := checkName(n.Name)
|
|
if err != nil {
|
|
return Dinner{}, false, err
|
|
}
|
|
var row db.Dinner
|
|
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
|
q := db.New(tx)
|
|
recipe, err := q.GetRecipe(ctx, n.RecipeID)
|
|
if err != nil {
|
|
return fmt.Errorf("dinners: add to Quest log: read recipe %s: %w", n.RecipeID, err)
|
|
}
|
|
existing, found, err := findRepeat(ctx, q, "", name, sql.NullString{String: recipe.SourceUrl, Valid: true})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if found {
|
|
if existing.Status != StatusRegular && existing.Status != StatusQuestLog {
|
|
return &DuplicateError{Existing: fromRow(existing)}
|
|
}
|
|
row = existing
|
|
return nil
|
|
}
|
|
now := store.Now().String()
|
|
row, err = q.CreateDinner(ctx, db.CreateDinnerParams{
|
|
ID: store.NewID(),
|
|
Name: name,
|
|
NormalizedName: harness.Normalize(name),
|
|
Status: StatusQuestLog,
|
|
Origin: n.Origin,
|
|
Method: recipe.Method,
|
|
MinutesTotal: recipe.MinutesTotal,
|
|
Difficulty: recipe.Difficulty,
|
|
RecipeID: sql.NullString{String: recipe.ID, Valid: true},
|
|
PhotoID: recipe.PhotoID,
|
|
GroundingStatus: GroundingGrounded,
|
|
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("dinners: add to Quest log: %w", err)
|
|
}
|
|
created = true
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return Dinner{}, false, err
|
|
}
|
|
return fromRow(row), created, nil
|
|
}
|
|
|
|
// List returns the dinners with status, which must be regular or
|
|
// quest_log, ordered by normalized name. The Quest log is the dinners
|
|
// accepted and not yet scheduled, so a quest_log dinner on the plan for the
|
|
// household's current week or a later one is left out of it.
|
|
func (s *Service) List(ctx context.Context, status string) ([]Dinner, error) {
|
|
if !slices.Contains(listable, status) {
|
|
return nil, invalid("status", "must be one of "+strings.Join(listable, ", "))
|
|
}
|
|
var rows []db.Dinner
|
|
var err error
|
|
if status == StatusQuestLog {
|
|
rows, err = s.listQuestLog(ctx)
|
|
} else {
|
|
rows, err = db.New(s.st.Reader).ListDinnersByStatus(ctx, status)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("dinners: list %s: %w", status, err)
|
|
}
|
|
out := make([]Dinner, len(rows))
|
|
for i, row := range rows {
|
|
out[i] = fromRow(row)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// listQuestLog reads the Quest log in one read transaction, so the
|
|
// household's current week and the plans come from the same snapshot.
|
|
func (s *Service) listQuestLog(ctx context.Context) (rows []db.Dinner, err error) {
|
|
tx, err := s.st.Reader.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin read: %w", err)
|
|
}
|
|
defer func() {
|
|
err = errors.Join(err, tx.Rollback())
|
|
}()
|
|
q := db.New(tx)
|
|
settings, err := q.GetHouseholdSettings(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read household settings: %w", err)
|
|
}
|
|
loc, err := household.LoadZone(settings.Timezone)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("household settings: %w", err)
|
|
}
|
|
thisWeek := household.WeekStart(household.Today(time.Now(), loc))
|
|
return q.ListUnplannedQuestLogDinners(ctx, thisWeek.String())
|
|
}
|
|
|
|
// Update applies p to dinner id, recording updatedBy. An invalid patch is a
|
|
// *ValidationError and a new name repeating another unarchived dinner a
|
|
// *DuplicateError; either changes nothing. Renaming a dinner raises its
|
|
// grounding_revision and drops the candidates offered for its old name:
|
|
// each was offered at an earlier revision, so none of them grounds it.
|
|
// Renaming a dinner still pending also enqueues a fresh KindGroundDinner
|
|
// job that grounds the new name, and clears its recipe_url when the
|
|
// normalized name changes: a job queued or running for the old name or
|
|
// link carries the old revision, so it can no longer write the dinner, and
|
|
// one running is stopped.
|
|
func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy string) (Dinner, error) {
|
|
if updatedBy == "" {
|
|
return Dinner{}, errors.New("dinners: update: no member to record as updated_by")
|
|
}
|
|
name, err := p.validate()
|
|
if err != nil {
|
|
return Dinner{}, err
|
|
}
|
|
|
|
var row db.Dinner
|
|
var renamed, regrounding bool
|
|
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
|
q := db.New(tx)
|
|
cur, err := q.GetDinner(ctx, id)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("dinners: read %s: %w", id, err)
|
|
}
|
|
params := db.UpdateDinnerParams{
|
|
ID: cur.ID,
|
|
Name: cur.Name,
|
|
NormalizedName: cur.NormalizedName,
|
|
Status: cur.Status,
|
|
Origin: cur.Origin,
|
|
Method: cur.Method,
|
|
MinutesTotal: cur.MinutesTotal,
|
|
Difficulty: cur.Difficulty,
|
|
Cuisine: cur.Cuisine,
|
|
RecipeID: cur.RecipeID,
|
|
RecipeUrl: cur.RecipeUrl,
|
|
PhotoID: cur.PhotoID,
|
|
GroundingStatus: cur.GroundingStatus,
|
|
GroundingRevision: cur.GroundingRevision,
|
|
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
|
UpdatedAt: store.Now().String(),
|
|
}
|
|
if p.Name != nil {
|
|
if err := checkUnique(ctx, q, cur.ID, name, sql.NullString{}); err != nil {
|
|
return err
|
|
}
|
|
params.Name = name
|
|
params.NormalizedName = harness.Normalize(name)
|
|
if name != cur.Name {
|
|
params.GroundingRevision++
|
|
}
|
|
if cur.GroundingStatus == GroundingPending && params.NormalizedName != cur.NormalizedName {
|
|
// The link was the old dinner's recipe; the new name is
|
|
// grounded by name, and a link can be given by hand.
|
|
params.RecipeUrl = sql.NullString{}
|
|
}
|
|
}
|
|
if p.Method != nil {
|
|
params.Method = sql.NullString{String: *p.Method, Valid: true}
|
|
}
|
|
if p.MinutesTotal != nil {
|
|
params.MinutesTotal = sql.NullInt64{Int64: int64(*p.MinutesTotal), Valid: true}
|
|
}
|
|
if p.Difficulty != nil {
|
|
params.Difficulty = sql.NullString{String: *p.Difficulty, Valid: true}
|
|
}
|
|
row, err = q.UpdateDinner(ctx, params)
|
|
if err != nil {
|
|
return fmt.Errorf("dinners: update %s: %w", id, err)
|
|
}
|
|
renamed = row.GroundingRevision != cur.GroundingRevision
|
|
regrounding = renamed && cur.GroundingStatus == GroundingPending
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return Dinner{}, err
|
|
}
|
|
if p.Method != nil || p.MinutesTotal != nil {
|
|
s.wake()
|
|
}
|
|
if renamed {
|
|
updateCommitted()
|
|
s.clearOffers(row.ID, row.GroundingRevision)
|
|
}
|
|
if regrounding {
|
|
s.stopGroundings(row.ID, row.GroundingRevision)
|
|
jobID, err := s.enqueueGrounding(ctx, row.ID, row.GroundingRevision)
|
|
if err != nil {
|
|
return Dinner{}, err
|
|
}
|
|
if jobID == nil {
|
|
row, err = db.New(s.st.Reader).GetDinner(context.WithoutCancel(ctx), row.ID)
|
|
if err != nil {
|
|
return Dinner{}, fmt.Errorf("dinners: read %s: %w", id, err)
|
|
}
|
|
}
|
|
}
|
|
return fromRow(row), nil
|
|
}
|
|
|
|
// Archive sets dinner id's status to archived, recording updatedBy. The row
|
|
// is kept, so its name still counts for Side quest dedupe. A
|
|
// KindGroundDinner job running for it is stopped, and its candidates no
|
|
// longer ground it.
|
|
func (s *Service) Archive(ctx context.Context, id, updatedBy string) (Dinner, error) {
|
|
if updatedBy == "" {
|
|
return Dinner{}, errors.New("dinners: archive: no member to record as updated_by")
|
|
}
|
|
row, err := db.New(s.st.Writer).ArchiveDinner(ctx, db.ArchiveDinnerParams{
|
|
ID: id,
|
|
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
|
UpdatedAt: store.Now().String(),
|
|
})
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Dinner{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return Dinner{}, fmt.Errorf("dinners: archive %s: %w", id, err)
|
|
}
|
|
s.stopGroundings(row.ID, math.MaxInt64)
|
|
s.clearOffers(row.ID, math.MaxInt64)
|
|
return fromRow(row), nil
|
|
}
|
|
|
|
// ToggleFavorite flips dinner id's is_favorite, recording updatedBy.
|
|
func (s *Service) ToggleFavorite(ctx context.Context, id, updatedBy string) (Dinner, error) {
|
|
if updatedBy == "" {
|
|
return Dinner{}, errors.New("dinners: favorite: no member to record as updated_by")
|
|
}
|
|
row, err := db.New(s.st.Writer).ToggleDinnerFavorite(ctx, db.ToggleDinnerFavoriteParams{
|
|
ID: id,
|
|
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
|
UpdatedAt: store.Now().String(),
|
|
})
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Dinner{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return Dinner{}, fmt.Errorf("dinners: favorite %s: %w", id, err)
|
|
}
|
|
return fromRow(row), nil
|
|
}
|
|
|
|
// validate checks every field of p and returns its trimmed name.
|
|
func (p Patch) validate() (string, error) {
|
|
var problems []Problem
|
|
var name string
|
|
if p.Name == nil && p.Method == nil && p.MinutesTotal == nil && p.Difficulty == nil {
|
|
return "", invalid("patch", "no fields to change")
|
|
}
|
|
if p.Name != nil {
|
|
n, err := checkName(*p.Name)
|
|
var verr *ValidationError
|
|
if errors.As(err, &verr) {
|
|
problems = append(problems, verr.Problems...)
|
|
}
|
|
name = n
|
|
}
|
|
if p.Method != nil && !slices.Contains(Methods, *p.Method) {
|
|
problems = append(problems, Problem{"method", "must be one of " + strings.Join(Methods, ", ")})
|
|
}
|
|
if p.MinutesTotal != nil && (*p.MinutesTotal < 1 || *p.MinutesTotal > MaxMinutes) {
|
|
problems = append(problems, Problem{"minutes_total", fmt.Sprintf("must be 1 to %d", MaxMinutes)})
|
|
}
|
|
if p.Difficulty != nil && !slices.Contains(Difficulties, *p.Difficulty) {
|
|
problems = append(problems, Problem{"difficulty", "must be one of " + strings.Join(Difficulties, ", ")})
|
|
}
|
|
if problems != nil {
|
|
return "", &ValidationError{Problems: problems}
|
|
}
|
|
return name, nil
|
|
}
|
|
|
|
// checkName returns raw trimmed, refusing a name that is empty, too long or
|
|
// has no words left once normalized (so it could not be deduplicated).
|
|
func checkName(raw string) (string, error) {
|
|
name := strings.TrimSpace(raw)
|
|
switch {
|
|
case name == "":
|
|
return "", invalid("name", "must not be empty")
|
|
case utf8.RuneCountInString(name) > MaxNameChars:
|
|
return "", invalid("name", fmt.Sprintf("must be at most %d characters", MaxNameChars))
|
|
case harness.Normalize(name) == "":
|
|
return "", invalid("name", "must name a dish, not only filler words")
|
|
}
|
|
return name, nil
|
|
}
|
|
|
|
// checkRecipeURL parses raw as an absolute https:// link to a recipe page,
|
|
// the only kind recipe grounding fetches.
|
|
func checkRecipeURL(raw string) (*url.URL, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if len(raw) > MaxRecipeURL {
|
|
return nil, invalid("recipe_url", fmt.Sprintf("must be at most %d bytes", MaxRecipeURL))
|
|
}
|
|
u, err := url.Parse(raw)
|
|
switch {
|
|
case err != nil:
|
|
return nil, invalid("recipe_url", "is not a URL")
|
|
case u.Scheme != "https" || u.Hostname() == "":
|
|
return nil, invalid("recipe_url", "must be an https:// link")
|
|
case u.User != nil:
|
|
return nil, invalid("recipe_url", "must not carry a user name or password")
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// checkUnique returns a *DuplicateError when name is harness.Similar to an
|
|
// unarchived dinner other than self, or recipeURL is one's recipe link: the
|
|
// source_url of the recipe it is grounded in, else the recipe_url still
|
|
// awaiting grounding.
|
|
func checkUnique(ctx context.Context, q *db.Queries, self, name string, recipeURL sql.NullString) error {
|
|
row, found, err := findRepeat(ctx, q, self, name, recipeURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if found {
|
|
return &DuplicateError{Existing: fromRow(row)}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// findRepeat returns the first unarchived dinner other than self that
|
|
// name or recipeURL repeats, as checkUnique decides it, and whether there
|
|
// is one.
|
|
func findRepeat(ctx context.Context, q *db.Queries, self, name string, recipeURL sql.NullString) (db.Dinner, bool, error) {
|
|
rows, err := q.ListUnarchivedDinners(ctx)
|
|
if err != nil {
|
|
return db.Dinner{}, false, fmt.Errorf("dinners: list for dedupe: %w", err)
|
|
}
|
|
for _, row := range rows {
|
|
if row.Dinner.ID == self {
|
|
continue
|
|
}
|
|
link := row.Dinner.RecipeUrl
|
|
if row.RecipeSourceUrl.Valid {
|
|
link = row.RecipeSourceUrl
|
|
}
|
|
if harness.Similar(name, row.Dinner.Name) || (recipeURL.Valid && link == recipeURL) {
|
|
return row.Dinner, true, nil
|
|
}
|
|
}
|
|
return db.Dinner{}, false, nil
|
|
}
|
|
|
|
// PhotoURL is where the API serves a list-sized copy of photo id.
|
|
func PhotoURL(id string) string {
|
|
return photoURL(id, photoListWidth)
|
|
}
|
|
|
|
// photoURL is where the API serves photo id at stored width size.
|
|
func photoURL(id string, size int) string {
|
|
return fmt.Sprintf("/media/photos/%s-%d.jpg", id, size)
|
|
}
|
|
|
|
func fromRow(r db.Dinner) Dinner {
|
|
d := Dinner{
|
|
ID: r.ID,
|
|
Name: r.Name,
|
|
Status: r.Status,
|
|
Origin: r.Origin,
|
|
Method: nullString(r.Method),
|
|
Difficulty: nullString(r.Difficulty),
|
|
Cuisine: nullString(r.Cuisine),
|
|
IsFavorite: r.IsFavorite == 1,
|
|
RecipeID: nullString(r.RecipeID),
|
|
GroundingStatus: r.GroundingStatus,
|
|
GroundingError: nullString(r.GroundingError),
|
|
UpdatedBy: nullString(r.UpdatedBy),
|
|
CreatedAt: r.CreatedAt,
|
|
UpdatedAt: r.UpdatedAt,
|
|
}
|
|
if r.MinutesTotal.Valid {
|
|
d.MinutesTotal = &r.MinutesTotal.Int64
|
|
}
|
|
if r.PhotoID.Valid {
|
|
u := PhotoURL(r.PhotoID.String)
|
|
d.PhotoURL = &u
|
|
}
|
|
return d
|
|
}
|
|
|
|
func nullString(s sql.NullString) *string {
|
|
if !s.Valid {
|
|
return nil
|
|
}
|
|
return &s.String
|
|
}
|