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;