From d6f732c119f01ffa8d9f6403e2428f4256ca70f2 Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Thu, 8 Oct 2026 21:21:15 -0400 Subject: [PATCH] Idea actions: add to Quest log and cook tonight (Feature 18) (#148) Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> --- docs/ARCHITECTURE.md | 28 ++ internal/dinners/dinners.go | 103 ++++++- internal/ideas/ideas.go | 73 ++++- internal/plan/plan.go | 34 +++ internal/plan/rotation_test.go | 2 +- internal/server/ideaactions_test.go | 416 ++++++++++++++++++++++++++++ internal/server/ideas.go | 129 +++++++++ internal/server/server.go | 6 +- 8 files changed, 785 insertions(+), 6 deletions(-) create mode 100644 internal/server/ideaactions_test.go diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b22453f..05db94d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -363,6 +363,34 @@ automatic** (D-021). idea is kept, fails with the same reasons, and a failed job shows no ideas through the same `FailedResult` view. "↻ Show me more" posts again with every name the tab has shown so far in `exclude`. +- Idea actions (Feature 18). Both act on idea `{i}` (0, 1, …; anything + else is 400 `invalid_request`) of a **succeeded** `ideas_from_ingredients` + or `quick_ideas` job, read from its stored result + (`ideas.Service.Idea`): its name and `recipe_id`, never grounded again. + A job still queued or running is 409 `ideas_not_ready` (its ideas may + still move); an unknown job, another kind, a failed job or an index past + its ideas is 404 `not_found`. `POST + /api/v1/ideas/{jobId}/items/{i}/quest-log` (no body) runs + `dinners.Service.AddToQuestLog`: in one transaction, an unarchived + dinner the idea repeats — the same rule that refuses a duplicate Regular + (`harness.Similar` name, or the recipe's link) — is answered 200 as it + is (a Regular stays a Regular); one awaiting the household's choice + (`suggested`, `tried`) is 409 `duplicate_dinner` and changes nothing; + otherwise it creates a dinner with status `quest_log`, origin + `ingredients` or `quick`, grounded in the idea's recipe (its method, + minutes, difficulty and photo), answered 201. Repeating the call + answers the same dinner. `POST /api/v1/ideas/{jobId}/items/{i}/cook-tonight` + (no body) takes today as the household-local date in the household + timezone and its Monday (`plan.Service.Tonight`); when that week has no + plan it answers 409 `no_plan` and changes nothing — no dinner, no plan + (a plan holds all seven days; the household plans the week first). + Otherwise it adds or finds the dinner as `quest-log` does and puts it + on today through `plan.Service.Swap`, exactly like a Draft board swap + (plan marked edited, revision raised, a synced plan becomes `changed`, + the day's calendar event id cleared, `is_new` for a Quest log dinner), + answering 200 `{dinner, plan}`. The week is always today's own, so no + other day is written; a dinner already on another day of the week is + 409 `dinner_already_planned`. `GET /api/v1/suggestions/deck` returns `{suggestions: [{id, dinner_id, name, photo_url, difficulty, method, minutes_total, cuisine, reason, cited_regular_ids, status, dinner_status, decided_at, created_at}], diff --git a/internal/dinners/dinners.go b/internal/dinners/dinners.go index 54f355c..b2eb82b 100644 --- a/internal/dinners/dinners.go +++ b/internal/dinners/dinners.go @@ -288,6 +288,89 @@ func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy str 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 @@ -551,9 +634,23 @@ func checkRecipeURL(raw string) (*url.URL, error) { // 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 fmt.Errorf("dinners: list for dedupe: %w", err) + return db.Dinner{}, false, fmt.Errorf("dinners: list for dedupe: %w", err) } for _, row := range rows { if row.Dinner.ID == self { @@ -564,10 +661,10 @@ func checkUnique(ctx context.Context, q *db.Queries, self, name string, recipeUR link = row.RecipeSourceUrl } if harness.Similar(name, row.Dinner.Name) || (recipeURL.Valid && link == recipeURL) { - return &DuplicateError{Existing: fromRow(row.Dinner)} + return row.Dinner, true, nil } } - return nil + return db.Dinner{}, false, nil } // PhotoURL is where the API serves a list-sized copy of photo id. diff --git a/internal/ideas/ideas.go b/internal/ideas/ideas.go index 49743f4e..41d1dc2 100644 --- a/internal/ideas/ideas.go +++ b/internal/ideas/ideas.go @@ -92,6 +92,7 @@ type QuickGenerator func(ctx context.Context, scope string, in skills.QuickIdeas type Service struct { st *store.Store grounder *recipes.Grounder + runner *jobs.Runner generate Generator quick QuickGenerator logger *slog.Logger @@ -120,7 +121,7 @@ func NewService(st *store.Store, grounder *recipes.Grounder, generate Generator, case logger == nil: return nil, errors.New("ideas: nil logger") } - s := &Service{st: st, grounder: grounder, generate: generate, quick: quick, logger: logger} + s := &Service{st: st, grounder: grounder, runner: runner, generate: generate, quick: quick, logger: logger} var err error if s.job, err = register(runner, KindFromIngredients, s.run, withoutIdeas[Result]); err != nil { return nil, err @@ -589,3 +590,73 @@ func quickIdeaOf(idea skills.QuickIdea, gr recipes.Grounded) QuickIdea { SiteName: gr.SiteName, } } + +// Origins of a dinner made from an idea (the dinners.origin column). +const ( + // OriginIngredients is an idea from Cook from what I have. + OriginIngredients = "ingredients" + // OriginQuick is an idea from Quick dinners. + OriginQuick = "quick" +) + +// ErrIdeaNotFound is an idea the household cannot act on: an unknown job, +// a job that does not find ideas, a failed job (which shows no ideas), or +// an index past the job's ideas. +var ErrIdeaNotFound = errors.New("idea not found") + +// ErrIdeasNotReady is an idea of a job still queued or running. Its ideas +// may still move: an idea that grounds later is put back in its place +// before those shown already, so an index names an idea only once the job +// has succeeded. +var ErrIdeasNotReady = errors.New("the ideas are still being found; act on one when the job has succeeded") + +// Chosen is the idea the household acts on: its name, the recipe it was +// grounded in, and the origin of a dinner made from it. +type Chosen struct { + Name string + RecipeID string + Origin string +} + +// Idea returns idea i, counted from 0, of the succeeded job jobID, as its +// stored result shows it: a dinner made from it uses the recipe it was +// grounded in, never grounding it again. A job still queued or running is +// ErrIdeasNotReady; any idea not shown by a succeeded KindFromIngredients +// or KindQuick job is ErrIdeaNotFound. +func (s *Service) Idea(ctx context.Context, jobID string, i int) (Chosen, error) { + job, err := s.runner.Get(ctx, jobID) + if errors.Is(err, jobs.ErrNotFound) { + return Chosen{}, ErrIdeaNotFound + } + if err != nil { + return Chosen{}, fmt.Errorf("ideas: read job: %w", err) + } + if job.Kind != KindFromIngredients && job.Kind != KindQuick { + return Chosen{}, ErrIdeaNotFound + } + switch job.Status { + case jobs.StatusQueued, jobs.StatusRunning: + return Chosen{}, ErrIdeasNotReady + case jobs.StatusSucceeded: + default: + return Chosen{}, ErrIdeaNotFound + } + if job.Kind == KindQuick { + var res QuickResult + if err := json.Unmarshal(job.Result, &res); err != nil { + return Chosen{}, fmt.Errorf("ideas: decode %s result: %w", job.Kind, err) + } + if i < 0 || i >= len(res.Ideas) { + return Chosen{}, ErrIdeaNotFound + } + return Chosen{Name: res.Ideas[i].Name, RecipeID: res.Ideas[i].RecipeID, Origin: OriginQuick}, nil + } + var res Result + if err := json.Unmarshal(job.Result, &res); err != nil { + return Chosen{}, fmt.Errorf("ideas: decode %s result: %w", job.Kind, err) + } + if i < 0 || i >= len(res.Ideas) { + return Chosen{}, ErrIdeaNotFound + } + return Chosen{Name: res.Ideas[i].Name, RecipeID: res.Ideas[i].RecipeID, Origin: OriginIngredients}, nil +} diff --git a/internal/plan/plan.go b/internal/plan/plan.go index c016212..a3d0576 100644 --- a/internal/plan/plan.go +++ b/internal/plan/plan.go @@ -423,6 +423,40 @@ func (s *Service) Swap(ctx context.Context, weekStart store.LocalDate, date, din return p, nil } +// Tonight returns today, the household-local date now in the household's +// zone, and the Monday starting its week, when that week has a plan: Swap +// of that week and day sets tonight's dinner. A week with no plan is +// ErrNotFound; no plan is made for it, since a plan holds all seven days. +// The week is always today's own, so Swap never writes a different day. +func (s *Service) Tonight(ctx context.Context) (week, today store.LocalDate, err error) { + tx, err := s.st.Reader.BeginTx(ctx, nil) + if err != nil { + return store.LocalDate{}, store.LocalDate{}, fmt.Errorf("plan: 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 store.LocalDate{}, store.LocalDate{}, fmt.Errorf("plan: read household settings: %w", err) + } + zone, err := household.LoadZone(settings.Timezone) + if err != nil { + return store.LocalDate{}, store.LocalDate{}, fmt.Errorf("plan: household settings: %w", err) + } + today = household.Today(time.Now(), zone) + week = household.WeekStart(today) + _, err = q.GetWeekPlan(ctx, week.String()) + if errors.Is(err, sql.ErrNoRows) { + return store.LocalDate{}, store.LocalDate{}, ErrNotFound + } + if err != nil { + return store.LocalDate{}, store.LocalDate{}, fmt.Errorf("plan: read week %s: %w", week, err) + } + return week, today, nil +} + // isNew is plan_days.is_new for a dinner with status: 1 for the Quest log. func isNew(status string) int64 { if status == dinners.StatusQuestLog { diff --git a/internal/plan/rotation_test.go b/internal/plan/rotation_test.go index f5b1f03..2684932 100644 --- a/internal/plan/rotation_test.go +++ b/internal/plan/rotation_test.go @@ -335,7 +335,7 @@ func TestOnlyKeepPromotesToRegular(t *testing.T) { // Each non-test Go file naming the regular status, and how often. uses := map[string]int{ - "dinners/dinners.go": 5, // the constant (2), listable, CreateRegular's status and origin + "dinners/dinners.go": 6, // the constant (2), listable, CreateRegular's status and origin, AddToQuestLog reusing a Regular as it is "plan/plan.go": 1, // plannable "plan/rotation.go": 1, // decisionStatus[DecisionKeep] "suggest/batch.go": 1, // reads which dinners are Regulars to cite diff --git a/internal/server/ideaactions_test.go b/internal/server/ideaactions_test.go new file mode 100644 index 0000000..3f1f299 --- /dev/null +++ b/internal/server/ideaactions_test.go @@ -0,0 +1,416 @@ +package server + +import ( + "context" + "database/sql" + "io" + "log/slog" + "net/http" + "net/url" + "testing" + "testing/fstest" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// actionsZone is the household zone of newIdeaActionsRouter. +const actionsZone = "America/New_York" + +// newIdeaActionsRouter returns a started router whose ideas, dinners and +// plan services share one store, whose household settings are seeded, so +// an idea's actions read the job, make the dinner and swap the plan of the +// same household. The ideas service runs both skills through the real +// harness with the scripted model and grounds with the scripted fakes. +func newIdeaActionsRouter(t *testing.T, model, normalize *llm.Fake, search *recipestest.Search, pages *recipestest.Pages, images *recipestest.Images) (http.Handler, *store.Store) { + t.Helper() + st := newTestStore(t) + if err := household.SeedSettings(t.Context(), st, actionsZone); err != nil { + t.Fatal(err) + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + runner, err := jobs.NewRunner(st, logger, 1) + if err != nil { + t.Fatal(err) + } + g := recipestest.NewGrounder(t, st, t.TempDir(), search, pages, images, normalize) + guard, err := harness.NewGuard(harness.DefaultMaxCalls) + if err != nil { + t.Fatal(err) + } + cfg := harness.Config{ + Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7}, + NewProvider: func(llm.Settings, string) (llm.Provider, error) { return model, nil }, + Store: st, + Guard: guard, + } + generate := func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error) { + return skills.IdeasFromIngredients(ctx, cfg, scope, in) + } + quick := func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error) { + return skills.QuickIdeas(ctx, cfg, scope, in) + } + ideasSvc, err := ideas.NewService(st, g, generate, quick, runner, logger) + if err != nil { + t.Fatal(err) + } + dinnerSvc, err := dinners.NewService(st, g, runner, logger) + if err != nil { + t.Fatal(err) + } + draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) { + t.Error("draft_week ran: an idea action must never draft a plan") + return skills.Week{}, context.Canceled + } + planSvc, err := plan.NewService(st, draft, runner, newTestGoogle(t), &url.URL{Scheme: "https", Host: "mealquest.example"}, logger) + if err != nil { + t.Fatal(err) + } + h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), + dinnerSvc, newTestSuggest(t), planSvc, newTestShopping(t), newTestGoogle(t), ideasSvc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + if err != nil { + t.Fatal(err) + } + startRunner(t, runner) + return h, st +} + +// finishedIdeasJob runs an ideas from ingredients job that keeps names, in +// order, and returns the router, store, job id and its ideas' recipe ids. +func finishedIdeasJob(t *testing.T, names ...string) (http.Handler, *store.Store, string, []string) { + t.Helper() + model, normalize, search, pages, images := scriptIdeas(t, names, names, nil) + h, st := newIdeaActionsRouter(t, model, normalize, search, pages, images) + id := startIdeasJob(t, h) + job := waitJob(t, h, id) + if job["status"] != "succeeded" { + t.Fatalf("ideas job = %v", job) + } + var recipeIDs []string + for _, i := range job["result"].(map[string]any)["ideas"].([]any) { + recipeIDs = append(recipeIDs, i.(map[string]any)["recipe_id"].(string)) + } + if len(recipeIDs) != len(names) { + t.Fatalf("ideas job kept %d ideas, want %d", len(recipeIDs), len(names)) + } + return h, st, id, recipeIDs +} + +func ideaPath(jobID, i, action string) string { + return "/api/v1/ideas/" + jobID + "/items/" + i + "/" + action +} + +// dinnerCount is how many dinners the store holds, archived or not. +func dinnerCount(t *testing.T, st *store.Store) int { + t.Helper() + var n int + if err := st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM dinners`).Scan(&n); err != nil { + t.Fatal(err) + } + return n +} + +// thisWeek is today in the household's zone and the Monday of its week. +func thisWeek(t *testing.T) (week, today store.LocalDate) { + t.Helper() + zone, err := household.LoadZone(actionsZone) + if err != nil { + t.Fatal(err) + } + today = household.Today(time.Now(), zone) + return household.WeekStart(today), today +} + +// planThisWeek stores a plan of this week with a Regular on every day and +// the given status, and returns the week's Monday and today. +func planThisWeek(t *testing.T, st *store.Store, status string) (week, today store.LocalDate) { + t.Helper() + ctx := t.Context() + week, today = thisWeek(t) + days, err := household.WeekDays(week) + if err != nil { + t.Fatal(err) + } + q := db.New(st.Writer) + now := store.Now().String() + planID := store.NewID() + if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{ID: planID, WeekStart: week.String(), CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + for _, date := range days { + name := "Regular " + date.String() + d, err := q.CreateDinner(ctx, db.CreateDinnerParams{ + ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: "regular", Origin: "regular", + GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now, + }) + if err != nil { + t.Fatal(err) + } + if err := q.PutPlanDay(ctx, db.PutPlanDayParams{ID: store.NewID(), PlanID: planID, Date: date.String(), + DinnerID: sql.NullString{String: d.ID, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + } + if _, err := st.Writer.ExecContext(ctx, `UPDATE week_plans SET status = ? WHERE id = ?`, status, planID); err != nil { + t.Fatal(err) + } + return week, today +} + +// planRevision is the revision of the plan of week. +func planRevision(t *testing.T, st *store.Store, week store.LocalDate) int64 { + t.Helper() + row, err := db.New(st.Reader).GetWeekPlan(t.Context(), week.String()) + if err != nil { + t.Fatal(err) + } + return row.Revision +} + +// Adding an idea to the Quest log makes a quest_log dinner grounded in the +// idea's own recipe, with origin ingredients, without grounding it again; +// asking again answers the same dinner and makes no second one. +func TestPostIdeaQuestLogAddsGroundedDinnerOnce(t *testing.T) { + t.Parallel() + h, st, id, recipeIDs := finishedIdeasJob(t, "Chicken Fried Rice", "Rice Pudding") + + rec, d := callDinners(t, h, http.MethodPost, ideaPath(id, "1", "quest-log"), "") + if rec.Code != http.StatusCreated { + t.Fatalf("POST quest-log = %d: %s", rec.Code, rec.Body) + } + if d["name"] != "Rice Pudding" || d["status"] != "quest_log" || d["origin"] != "ingredients" || + d["grounding_status"] != "grounded" || d["recipe_id"] != recipeIDs[1] || d["photo_url"] == nil || + d["method"] != "stovetop" || d["minutes_total"] != 25.0 || d["updated_by"] != testSession.Subject { + t.Fatalf("dinner = %v, want Rice Pudding in the Quest log grounded in %s", d, recipeIDs[1]) + } + + rec, again := callDinners(t, h, http.MethodPost, ideaPath(id, "1", "quest-log"), "") + if rec.Code != http.StatusOK || again["id"] != d["id"] || again["status"] != "quest_log" { + t.Fatalf("POST quest-log again = %d %v, want 200 with dinner %v", rec.Code, again, d["id"]) + } + if n := dinnerCount(t, st); n != 1 { + t.Fatalf("dinners = %d, want 1", n) + } + if names := listNames(t, h, "quest_log"); len(names) != 1 || names[0] != "Rice Pudding" { + t.Fatalf("Quest log = %q", names) + } +} + +// A Quick dinners idea makes a dinner with origin quick. +func TestPostIdeaQuestLogQuickOrigin(t *testing.T) { + t.Parallel() + model, normalize, search, pages, images := newQuickFakes() + quickRecipe(t, normalize, search, pages, images, "Chicken Fried Rice", "Chicken Fried Rice", 25) + quickReply(model, [3]any{"Chicken Fried Rice", "stovetop", 20}) + h, _ := newIdeaActionsRouter(t, model, normalize, search, pages, images) + rec, body := callDinners(t, h, http.MethodPost, quickPath, `{"kind": "under30", "exclude": []}`) + if rec.Code != http.StatusAccepted { + t.Fatalf("POST quick = %d: %s", rec.Code, rec.Body) + } + id := body["jobId"].(string) + if job := waitJob(t, h, id); job["status"] != "succeeded" { + t.Fatalf("quick job = %v", job) + } + + rec, d := callDinners(t, h, http.MethodPost, ideaPath(id, "0", "quest-log"), "") + if rec.Code != http.StatusCreated || d["name"] != "Chicken Fried Rice" || d["status"] != "quest_log" || d["origin"] != "quick" { + t.Fatalf("POST quest-log = %d %v, want a quick Quest log dinner", rec.Code, d) + } +} + +// An idea repeating a dinner the household already has is that dinner, as +// it is: a Regular stays a Regular. One awaiting the household's choice +// is refused and nothing changes. +func TestPostIdeaQuestLogReusesExistingDinner(t *testing.T) { + t.Parallel() + h, st, id, _ := finishedIdeasJob(t, "Chicken Fried Rice", "Rice Pudding") + regular := createDinner(t, h, `{"name": "chicken fried rice"}`) + + rec, d := callDinners(t, h, http.MethodPost, ideaPath(id, "0", "quest-log"), "") + if rec.Code != http.StatusOK || d["id"] != regular || d["status"] != "regular" || d["origin"] != "regular" { + t.Fatalf("POST quest-log = %d %v, want 200 with Regular %s as it is", rec.Code, d, regular) + } + + now := store.Now().String() + if _, err := db.New(st.Writer).CreateDinner(t.Context(), db.CreateDinnerParams{ + ID: store.NewID(), Name: "Rice pudding", NormalizedName: harness.Normalize("Rice pudding"), Status: "suggested", + Origin: "side_quest", GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatal(err) + } + rec, body := callDinners(t, h, http.MethodPost, ideaPath(id, "1", "quest-log"), "") + if rec.Code != http.StatusConflict || errorCode(body) != "duplicate_dinner" { + t.Fatalf("POST quest-log of a suggested Side quest = %d %v, want 409 duplicate_dinner", rec.Code, body) + } + if n := dinnerCount(t, st); n != 2 { + t.Fatalf("dinners = %d, want the Regular and the Side quest only", n) + } +} + +// Cook tonight puts the idea's dinner on today of this week's plan through +// the plan's swap: the day is new, the plan's revision rises and a synced +// plan becomes changed. Asking again plans the same dinner. +func TestPostIdeaCookTonightSwapsToday(t *testing.T) { + t.Parallel() + h, st, id, recipeIDs := finishedIdeasJob(t, "Chicken Fried Rice") + week, today := planThisWeek(t, st, plan.StatusSynced) + before := planRevision(t, st, week) + + rec, body := callDinners(t, h, http.MethodPost, ideaPath(id, "0", "cook-tonight"), "") + if rec.Code != http.StatusOK { + t.Fatalf("POST cook-tonight = %d: %s", rec.Code, rec.Body) + } + d := body["dinner"].(map[string]any) + if d["name"] != "Chicken Fried Rice" || d["status"] != "quest_log" || d["origin"] != "ingredients" || d["recipe_id"] != recipeIDs[0] { + t.Fatalf("dinner = %v", d) + } + assertTonight := func(body map[string]any) { + t.Helper() + p := body["plan"].(map[string]any) + if p["week_start"] != week.String() || p["status"] != plan.StatusChanged { + t.Fatalf("plan = %v, want week %s changed", p, week) + } + found := false + for _, day := range p["days"].([]any) { + day := day.(map[string]any) + planned := day["dinner"].(map[string]any)["id"] == d["id"] + if day["date"] == today.String() { + found = true + if !planned || day["is_new"] != true { + t.Fatalf("today %s = %v, want %v new", today, day, d["id"]) + } + } else if planned { + t.Fatalf("dinner also on %v", day["date"]) + } + } + if !found { + t.Fatalf("plan has no day %s", today) + } + } + assertTonight(body) + if after := planRevision(t, st, week); after != before+1 { + t.Fatalf("plan revision = %d, want %d", after, before+1) + } + + rec, body = callDinners(t, h, http.MethodPost, ideaPath(id, "0", "cook-tonight"), "") + if rec.Code != http.StatusOK || body["dinner"].(map[string]any)["id"] != d["id"] { + t.Fatalf("POST cook-tonight again = %d %v", rec.Code, body) + } + assertTonight(body) + if n := dinnerCount(t, st); n != 8 { + t.Fatalf("dinners = %d, want the seven Regulars and one idea", n) + } +} + +// Cook tonight reuses a dinner the household already has. +func TestPostIdeaCookTonightReusesExistingDinner(t *testing.T) { + t.Parallel() + h, st, id, _ := finishedIdeasJob(t, "Chicken Fried Rice") + _, today := planThisWeek(t, st, plan.StatusDraft) + regular := createDinner(t, h, `{"name": "Chicken fried rice"}`) + + rec, body := callDinners(t, h, http.MethodPost, ideaPath(id, "0", "cook-tonight"), "") + if rec.Code != http.StatusOK || body["dinner"].(map[string]any)["id"] != regular { + t.Fatalf("POST cook-tonight = %d %v, want Regular %s", rec.Code, body, regular) + } + for _, day := range body["plan"].(map[string]any)["days"].([]any) { + day := day.(map[string]any) + if day["date"] == today.String() && (day["dinner"].(map[string]any)["id"] != regular || day["is_new"] != false) { + t.Fatalf("today = %v, want Regular %s", day, regular) + } + } +} + +// With no plan for this week, cook tonight answers 409 no_plan and changes +// nothing: no dinner is made and no plan is drafted or stored. +func TestPostIdeaCookTonightWithoutPlan(t *testing.T) { + t.Parallel() + h, st, id, _ := finishedIdeasJob(t, "Chicken Fried Rice") + + rec, body := callDinners(t, h, http.MethodPost, ideaPath(id, "0", "cook-tonight"), "") + if rec.Code != http.StatusConflict || errorCode(body) != "no_plan" { + t.Fatalf("POST cook-tonight = %d %v, want 409 no_plan", rec.Code, body) + } + if n := dinnerCount(t, st); n != 0 { + t.Fatalf("dinners = %d, want none", n) + } + var plans, drafts int + if err := st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM week_plans`).Scan(&plans); err != nil { + t.Fatal(err) + } + if err := st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&drafts); err != nil { + t.Fatal(err) + } + if plans != 0 || drafts != 0 { + t.Fatalf("plans = %d, drafts = %d, want none", plans, drafts) + } +} + +// An idea that is not an index of a succeeded ideas job is refused. +func TestPostIdeaActionsRefuse(t *testing.T) { + t.Parallel() + h, st, id, _ := finishedIdeasJob(t, "Chicken Fried Rice") + planThisWeek(t, st, plan.StatusDraft) + // A Regular's grounding job is a job, but finds no ideas. + rec, body := callDinners(t, h, http.MethodPost, dinnersPath, `{"name": "Beef Stew"}`) + if rec.Code != http.StatusCreated { + t.Fatalf("POST /dinners = %d: %s", rec.Code, rec.Body) + } + other := body["grounding_job_id"].(string) + + for _, action := range []string{"quest-log", "cook-tonight"} { + for _, tc := range []struct { + job, i string + status int + code string + }{ + {id, "x", http.StatusBadRequest, "invalid_request"}, + {id, "-1", http.StatusBadRequest, "invalid_request"}, + {id, "01", http.StatusBadRequest, "invalid_request"}, + {id, "1", http.StatusNotFound, "not_found"}, + {"unknown", "0", http.StatusNotFound, "not_found"}, + {other, "0", http.StatusNotFound, "not_found"}, + } { + rec, body := callDinners(t, h, http.MethodPost, ideaPath(tc.job, tc.i, action), "") + if rec.Code != tc.status || errorCode(body) != tc.code { + t.Errorf("POST %s %s/%s = %d %v, want %d %s", action, tc.job, tc.i, rec.Code, body, tc.status, tc.code) + } + } + } + if n := dinnerCount(t, st); n != 8 { + t.Fatalf("dinners = %d, want the plan's seven Regulars and Beef Stew only", n) + } +} + +// While the job still finds ideas, an idea is not acted on: its index may +// still move. +func TestPostIdeaActionsWhileJobRuns(t *testing.T) { + t.Parallel() + model, normalize, search, pages, images := scriptIdeas(t, []string{"Chicken Fried Rice"}, []string{"Chicken Fried Rice"}, nil) + gate := make(chan struct{}) + search.Gates = map[string]chan struct{}{"Chicken Fried Rice": gate} + h, st := newIdeaActionsRouter(t, model, normalize, search, pages, images) + id := startIdeasJob(t, h) + + for _, action := range []string{"quest-log", "cook-tonight"} { + rec, body := callDinners(t, h, http.MethodPost, ideaPath(id, "0", action), "") + if rec.Code != http.StatusConflict || errorCode(body) != "ideas_not_ready" { + t.Errorf("POST %s = %d %v, want 409 ideas_not_ready", action, rec.Code, body) + } + } + close(gate) + waitJob(t, h, id) + if n := dinnerCount(t, st); n != 0 { + t.Fatalf("dinners = %d, want none", n) + } +} diff --git a/internal/server/ideas.go b/internal/server/ideas.go index edd6c7e..c6f2145 100644 --- a/internal/server/ideas.go +++ b/internal/server/ideas.go @@ -4,8 +4,13 @@ import ( "errors" "log/slog" "net/http" + "strconv" + "github.com/go-chi/chi/v5" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" ) // maxIdeasBody bounds a POST /ideas/from-ingredients or /ideas/quick body. @@ -93,3 +98,127 @@ func postIdeasQuick(logger *slog.Logger, svc *ideas.Service) http.HandlerFunc { writeJSON(w, r, logger, http.StatusAccepted, started) } } + +// chosenIdeaOrFail returns the idea {i} of ideas job {jobId}, or answers +// 400 invalid_request when i is not an index (0, 1, ...), 404 not_found +// for an idea no succeeded ideas job shows, or 409 ideas_not_ready while +// the job is still finding ideas. +func chosenIdeaOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger, svc *ideas.Service) (ideas.Chosen, bool) { + raw := chi.URLParam(r, "i") + i, err := strconv.Atoi(raw) + if err != nil || i < 0 || strconv.Itoa(i) != raw { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "i: must be an idea's index, 0 or more") + return ideas.Chosen{}, false + } + idea, err := svc.Idea(r.Context(), chi.URLParam(r, "jobId"), i) + switch { + case errors.Is(err, ideas.ErrIdeaNotFound): + writeError(w, r, logger, http.StatusNotFound, "not_found", "idea not found") + return ideas.Chosen{}, false + case errors.Is(err, ideas.ErrIdeasNotReady): + writeError(w, r, logger, http.StatusConflict, "ideas_not_ready", err.Error()) + return ideas.Chosen{}, false + case err != nil: + logger.ErrorContext(r.Context(), "read idea", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return ideas.Chosen{}, false + } + return idea, true +} + +// postIdeaQuestLog adds idea {i} of ideas job {jobId} to the Quest log, +// recording the session's actor as updated_by, as a dinner grounded in +// the recipe the idea was grounded in, with origin ingredients or quick. +// It answers 201 with the dinner made, or 200 with the dinner the idea +// repeats (see dinners.Service.AddToQuestLog), so asking again answers the +// same dinner. A repeated dinner awaiting the household's choice is 409 +// duplicate_dinner. +func postIdeaQuestLog(logger *slog.Logger, ideasSvc *ideas.Service, dinnerSvc *dinners.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + actor, ok := actorOrFail(w, r, logger) + if !ok { + return + } + idea, ok := chosenIdeaOrFail(w, r, logger, ideasSvc) + if !ok { + return + } + d, created, err := dinnerSvc.AddToQuestLog(r.Context(), dinners.NewQuestLog(idea), actor.Subject) + if err != nil { + writeDinnerError(w, r, logger, "add idea to Quest log", err) + return + } + status := http.StatusOK + if created { + status = http.StatusCreated + } + writeJSON(w, r, logger, status, d) + } +} + +// cookedTonight is the POST /ideas/{jobId}/items/{i}/cook-tonight answer: +// the idea's dinner and the week's plan with it tonight. +type cookedTonight struct { + Dinner dinners.Dinner `json:"dinner"` + Plan plan.Plan `json:"plan"` +} + +// postIdeaCookTonight plans idea {i} of ideas job {jobId} tonight: today, +// in the household's zone, of the plan of today's week. The idea's dinner +// is added or found as postIdeaQuestLog does, then swapped onto today +// through plan.Service.Swap, exactly as a swap on the Draft board, so the +// plan is marked edited and a synced plan becomes changed. It answers 200 +// with the dinner and the plan as swapped; asking again plans the same +// dinner again. +// +// When today's week has no plan it answers 409 no_plan and changes +// nothing: no dinner is added and no plan is made, since a plan holds all +// seven days; the household plans the week first. The week is always +// today's own, so no other day is ever written. A dinner already on +// another day of the week is 409 dinner_already_planned, after the dinner +// is added or found. +func postIdeaCookTonight(logger *slog.Logger, ideasSvc *ideas.Service, dinnerSvc *dinners.Service, planSvc *plan.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + actor, ok := actorOrFail(w, r, logger) + if !ok { + return + } + idea, ok := chosenIdeaOrFail(w, r, logger, ideasSvc) + if !ok { + return + } + week, today, err := planSvc.Tonight(r.Context()) + if errors.Is(err, plan.ErrNotFound) { + writeNoPlan(w, r, logger) + return + } + if err != nil { + writePlanError(w, r, logger, "find tonight's plan", err) + return + } + d, _, err := dinnerSvc.AddToQuestLog(r.Context(), dinners.NewQuestLog(idea), actor.Subject) + if err != nil { + writeDinnerError(w, r, logger, "add idea to cook tonight", err) + return + } + p, err := planSvc.Swap(r.Context(), week, today.String(), d.ID) + if errors.Is(err, plan.ErrNotFound) { + // Plans are never deleted, so the plan Tonight found is still + // there; this is said loudly rather than answered as success. + logger.ErrorContext(r.Context(), "plan of tonight's week gone before the swap", "week", week.String()) + writeNoPlan(w, r, logger) + return + } + if err != nil { + writePlanError(w, r, logger, "cook idea tonight", err) + return + } + writeJSON(w, r, logger, http.StatusOK, cookedTonight{Dinner: d, Plan: p}) + } +} + +// writeNoPlan answers 409 no_plan: today's week has no plan to put +// tonight's dinner on, so the household plans the week first. +func writeNoPlan(w http.ResponseWriter, r *http.Request, logger *slog.Logger) { + writeError(w, r, logger, http.StatusConflict, "no_plan", "this week has no plan yet; plan this week first") +} diff --git a/internal/server/server.go b/internal/server/server.go index 4e3a8df..5ec718c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -57,7 +57,9 @@ type RouteMounter interface { // /api/v1/integrations/google and its /connect, /callback and /calendars, // PUT /api/v1/integrations/google/calendar and DELETE // /api/v1/integrations/google; ideasSvc serves POST -// /api/v1/ideas/from-ingredients and POST /api/v1/ideas/quick; runner serves +// /api/v1/ideas/from-ingredients and POST /api/v1/ideas/quick, and with +// dinnerSvc POST /api/v1/ideas/{jobId}/items/{i}/quest-log and, with +// planSvc too, /cook-tonight; runner serves // POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves // GET /media/photos/{id}-{size}.jpg; assets is the web build // served for every other GET. /health, /auth and the web build are public. @@ -165,6 +167,8 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a apiV1.Put("/integrations/google/calendar", putGoogleCalendar(logger, googleSvc)) apiV1.Post("/ideas/from-ingredients", postIdeasFromIngredients(logger, ideasSvc)) apiV1.Post("/ideas/quick", postIdeasQuick(logger, ideasSvc)) + apiV1.Post("/ideas/{jobId}/items/{i}/quest-log", postIdeaQuestLog(logger, ideasSvc, dinnerSvc)) + apiV1.Post("/ideas/{jobId}/items/{i}/cook-tonight", postIdeaCookTonight(logger, ideasSvc, dinnerSvc, planSvc)) apiV1.Post("/jobs", postJob(logger, runner)) apiV1.Get("/jobs/{id}", getJob(logger, runner)) media.Get("/photos/{name}", getPhoto(logger, photos))