Keep a dropped dinner's name immutable and 404 a never-checked dinner
CI / ci (push) Successful in 3m37s
CI / ci (pull_request) Successful in 3m14s

R-01: a Rotation check decided drop now records the dinner's name at the
decision in rotation_reviews.name (migration 0014, a table rebuild as 0012
did for suggestions; checks dropped before it take their dinner's name).
ListPassedNames and ListRecentPassedNames read that name instead of the
mutable dinners.name, so renaming the archived dinner no longer lets the
dropped dish back into a Side quest batch. A CHECK keeps a name on drops
only.

R-02: deciding on a dinner that has never had a Rotation check is now
ErrReviewNotFound, 404 not_found; a dinner whose checks are all decided,
such as a second decision, stays 409 no_pending_review.

Tests: TestLaterBatchNeverResuggestsRenamedDrop (drop, rename, next batch
still refuses the original name), TestMigrateRotationReviewName (0013 to
0014 upgrade and constraints), TestPostRotationReviewNeverChecked (404),
and TestDecideReview/TestDecideReviewRefused updated.

Maestro: a1887d1c-c405-478c-ac7d-8cb8b40d280d

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 07:32:05 -04:00
co-authored by Claude Opus 5.5
parent f97b4c7b8c
commit cca6a82810
16 changed files with 320 additions and 58 deletions
+9 -7
View File
@@ -101,7 +101,7 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
| `suggestions` | dinner_id, batch_id, name (the name the dish was suggested under, kept whatever its dinner is later renamed, so a Pass stays an immutable record every later batch dedupes against), reason, cited_regular_ids (JSON), status, decided_at. | | `suggestions` | dinner_id, batch_id, name (the name the dish was suggested under, kept whatever its dinner is later renamed, so a Pass stays an immutable record every later batch dedupes against), reason, cited_regular_ids (JSON), status, decided_at. |
| `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at, revision (raised by every swap and stored draft). | | `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at, revision (raised by every swap and stored draft). |
| `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id. | | `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id. |
| `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), decided_at, updated_by. | | `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), name (set only by a drop: the dinner's name when dropped, kept whatever it is later renamed, so a drop stays among the passed names), decided_at, updated_by. |
| `shopping_lists` | week_start (unique), plan_id, title, status (`current` / `archived`), sent_to_chat_at, frozen_at. | | `shopping_lists` | week_start (unique), plan_id, title, status (`current` / `archived`), sent_to_chat_at, frozen_at. |
| `shopping_groups` / `shopping_items` | One group per dinner/day; item name, qty, needed (bool), position. | | `shopping_groups` / `shopping_items` | One group per dinner/day; item name, qty, needed (bool), position. |
| `ai_settings` | One row: provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at. | | `ai_settings` | One row: provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at. |
@@ -378,12 +378,14 @@ automatic** (D-021).
The check is closed with `decided_at` and the session's `updated_by`, The check is closed with `decided_at` and the session's `updated_by`,
and the answer is `{dinner, plan_date, decision, dinner_status, and the answer is `{dinner, plan_date, decision, dinner_status,
decided_at, updated_by}`. Any other decision is 400 `invalid_decision`, decided_at, updated_by}`. Any other decision is 400 `invalid_decision`,
an unknown dinner 404, a dinner with no pending check (including a an unknown dinner or a dinner never checked 404 `not_found`, a dinner
second decision) 409 `no_pending_review`, and one no longer tried 409 whose checks are all decided (including a second decision) 409
`dinner_not_tried`. Keep is the only path from tried to Regular. A `no_pending_review`, and one no longer tried 409 `dinner_not_tried`.
dropped dinner's name joins the passed names (`ListPassedNames`) the Keep is the only path from tried to Regular. A drop records the
Side quest dedupe and household context use, so it is never suggested dinner's name on the check (`rotation_reviews.name`), and that name
again. joins the passed names (`ListPassedNames`) the Side quest dedupe and
household context use, so it is never suggested again, even after the
dinner is renamed.
- `/health` — liveness. - `/health` — liveness.
- Everything else serves the embedded PWA with SPA fallback. - Everything else serves the embedded PWA with SPA fallback.
+2 -3
View File
@@ -59,10 +59,9 @@ SELECT name FROM (
FROM suggestions FROM suggestions
WHERE status = 'declined' WHERE status = 'declined'
UNION ALL UNION ALL
SELECT dinners.name, rotation_reviews.decided_at, rotation_reviews.id SELECT name, decided_at, id
FROM rotation_reviews FROM rotation_reviews
JOIN dinners ON dinners.id = rotation_reviews.dinner_id WHERE decision = 'drop'
WHERE rotation_reviews.decision = 'drop'
) )
ORDER BY decided_at DESC, id DESC ORDER BY decided_at DESC, id DESC
LIMIT ? LIMIT ?
+1
View File
@@ -228,6 +228,7 @@ type RotationReview struct {
PlanDate string PlanDate string
Status string Status string
Decision sql.NullString Decision sql.NullString
Name sql.NullString
DecidedAt sql.NullString DecidedAt sql.NullString
UpdatedBy sql.NullString UpdatedBy sql.NullString
CreatedAt string CreatedAt string
+7 -4
View File
@@ -25,6 +25,8 @@ type Querier interface {
CountDeck(ctx context.Context) (int64, error) CountDeck(ctx context.Context) (int64, error)
// How many Regulars the household has; archived dinners are not counted. // How many Regulars the household has; archived dinners are not counted.
CountRegularDinners(ctx context.Context) (int64, error) CountRegularDinners(ctx context.Context) (int64, error)
// How many Rotation checks dinner_id has had, pending or decided.
CountRotationReviews(ctx context.Context, dinnerID string) (int64, error)
CreateAIRun(ctx context.Context, arg CreateAIRunParams) error CreateAIRun(ctx context.Context, arg CreateAIRunParams) error
CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error) CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error)
CreateJob(ctx context.Context, arg CreateJobParams) error CreateJob(ctx context.Context, arg CreateJobParams) error
@@ -40,8 +42,9 @@ type Querier interface {
CreateSuggestion(ctx context.Context, arg CreateSuggestionParams) error CreateSuggestion(ctx context.Context, arg CreateSuggestionParams) error
CreateSuggestionBatch(ctx context.Context, arg CreateSuggestionBatchParams) (SuggestionBatch, error) CreateSuggestionBatch(ctx context.Context, arg CreateSuggestionBatchParams) (SuggestionBatch, error)
CreateWeekPlan(ctx context.Context, arg CreateWeekPlanParams) error CreateWeekPlan(ctx context.Context, arg CreateWeekPlanParams) error
// Records the household's decision on a pending Rotation check; one // Records the household's decision on a pending Rotation check, with the
// already decided is left as it is (0 rows). // dinner's name for a drop and NULL otherwise; one already decided is left
// as it is (0 rows).
DecideRotationReview(ctx context.Context, arg DecideRotationReviewParams) (int64, error) DecideRotationReview(ctx context.Context, arg DecideRotationReviewParams) (int64, error)
// Records the household's decision on a pending suggestion; one already // Records the household's decision on a pending suggestion; one already
// decided is left as it is (0 rows). // decided is left as it is (0 rows).
@@ -136,8 +139,8 @@ type Querier interface {
ListDraftPool(ctx context.Context, arg ListDraftPoolParams) ([]Dinner, error) ListDraftPool(ctx context.Context, arg ListDraftPoolParams) ([]Dinner, error)
// Every name the household turned down, oldest decision first: the name // Every name the household turned down, oldest decision first: the name
// each declined suggestion was suggested under, which renaming its dinner // each declined suggestion was suggested under, which renaming its dinner
// does not change, and the name of each dinner a Rotation check dropped // does not change, and the name each dinner a Rotation check dropped
// ("Not for us"), which archiving keeps. // ("Not for us") had when dropped, which renaming it does not change.
ListPassedNames(ctx context.Context) ([]string, error) ListPassedNames(ctx context.Context) ([]string, error)
// The pending Rotation checks whose dinner is still tried, oldest plan // The pending Rotation checks whose dinner is still tried, oldest plan
// date first, with their dinners. // date first, with their dinners.
+24 -7
View File
@@ -10,6 +10,18 @@ import (
"database/sql" "database/sql"
) )
const countRotationReviews = `-- name: CountRotationReviews :one
SELECT COUNT(*) FROM rotation_reviews WHERE dinner_id = ?
`
// How many Rotation checks dinner_id has had, pending or decided.
func (q *Queries) CountRotationReviews(ctx context.Context, dinnerID string) (int64, error) {
row := q.db.QueryRowContext(ctx, countRotationReviews, dinnerID)
var count int64
err := row.Scan(&count)
return count, err
}
const createRotationReview = `-- name: CreateRotationReview :exec const createRotationReview = `-- name: CreateRotationReview :exec
INSERT INTO rotation_reviews (id, dinner_id, plan_date, status, created_at, updated_at) INSERT INTO rotation_reviews (id, dinner_id, plan_date, status, created_at, updated_at)
VALUES (?, ?, ?, 'pending', ?, ?) VALUES (?, ?, ?, 'pending', ?, ?)
@@ -38,24 +50,28 @@ const decideRotationReview = `-- name: DecideRotationReview :execrows
UPDATE rotation_reviews UPDATE rotation_reviews
SET status = 'decided', SET status = 'decided',
decision = ?1, decision = ?1,
decided_at = ?2, name = ?2,
updated_by = ?3, decided_at = ?3,
updated_at = ?2 updated_by = ?4,
WHERE id = ?4 AND status = 'pending' updated_at = ?3
WHERE id = ?5 AND status = 'pending'
` `
type DecideRotationReviewParams struct { type DecideRotationReviewParams struct {
Decision sql.NullString Decision sql.NullString
Name sql.NullString
Now sql.NullString Now sql.NullString
UpdatedBy sql.NullString UpdatedBy sql.NullString
ID string ID string
} }
// Records the household's decision on a pending Rotation check; one // Records the household's decision on a pending Rotation check, with the
// already decided is left as it is (0 rows). // dinner's name for a drop and NULL otherwise; one already decided is left
// as it is (0 rows).
func (q *Queries) DecideRotationReview(ctx context.Context, arg DecideRotationReviewParams) (int64, error) { func (q *Queries) DecideRotationReview(ctx context.Context, arg DecideRotationReviewParams) (int64, error) {
result, err := q.db.ExecContext(ctx, decideRotationReview, result, err := q.db.ExecContext(ctx, decideRotationReview,
arg.Decision, arg.Decision,
arg.Name,
arg.Now, arg.Now,
arg.UpdatedBy, arg.UpdatedBy,
arg.ID, arg.ID,
@@ -67,7 +83,7 @@ func (q *Queries) DecideRotationReview(ctx context.Context, arg DecideRotationRe
} }
const getPendingRotationReview = `-- name: GetPendingRotationReview :one const getPendingRotationReview = `-- name: GetPendingRotationReview :one
SELECT id, dinner_id, plan_date, status, decision, decided_at, updated_by, created_at, updated_at FROM rotation_reviews SELECT id, dinner_id, plan_date, status, decision, name, decided_at, updated_by, created_at, updated_at FROM rotation_reviews
WHERE dinner_id = ? AND status = 'pending' WHERE dinner_id = ? AND status = 'pending'
` `
@@ -83,6 +99,7 @@ func (q *Queries) GetPendingRotationReview(ctx context.Context, dinnerID string)
&i.PlanDate, &i.PlanDate,
&i.Status, &i.Status,
&i.Decision, &i.Decision,
&i.Name,
&i.DecidedAt, &i.DecidedAt,
&i.UpdatedBy, &i.UpdatedBy,
&i.CreatedAt, &i.CreatedAt,
+4 -5
View File
@@ -354,18 +354,17 @@ SELECT name FROM (
FROM suggestions FROM suggestions
WHERE status = 'declined' WHERE status = 'declined'
UNION ALL UNION ALL
SELECT dinners.name, rotation_reviews.decided_at, rotation_reviews.id SELECT name, decided_at, id
FROM rotation_reviews FROM rotation_reviews
JOIN dinners ON dinners.id = rotation_reviews.dinner_id WHERE decision = 'drop'
WHERE rotation_reviews.decision = 'drop'
) )
ORDER BY decided_at, id ORDER BY decided_at, id
` `
// Every name the household turned down, oldest decision first: the name // Every name the household turned down, oldest decision first: the name
// each declined suggestion was suggested under, which renaming its dinner // each declined suggestion was suggested under, which renaming its dinner
// does not change, and the name of each dinner a Rotation check dropped // does not change, and the name each dinner a Rotation check dropped
// ("Not for us"), which archiving keeps. // ("Not for us") had when dropped, which renaming it does not change.
func (q *Queries) ListPassedNames(ctx context.Context) ([]string, error) { func (q *Queries) ListPassedNames(ctx context.Context) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listPassedNames) rows, err := q.db.QueryContext(ctx, listPassedNames)
if err != nil { if err != nil {
+19 -5
View File
@@ -32,8 +32,12 @@ var decisionStatus = map[string]string{
DecisionNotMade: dinners.StatusQuestLog, DecisionNotMade: dinners.StatusQuestLog,
} }
// ErrNoPendingReview is a decision on a dinner with no pending Rotation // ErrReviewNotFound is a decision on a dinner that has never had a
// check: it was never tried, or its check is already decided. // Rotation check: it was never tried.
var ErrReviewNotFound = errors.New("the dinner has no Rotation check")
// ErrNoPendingReview is a decision on a dinner whose Rotation checks are
// all decided, such as a second decision.
var ErrNoPendingReview = errors.New("the dinner has no pending Rotation check") var ErrNoPendingReview = errors.New("the dinner has no pending Rotation check")
// ErrDinnerNotTried is a decision on a pending Rotation check whose dinner // ErrDinnerNotTried is a decision on a pending Rotation check whose dinner
@@ -130,9 +134,11 @@ func (s *Service) PendingReviews(ctx context.Context) ([]Review, error) {
// DecideReview applies decision to the pending Rotation check of dinnerID // DecideReview applies decision to the pending Rotation check of dinnerID
// and closes it, recording updatedBy: keep makes the dinner a Regular, // and closes it, recording updatedBy: keep makes the dinner a Regular,
// drop archives it and not_made returns it to the Quest log; its origin is // drop archives it and not_made returns it to the Quest log; its origin is
// kept. An unknown dinner is ErrDinnerNotFound, a dinner with no pending // kept; a drop records the dinner's name on the check, so renaming the
// check (including a second decision) ErrNoPendingReview, and one no // dinner later never takes it off the passed names. An unknown dinner is
// longer tried ErrDinnerNotTried; none of them changes anything. // ErrDinnerNotFound, a dinner never checked ErrReviewNotFound, one whose
// checks are all decided (such as a second decision) ErrNoPendingReview,
// and one no longer tried ErrDinnerNotTried; none of them changes anything.
func (s *Service) DecideReview(ctx context.Context, dinnerID, decision, updatedBy string) (Decided, error) { func (s *Service) DecideReview(ctx context.Context, dinnerID, decision, updatedBy string) (Decided, error) {
status, ok := decisionStatus[decision] status, ok := decisionStatus[decision]
if !ok { if !ok {
@@ -153,6 +159,13 @@ func (s *Service) DecideReview(ctx context.Context, dinnerID, decision, updatedB
} }
review, err := q.GetPendingRotationReview(ctx, d.ID) review, err := q.GetPendingRotationReview(ctx, d.ID)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
checks, err := q.CountRotationReviews(ctx, d.ID)
if err != nil {
return fmt.Errorf("plan: count Rotation checks of %s: %w", d.ID, err)
}
if checks == 0 {
return ErrReviewNotFound
}
return ErrNoPendingReview return ErrNoPendingReview
} }
if err != nil { if err != nil {
@@ -173,6 +186,7 @@ func (s *Service) DecideReview(ctx context.Context, dinnerID, decision, updatedB
} }
closed, err := q.DecideRotationReview(ctx, db.DecideRotationReviewParams{ closed, err := q.DecideRotationReview(ctx, db.DecideRotationReviewParams{
Decision: sql.NullString{String: decision, Valid: true}, Decision: sql.NullString{String: decision, Valid: true},
Name: sql.NullString{String: d.Name, Valid: decision == DecisionDrop},
Now: sql.NullString{String: now, Valid: true}, Now: sql.NullString{String: now, Valid: true},
UpdatedBy: sql.NullString{String: updatedBy, Valid: true}, UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
ID: review.ID, ID: review.ID,
+16 -10
View File
@@ -55,14 +55,14 @@ func (e *env) dinnerRow(t *testing.T, name string) db.Dinner {
// review is a rotation_reviews row as stored. // review is a rotation_reviews row as stored.
type review struct { type review struct {
planDate, status string planDate, status string
decision, decidedAt, updatedBy sql.NullString decision, name, decidedAt, updatedBy sql.NullString
} }
func (e *env) reviews(t *testing.T, name string) []review { func (e *env) reviews(t *testing.T, name string) []review {
t.Helper() t.Helper()
rows, err := e.st.Reader.QueryContext(t.Context(), rows, err := e.st.Reader.QueryContext(t.Context(),
`SELECT plan_date, status, decision, decided_at, updated_by FROM rotation_reviews WHERE dinner_id = ? ORDER BY plan_date`, e.ids[name]) `SELECT plan_date, status, decision, name, decided_at, updated_by FROM rotation_reviews WHERE dinner_id = ? ORDER BY plan_date`, e.ids[name])
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -70,7 +70,7 @@ func (e *env) reviews(t *testing.T, name string) []review {
var out []review var out []review
for rows.Next() { for rows.Next() {
var r review var r review
if err := rows.Scan(&r.planDate, &r.status, &r.decision, &r.decidedAt, &r.updatedBy); err != nil { if err := rows.Scan(&r.planDate, &r.status, &r.decision, &r.name, &r.decidedAt, &r.updatedBy); err != nil {
t.Fatal(err) t.Fatal(err)
} }
out = append(out, r) out = append(out, r)
@@ -103,7 +103,7 @@ func TestMarkTried(t *testing.T) {
} }
got := e.reviews(t, "Pad Thai") got := e.reviews(t, "Pad Thai")
if len(got) != 1 || got[0].planDate != padThaiDay.String() || got[0].status != "pending" || if len(got) != 1 || got[0].planDate != padThaiDay.String() || got[0].status != "pending" ||
got[0].decision.Valid || got[0].decidedAt.Valid || got[0].updatedBy.Valid { got[0].decision.Valid || got[0].name.Valid || got[0].decidedAt.Valid || got[0].updatedBy.Valid {
t.Fatalf("Pad Thai reviews = %+v, want one pending for %s", got, padThaiDay) t.Fatalf("Pad Thai reviews = %+v, want one pending for %s", got, padThaiDay)
} }
if got := e.dinnerRow(t, "Shawarma Bowls").Status; got != "quest_log" { if got := e.dinnerRow(t, "Shawarma Bowls").Status; got != "quest_log" {
@@ -197,10 +197,17 @@ func TestDecideReview(t *testing.T) {
t.Errorf("Pad Thai = status %s origin %s name %s updated_by %v, want %s side_quest Pad Thai %s", t.Errorf("Pad Thai = status %s origin %s name %s updated_by %v, want %s side_quest Pad Thai %s",
row.Status, row.Origin, row.Name, row.UpdatedBy, tc.status, reviewer) row.Status, row.Origin, row.Name, row.UpdatedBy, tc.status, reviewer)
} }
// Only a drop records the dinner's name.
wantPassed := tc.decision == plan.DecisionDrop
wantName := sql.NullString{}
if wantPassed {
wantName = sql.NullString{String: "Pad Thai", Valid: true}
}
got := e.reviews(t, "Pad Thai") got := e.reviews(t, "Pad Thai")
if len(got) != 1 || got[0].status != "decided" || got[0].decision.String != tc.decision || if len(got) != 1 || got[0].status != "decided" || got[0].decision.String != tc.decision ||
got[0].decidedAt.String != d.DecidedAt || got[0].updatedBy.String != reviewer { got[0].decidedAt.String != d.DecidedAt || got[0].updatedBy.String != reviewer ||
t.Errorf("reviews = %+v, want one decided %s by %s at %s", got, tc.decision, reviewer, d.DecidedAt) got[0].name != wantName {
t.Errorf("reviews = %+v, want one decided %s by %s at %s, named only by a drop", got, tc.decision, reviewer, d.DecidedAt)
} }
if pending, err := e.svc.PendingReviews(t.Context()); err != nil || len(pending) != 0 { if pending, err := e.svc.PendingReviews(t.Context()); err != nil || len(pending) != 0 {
t.Errorf("pending after deciding = %+v, %v, want none", pending, err) t.Errorf("pending after deciding = %+v, %v, want none", pending, err)
@@ -224,7 +231,6 @@ func TestDecideReview(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
wantPassed := tc.decision == plan.DecisionDrop
if slices.Contains(passed, "Pad Thai") != wantPassed || slices.Contains(recent, "Pad Thai") != wantPassed { if slices.Contains(passed, "Pad Thai") != wantPassed || slices.Contains(recent, "Pad Thai") != wantPassed {
t.Errorf("passed names = %v, recent %v; want Pad Thai among them: %t", passed, recent, wantPassed) t.Errorf("passed names = %v, recent %v; want Pad Thai among them: %t", passed, recent, wantPassed)
} }
@@ -246,8 +252,8 @@ func TestDecideReviewRefused(t *testing.T) {
t.Errorf("unknown dinner: %v, want ErrDinnerNotFound", err) t.Errorf("unknown dinner: %v, want ErrDinnerNotFound", err)
} }
// Never tried: Shawarma Bowls is in the Quest log with no check. // Never tried: Shawarma Bowls is in the Quest log with no check.
if _, err := e.svc.DecideReview(ctx, e.ids["Shawarma Bowls"], plan.DecisionKeep, reviewer); !errors.Is(err, plan.ErrNoPendingReview) { if _, err := e.svc.DecideReview(ctx, e.ids["Shawarma Bowls"], plan.DecisionKeep, reviewer); !errors.Is(err, plan.ErrReviewNotFound) {
t.Errorf("dinner never tried: %v, want ErrNoPendingReview", err) t.Errorf("dinner never tried: %v, want ErrReviewNotFound", err)
} }
if got := e.dinnerRow(t, "Shawarma Bowls").Status; got != "quest_log" { if got := e.dinnerRow(t, "Shawarma Bowls").Status; got != "quest_log" {
t.Errorf("Shawarma Bowls status = %s, want quest_log", got) t.Errorf("Shawarma Bowls status = %s, want quest_log", got)
+5 -3
View File
@@ -64,9 +64,9 @@ func postRotationReview(logger *slog.Logger, svc *plan.Service) http.HandlerFunc
} }
// writeRotationError maps a Rotation check error to its API error: a // writeRotationError maps a Rotation check error to its API error: a
// decision other than keep, drop or not_made is 400, an unknown dinner // decision other than keep, drop or not_made is 400, an unknown dinner or
// 404, and a dinner with no pending check (such as a second decision) or // a dinner never checked 404, and a dinner whose checks are all decided
// no longer tried 409. // (such as a second decision) or no longer tried 409.
func writeRotationError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) { func writeRotationError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
var invalid *plan.InvalidDecisionError var invalid *plan.InvalidDecisionError
switch { switch {
@@ -74,6 +74,8 @@ func writeRotationError(w http.ResponseWriter, r *http.Request, logger *slog.Log
writeError(w, r, logger, http.StatusBadRequest, "invalid_decision", invalid.Error()) writeError(w, r, logger, http.StatusBadRequest, "invalid_decision", invalid.Error())
case errors.Is(err, plan.ErrDinnerNotFound): case errors.Is(err, plan.ErrDinnerNotFound):
writeError(w, r, logger, http.StatusNotFound, "not_found", "dinner not found") writeError(w, r, logger, http.StatusNotFound, "not_found", "dinner not found")
case errors.Is(err, plan.ErrReviewNotFound):
writeError(w, r, logger, http.StatusNotFound, "not_found", "rotation check not found")
case errors.Is(err, plan.ErrNoPendingReview): case errors.Is(err, plan.ErrNoPendingReview):
writeError(w, r, logger, http.StatusConflict, "no_pending_review", err.Error()) writeError(w, r, logger, http.StatusConflict, "no_pending_review", err.Error())
case errors.Is(err, plan.ErrDinnerNotTried): case errors.Is(err, plan.ErrDinnerNotTried):
+18
View File
@@ -97,6 +97,24 @@ func TestPostRotationReview(t *testing.T) {
} }
} }
// A dinner that has never had a Rotation check is 404; a second decision
// on one already decided is 409 (TestPostRotationReview).
func TestPostRotationReviewNeverChecked(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+e.questLog+"/rotation-review", `{"decision":"keep"}`)
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
t.Errorf("never checked = %d %v, want 404 not_found", rec.Code, body)
}
var status string
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT status FROM dinners WHERE id = ?`, e.questLog).Scan(&status); err != nil {
t.Fatal(err)
}
if status != "quest_log" {
t.Errorf("status after a refused decision = %s, want quest_log", status)
}
}
func TestPostRotationReviewRefused(t *testing.T) { func TestPostRotationReviewRefused(t *testing.T) {
t.Parallel() t.Parallel()
e := newPlansRouter(t) e := newPlansRouter(t)
+89 -4
View File
@@ -176,8 +176,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil { if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision" { if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision", got) t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name", got)
} }
} }
@@ -197,8 +197,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil { if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision" { if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision", got) t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name", got)
} }
if !tableExists(t, s, "dinners") { if !tableExists(t, s, "dinners") {
t.Error("table from 0001_init was not created") t.Error("table from 0001_init was not created")
@@ -633,3 +633,88 @@ func TestMigrateSuggestionName(t *testing.T) {
t.Error("suggestions_copy left behind") t.Error("suggestions_copy left behind")
} }
} }
// TestMigrateRotationReviewName upgrades a database at 0013 holding a
// pending, a kept and a dropped Rotation check and checks 0014 keeps all
// three, gives only the drop its dinner's name, and refuses a drop with no
// name or a name on any other check.
func TestMigrateRotationReviewName(t *testing.T) {
ctx := context.Background()
s := openTestStore(t)
before := fstest.MapFS{}
for _, name := range []string{
"0000_bootstrap.sql", "0001_init.sql", "0002_household_settings_updated_by.sql", "0003_jobs_status.sql",
"0004_ai_provider_presets.sql", "0005_ai_provider_xai.sql", "0006_ai_runs_status.sql", "0007_recipe_grounding.sql",
"0008_dinner_recipe_url.sql", "0009_dinner_grounding_error.sql", "0010_dinner_grounding_revision.sql",
"0011_ground_dinner_job_revision.sql", "0012_suggestion_name.sql", "0013_week_plan_revision.sql",
} {
body, err := fs.ReadFile(migrations.FS, name)
if err != nil {
t.Fatal(err)
}
before[name] = &fstest.MapFile{Data: body}
}
if err := Migrate(ctx, s.Writer, before); err != nil {
t.Fatal(err)
}
for _, stmt := range []string{
`INSERT INTO dinners (id, name, normalized_name, status, origin, is_favorite, grounding_status, created_at, updated_at)
VALUES ('dinner-1', 'Pho', 'pho', 'tried', 'side_quest', 0, 'grounded', 'c', 'u'),
('dinner-2', 'Ramen', 'ramen', 'regular', 'side_quest', 0, 'grounded', 'c', 'u'),
('dinner-3', 'Laksa', 'laksa', 'archived', 'side_quest', 0, 'grounded', 'c', 'u')`,
`INSERT INTO rotation_reviews (id, dinner_id, plan_date, status, decision, decided_at, updated_by, created_at, updated_at)
VALUES ('review-1', 'dinner-1', '2026-10-12', 'pending', NULL, NULL, NULL, 'c1', 'u1'),
('review-2', 'dinner-2', '2026-10-13', 'decided', 'keep', 'd2', 'member-1', 'c2', 'u2'),
('review-3', 'dinner-3', '2026-10-14', 'decided', 'drop', 'd3', 'member-1', 'c3', 'u3')`,
} {
if _, err := s.Writer.Exec(stmt); err != nil {
t.Fatalf("seed: %v", err)
}
}
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
rows, err := s.Reader.Query(`SELECT id, dinner_id, plan_date, status, COALESCE(decision, ''), COALESCE(name, ''),
COALESCE(decided_at, ''), COALESCE(updated_by, ''), created_at, updated_at
FROM rotation_reviews ORDER BY id`)
if err != nil {
t.Fatal(err)
}
var got [][]string
for rows.Next() {
r := make([]string, 10)
if err := rows.Scan(&r[0], &r[1], &r[2], &r[3], &r[4], &r[5], &r[6], &r[7], &r[8], &r[9]); err != nil {
t.Fatal(err)
}
got = append(got, r)
}
if err := rows.Close(); err != nil {
t.Fatal(err)
}
want := [][]string{
{"review-1", "dinner-1", "2026-10-12", "pending", "", "", "", "", "c1", "u1"},
{"review-2", "dinner-2", "2026-10-13", "decided", "keep", "", "d2", "member-1", "c2", "u2"},
{"review-3", "dinner-3", "2026-10-14", "decided", "drop", "Laksa", "d3", "member-1", "c3", "u3"},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("rotation_reviews after 0014 =\n %v\nwant\n %v", got, want)
}
for _, tc := range []struct{ name, stmt, want string }{
{"drop with no name", `UPDATE rotation_reviews SET name = NULL WHERE id = 'review-3'`, "CHECK constraint failed"},
{"empty name", `UPDATE rotation_reviews SET name = '' WHERE id = 'review-3'`, "CHECK constraint failed"},
{"name on a keep", `UPDATE rotation_reviews SET name = 'Ramen' WHERE id = 'review-2'`, "CHECK constraint failed"},
{"name on a pending check", `UPDATE rotation_reviews SET name = 'Pho' WHERE id = 'review-1'`, "CHECK constraint failed"},
{"unknown dinner", `UPDATE rotation_reviews SET dinner_id = 'nope' WHERE id = 'review-1'`, "FOREIGN KEY constraint failed"},
{"second check of a dinner and date", `INSERT INTO rotation_reviews (id, dinner_id, plan_date, status, created_at, updated_at)
VALUES ('review-4', 'dinner-1', '2026-10-12', 'pending', 'c', 'u')`, "UNIQUE constraint failed"},
} {
if _, err := s.Writer.Exec(tc.stmt); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("%s: error %v, want %s", tc.name, err, tc.want)
}
}
if tableExists(t, s, "rotation_reviews_copy") {
t.Error("rotation_reviews_copy left behind")
}
}
+69
View File
@@ -21,6 +21,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" "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/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" "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" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
@@ -912,6 +913,74 @@ func TestLaterBatchNeverResuggestsRenamedPass(t *testing.T) {
} }
} }
// TestLaterBatchNeverResuggestsRenamedDrop drops a tried dinner at its
// Rotation check ("Not for us"), renames its archived dinner, and checks
// the next batch still refuses the name it was dropped under.
func TestLaterBatchNeverResuggestsRenamedDrop(t *testing.T) {
e := newEnv(t)
ctx := t.Context()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
dinnerSvc, err := dinners.NewService(e.st, e.grounder, e.runner, logger)
if err != nil {
t.Fatal(err)
}
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
return skills.Week{}, errors.New("no draft in this test")
}
planSvc, err := plan.NewService(e.st, fail, e.runner, logger)
if err != nil {
t.Fatal(err)
}
id := e.dinner(t, "Pad Thai", "quest_log")
q := db.New(e.st.Writer)
now := store.Now().String()
planID := store.NewID()
if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{ID: planID, WeekStart: "2026-10-12", CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
if err := q.PutPlanDay(ctx, db.PutPlanDayParams{ID: store.NewID(), PlanID: planID, Date: "2026-10-14",
DinnerID: sql.NullString{String: id, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
day, err := store.ParseLocalDate("2026-10-14")
if err != nil {
t.Fatal(err)
}
if n, err := planSvc.MarkTried(ctx, day); err != nil || n != 1 {
t.Fatalf("MarkTried = %d, %v, want 1", n, err)
}
if _, err := planSvc.DecideReview(ctx, id, plan.DecisionDrop, member); err != nil {
t.Fatal(err)
}
renamed := "Peanut Noodles"
if _, err := dinnerSvc.Update(ctx, id, dinners.Patch{Name: &renamed}, member); err != nil {
t.Fatal(err)
}
// The respelled dropped dish would ground, so only the passed names
// keep it out of the deck.
e.recipe(t, "pad thai!")
e.recipe(t, "Beef Ragu")
e.reply(
sideQuest("pad thai!", "Noodles, like your Chicken Fajitas.", "Chicken Fajitas"),
sideQuest("Beef Ragu", "Slow beef like your Beef Chili.", "Beef Chili"),
)
e.start(t)
res := e.runBatch(t)
if len(res.SuggestionIDs) != 1 || res.Dropped != 1 {
t.Errorf("batch result = %+v, want 1 kept and the dropped dish dropped", res)
}
if got := e.deckNames(t); !slices.Equal(got, []string{"Beef Ragu"}) {
t.Errorf("deck = %v, want [Beef Ragu]", got)
}
e.mu.Lock()
in := e.inputs[0]
e.mu.Unlock()
if !slices.Equal(in.Declined, []string{"Pad Thai"}) || !slices.Equal(in.Context.Passed, []string{"Pad Thai"}) {
t.Errorf("batch declined %v, passed %v; want the name it was dropped under in both", in.Declined, in.Context.Passed)
}
}
// TestDecisionNeverCommitsWithoutItsReplenish makes the replenish batch's // TestDecisionNeverCommitsWithoutItsReplenish makes the replenish batch's
// job fail to store: the decision must not be recorded either, so the // job fail to store: the decision must not be recorded either, so the
// retry decides again and starts the replenish. // retry decides again and starts the replenish.
+43
View File
@@ -0,0 +1,43 @@
-- 0014_rotation_review_name: a Rotation check decided drop ("Not for us")
-- keeps the name its dinner had when the household dropped it. The dinner
-- can be renamed, even once archived, so the passed names every later
-- batch refuses are read from here, never from the dinner, as 0012 does
-- for a Pass. Only a drop has a name. A check dropped before this takes
-- its dinner's name now.
-- SQLite cannot add a column with a CHECK that existing drops would fail
-- before they are filled in, so the table is rebuilt as 0012 does: copy
-- the rows aside, drop the table, create it again, copy the rows back and
-- drop the copy. Nothing references rotation_reviews.
CREATE TABLE rotation_reviews_copy AS SELECT * FROM rotation_reviews;
DROP TABLE rotation_reviews;
CREATE TABLE rotation_reviews (
id TEXT NOT NULL PRIMARY KEY,
dinner_id TEXT NOT NULL REFERENCES dinners (id),
plan_date TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending', 'decided')),
decision TEXT CHECK (decision IN ('keep', 'drop', 'not_made')),
name TEXT CHECK (name <> ''), -- the dinner's name when dropped
decided_at TEXT,
updated_by TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (dinner_id, plan_date),
CHECK ((decision IS 'drop') = (name IS NOT NULL))
);
INSERT INTO rotation_reviews (
id, dinner_id, plan_date, status, decision, name, decided_at, updated_by, created_at, updated_at
)
SELECT
rotation_reviews_copy.id, rotation_reviews_copy.dinner_id, rotation_reviews_copy.plan_date,
rotation_reviews_copy.status, rotation_reviews_copy.decision,
CASE WHEN rotation_reviews_copy.decision = 'drop' THEN dinners.name END,
rotation_reviews_copy.decided_at, rotation_reviews_copy.updated_by,
rotation_reviews_copy.created_at, rotation_reviews_copy.updated_at
FROM rotation_reviews_copy
JOIN dinners ON dinners.id = rotation_reviews_copy.dinner_id;
DROP TABLE rotation_reviews_copy;
+2 -3
View File
@@ -8,10 +8,9 @@ SELECT name FROM (
FROM suggestions FROM suggestions
WHERE status = 'declined' WHERE status = 'declined'
UNION ALL UNION ALL
SELECT dinners.name, rotation_reviews.decided_at, rotation_reviews.id SELECT name, decided_at, id
FROM rotation_reviews FROM rotation_reviews
JOIN dinners ON dinners.id = rotation_reviews.dinner_id WHERE decision = 'drop'
WHERE rotation_reviews.decision = 'drop'
) )
ORDER BY decided_at DESC, id DESC ORDER BY decided_at DESC, id DESC
LIMIT ?; LIMIT ?;
+8 -2
View File
@@ -40,12 +40,18 @@ ORDER BY rotation_reviews.plan_date, rotation_reviews.id;
SELECT * FROM rotation_reviews SELECT * FROM rotation_reviews
WHERE dinner_id = ? AND status = 'pending'; WHERE dinner_id = ? AND status = 'pending';
-- name: CountRotationReviews :one
-- How many Rotation checks dinner_id has had, pending or decided.
SELECT COUNT(*) FROM rotation_reviews WHERE dinner_id = ?;
-- name: DecideRotationReview :execrows -- name: DecideRotationReview :execrows
-- Records the household's decision on a pending Rotation check; one -- Records the household's decision on a pending Rotation check, with the
-- already decided is left as it is (0 rows). -- dinner's name for a drop and NULL otherwise; one already decided is left
-- as it is (0 rows).
UPDATE rotation_reviews UPDATE rotation_reviews
SET status = 'decided', SET status = 'decided',
decision = sqlc.arg(decision), decision = sqlc.arg(decision),
name = sqlc.narg(name),
decided_at = sqlc.arg(now), decided_at = sqlc.arg(now),
updated_by = sqlc.arg(updated_by), updated_by = sqlc.arg(updated_by),
updated_at = sqlc.arg(now) updated_at = sqlc.arg(now)
+4 -5
View File
@@ -89,17 +89,16 @@ WHERE suggestions.status = 'pending' AND dinners.status = 'suggested';
-- name: ListPassedNames :many -- name: ListPassedNames :many
-- Every name the household turned down, oldest decision first: the name -- Every name the household turned down, oldest decision first: the name
-- each declined suggestion was suggested under, which renaming its dinner -- each declined suggestion was suggested under, which renaming its dinner
-- does not change, and the name of each dinner a Rotation check dropped -- does not change, and the name each dinner a Rotation check dropped
-- ("Not for us"), which archiving keeps. -- ("Not for us") had when dropped, which renaming it does not change.
SELECT name FROM ( SELECT name FROM (
SELECT name, decided_at, id SELECT name, decided_at, id
FROM suggestions FROM suggestions
WHERE status = 'declined' WHERE status = 'declined'
UNION ALL UNION ALL
SELECT dinners.name, rotation_reviews.decided_at, rotation_reviews.id SELECT name, decided_at, id
FROM rotation_reviews FROM rotation_reviews
JOIN dinners ON dinners.id = rotation_reviews.dinner_id WHERE decision = 'drop'
WHERE rotation_reviews.decision = 'drop'
) )
ORDER BY decided_at, id; ORDER BY decided_at, id;