Add idea actions: add to Quest log and cook tonight (Feature 18)
CI / ci (push) Successful in 5m42s
CI / ci (pull_request) Successful in 5m44s

POST /api/v1/ideas/{jobId}/items/{i}/quest-log adds idea i of a
succeeded ideas job to the Quest log as a dinner grounded in the idea's
own recipe, with origin ingredients or quick, or answers the dinner it
repeats as it is (the Regular dedupe rule), so repeating the call is
idempotent. POST .../cook-tonight does the same, then puts the dinner on
today (household timezone) of this week's plan through plan.Service.Swap;
with no plan for the week it answers 409 no_plan and changes nothing.

Maestro task 54dcd487-1597-4a91-8493-97f3a7635e2d.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 20:58:20 -04:00
co-authored by Claude Opus 5.5
parent 659d082178
commit e93c9c63f6
8 changed files with 785 additions and 6 deletions
+28
View File
@@ -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}],
+100 -3
View File
@@ -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.
+72 -1
View File
@@ -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
}
+34
View File
@@ -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 {
+1 -1
View File
@@ -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
+416
View File
@@ -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)
}
}
+129
View File
@@ -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")
}
+5 -1
View File
@@ -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))