Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
739 lines
25 KiB
Go
739 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"`
|
|
|
|
// PhotoBackfillEligible is whether a photo backfill grounds the dinner
|
|
// (PhotoBackfillEligible): false for one with a pasted recipe link,
|
|
// which a search never replaces.
|
|
PhotoBackfillEligible bool `json:"photo_backfill_eligible"`
|
|
}
|
|
|
|
// 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,
|
|
|
|
PhotoBackfillEligible: PhotoBackfillEligible(r),
|
|
}
|
|
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
|
|
}
|