From 2217f4e5ad383c06a95bd52b2ec36ce7199e0a91 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Thu, 8 Oct 2026 18:50:18 -0400 Subject: [PATCH] Add GET /api/v1/dinners/{id} dinner detail Returns a dinner with its recipe for the recipe detail screen and Kitchen reading mode: photo URLs at both cached sizes (400/1200) or null, ingredients and steps in recipe position order, the source page for a fetched recipe, grounding status and error, and why_picked, the reason of the suggestion that created the dinner (null when none). Unknown and archived dinners are 404. Read in one transaction. Adds the GetDinnerSuggestionReason query (sqlc regenerated). The dinners test env now serves photos from the grounder's own store and data directory so photo URLs can be fetched in tests. Maestro task 099e9952-7f98-4700-8369-241c589a1e9d. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 13 ++ internal/db/querier.go | 3 + internal/db/suggestions.sql.go | 16 ++ internal/dinners/detail.go | 156 +++++++++++++++++++ internal/dinners/dinners.go | 7 +- internal/server/dinnerdetail_test.go | 215 +++++++++++++++++++++++++++ internal/server/dinners.go | 14 ++ internal/server/dinners_test.go | 10 +- internal/server/server.go | 3 +- migrations/queries/suggestions.sql | 8 + 10 files changed, 441 insertions(+), 4 deletions(-) create mode 100644 internal/dinners/detail.go create mode 100644 internal/server/dinnerdetail_test.go diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6522df2..3a7765c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -204,6 +204,19 @@ automatic** (D-021). sets status `archived` and keeps the row; `POST /api/v1/dinners/{id}/favorite` toggles `is_favorite`. Every write records the session's subject as `updated_by`; an unknown id is 404. + `GET /api/v1/dinners/{id}` returns the dinner with its recipe, read in + one transaction: `{id, name, status, origin, method, minutes_total, + difficulty, cuisine, is_favorite, photo_urls, ingredients, steps, + source, grounding_status, grounding_error, why_picked}`. `photo_urls` is + `{"400": url, "1200": url}` (`/media/photos/{photo_id}-{size}.jpg`) or + null with no photo; `ingredients` is `[{name, qty, unit, note, + raw_text}]` and `steps` the step texts, both in recipe position order and + empty (`[]`) with no recipe, as for a dinner whose grounding is + `pending` or `failed`; a recipe entered by hand has only `raw_text`. + `source` is `{url, site_name}` of the page the recipe was fetched from, + null for a manual recipe or none; `why_picked` is the reason of the + suggestion that created the dinner (its earliest), null for one no + suggestion created. An unknown or archived dinner is 404. - Dinner grounding. `POST /api/v1/dinners` also enqueues a `ground_dinner` job and answers with its `grounding_job_id` without waiting for it (`GET /api/v1/jobs/{id}` reports it); if the job cannot be enqueued it diff --git a/internal/db/querier.go b/internal/db/querier.go index 00e2e22..ce49c12 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -103,6 +103,9 @@ type Querier interface { // Returns sql.ErrNoRows until the row exists. GetChatSettings(ctx context.Context) (ChatSetting, error) GetDinner(ctx context.Context, id string) (Dinner, error) + // The reason of the suggestion that created dinner_id, its earliest. + // Returns sql.ErrNoRows for a dinner no suggestion created. + GetDinnerSuggestionReason(ctx context.Context, dinnerID string) (string, error) // Returns sql.ErrNoRows until the row exists. GetHouseholdSettings(ctx context.Context) (HouseholdSetting, error) // Returns sql.ErrNoRows until the row exists. diff --git a/internal/db/suggestions.sql.go b/internal/db/suggestions.sql.go index e7f12d7..68430df 100644 --- a/internal/db/suggestions.sql.go +++ b/internal/db/suggestions.sql.go @@ -187,6 +187,22 @@ func (q *Queries) GetActiveSuggestionBatch(ctx context.Context) (SuggestionBatch return i, err } +const getDinnerSuggestionReason = `-- name: GetDinnerSuggestionReason :one +SELECT reason FROM suggestions +WHERE dinner_id = ? +ORDER BY created_at, id +LIMIT 1 +` + +// The reason of the suggestion that created dinner_id, its earliest. +// Returns sql.ErrNoRows for a dinner no suggestion created. +func (q *Queries) GetDinnerSuggestionReason(ctx context.Context, dinnerID string) (string, error) { + row := q.db.QueryRowContext(ctx, getDinnerSuggestionReason, dinnerID) + var reason string + err := row.Scan(&reason) + return reason, err +} + const getLatestSuggestionBatch = `-- name: GetLatestSuggestionBatch :one SELECT id, "trigger", status, ai_run_id, created_at, updated_at FROM suggestion_batches ORDER BY id DESC diff --git a/internal/dinners/detail.go b/internal/dinners/detail.go new file mode 100644 index 0000000..47eb043 --- /dev/null +++ b/internal/dinners/detail.go @@ -0,0 +1,156 @@ +package dinners + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strconv" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" +) + +// Detail is a dinner with its recipe, as the recipe detail screen and +// Kitchen reading mode show it. +type Detail 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"` + // PhotoURLs is the cached photo at each stored width, keyed by width + // ("400", "1200"); null when the dinner has no photo. + PhotoURLs map[string]string `json:"photo_urls"` + // Ingredients and Steps are the recipe's, in recipe order; empty when + // the dinner has no recipe. + Ingredients []Ingredient `json:"ingredients"` + Steps []string `json:"steps"` + // Source is the recipe page the dinner is grounded in; null for a + // recipe entered by hand or a dinner with no recipe. + Source *Source `json:"source"` + 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"` + // WhyPicked is the reason of the suggestion that created the dinner; + // null for a dinner no suggestion created. + WhyPicked *string `json:"why_picked"` +} + +// Ingredient is one recipe line. RawText is the line as the recipe gives +// it; Name, Qty, Unit and Note are normalize_recipe's reading of it, each +// null when it has none, and Name, Qty and Unit always null for a recipe +// entered by hand (see IsManualRecipe). +type Ingredient struct { + Name *string `json:"name"` + Qty *string `json:"qty"` + Unit *string `json:"unit"` + Note *string `json:"note"` + RawText string `json:"raw_text"` +} + +// Source is the page a recipe was fetched from. +type Source struct { + URL string `json:"url"` + SiteName *string `json:"site_name"` +} + +// Get returns dinner id with its recipe and the reason it was suggested, +// read in one read transaction. An unknown or archived dinner is +// ErrNotFound: an archived dinner has no detail to show. +func (s *Service) Get(ctx context.Context, id string) (d Detail, err error) { + tx, err := s.st.Reader.BeginTx(ctx, nil) + if err != nil { + return Detail{}, fmt.Errorf("dinners: begin read: %w", err) + } + defer func() { + err = errors.Join(err, tx.Rollback()) + }() + q := db.New(tx) + row, err := q.GetDinner(ctx, id) + if errors.Is(err, sql.ErrNoRows) { + return Detail{}, ErrNotFound + } + if err != nil { + return Detail{}, fmt.Errorf("dinners: read %s: %w", id, err) + } + if row.Status == StatusArchived { + return Detail{}, ErrNotFound + } + d = Detail{ + ID: row.ID, + Name: row.Name, + Status: row.Status, + Origin: row.Origin, + Method: nullString(row.Method), + Difficulty: nullString(row.Difficulty), + Cuisine: nullString(row.Cuisine), + IsFavorite: row.IsFavorite == 1, + Ingredients: []Ingredient{}, + Steps: []string{}, + GroundingStatus: row.GroundingStatus, + GroundingError: nullString(row.GroundingError), + } + if row.MinutesTotal.Valid { + d.MinutesTotal = &row.MinutesTotal.Int64 + } + if row.PhotoID.Valid { + d.PhotoURLs = make(map[string]string, len(recipes.PhotoSizes)) + for _, size := range recipes.PhotoSizes { + d.PhotoURLs[strconv.Itoa(size)] = photoURL(row.PhotoID.String, size) + } + } + if (row.GroundingStatus == GroundingGrounded || row.GroundingStatus == GroundingManual) && !row.RecipeID.Valid { + return Detail{}, fmt.Errorf("dinners: %s is %s with no recipe", id, row.GroundingStatus) + } + if row.RecipeID.Valid { + if err := readRecipe(ctx, q, row.RecipeID.String, &d); err != nil { + return Detail{}, fmt.Errorf("dinners: read recipe of %s: %w", id, err) + } + } + reason, err := q.GetDinnerSuggestionReason(ctx, id) + switch { + case err == nil: + d.WhyPicked = &reason + case !errors.Is(err, sql.ErrNoRows): + return Detail{}, fmt.Errorf("dinners: read suggestion reason of %s: %w", id, err) + } + return d, nil +} + +// readRecipe sets d's Source, Ingredients and Steps from recipe recipeID. +func readRecipe(ctx context.Context, q *db.Queries, recipeID string, d *Detail) error { + rec, err := q.GetRecipe(ctx, recipeID) + if err != nil { + return fmt.Errorf("recipe %s: %w", recipeID, err) + } + if !IsManualRecipe(rec.SourceUrl) { + d.Source = &Source{URL: rec.SourceUrl, SiteName: nullString(rec.SiteName)} + } + ings, err := q.ListRecipeIngredients(ctx, recipeID) + if err != nil { + return fmt.Errorf("ingredients: %w", err) + } + for _, ing := range ings { + d.Ingredients = append(d.Ingredients, Ingredient{ + Name: nullString(ing.Name), + Qty: nullString(ing.Qty), + Unit: nullString(ing.Unit), + Note: nullString(ing.Note), + RawText: ing.RawText, + }) + } + steps, err := q.ListRecipeSteps(ctx, recipeID) + if err != nil { + return fmt.Errorf("steps: %w", err) + } + for _, step := range steps { + d.Steps = append(d.Steps, step.Text) + } + return nil +} diff --git a/internal/dinners/dinners.go b/internal/dinners/dinners.go index 54f355c..bf5154b 100644 --- a/internal/dinners/dinners.go +++ b/internal/dinners/dinners.go @@ -572,7 +572,12 @@ func checkUnique(ctx context.Context, q *db.Queries, self, name string, recipeUR // PhotoURL is where the API serves a list-sized copy of photo id. func PhotoURL(id string) string { - return fmt.Sprintf("/media/photos/%s-%d.jpg", id, photoListWidth) + 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 { diff --git a/internal/server/dinnerdetail_test.go b/internal/server/dinnerdetail_test.go new file mode 100644 index 0000000..06c860e --- /dev/null +++ b/internal/server/dinnerdetail_test.go @@ -0,0 +1,215 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "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/recipes/recipestest" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// getDetail answers GET /dinners/{id} for id, failing unless it is 200, and +// returns the body both raw (to tell null from absent) and decoded. +func getDetail(t *testing.T, h http.Handler, id string) (map[string]any, dinners.Detail) { + t.Helper() + rec, raw := callDinners(t, h, http.MethodGet, dinnersPath+"/"+id, "") + if rec.Code != http.StatusOK { + t.Fatalf("GET dinner = %d: %s", rec.Code, rec.Body) + } + var d dinners.Detail + dec := json.NewDecoder(rec.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(&d); err != nil { + t.Fatalf("decode detail: %v", err) + } + return raw, d +} + +// wantNull fails unless each key is present in raw and null. +func wantNull(t *testing.T, raw map[string]any, keys ...string) { + t.Helper() + for _, k := range keys { + if v, ok := raw[k]; !ok || v != nil { + t.Errorf("%s = %v (present %v), want null", k, v, ok) + } + } +} + +// TestGetDinnerGroundedSideQuest: a Side quest grounded in a fetched page +// shows its photo at both stored sizes, its recipe's ingredients and steps +// in recipe order (not row order), its source page and the reason it was +// suggested. +func TestGetDinnerGroundedSideQuest(t *testing.T) { + t.Parallel() + e := newDinnersEnv(t).withRecipes(t) + e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40)) + e.start(t) + rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Chicken curry"}`) + if rec.Code != http.StatusCreated { + t.Fatalf("POST = %d: %s", rec.Code, rec.Body) + } + if job := waitJob(t, e.h, created["grounding_job_id"].(string)); job["status"] != "succeeded" { + t.Fatalf("grounding job = %v", job) + } + id := created["id"].(string) + + // Make it an accepted Side quest with the suggestion that created it, + // and reverse its recipe's positions so row order disagrees with + // recipe order. + var recipeID, photoID string + if err := e.st.Reader.QueryRow(`SELECT recipe_id, photo_id FROM dinners WHERE id = ?`, id).Scan(&recipeID, &photoID); err != nil { + t.Fatal(err) + } + for _, stmt := range []string{ + `UPDATE dinners SET status = 'quest_log', origin = 'side_quest', is_favorite = 1 WHERE id = ?`, + `UPDATE recipe_ingredients SET position = 1 - position WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`, + `UPDATE recipe_steps SET position = 1 - position WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`, + } { + if _, err := e.st.Writer.Exec(stmt, id); err != nil { + t.Fatal(err) + } + } + q := db.New(e.st.Writer) + now := store.Now().String() + batch, err := q.CreateSuggestionBatch(t.Context(), db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now}) + if err != nil { + t.Fatal(err) + } + const reason = "Like your Tikka masala, but quicker." + if err := q.CreateSuggestion(t.Context(), db.CreateSuggestionParams{ + ID: store.NewID(), DinnerID: id, BatchID: batch.ID, Name: "Chicken curry", + Reason: reason, CitedRegularIds: "[]", CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatal(err) + } + + _, got := getDetail(t, e.h, id) + str := func(s string) *string { return &s } + minutes := int64(40) + want := dinners.Detail{ + ID: id, + Name: "Chicken curry", + Status: "quest_log", + Origin: "side_quest", + Method: str("stovetop"), + MinutesTotal: &minutes, + Difficulty: str("easy"), + IsFavorite: true, + PhotoURLs: map[string]string{ + "400": "/media/photos/" + photoID + "-400.jpg", + "1200": "/media/photos/" + photoID + "-1200.jpg", + }, + Ingredients: []dinners.Ingredient{ + {Name: str("garlic"), Qty: str("2"), Unit: str("cloves"), Note: str("minced"), RawText: "2 cloves garlic, minced"}, + {Name: str("onion"), Qty: str("1"), RawText: "1 onion"}, + }, + Steps: []string{"Fry with the garlic.", "Chop the onion."}, + Source: &dinners.Source{URL: curryPage, SiteName: str("Curry House")}, + GroundingStatus: "grounded", + WhyPicked: str(reason), + } + if !reflect.DeepEqual(got, want) { + gj, _ := json.Marshal(got) + wj, _ := json.Marshal(want) + t.Errorf("detail =\n %s\nwant\n %s", gj, wj) + } + // Each photo URL is served. + for _, u := range got.PhotoURLs { + rec := httptest.NewRecorder() + e.h.ServeHTTP(rec, signedIn(http.MethodGet, u, nil)) + if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "image/jpeg" { + t.Errorf("GET %s = %d %s", u, rec.Code, rec.Header().Get("Content-Type")) + } + } +} + +// TestGetDinnerManualRegular: a Regular added by hand and given a recipe +// typed in has no photo, no source and no reason, and its ingredients are +// the typed lines with nothing normalized. +func TestGetDinnerManualRegular(t *testing.T) { + t.Parallel() + e := newDinnersEnv(t) + id := createDinner(t, e.h, `{"name":"Family chili"}`) + if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", + `{"manual":{"ingredients":["1 lb beef","1 can beans"],"steps":["Brown.","Simmer."],"minutes":240,"method":"crockpot"}}`); rec.Code != http.StatusOK { + t.Fatalf("POST ground = %d: %s", rec.Code, rec.Body) + } + + raw, got := getDetail(t, e.h, id) + wantNull(t, raw, "photo_urls", "source", "why_picked", "difficulty", "grounding_error") + str := func(s string) *string { return &s } + minutes := int64(240) + want := dinners.Detail{ + ID: id, + Name: "Family chili", + Status: "regular", + Origin: "regular", + Method: str("crockpot"), + MinutesTotal: &minutes, + Ingredients: []dinners.Ingredient{{RawText: "1 lb beef"}, {RawText: "1 can beans"}}, + Steps: []string{"Brown.", "Simmer."}, + GroundingStatus: "manual", + } + if !reflect.DeepEqual(got, want) { + gj, _ := json.Marshal(got) + wj, _ := json.Marshal(want) + t.Errorf("detail =\n %s\nwant\n %s", gj, wj) + } +} + +// TestGetDinnerFailedGrounding: a dinner whose grounding failed shows its +// real state — failed with its reason, no photo, no source and empty +// ingredients and steps — never a pretend recipe. +func TestGetDinnerFailedGrounding(t *testing.T) { + t.Parallel() + e := newDinnersEnv(t).withRecipes(t) + e.start(t) + rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Nothing matches this"}`) + if rec.Code != http.StatusCreated { + t.Fatalf("POST = %d: %s", rec.Code, rec.Body) + } + if job := waitJob(t, e.h, created["grounding_job_id"].(string)); job["status"] != "failed" { + t.Fatalf("grounding job = %v, want failed", job) + } + + raw, got := getDetail(t, e.h, created["id"].(string)) + wantNull(t, raw, "photo_urls", "source", "why_picked", "method", "minutes_total", "difficulty") + if ings, ok := raw["ingredients"].([]any); !ok || len(ings) != 0 { + t.Errorf("ingredients = %v, want []", raw["ingredients"]) + } + if steps, ok := raw["steps"].([]any); !ok || len(steps) != 0 { + t.Errorf("steps = %v, want []", raw["steps"]) + } + if got.GroundingStatus != "failed" || got.GroundingError == nil || *got.GroundingError != dinners.ReasonNoRecipe || + got.Name != "Nothing matches this" || got.Status != "regular" || got.Origin != "regular" { + t.Errorf("detail = %+v, want the failed Regular with no_recipe_found", got) + } +} + +// TestGetDinnerNotFound: an unknown dinner and an archived one are both +// 404, and the detail needs a session like every /api/v1 route. +func TestGetDinnerNotFound(t *testing.T) { + t.Parallel() + e := newDinnersEnv(t) + id := createDinner(t, e.h, `{"name":"Old favourite"}`) + if rec, _ := callDinners(t, e.h, http.MethodDelete, dinnersPath+"/"+id, ""); rec.Code != http.StatusOK { + t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body) + } + for _, target := range []string{id, store.NewID()} { + rec, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+target, "") + if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" { + t.Errorf("GET %s = %d %v, want 404 not_found", target, rec.Code, body) + } + } + + live := createDinner(t, e.h, `{"name":"Taco night"}`) + req := httptest.NewRequest(http.MethodGet, dinnersPath+"/"+live, nil) + if rec, body := serve(t, e.h, req); rec.Code != http.StatusUnauthorized || errorCode(body) != "unauthenticated" { + t.Errorf("GET without a session = %d %v, want 401", rec.Code, body) + } +} diff --git a/internal/server/dinners.go b/internal/server/dinners.go index fab52ac..cb09073 100644 --- a/internal/server/dinners.go +++ b/internal/server/dinners.go @@ -70,6 +70,20 @@ func getDinners(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc { } } +// getDinner answers GET /dinners/{id} with the dinner, its recipe and the +// reason it was suggested. An archived dinner is 404, like an unknown one, +// not a 200 with its status: it has no detail to show. +func getDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + d, err := svc.Get(r.Context(), chi.URLParam(r, "id")) + if err != nil { + writeDinnerError(w, r, logger, "get dinner", err) + return + } + writeJSON(w, r, logger, http.StatusOK, d) + } +} + // postDinner adds a Regular, recording the session's actor as updated_by, // and answers 201 with it and its grounding_job_id without waiting for the // job. When the job cannot be enqueued it still answers 201, with the dinner diff --git a/internal/server/dinners_test.go b/internal/server/dinners_test.go index 229b355..39a8712 100644 --- a/internal/server/dinners_test.go +++ b/internal/server/dinners_test.go @@ -86,13 +86,19 @@ func newDinnersEnv(t *testing.T) *dinnersEnv { t.Fatal(err) } e.runner = runner - g := recipestest.NewGrounder(t, st, t.TempDir(), e.search, e.pages, e.images, e.model) + // The router serves the photos the grounder caches. + dataDir := t.TempDir() + g := recipestest.NewGrounder(t, st, dataDir, e.search, e.pages, e.images, e.model) + photos, err := recipes.NewPhotoCache(e.images, st, dataDir) + if err != nil { + t.Fatal(err) + } svc, err := dinners.NewService(st, g, runner, logger) if err != nil { t.Fatal(err) } e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), - svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/server.go b/internal/server/server.go index 931c1e3..ed3d05b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -39,7 +39,7 @@ type RouteMounter interface { // /media; settings serves /api/v1/settings/household; search serves // /api/v1/settings/recipe-search and its /test; ai serves // /api/v1/settings/ai, its /api-key, /verify, /models and /reset-prompt; -// dinnerSvc serves GET and POST /api/v1/dinners, PATCH and DELETE +// dinnerSvc serves GET and POST /api/v1/dinners, GET, PATCH and DELETE // /api/v1/dinners/{id}, POST /api/v1/dinners/{id}/favorite, GET // /api/v1/dinners/{id}/recipe-candidates and POST /api/v1/dinners/{id}/ground; // suggestSvc serves POST /api/v1/suggestions/batches, GET @@ -130,6 +130,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a apiV1.Post("/settings/ai/reset-prompt", postAIResetPrompt(logger, ai)) apiV1.Get("/dinners", getDinners(logger, dinnerSvc)) apiV1.Post("/dinners", postDinner(logger, dinnerSvc)) + apiV1.Get("/dinners/{id}", getDinner(logger, dinnerSvc)) apiV1.Patch("/dinners/{id}", patchDinner(logger, dinnerSvc)) apiV1.Delete("/dinners/{id}", deleteDinner(logger, dinnerSvc)) apiV1.Post("/dinners/{id}/favorite", postDinnerFavorite(logger, dinnerSvc)) diff --git a/migrations/queries/suggestions.sql b/migrations/queries/suggestions.sql index 1068622..a68cb6a 100644 --- a/migrations/queries/suggestions.sql +++ b/migrations/queries/suggestions.sql @@ -116,3 +116,11 @@ WHERE id = sqlc.arg(id) AND status = 'pending'; UPDATE dinners SET status = sqlc.arg(status), updated_by = sqlc.arg(updated_by), updated_at = sqlc.arg(updated_at) WHERE id = sqlc.arg(id) AND status = 'suggested'; + +-- name: GetDinnerSuggestionReason :one +-- The reason of the suggestion that created dinner_id, its earliest. +-- Returns sql.ErrNoRows for a dinner no suggestion created. +SELECT reason FROM suggestions +WHERE dinner_id = ? +ORDER BY created_at, id +LIMIT 1;