Rotation check: tried dinners and keep/drop decisions #116
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 ?
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -56,13 +56,13 @@ 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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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 ?;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user