Fix Feature 12 review findings F2–F8 on Regular grounding and onboarding
Grounding (F2–F5): - Each ground_dinner job carries the dinner's new grounding_revision (migration 0010). Renaming a pending Regular raises the revision, and a job only grounds and writes a dinner still at its revision, so a queued original job no longer grounds the new name or decides the outcome. - Background grounding keeps a method, minutes or difficulty a member saved while the Regular was pending, and fills only those left empty. - Grounding by hand clears the pasted recipe_url, and link deduplication uses the source_url of the attached recipe, else the pending link. - Archived dinners are not grounded: the job checks before any search, fetch or model call, and its final update requires an unarchived row. Onboarding (F6, F7): - Step 3 shows "Add Regulars" back to step 2 when the server refuses to finish with fewer than three Regulars. - Common picks are hidden by a copy of harness Normalize/Similar, checked against the same shared cases as the Go code. Docs (F8): ARCHITECTURE.md covers the three-Regular minimum, step-3 recovery, the grounding revision and the dedupe rules. F1 was fixed in 1304487; its test now also asserts the old link is never grounded and recipe_url stays cleared. Maestro task 5037ff86-181d-448e-927b-c2fd2258a25d. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+31
-11
@@ -96,7 +96,7 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
|
||||
| `recipe_ingredients` | recipe_id, position, raw_text, name, qty, unit, note. |
|
||||
| `recipe_steps` | recipe_id, position, text. |
|
||||
| `photos` | source_url, local_path, width, height, sha256, fetched_at. |
|
||||
| `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, recipe_url (a pasted recipe link awaiting grounding), photo_id, grounding_status (`pending` / `grounded` / `failed` / `manual`), grounding_error (why grounding failed), updated_by. |
|
||||
| `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, recipe_url (a pasted recipe link awaiting grounding), photo_id, grounding_status (`pending` / `grounded` / `failed` / `manual`), grounding_error (why grounding failed), grounding_revision (raised when a pending dinner is renamed, so only the latest `ground_dinner` job may write it), updated_by. |
|
||||
| `suggestion_batches` | trigger, status, ai_run_id. |
|
||||
| `suggestions` | dinner_id, batch_id, 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. |
|
||||
@@ -187,8 +187,10 @@ automatic** (D-021).
|
||||
"Beef stew"); a link that names nothing is 400 — add it by name. A name
|
||||
is trimmed, at most 120 characters and must keep a word after
|
||||
`harness.Normalize`, which sets `normalized_name`. A name
|
||||
`harness.Similar` to an unarchived dinner's, or a link one already has,
|
||||
is 409 `duplicate_dinner`; archived names may be added again.
|
||||
`harness.Similar` to an unarchived dinner's, or a link one already has
|
||||
(the source_url of the recipe it is grounded in, else its pasted
|
||||
`recipe_url`), is 409 `duplicate_dinner`; archived names may be added
|
||||
again.
|
||||
`GET /api/v1/dinners?status=regular|quest_log` returns
|
||||
`{dinners: [...]}` ordered by normalized name, each with `photo_url`
|
||||
(`/media/photos/{photo_id}-400.jpg`, or null) and `grounding_status`;
|
||||
@@ -211,11 +213,19 @@ automatic** (D-021).
|
||||
`failed` with `grounding_error` — also the job's failure code — one of
|
||||
`no_recipe_found`, `recipe_unusable` (the pasted link), `search_not_configured`,
|
||||
`search_failed`, `ai_failed`, `interrupted` (the server stopped) or
|
||||
`grounding_error`. It only writes a dinner still `pending`, so a fix-up
|
||||
made meanwhile wins. At boot, after the job runner fails the jobs a
|
||||
previous process left running, every dinner still `pending` with no
|
||||
`ground_dinner` job queued or running is set `failed` with `interrupted`,
|
||||
so no dinner stays pending with nothing left to ground it.
|
||||
`grounding_error`. Each job carries the dinner's `grounding_revision`
|
||||
from when it was enqueued; renaming a `pending` dinner clears its
|
||||
`recipe_url`, raises the revision and enqueues a job for the new name.
|
||||
A job for a dinner archived, no longer `pending` or at another revision
|
||||
does no work, and a job only writes a dinner still unarchived, `pending`
|
||||
and at its revision, so a fix-up made meanwhile wins and an earlier job
|
||||
for an old name — queued or running — never decides the outcome. A
|
||||
method, minutes_total or difficulty a member saved while the dinner was
|
||||
`pending` is kept; grounding fills only those left empty. At boot, after
|
||||
the job runner fails the jobs a previous process left running, every
|
||||
dinner still `pending` with no `ground_dinner` job for its revision
|
||||
queued or running is set `failed` with `interrupted`, so no dinner stays
|
||||
pending with nothing left to ground it.
|
||||
`GET /api/v1/dinners/{id}/recipe-candidates`
|
||||
searches for the dinner's name and returns `{candidates: [{candidate_id,
|
||||
title, site_name, photo_url, minutes}]}`, at most 3, ranked as `Ground`
|
||||
@@ -229,8 +239,9 @@ automatic** (D-021).
|
||||
— no model runs — as a `recipes` row whose source_url is
|
||||
`mealquest:manual:{recipe_id}` and whose raw_jsonld is a schema.org
|
||||
Recipe built from it, and sets `manual` with its method and minutes, no
|
||||
photo and the dinner's difficulty unchanged. It answers 200 with the
|
||||
dinner; an invalid body is 400, an unknown dinner 404, a page that cannot
|
||||
photo and the dinner's difficulty unchanged. Either clears the dinner's
|
||||
pasted `recipe_url`: the recipe chosen replaces it. It answers 200 with
|
||||
the dinner; an invalid body is 400, an unknown dinner 404, a page that cannot
|
||||
ground it 422 `ungroundable` (the dinner is unchanged), recipe search not
|
||||
set up 409 `not_configured`, a search or model failure 502
|
||||
`search_failed` / `ai_failed` (only the reason code is logged, never the
|
||||
@@ -488,13 +499,22 @@ First run: while `household_settings.onboarding_completed_at` (read from
|
||||
`GET /api/v1/settings/household`) is null, every route redirects to
|
||||
`/onboarding`. Onboarding is a full screen outside the app shell, in three
|
||||
steps: the welcome (`/onboarding`), the Regulars (`/onboarding/regulars`,
|
||||
mock 1h, adding and archiving through the dinners API optimistically) and
|
||||
mock 1h, adding and archiving through the dinners API optimistically; Next
|
||||
waits for three saved Regulars, and a common pick is not offered once a
|
||||
Regular has a name `harness.Similar` to it — the PWA's copy of the name
|
||||
rules in `web/src/dinners/names.ts` runs the same cases,
|
||||
`internal/ai/harness/testdata/dedupe_cases.json`, as the server's) and
|
||||
notifications (`/onboarding/notifications`: what MealQuest notifies about,
|
||||
the iOS install hint, "Turn on notifications" and "Maybe later"). Either
|
||||
button sends `PATCH /api/v1/settings/household` with
|
||||
`{"onboarding_completed": true}`; the server sets `onboarding_completed_at`
|
||||
to the time of that update (keeping the first time if it is already set;
|
||||
the field itself is read-only and `false` is refused) and the app goes Home.
|
||||
Completing needs at least three Regulars (`household.MinRegulars`); with
|
||||
fewer the server answers 400 `invalid_settings` naming
|
||||
`onboarding_completed` and changes nothing. Step 3 can be reached with
|
||||
fewer — by its URL, or after another member removes a Regular — so it then
|
||||
shows the server's reason with an "Add Regulars" button back to step 2.
|
||||
Notification permission is never asked for on page load; until Feature 19
|
||||
wires Web Push, "Turn on notifications" asks for nothing. Offline on the saved pages the
|
||||
settings cannot be read (the service worker never caches `/api/v1/settings`),
|
||||
|
||||
@@ -1,10 +1,45 @@
|
||||
package harness
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// TestSharedDedupeCases runs testdata/dedupe_cases.json, which the PWA's
|
||||
// copy of Normalize and Similar (web/src/dinners/names.ts) runs too, so the
|
||||
// two cannot drift apart on these names.
|
||||
func TestSharedDedupeCases(t *testing.T) {
|
||||
raw, err := os.ReadFile("testdata/dedupe_cases.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var cases struct {
|
||||
Normalize []struct{ Name, Want string }
|
||||
Similar []struct {
|
||||
A, B string
|
||||
Want bool
|
||||
}
|
||||
}
|
||||
if err := json.Unmarshal(raw, &cases); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cases.Normalize) == 0 || len(cases.Similar) == 0 {
|
||||
t.Fatal("dedupe_cases.json has no cases")
|
||||
}
|
||||
for _, c := range cases.Normalize {
|
||||
if got := Normalize(c.Name); got != c.Want {
|
||||
t.Errorf("Normalize(%q) = %q, want %q", c.Name, got, c.Want)
|
||||
}
|
||||
}
|
||||
for _, c := range cases.Similar {
|
||||
if got := Similar(c.A, c.B); got != c.Want {
|
||||
t.Errorf("Similar(%q, %q) = %v, want %v", c.A, c.B, got, c.Want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, want string
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
{
|
||||
"normalize": [
|
||||
{
|
||||
"name": "Easy Chicken Tikka-Masala!",
|
||||
"want": "chicken tikka masala"
|
||||
},
|
||||
{
|
||||
"name": " Homemade Chili ",
|
||||
"want": "chili"
|
||||
},
|
||||
{
|
||||
"name": "Grandma's Stew",
|
||||
"want": "grandmas stew"
|
||||
},
|
||||
{
|
||||
"name": "Grandma’s Stew",
|
||||
"want": "grandmas stew"
|
||||
},
|
||||
{
|
||||
"name": "İskender Kebab",
|
||||
"want": "iskender kebab"
|
||||
},
|
||||
{
|
||||
"name": "Paella à la Valenciana",
|
||||
"want": "paella la valenciana"
|
||||
},
|
||||
{
|
||||
"name": "Crème Brûlée",
|
||||
"want": "creme brulee"
|
||||
},
|
||||
{
|
||||
"name": "Crème Brûlée",
|
||||
"want": "creme brulee"
|
||||
},
|
||||
{
|
||||
"name": "Smørrebrød",
|
||||
"want": "smorrebrod"
|
||||
},
|
||||
{
|
||||
"name": "Pierogi z Łososiem",
|
||||
"want": "pierogi z lososiem"
|
||||
},
|
||||
{
|
||||
"name": "Straße Würstchen",
|
||||
"want": "strasse wurstchen"
|
||||
},
|
||||
{
|
||||
"name": "Œufs en Cocotte",
|
||||
"want": "oeufs en cocotte"
|
||||
},
|
||||
{
|
||||
"name": "Æbleskiver",
|
||||
"want": "aebleskiver"
|
||||
},
|
||||
{
|
||||
"name": "Phở Bò",
|
||||
"want": "pho bo"
|
||||
},
|
||||
{
|
||||
"name": "The Best Quick Recipe",
|
||||
"want": ""
|
||||
},
|
||||
{
|
||||
"name": "Chicken stir-fry",
|
||||
"want": "chicken stir fry"
|
||||
},
|
||||
{
|
||||
"name": "7-layer dip",
|
||||
"want": "7 layer dip"
|
||||
}
|
||||
],
|
||||
"similar": [
|
||||
{
|
||||
"a": "Homemade chili",
|
||||
"b": "Chili",
|
||||
"want": true
|
||||
},
|
||||
{
|
||||
"a": "Easy Spaghetti Bolognese",
|
||||
"b": "spaghetti bolognese",
|
||||
"want": true
|
||||
},
|
||||
{
|
||||
"a": "Chicken stir fry",
|
||||
"b": "Chicken stir-fry",
|
||||
"want": true
|
||||
},
|
||||
{
|
||||
"a": "Crème brûlée",
|
||||
"b": "Creme brulee",
|
||||
"want": true
|
||||
},
|
||||
{
|
||||
"a": "Smørrebrød",
|
||||
"b": "smorrebrod",
|
||||
"want": true
|
||||
},
|
||||
{
|
||||
"a": "Beef tacos",
|
||||
"b": "Fish tacos",
|
||||
"want": false
|
||||
},
|
||||
{
|
||||
"a": "Chili",
|
||||
"b": "White chicken chili",
|
||||
"want": false
|
||||
},
|
||||
{
|
||||
"a": "Pot roast",
|
||||
"b": "Roast",
|
||||
"want": false
|
||||
},
|
||||
{
|
||||
"a": "The best",
|
||||
"b": "Easy recipe",
|
||||
"want": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -268,7 +268,7 @@ func TestDinners(t *testing.T) {
|
||||
}
|
||||
var unarchivedIDs []string
|
||||
for _, d := range unarchived {
|
||||
unarchivedIDs = append(unarchivedIDs, d.ID)
|
||||
unarchivedIDs = append(unarchivedIDs, d.Dinner.ID)
|
||||
}
|
||||
assertIDs(t, "unarchived", unarchivedIDs, other.ID, third.ID)
|
||||
_, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{ID: "missing", UpdatedAt: archivedAt})
|
||||
|
||||
+115
-82
@@ -16,7 +16,7 @@ SET status = 'archived',
|
||||
updated_by = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
|
||||
`
|
||||
|
||||
type ArchiveDinnerParams struct {
|
||||
@@ -48,6 +48,7 @@ func (q *Queries) ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (D
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -75,7 +76,7 @@ INSERT INTO dinners (
|
||||
?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?
|
||||
)
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
|
||||
`
|
||||
|
||||
type CreateDinnerParams struct {
|
||||
@@ -138,6 +139,7 @@ func (q *Queries) CreateDinner(ctx context.Context, arg CreateDinnerParams) (Din
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -153,6 +155,7 @@ WHERE grounding_status = 'pending'
|
||||
WHERE jobs.kind = ?3
|
||||
AND jobs.status IN ('queued', 'running')
|
||||
AND json_extract(jobs.payload, '$.dinner_id') = dinners.id
|
||||
AND json_extract(jobs.payload, '$.revision') = dinners.grounding_revision
|
||||
)
|
||||
`
|
||||
|
||||
@@ -162,10 +165,10 @@ type FailOrphanedPendingDinnerGroundingsParams struct {
|
||||
Kind string
|
||||
}
|
||||
|
||||
// Fails every dinner still pending whose grounding job is no longer queued
|
||||
// or running: the process stopped before the job, or before its handler
|
||||
// recorded a result. Run at boot, after the job runner fails the jobs a
|
||||
// previous process left running.
|
||||
// Fails every dinner still pending whose grounding job for its current
|
||||
// grounding_revision is no longer queued or running: the process stopped
|
||||
// before the job, or before its handler recorded a result. Run at boot,
|
||||
// after the job runner fails the jobs a previous process left running.
|
||||
func (q *Queries) FailOrphanedPendingDinnerGroundings(ctx context.Context, arg FailOrphanedPendingDinnerGroundingsParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, failOrphanedPendingDinnerGroundings, arg.GroundingError, arg.UpdatedAt, arg.Kind)
|
||||
if err != nil {
|
||||
@@ -179,25 +182,29 @@ UPDATE dinners
|
||||
SET grounding_status = 'failed',
|
||||
grounding_error = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ? AND grounding_status = 'pending' AND normalized_name = ?
|
||||
WHERE id = ?
|
||||
AND status <> 'archived'
|
||||
AND grounding_status = 'pending'
|
||||
AND grounding_revision = ?
|
||||
`
|
||||
|
||||
type FailPendingDinnerGroundingParams struct {
|
||||
GroundingError sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
NormalizedName string
|
||||
GroundingError sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
GroundingRevision int64
|
||||
}
|
||||
|
||||
// Records why the background grounding of a dinner still pending under the
|
||||
// normalized name it was grounded for failed; a dinner grounded some other
|
||||
// way or renamed meanwhile is left as it is (0 rows).
|
||||
// Records why the background grounding of an unarchived dinner still
|
||||
// pending at the grounding_revision it was grounded for failed; a dinner
|
||||
// grounded some other way, renamed or archived meanwhile is left as it is
|
||||
// (0 rows).
|
||||
func (q *Queries) FailPendingDinnerGrounding(ctx context.Context, arg FailPendingDinnerGroundingParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, failPendingDinnerGrounding,
|
||||
arg.GroundingError,
|
||||
arg.UpdatedAt,
|
||||
arg.ID,
|
||||
arg.NormalizedName,
|
||||
arg.GroundingRevision,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -206,7 +213,7 @@ func (q *Queries) FailPendingDinnerGrounding(ctx context.Context, arg FailPendin
|
||||
}
|
||||
|
||||
const getDinner = `-- name: GetDinner :one
|
||||
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error FROM dinners
|
||||
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision FROM dinners
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
@@ -232,6 +239,7 @@ func (q *Queries) GetDinner(ctx context.Context, id string) (Dinner, error) {
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -239,6 +247,7 @@ func (q *Queries) GetDinner(ctx context.Context, id string) (Dinner, error) {
|
||||
const groundDinner = `-- name: GroundDinner :one
|
||||
UPDATE dinners
|
||||
SET recipe_id = ?,
|
||||
recipe_url = NULL,
|
||||
photo_id = ?,
|
||||
method = ?,
|
||||
minutes_total = ?,
|
||||
@@ -248,7 +257,7 @@ SET recipe_id = ?,
|
||||
updated_by = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
|
||||
`
|
||||
|
||||
type GroundDinnerParams struct {
|
||||
@@ -263,8 +272,9 @@ type GroundDinnerParams struct {
|
||||
ID string
|
||||
}
|
||||
|
||||
// Records a dinner's grounding (grounded or manual) and clears any
|
||||
// grounding_error. An unknown id returns sql.ErrNoRows.
|
||||
// Records a dinner's grounding by hand (grounded or manual) and clears any
|
||||
// grounding_error and pasted recipe_url: the recipe chosen replaces the
|
||||
// link. An unknown id returns sql.ErrNoRows.
|
||||
func (q *Queries) GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error) {
|
||||
row := q.db.QueryRowContext(ctx, groundDinner,
|
||||
arg.RecipeID,
|
||||
@@ -297,37 +307,43 @@ func (q *Queries) GroundDinner(ctx context.Context, arg GroundDinnerParams) (Din
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const groundPendingDinner = `-- name: GroundPendingDinner :execrows
|
||||
UPDATE dinners
|
||||
SET recipe_id = ?,
|
||||
photo_id = ?,
|
||||
method = ?,
|
||||
minutes_total = ?,
|
||||
difficulty = ?,
|
||||
SET recipe_id = ?1,
|
||||
photo_id = ?2,
|
||||
method = COALESCE(method, ?3),
|
||||
minutes_total = COALESCE(minutes_total, ?4),
|
||||
difficulty = COALESCE(difficulty, ?5),
|
||||
grounding_status = 'grounded',
|
||||
grounding_error = NULL,
|
||||
updated_at = ?
|
||||
WHERE id = ? AND grounding_status = 'pending' AND normalized_name = ?
|
||||
updated_at = ?6
|
||||
WHERE id = ?7
|
||||
AND status <> 'archived'
|
||||
AND grounding_status = 'pending'
|
||||
AND grounding_revision = ?8
|
||||
`
|
||||
|
||||
type GroundPendingDinnerParams struct {
|
||||
RecipeID sql.NullString
|
||||
PhotoID sql.NullString
|
||||
Method sql.NullString
|
||||
MinutesTotal sql.NullInt64
|
||||
Difficulty sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
NormalizedName string
|
||||
RecipeID sql.NullString
|
||||
PhotoID sql.NullString
|
||||
Method sql.NullString
|
||||
MinutesTotal sql.NullInt64
|
||||
Difficulty sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
GroundingRevision int64
|
||||
}
|
||||
|
||||
// Records the background grounding of a dinner still pending under the
|
||||
// normalized name it was grounded for; a dinner grounded some other way or
|
||||
// renamed meanwhile is left as it is (0 rows).
|
||||
// Records the background grounding of an unarchived dinner still pending at
|
||||
// the grounding_revision it was grounded for; a dinner grounded some other
|
||||
// way, renamed or archived meanwhile is left as it is (0 rows). A method,
|
||||
// minutes_total or difficulty already set is kept: a pending dinner has
|
||||
// them only when a member edited them while it was grounding.
|
||||
func (q *Queries) GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, groundPendingDinner,
|
||||
arg.RecipeID,
|
||||
@@ -337,7 +353,7 @@ func (q *Queries) GroundPendingDinner(ctx context.Context, arg GroundPendingDinn
|
||||
arg.Difficulty,
|
||||
arg.UpdatedAt,
|
||||
arg.ID,
|
||||
arg.NormalizedName,
|
||||
arg.GroundingRevision,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -346,7 +362,7 @@ func (q *Queries) GroundPendingDinner(ctx context.Context, arg GroundPendingDinn
|
||||
}
|
||||
|
||||
const listDinnersByStatus = `-- name: ListDinnersByStatus :many
|
||||
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error FROM dinners
|
||||
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision FROM dinners
|
||||
WHERE status = ?
|
||||
ORDER BY normalized_name, id
|
||||
`
|
||||
@@ -379,6 +395,7 @@ func (q *Queries) ListDinnersByStatus(ctx context.Context, status string) ([]Din
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -394,40 +411,50 @@ func (q *Queries) ListDinnersByStatus(ctx context.Context, status string) ([]Din
|
||||
}
|
||||
|
||||
const listUnarchivedDinners = `-- name: ListUnarchivedDinners :many
|
||||
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error FROM dinners
|
||||
WHERE status <> 'archived'
|
||||
ORDER BY id
|
||||
SELECT dinners.id, dinners.name, dinners.normalized_name, dinners.status, dinners.origin, dinners.method, dinners.minutes_total, dinners.difficulty, dinners.cuisine, dinners.is_favorite, dinners.recipe_id, dinners.photo_id, dinners.grounding_status, dinners.updated_by, dinners.created_at, dinners.updated_at, dinners.recipe_url, dinners.grounding_error, dinners.grounding_revision, recipes.source_url AS recipe_source_url
|
||||
FROM dinners
|
||||
LEFT JOIN recipes ON recipes.id = dinners.recipe_id
|
||||
WHERE dinners.status <> 'archived'
|
||||
ORDER BY dinners.id
|
||||
`
|
||||
|
||||
// Every dinner a new or renamed dinner must not repeat.
|
||||
func (q *Queries) ListUnarchivedDinners(ctx context.Context) ([]Dinner, error) {
|
||||
type ListUnarchivedDinnersRow struct {
|
||||
Dinner Dinner
|
||||
RecipeSourceUrl sql.NullString
|
||||
}
|
||||
|
||||
// Every dinner a new or renamed dinner must not repeat, with the
|
||||
// source_url of the recipe it is grounded in (NULL while it has none).
|
||||
func (q *Queries) ListUnarchivedDinners(ctx context.Context) ([]ListUnarchivedDinnersRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listUnarchivedDinners)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Dinner
|
||||
var items []ListUnarchivedDinnersRow
|
||||
for rows.Next() {
|
||||
var i Dinner
|
||||
var i ListUnarchivedDinnersRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.NormalizedName,
|
||||
&i.Status,
|
||||
&i.Origin,
|
||||
&i.Method,
|
||||
&i.MinutesTotal,
|
||||
&i.Difficulty,
|
||||
&i.Cuisine,
|
||||
&i.IsFavorite,
|
||||
&i.RecipeID,
|
||||
&i.PhotoID,
|
||||
&i.GroundingStatus,
|
||||
&i.UpdatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.Dinner.ID,
|
||||
&i.Dinner.Name,
|
||||
&i.Dinner.NormalizedName,
|
||||
&i.Dinner.Status,
|
||||
&i.Dinner.Origin,
|
||||
&i.Dinner.Method,
|
||||
&i.Dinner.MinutesTotal,
|
||||
&i.Dinner.Difficulty,
|
||||
&i.Dinner.Cuisine,
|
||||
&i.Dinner.IsFavorite,
|
||||
&i.Dinner.RecipeID,
|
||||
&i.Dinner.PhotoID,
|
||||
&i.Dinner.GroundingStatus,
|
||||
&i.Dinner.UpdatedBy,
|
||||
&i.Dinner.CreatedAt,
|
||||
&i.Dinner.UpdatedAt,
|
||||
&i.Dinner.RecipeUrl,
|
||||
&i.Dinner.GroundingError,
|
||||
&i.Dinner.GroundingRevision,
|
||||
&i.RecipeSourceUrl,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -448,7 +475,7 @@ SET is_favorite = ?,
|
||||
updated_by = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
|
||||
`
|
||||
|
||||
type SetDinnerFavoriteParams struct {
|
||||
@@ -486,6 +513,7 @@ func (q *Queries) SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoritePa
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -496,7 +524,7 @@ SET is_favorite = 1 - is_favorite,
|
||||
updated_by = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
|
||||
`
|
||||
|
||||
type ToggleDinnerFavoriteParams struct {
|
||||
@@ -528,6 +556,7 @@ func (q *Queries) ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavo
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -546,28 +575,30 @@ SET name = ?,
|
||||
recipe_url = ?,
|
||||
photo_id = ?,
|
||||
grounding_status = ?,
|
||||
grounding_revision = ?,
|
||||
updated_by = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
|
||||
`
|
||||
|
||||
type UpdateDinnerParams struct {
|
||||
Name string
|
||||
NormalizedName string
|
||||
Status string
|
||||
Origin string
|
||||
Method sql.NullString
|
||||
MinutesTotal sql.NullInt64
|
||||
Difficulty sql.NullString
|
||||
Cuisine sql.NullString
|
||||
RecipeID sql.NullString
|
||||
RecipeUrl sql.NullString
|
||||
PhotoID sql.NullString
|
||||
GroundingStatus string
|
||||
UpdatedBy sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
Name string
|
||||
NormalizedName string
|
||||
Status string
|
||||
Origin string
|
||||
Method sql.NullString
|
||||
MinutesTotal sql.NullInt64
|
||||
Difficulty sql.NullString
|
||||
Cuisine sql.NullString
|
||||
RecipeID sql.NullString
|
||||
RecipeUrl sql.NullString
|
||||
PhotoID sql.NullString
|
||||
GroundingStatus string
|
||||
GroundingRevision int64
|
||||
UpdatedBy sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// An unknown id returns sql.ErrNoRows.
|
||||
@@ -585,6 +616,7 @@ func (q *Queries) UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Din
|
||||
arg.RecipeUrl,
|
||||
arg.PhotoID,
|
||||
arg.GroundingStatus,
|
||||
arg.GroundingRevision,
|
||||
arg.UpdatedBy,
|
||||
arg.UpdatedAt,
|
||||
arg.ID,
|
||||
@@ -609,6 +641,7 @@ func (q *Queries) UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Din
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
+19
-18
@@ -51,24 +51,25 @@ type ChatSetting struct {
|
||||
}
|
||||
|
||||
type Dinner struct {
|
||||
ID string
|
||||
Name string
|
||||
NormalizedName string
|
||||
Status string
|
||||
Origin string
|
||||
Method sql.NullString
|
||||
MinutesTotal sql.NullInt64
|
||||
Difficulty sql.NullString
|
||||
Cuisine sql.NullString
|
||||
IsFavorite int64
|
||||
RecipeID sql.NullString
|
||||
PhotoID sql.NullString
|
||||
GroundingStatus string
|
||||
UpdatedBy sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
RecipeUrl sql.NullString
|
||||
GroundingError sql.NullString
|
||||
ID string
|
||||
Name string
|
||||
NormalizedName string
|
||||
Status string
|
||||
Origin string
|
||||
Method sql.NullString
|
||||
MinutesTotal sql.NullInt64
|
||||
Difficulty sql.NullString
|
||||
Cuisine sql.NullString
|
||||
IsFavorite int64
|
||||
RecipeID sql.NullString
|
||||
PhotoID sql.NullString
|
||||
GroundingStatus string
|
||||
UpdatedBy sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
RecipeUrl sql.NullString
|
||||
GroundingError sql.NullString
|
||||
GroundingRevision int64
|
||||
}
|
||||
|
||||
type HouseholdSetting struct {
|
||||
|
||||
+19
-14
@@ -36,14 +36,15 @@ type Querier interface {
|
||||
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
|
||||
DeleteRecipeSteps(ctx context.Context, recipeID string) error
|
||||
FailJob(ctx context.Context, arg FailJobParams) (int64, error)
|
||||
// Fails every dinner still pending whose grounding job is no longer queued
|
||||
// or running: the process stopped before the job, or before its handler
|
||||
// recorded a result. Run at boot, after the job runner fails the jobs a
|
||||
// previous process left running.
|
||||
// Fails every dinner still pending whose grounding job for its current
|
||||
// grounding_revision is no longer queued or running: the process stopped
|
||||
// before the job, or before its handler recorded a result. Run at boot,
|
||||
// after the job runner fails the jobs a previous process left running.
|
||||
FailOrphanedPendingDinnerGroundings(ctx context.Context, arg FailOrphanedPendingDinnerGroundingsParams) (int64, error)
|
||||
// Records why the background grounding of a dinner still pending under the
|
||||
// normalized name it was grounded for failed; a dinner grounded some other
|
||||
// way or renamed meanwhile is left as it is (0 rows).
|
||||
// Records why the background grounding of an unarchived dinner still
|
||||
// pending at the grounding_revision it was grounded for failed; a dinner
|
||||
// grounded some other way, renamed or archived meanwhile is left as it is
|
||||
// (0 rows).
|
||||
FailPendingDinnerGrounding(ctx context.Context, arg FailPendingDinnerGroundingParams) (int64, error)
|
||||
// Fails every job left running, at boot before any worker starts.
|
||||
FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error)
|
||||
@@ -65,12 +66,15 @@ type Querier interface {
|
||||
GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error)
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error)
|
||||
// Records a dinner's grounding (grounded or manual) and clears any
|
||||
// grounding_error. An unknown id returns sql.ErrNoRows.
|
||||
// Records a dinner's grounding by hand (grounded or manual) and clears any
|
||||
// grounding_error and pasted recipe_url: the recipe chosen replaces the
|
||||
// link. An unknown id returns sql.ErrNoRows.
|
||||
GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error)
|
||||
// Records the background grounding of a dinner still pending under the
|
||||
// normalized name it was grounded for; a dinner grounded some other way or
|
||||
// renamed meanwhile is left as it is (0 rows).
|
||||
// Records the background grounding of an unarchived dinner still pending at
|
||||
// the grounding_revision it was grounded for; a dinner grounded some other
|
||||
// way, renamed or archived meanwhile is left as it is (0 rows). A method,
|
||||
// minutes_total or difficulty already set is kept: a pending dinner has
|
||||
// them only when a member edited them while it was grounding.
|
||||
GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error)
|
||||
ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error)
|
||||
// The planned dinners dated from from_date up to but not including
|
||||
@@ -81,8 +85,9 @@ type Querier interface {
|
||||
ListRecentDeclinedNames(ctx context.Context, limit int64) ([]string, error)
|
||||
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
|
||||
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
|
||||
// Every dinner a new or renamed dinner must not repeat.
|
||||
ListUnarchivedDinners(ctx context.Context) ([]Dinner, error)
|
||||
// Every dinner a new or renamed dinner must not repeat, with the
|
||||
// source_url of the recipe it is grounded in (NULL while it has none).
|
||||
ListUnarchivedDinners(ctx context.Context) ([]ListUnarchivedDinnersRow, error)
|
||||
// Stores a partial result of a running job.
|
||||
PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error)
|
||||
// An unknown id returns sql.ErrNoRows.
|
||||
|
||||
+54
-46
@@ -176,7 +176,7 @@ func (s *Service) CreateRegular(ctx context.Context, r NewRegular, updatedBy str
|
||||
if err != nil {
|
||||
return Created{}, err
|
||||
}
|
||||
jobID, err := s.enqueueGrounding(ctx, d.ID, harness.Normalize(d.Name))
|
||||
jobID, err := s.enqueueGrounding(ctx, d.ID, d.GroundingRevision)
|
||||
if err != nil {
|
||||
return Created{}, err
|
||||
}
|
||||
@@ -187,60 +187,59 @@ func (s *Service) CreateRegular(ctx context.Context, r NewRegular, updatedBy str
|
||||
}
|
||||
return Created{Dinner: fromRow(row)}, nil
|
||||
}
|
||||
return Created{Dinner: d, GroundingJobID: jobID}, nil
|
||||
return Created{Dinner: fromRow(d), GroundingJobID: jobID}, nil
|
||||
}
|
||||
|
||||
// enqueueGrounding enqueues the KindGroundDinner job for pending dinner id,
|
||||
// whose normalized name is normalizedName, and returns its id. When the job
|
||||
// cannot be enqueued, the dinner's grounding is failed with
|
||||
// ReasonGroundingError, so it is not left pending with no job, and the id
|
||||
// is nil.
|
||||
func (s *Service) enqueueGrounding(ctx context.Context, id, normalizedName string) (*string, error) {
|
||||
jobID, err := s.groundJob.Enqueue(ctx, GroundJob{DinnerID: id})
|
||||
// enqueueGrounding enqueues the KindGroundDinner job for pending dinner id
|
||||
// at grounding_revision revision and returns its id. When the job cannot be
|
||||
// enqueued, the dinner's grounding is failed with ReasonGroundingError, so
|
||||
// it is not left pending with no job, and the id is nil.
|
||||
func (s *Service) enqueueGrounding(ctx context.Context, id string, revision int64) (*string, error) {
|
||||
jobID, err := s.groundJob.Enqueue(ctx, GroundJob{DinnerID: id, Revision: revision})
|
||||
if err == nil {
|
||||
return &jobID, nil
|
||||
}
|
||||
// The error is a database error, never a dependency's text.
|
||||
s.logger.ErrorContext(ctx, "dinner grounding not enqueued", "dinner_id", id, "error", err)
|
||||
if _, err := db.New(s.st.Writer).FailPendingDinnerGrounding(context.WithoutCancel(ctx), db.FailPendingDinnerGroundingParams{
|
||||
GroundingError: sql.NullString{String: ReasonGroundingError, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: id,
|
||||
NormalizedName: normalizedName,
|
||||
GroundingError: sql.NullString{String: ReasonGroundingError, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: id,
|
||||
GroundingRevision: revision,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("dinners: record failed grounding of %s: %w", id, err)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy string) (Dinner, error) {
|
||||
func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy string) (db.Dinner, error) {
|
||||
if updatedBy == "" {
|
||||
return Dinner{}, errors.New("dinners: create: no member to record as updated_by")
|
||||
return db.Dinner{}, errors.New("dinners: create: no member to record as updated_by")
|
||||
}
|
||||
var name string
|
||||
var recipeURL sql.NullString
|
||||
switch {
|
||||
case r.Name != nil && r.RecipeURL != nil:
|
||||
return Dinner{}, invalid("name", "give a name or a recipe_url, not both")
|
||||
return db.Dinner{}, invalid("name", "give a name or a recipe_url, not both")
|
||||
case r.Name != nil:
|
||||
n, err := checkName(*r.Name)
|
||||
if err != nil {
|
||||
return Dinner{}, err
|
||||
return db.Dinner{}, err
|
||||
}
|
||||
name = n
|
||||
case r.RecipeURL != nil:
|
||||
u, err := checkRecipeURL(*r.RecipeURL)
|
||||
if err != nil {
|
||||
return Dinner{}, err
|
||||
return db.Dinner{}, err
|
||||
}
|
||||
n, ok := nameFromURL(u)
|
||||
if !ok {
|
||||
return Dinner{}, invalid("recipe_url", "the link does not name a dinner; add it by name instead")
|
||||
return db.Dinner{}, invalid("recipe_url", "the link does not name a dinner; add it by name instead")
|
||||
}
|
||||
name = n
|
||||
recipeURL = sql.NullString{String: u.String(), Valid: true}
|
||||
default:
|
||||
return Dinner{}, invalid("name", "give a name or a recipe_url")
|
||||
return db.Dinner{}, invalid("name", "give a name or a recipe_url")
|
||||
}
|
||||
|
||||
var row db.Dinner
|
||||
@@ -269,9 +268,9 @@ func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy str
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Dinner{}, err
|
||||
return db.Dinner{}, err
|
||||
}
|
||||
return fromRow(row), nil
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// List returns the dinners with status, which must be regular or
|
||||
@@ -294,9 +293,10 @@ func (s *Service) List(ctx context.Context, status string) ([]Dinner, error) {
|
||||
// Update applies p to dinner id, recording updatedBy. An invalid patch is a
|
||||
// *ValidationError and a new name repeating another unarchived dinner a
|
||||
// *DuplicateError; either changes nothing. Renaming a dinner still pending
|
||||
// clears its recipe_url and enqueues a fresh KindGroundDinner job that
|
||||
// grounds the new name: the job already running was for the old name or
|
||||
// link, and its result no longer applies.
|
||||
// clears its recipe_url, raises its grounding_revision and enqueues a fresh
|
||||
// KindGroundDinner job that grounds the new name: a job queued or running
|
||||
// for the old name or link carries the old revision, so it can no longer
|
||||
// write the dinner.
|
||||
func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy string) (Dinner, error) {
|
||||
if updatedBy == "" {
|
||||
return Dinner{}, errors.New("dinners: update: no member to record as updated_by")
|
||||
@@ -318,21 +318,22 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
|
||||
return fmt.Errorf("dinners: read %s: %w", id, err)
|
||||
}
|
||||
params := db.UpdateDinnerParams{
|
||||
ID: cur.ID,
|
||||
Name: cur.Name,
|
||||
NormalizedName: cur.NormalizedName,
|
||||
Status: cur.Status,
|
||||
Origin: cur.Origin,
|
||||
Method: cur.Method,
|
||||
MinutesTotal: cur.MinutesTotal,
|
||||
Difficulty: cur.Difficulty,
|
||||
Cuisine: cur.Cuisine,
|
||||
RecipeID: cur.RecipeID,
|
||||
RecipeUrl: cur.RecipeUrl,
|
||||
PhotoID: cur.PhotoID,
|
||||
GroundingStatus: cur.GroundingStatus,
|
||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: cur.ID,
|
||||
Name: cur.Name,
|
||||
NormalizedName: cur.NormalizedName,
|
||||
Status: cur.Status,
|
||||
Origin: cur.Origin,
|
||||
Method: cur.Method,
|
||||
MinutesTotal: cur.MinutesTotal,
|
||||
Difficulty: cur.Difficulty,
|
||||
Cuisine: cur.Cuisine,
|
||||
RecipeID: cur.RecipeID,
|
||||
RecipeUrl: cur.RecipeUrl,
|
||||
PhotoID: cur.PhotoID,
|
||||
GroundingStatus: cur.GroundingStatus,
|
||||
GroundingRevision: cur.GroundingRevision,
|
||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
}
|
||||
if p.Name != nil {
|
||||
if err := checkUnique(ctx, q, cur.ID, name, sql.NullString{}); err != nil {
|
||||
@@ -344,6 +345,7 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
|
||||
// The link was the old dinner's recipe; the new name is
|
||||
// grounded by name, and a link can be given by hand.
|
||||
params.RecipeUrl = sql.NullString{}
|
||||
params.GroundingRevision++
|
||||
}
|
||||
}
|
||||
if p.Method != nil {
|
||||
@@ -359,14 +361,14 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
|
||||
if err != nil {
|
||||
return fmt.Errorf("dinners: update %s: %w", id, err)
|
||||
}
|
||||
regrounding = row.GroundingStatus == GroundingPending && row.NormalizedName != cur.NormalizedName
|
||||
regrounding = row.GroundingRevision != cur.GroundingRevision
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Dinner{}, err
|
||||
}
|
||||
if regrounding {
|
||||
jobID, err := s.enqueueGrounding(ctx, row.ID, row.NormalizedName)
|
||||
jobID, err := s.enqueueGrounding(ctx, row.ID, row.GroundingRevision)
|
||||
if err != nil {
|
||||
return Dinner{}, err
|
||||
}
|
||||
@@ -514,18 +516,24 @@ func nameFromURL(u *url.URL) (name string, ok bool) {
|
||||
}
|
||||
|
||||
// checkUnique returns a *DuplicateError when name is harness.Similar to an
|
||||
// unarchived dinner other than self, or recipeURL is one's recipe link.
|
||||
// unarchived dinner other than self, or recipeURL is one's recipe link: the
|
||||
// source_url of the recipe it is grounded in, else the recipe_url still
|
||||
// awaiting grounding.
|
||||
func checkUnique(ctx context.Context, q *db.Queries, self, name string, recipeURL sql.NullString) error {
|
||||
rows, err := q.ListUnarchivedDinners(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dinners: list for dedupe: %w", err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.ID == self {
|
||||
if row.Dinner.ID == self {
|
||||
continue
|
||||
}
|
||||
if harness.Similar(name, row.Name) || (recipeURL.Valid && row.RecipeUrl == recipeURL) {
|
||||
return &DuplicateError{Existing: fromRow(row)}
|
||||
link := row.Dinner.RecipeUrl
|
||||
if row.RecipeSourceUrl.Valid {
|
||||
link = row.RecipeSourceUrl
|
||||
}
|
||||
if harness.Similar(name, row.Dinner.Name) || (recipeURL.Valid && link == recipeURL) {
|
||||
return &DuplicateError{Existing: fromRow(row.Dinner)}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package dinners
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -12,3 +13,11 @@ func SetGroundTimeout(t *testing.T, d time.Duration) {
|
||||
groundTimeout = d
|
||||
t.Cleanup(func() { groundTimeout = old })
|
||||
}
|
||||
|
||||
// RunGroundJob runs the KindGroundDinner handler for p in the caller's
|
||||
// goroutine, as the job runner would, and returns the grounding status it
|
||||
// reports.
|
||||
func (s *Service) RunGroundJob(ctx context.Context, p GroundJob) (string, error) {
|
||||
r, err := s.runGroundJob(ctx, p, nil)
|
||||
return r.GroundingStatus, err
|
||||
}
|
||||
|
||||
@@ -76,14 +76,17 @@ var groundTimeout = GroundTimeout
|
||||
// runs past GroundTimeout; the dinner is left as it was.
|
||||
var ErrGroundTimeout = errors.New("dinners: grounding the recipe took too long")
|
||||
|
||||
// GroundJob is the KindGroundDinner payload.
|
||||
// GroundJob is the KindGroundDinner payload: the dinner to ground and its
|
||||
// grounding_revision when the job was enqueued.
|
||||
type GroundJob struct {
|
||||
DinnerID string `json:"dinner_id"`
|
||||
Revision int64 `json:"revision"`
|
||||
}
|
||||
|
||||
// groundResult is the KindGroundDinner result: the dinner's grounding
|
||||
// status once the job ends. It is not grounded when the dinner was
|
||||
// grounded by hand before the job finished.
|
||||
// status once the job ends. It is not grounded by this job when the dinner
|
||||
// was grounded by hand before the job finished, renamed (a later job
|
||||
// grounds it) or archived; the job then reports the status it found.
|
||||
type groundResult struct {
|
||||
DinnerID string `json:"dinner_id"`
|
||||
GroundingStatus string `json:"grounding_status"`
|
||||
@@ -93,18 +96,21 @@ func (r groundResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
||||
|
||||
// runGroundJob grounds a pending dinner as its creation asked: its
|
||||
// recipe_url with GroundURL, else its name with Ground. Success records
|
||||
// the recipe, photo, method, minutes and difficulty with grounding_status
|
||||
// grounded; failure records grounding_status failed with its Reason, which
|
||||
// is also the job's failure code. Either is recorded only while the dinner
|
||||
// is still pending under the normalized name read here: a dinner grounded
|
||||
// by hand meanwhile is left as it is, and one renamed meanwhile is left
|
||||
// pending for the job its rename enqueued.
|
||||
// the recipe and photo with grounding_status grounded, and the recipe's
|
||||
// method, minutes and difficulty where a member has not set them; failure
|
||||
// records grounding_status failed with its Reason, which is also the job's
|
||||
// failure code. A dinner archived, no longer pending, or at a
|
||||
// grounding_revision other than p.Revision is not grounded at all, and
|
||||
// either result is recorded only while the dinner is still unarchived,
|
||||
// pending and at p.Revision: a dinner grounded by hand meanwhile is left as
|
||||
// it is, and one renamed or archived meanwhile is left pending — for the
|
||||
// job its rename enqueued, or for none.
|
||||
func (s *Service) runGroundJob(ctx context.Context, p GroundJob, _ func(groundResult) error) (groundResult, error) {
|
||||
row, err := db.New(s.st.Reader).GetDinner(ctx, p.DinnerID)
|
||||
if err != nil {
|
||||
return groundResult{}, fmt.Errorf("dinners: ground: read %s: %w", p.DinnerID, err)
|
||||
}
|
||||
if row.GroundingStatus != GroundingPending {
|
||||
if row.Status == StatusArchived || row.GroundingStatus != GroundingPending || row.GroundingRevision != p.Revision {
|
||||
return groundResult{DinnerID: row.ID, GroundingStatus: row.GroundingStatus}, nil
|
||||
}
|
||||
|
||||
@@ -124,10 +130,10 @@ func (s *Service) runGroundJob(ctx context.Context, p GroundJob, _ func(groundRe
|
||||
s.logger.InfoContext(ctx, "dinner grounding failed", "dinner_id", row.ID, "reason", reason)
|
||||
write := context.WithoutCancel(ctx)
|
||||
if _, failErr := db.New(s.st.Writer).FailPendingDinnerGrounding(write, db.FailPendingDinnerGroundingParams{
|
||||
GroundingError: sql.NullString{String: reason, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: row.ID,
|
||||
NormalizedName: row.NormalizedName,
|
||||
GroundingError: sql.NullString{String: reason, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: row.ID,
|
||||
GroundingRevision: p.Revision,
|
||||
}); failErr != nil {
|
||||
return groundResult{}, fmt.Errorf("dinners: ground: record failure of %s: %w", row.ID, failErr)
|
||||
}
|
||||
@@ -138,14 +144,14 @@ func (s *Service) runGroundJob(ctx context.Context, p GroundJob, _ func(groundRe
|
||||
}
|
||||
|
||||
n, err := db.New(s.st.Writer).GroundPendingDinner(ctx, db.GroundPendingDinnerParams{
|
||||
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
|
||||
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
|
||||
Method: sql.NullString{String: gr.Method, Valid: true},
|
||||
MinutesTotal: nullInt(gr.MinutesTotal),
|
||||
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: row.ID,
|
||||
NormalizedName: row.NormalizedName,
|
||||
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
|
||||
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
|
||||
Method: sql.NullString{String: gr.Method, Valid: true},
|
||||
MinutesTotal: nullInt(gr.MinutesTotal),
|
||||
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: row.ID,
|
||||
GroundingRevision: p.Revision,
|
||||
})
|
||||
if err != nil {
|
||||
return groundResult{}, fmt.Errorf("dinners: ground: record %s: %w", row.ID, err)
|
||||
|
||||
@@ -2,6 +2,8 @@ package dinners_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -385,6 +387,168 @@ func TestRenameLinkWhilePendingGroundsNewName(t *testing.T) {
|
||||
if got := e.search.Queries(); !slices.Equal(got, []string{"Beef stew"}) {
|
||||
t.Errorf("searched %q, want only the new name", got)
|
||||
}
|
||||
if row.RecipeUrl.Valid {
|
||||
t.Errorf("recipe_url = %q, want the old link dropped", row.RecipeUrl.String)
|
||||
}
|
||||
if _, err := db.New(e.st.Reader).GetRecipeBySourceURL(t.Context(), curryA); !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Errorf("old link's recipe lookup err = %v, want it never grounded (sql.ErrNoRows)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// queuedJob reads the payload of queued KindGroundDinner job id.
|
||||
func (e *env) queuedJob(t *testing.T, id string) dinners.GroundJob {
|
||||
t.Helper()
|
||||
var payload string
|
||||
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT payload FROM jobs WHERE id = ?`, id).Scan(&payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var p dinners.GroundJob
|
||||
if err := json.Unmarshal([]byte(payload), &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// TestRenameBeforeJobStartsSkipsStaleJob: a Regular renamed before its
|
||||
// first job starts is grounded only by the job its rename enqueued. The
|
||||
// first job, though it would read the new name, does no work and writes
|
||||
// nothing, so its failure cannot leave the dinner "Needs a recipe" while
|
||||
// the rename's job finds a recipe.
|
||||
func TestRenameBeforeJobStartsSkipsStaleJob(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newUnstartedEnv(t)
|
||||
e.search.Results["Beef stew"] = []recipes.Candidate{{URL: stew}}
|
||||
|
||||
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := e.queuedJob(t, *c.GroundingJobID)
|
||||
if first.DinnerID != c.ID || first.Revision != e.dinner(t, c.ID).GroundingRevision {
|
||||
t.Fatalf("first job = %+v, want dinner %s at its revision", first, c.ID)
|
||||
}
|
||||
if _, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Name: ptr("Beef stew")}, member); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renamed := e.dinner(t, c.ID)
|
||||
if renamed.GroundingRevision != first.Revision+1 {
|
||||
t.Fatalf("revision after rename = %d, want %d", renamed.GroundingRevision, first.Revision+1)
|
||||
}
|
||||
|
||||
// The first job would fail if it searched.
|
||||
e.search.Err = recipes.ErrSearchRateLimited
|
||||
status, err := e.svc.RunGroundJob(t.Context(), first)
|
||||
if err != nil || status != dinners.GroundingPending {
|
||||
t.Fatalf("first job = %q, %v; want pending, no error", status, err)
|
||||
}
|
||||
if q := e.search.Queries(); len(q) != 0 {
|
||||
t.Errorf("first job searched %q, want no work for a stale revision", q)
|
||||
}
|
||||
if row := e.dinner(t, c.ID); row != renamed {
|
||||
t.Fatalf("first job wrote the dinner: %+v, was %+v", row, renamed)
|
||||
}
|
||||
|
||||
e.search.Err = nil
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
|
||||
status, err = e.svc.RunGroundJob(t.Context(), dinners.GroundJob{DinnerID: c.ID, Revision: renamed.GroundingRevision})
|
||||
if err != nil || status != dinners.GroundingGrounded {
|
||||
t.Fatalf("rename's job = %q, %v; want grounded", status, err)
|
||||
}
|
||||
row := e.dinner(t, c.ID)
|
||||
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), row.RecipeID.String)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.GroundingStatus != dinners.GroundingGrounded || rec.SourceUrl != stew {
|
||||
t.Errorf("dinner = %+v recipe %s, want grounded in %s", row, rec.SourceUrl, stew)
|
||||
}
|
||||
|
||||
// A stale job finishing last changes nothing either.
|
||||
if status, err := e.svc.RunGroundJob(t.Context(), first); err != nil || status != dinners.GroundingGrounded {
|
||||
t.Errorf("first job run last = %q, %v; want grounded reported, no error", status, err)
|
||||
}
|
||||
if after := e.dinner(t, c.ID); after != row {
|
||||
t.Errorf("stale job changed the grounded dinner: %+v, was %+v", after, row)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditWhilePendingIsKept: a method or minutes a member saves while a
|
||||
// Regular is grounding is kept when grounding succeeds; what the member
|
||||
// did not set comes from the recipe.
|
||||
func TestEditWhilePendingIsKept(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
e.search.Gate = make(chan struct{})
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
||||
|
||||
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.waitQuery(t, "Chicken curry")
|
||||
if _, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Method: ptr("grill"), MinutesTotal: ptr(15)}, member); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
close(e.search.Gate)
|
||||
|
||||
row := e.waitGrounding(t, c.ID)
|
||||
if row.GroundingStatus != dinners.GroundingGrounded || !row.RecipeID.Valid || !row.PhotoID.Valid {
|
||||
t.Fatalf("dinner = %+v, want grounded", row)
|
||||
}
|
||||
if row.Method.String != "grill" || row.MinutesTotal.Int64 != 15 || row.Difficulty.String != "easy" {
|
||||
t.Errorf("dinner = %s, %d minutes, %s; want the member's grill and 15 minutes, the recipe's easy",
|
||||
row.Method.String, row.MinutesTotal.Int64, row.Difficulty.String)
|
||||
}
|
||||
}
|
||||
|
||||
// TestArchiveWhilePendingStopsGrounding: a pending Regular archived before
|
||||
// its job starts is not searched for, and one archived while its job runs
|
||||
// is not written; neither ends up grounded.
|
||||
func TestArchiveWhilePendingStopsGrounding(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("before the job starts", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newUnstartedEnv(t)
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
||||
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.svc.Archive(t.Context(), c.ID, member); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.start(t)
|
||||
if job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusSucceeded {
|
||||
t.Fatalf("job = %+v, want succeeded with nothing to do", job)
|
||||
}
|
||||
if q := e.search.Queries(); len(q) != 0 {
|
||||
t.Errorf("searched %q for an archived dinner", q)
|
||||
}
|
||||
if row := e.dinner(t, c.ID); row.Status != dinners.StatusArchived || row.GroundingStatus == dinners.GroundingGrounded || row.RecipeID.Valid {
|
||||
t.Errorf("dinner = %+v, want archived and not grounded", row)
|
||||
}
|
||||
})
|
||||
t.Run("while the job runs", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
e.search.Gate = make(chan struct{})
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
||||
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.waitQuery(t, "Chicken curry")
|
||||
if _, err := e.svc.Archive(t.Context(), c.ID, member); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
close(e.search.Gate)
|
||||
if job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusSucceeded {
|
||||
t.Fatalf("job = %+v", job)
|
||||
}
|
||||
if row := e.dinner(t, c.ID); row.Status != dinners.StatusArchived || row.GroundingStatus == dinners.GroundingGrounded || row.RecipeID.Valid {
|
||||
t.Errorf("dinner = %+v, want archived and not grounded", row)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestRenameWhilePendingEnqueueFailure: a pending Regular renamed when the
|
||||
@@ -530,6 +694,59 @@ func TestFixViaCandidate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFixReplacesPendingLink: fixing a Regular added from a link that held
|
||||
// no recipe drops that link, so it no longer blocks adding the link again,
|
||||
// while the link of the recipe chosen does.
|
||||
func TestFixReplacesPendingLink(t *testing.T) {
|
||||
t.Parallel()
|
||||
const lost = "https://gone.example/recipes/lamb-tagine"
|
||||
tests := []struct {
|
||||
name string
|
||||
fix dinners.Fix
|
||||
// linkTaken is the recipe link the fixed dinner now has, if any.
|
||||
linkTaken string
|
||||
}{
|
||||
{"candidate", dinners.Fix{CandidateID: ptr(curryB)}, curryB},
|
||||
{"manual", dinners.Fix{Manual: &dinners.ManualRecipe{Ingredients: []string{"1 lamb"}, Minutes: ptr(90), Method: ptr("oven")}}, ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{RecipeURL: ptr(lost)}, member)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusFailed {
|
||||
t.Fatalf("job = %+v, want failed", job)
|
||||
}
|
||||
// Renamed, so only the link could repeat it.
|
||||
if _, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Name: ptr("Weeknight dinner")}, member); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
|
||||
d, err := e.svc.Ground(t.Context(), c.ID, tt.fix, member)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.RecipeURL != nil {
|
||||
t.Errorf("recipe_url = %q after the fix, want the unusable link dropped", *d.RecipeURL)
|
||||
}
|
||||
|
||||
if _, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{RecipeURL: ptr(lost)}, member); err != nil {
|
||||
t.Errorf("adding the dropped link again: %v, want added", err)
|
||||
}
|
||||
if tt.linkTaken != "" {
|
||||
_, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{RecipeURL: ptr(tt.linkTaken)}, member)
|
||||
var dup *dinners.DuplicateError
|
||||
if !errors.As(err, &dup) || dup.Existing.ID != c.ID {
|
||||
t.Errorf("adding the fixed dinner's recipe link: err = %v, want a duplicate of %s", err, c.ID)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixViaRecipeURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
|
||||
@@ -175,8 +175,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
|
||||
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
|
||||
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" {
|
||||
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", got)
|
||||
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" {
|
||||
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", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,8 +196,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
|
||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||
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" {
|
||||
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", got)
|
||||
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" {
|
||||
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", got)
|
||||
}
|
||||
if !tableExists(t, s, "dinners") {
|
||||
t.Error("table from 0001_init was not created")
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- 0010_dinner_grounding_revision: each ground_dinner job carries the
|
||||
-- dinner's grounding_revision when it was enqueued, and records its result
|
||||
-- only while the dinner still has that revision. Renaming a pending dinner
|
||||
-- raises the revision before enqueuing the job for the new name, so an
|
||||
-- earlier job — queued or running — can no longer write the dinner.
|
||||
|
||||
ALTER TABLE dinners ADD COLUMN grounding_revision INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -25,10 +25,13 @@ SELECT COUNT(*) FROM dinners
|
||||
WHERE status = 'regular';
|
||||
|
||||
-- name: ListUnarchivedDinners :many
|
||||
-- Every dinner a new or renamed dinner must not repeat.
|
||||
SELECT * FROM dinners
|
||||
WHERE status <> 'archived'
|
||||
ORDER BY id;
|
||||
-- Every dinner a new or renamed dinner must not repeat, with the
|
||||
-- source_url of the recipe it is grounded in (NULL while it has none).
|
||||
SELECT sqlc.embed(dinners), recipes.source_url AS recipe_source_url
|
||||
FROM dinners
|
||||
LEFT JOIN recipes ON recipes.id = dinners.recipe_id
|
||||
WHERE dinners.status <> 'archived'
|
||||
ORDER BY dinners.id;
|
||||
|
||||
-- name: UpdateDinner :one
|
||||
-- An unknown id returns sql.ErrNoRows.
|
||||
@@ -45,6 +48,7 @@ SET name = ?,
|
||||
recipe_url = ?,
|
||||
photo_id = ?,
|
||||
grounding_status = ?,
|
||||
grounding_revision = ?,
|
||||
updated_by = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
@@ -78,10 +82,12 @@ WHERE id = ?
|
||||
RETURNING *;
|
||||
|
||||
-- name: GroundDinner :one
|
||||
-- Records a dinner's grounding (grounded or manual) and clears any
|
||||
-- grounding_error. An unknown id returns sql.ErrNoRows.
|
||||
-- Records a dinner's grounding by hand (grounded or manual) and clears any
|
||||
-- grounding_error and pasted recipe_url: the recipe chosen replaces the
|
||||
-- link. An unknown id returns sql.ErrNoRows.
|
||||
UPDATE dinners
|
||||
SET recipe_id = ?,
|
||||
recipe_url = NULL,
|
||||
photo_id = ?,
|
||||
method = ?,
|
||||
minutes_total = ?,
|
||||
@@ -94,35 +100,44 @@ WHERE id = ?
|
||||
RETURNING *;
|
||||
|
||||
-- name: GroundPendingDinner :execrows
|
||||
-- Records the background grounding of a dinner still pending under the
|
||||
-- normalized name it was grounded for; a dinner grounded some other way or
|
||||
-- renamed meanwhile is left as it is (0 rows).
|
||||
-- Records the background grounding of an unarchived dinner still pending at
|
||||
-- the grounding_revision it was grounded for; a dinner grounded some other
|
||||
-- way, renamed or archived meanwhile is left as it is (0 rows). A method,
|
||||
-- minutes_total or difficulty already set is kept: a pending dinner has
|
||||
-- them only when a member edited them while it was grounding.
|
||||
UPDATE dinners
|
||||
SET recipe_id = ?,
|
||||
photo_id = ?,
|
||||
method = ?,
|
||||
minutes_total = ?,
|
||||
difficulty = ?,
|
||||
SET recipe_id = sqlc.arg(recipe_id),
|
||||
photo_id = sqlc.arg(photo_id),
|
||||
method = COALESCE(method, sqlc.arg(method)),
|
||||
minutes_total = COALESCE(minutes_total, sqlc.arg(minutes_total)),
|
||||
difficulty = COALESCE(difficulty, sqlc.arg(difficulty)),
|
||||
grounding_status = 'grounded',
|
||||
grounding_error = NULL,
|
||||
updated_at = ?
|
||||
WHERE id = ? AND grounding_status = 'pending' AND normalized_name = ?;
|
||||
updated_at = sqlc.arg(updated_at)
|
||||
WHERE id = sqlc.arg(id)
|
||||
AND status <> 'archived'
|
||||
AND grounding_status = 'pending'
|
||||
AND grounding_revision = sqlc.arg(grounding_revision);
|
||||
|
||||
-- name: FailPendingDinnerGrounding :execrows
|
||||
-- Records why the background grounding of a dinner still pending under the
|
||||
-- normalized name it was grounded for failed; a dinner grounded some other
|
||||
-- way or renamed meanwhile is left as it is (0 rows).
|
||||
-- Records why the background grounding of an unarchived dinner still
|
||||
-- pending at the grounding_revision it was grounded for failed; a dinner
|
||||
-- grounded some other way, renamed or archived meanwhile is left as it is
|
||||
-- (0 rows).
|
||||
UPDATE dinners
|
||||
SET grounding_status = 'failed',
|
||||
grounding_error = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ? AND grounding_status = 'pending' AND normalized_name = ?;
|
||||
WHERE id = ?
|
||||
AND status <> 'archived'
|
||||
AND grounding_status = 'pending'
|
||||
AND grounding_revision = ?;
|
||||
|
||||
-- name: FailOrphanedPendingDinnerGroundings :execrows
|
||||
-- Fails every dinner still pending whose grounding job is no longer queued
|
||||
-- or running: the process stopped before the job, or before its handler
|
||||
-- recorded a result. Run at boot, after the job runner fails the jobs a
|
||||
-- previous process left running.
|
||||
-- Fails every dinner still pending whose grounding job for its current
|
||||
-- grounding_revision is no longer queued or running: the process stopped
|
||||
-- before the job, or before its handler recorded a result. Run at boot,
|
||||
-- after the job runner fails the jobs a previous process left running.
|
||||
UPDATE dinners
|
||||
SET grounding_status = 'failed',
|
||||
grounding_error = sqlc.arg(grounding_error),
|
||||
@@ -133,4 +148,5 @@ WHERE grounding_status = 'pending'
|
||||
WHERE jobs.kind = sqlc.arg(kind)
|
||||
AND jobs.status IN ('queued', 'running')
|
||||
AND json_extract(jobs.payload, '$.dinner_id') = dinners.id
|
||||
AND json_extract(jobs.payload, '$.revision') = dinners.grounding_revision
|
||||
);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import cases from '../../../internal/ai/harness/testdata/dedupe_cases.json'
|
||||
import { normalizeName, similarNames } from './names.ts'
|
||||
|
||||
// The cases internal/ai/harness's tests run against Normalize and Similar.
|
||||
describe('dinner names, as the server compares them', () => {
|
||||
it('has cases to run', () => {
|
||||
expect(cases.normalize.length).toBeGreaterThan(0)
|
||||
expect(cases.similar.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.each(cases.normalize)('normalizes "$name" to "$want"', ({ name, want }) => {
|
||||
expect(normalizeName(name)).toBe(want)
|
||||
})
|
||||
|
||||
it.each(cases.similar)('"$a" and "$b" similar: $want', ({ a, b, want }) => {
|
||||
expect(similarNames(a, b)).toBe(want)
|
||||
expect(similarNames(b, a)).toBe(want)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
// The PWA's copy of the server's dinner-name rules (internal/ai/harness
|
||||
// Normalize and Similar), so a screen can tell beforehand that the dinners
|
||||
// API would refuse a name as a duplicate. The server still decides.
|
||||
// internal/ai/harness/testdata/dedupe_cases.json is run against both.
|
||||
|
||||
// SIMILAR_THRESHOLD is harness.SimilarThreshold.
|
||||
const SIMILAR_THRESHOLD = 0.85
|
||||
|
||||
// fillerWords is harness's fillerWords.
|
||||
const fillerWords = new Set(['easy', 'best', 'homemade', 'quick', 'simple', 'the', 'a', 'recipe', 'classic'])
|
||||
|
||||
// letterFolds are the letters harness folds that canonical decomposition
|
||||
// leaves whole: its ligatures and extra letters, and the common stroked
|
||||
// Latin letters. Letters with accents fold by decomposing and dropping the
|
||||
// marks, as harness's table does; rarer stroked letters are compared as
|
||||
// they are.
|
||||
const letterFolds: Record<string, string> = {
|
||||
ß: 'ss',
|
||||
æ: 'ae',
|
||||
œ: 'oe',
|
||||
þ: 'th',
|
||||
ð: 'd',
|
||||
ı: 'i',
|
||||
ʼn: 'n',
|
||||
ij: 'ij',
|
||||
ø: 'o',
|
||||
đ: 'd',
|
||||
ħ: 'h',
|
||||
ŀ: 'l',
|
||||
ł: 'l',
|
||||
ŧ: 't',
|
||||
ƀ: 'b',
|
||||
ƶ: 'z',
|
||||
ǥ: 'g',
|
||||
ɨ: 'i',
|
||||
}
|
||||
|
||||
const combiningMark = /\p{Mn}/u
|
||||
const wordChar = /[\p{L}\p{Nd}]/u
|
||||
|
||||
// normalizeName is harness.Normalize: lowercased, accents dropped, letters
|
||||
// folded, apostrophes removed, other punctuation turned into spaces and
|
||||
// filler words dropped. "Easy Chicken Tikka-Masala!" is
|
||||
// "chicken tikka masala".
|
||||
export function normalizeName(name: string): string {
|
||||
let out = ''
|
||||
for (const ch of name.toLowerCase().normalize('NFD')) {
|
||||
if (combiningMark.test(ch) || ch === "'" || ch === '’') continue
|
||||
const fold = letterFolds[ch]
|
||||
if (fold !== undefined) out += fold
|
||||
else if (wordChar.test(ch)) out += ch
|
||||
else out += ' '
|
||||
}
|
||||
return out
|
||||
.split(' ')
|
||||
.filter((word) => word !== '' && !fillerWords.has(word))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function words(name: string): Set<string> {
|
||||
const normalized = normalizeName(name)
|
||||
return new Set(normalized === '' ? [] : normalized.split(' '))
|
||||
}
|
||||
|
||||
// similarNames is harness.Similar: whether a and b name the same dinner, by
|
||||
// the share of the words of their normalized forms they have in common.
|
||||
export function similarNames(a: string, b: string): boolean {
|
||||
const left = words(a)
|
||||
const right = words(b)
|
||||
if (left.size === 0 || right.size === 0) return false
|
||||
let shared = 0
|
||||
for (const word of left) if (right.has(word)) shared++
|
||||
return shared / (left.size + right.size - shared) >= SIMILAR_THRESHOLD
|
||||
}
|
||||
@@ -100,6 +100,7 @@ async function renderStep(enableNotifications?: EnableNotifications) {
|
||||
element={<NotificationsStep enableNotifications={enableNotifications} />}
|
||||
/>
|
||||
<Route path="/" element={<h1>Home</h1>} />
|
||||
<Route path="/onboarding/regulars" element={<h1>Regulars step</h1>} />
|
||||
</Routes>
|
||||
</AuthBootstrap>
|
||||
</ApiProviders>
|
||||
@@ -257,6 +258,41 @@ describe('onboarding step 3', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('onboarding step 3 with too few Regulars', () => {
|
||||
it('says why and offers a way back to add Regulars', async () => {
|
||||
const server = await renderStep()
|
||||
fireEvent.click(later())
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
server.held[0]!.answer(
|
||||
jsonResponse(400, {
|
||||
error: {
|
||||
code: 'invalid_settings',
|
||||
message: 'invalid household settings: onboarding_completed: add at least 3 Regulars first (have 2)',
|
||||
retryable: false,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'Could not finish setting up: invalid household settings: onboarding_completed: add at least 3 Regulars first (have 2)',
|
||||
)
|
||||
const addRegulars = screen.getByRole('button', { name: 'Add Regulars' })
|
||||
expectMinHitArea(addRegulars)
|
||||
fireEvent.click(addRegulars)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Regulars step' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers no way back for any other refusal', async () => {
|
||||
const server = await renderStep()
|
||||
fireEvent.click(later())
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
server.held[0]!.answer(errorResponse(500, 'internal'))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not finish setting up: internal message')
|
||||
expect(screen.queryByRole('button', { name: 'Add Regulars' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('notificationsUnavailable', () => {
|
||||
it('answers "unavailable" without asking for permission', async () => {
|
||||
await expect(notificationsUnavailable()).resolves.toBe('unavailable')
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react'
|
||||
import { CalendarDays, ShoppingBasket, Sparkles, UtensilsCrossed, type LucideIcon } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { InstallHint } from '../pwa/InstallHint.tsx'
|
||||
import { useCompleteOnboarding } from '../settings/api.ts'
|
||||
import { needsMoreRegulars, useCompleteOnboarding } from '../settings/api.ts'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { OnboardingStep } from './OnboardingStep.tsx'
|
||||
@@ -50,12 +50,15 @@ export type NotificationsStepProps = {
|
||||
|
||||
// NotificationsStep is onboarding step 3: what MealQuest notifies about, the
|
||||
// iOS install hint, and finishing onboarding. Either button records that
|
||||
// onboarding is finished and goes Home.
|
||||
// onboarding is finished and goes Home. When the server refuses because the
|
||||
// household has fewer than three Regulars, the reason shows with a way back
|
||||
// to step 2 to add them.
|
||||
export function NotificationsStep({ enableNotifications = notificationsUnavailable }: NotificationsStepProps) {
|
||||
const navigate = useNavigate()
|
||||
const complete = useCompleteOnboarding()
|
||||
const [enabling, setEnabling] = useState(false)
|
||||
const [problem, setProblem] = useState<string | null>(null)
|
||||
const [fewRegulars, setFewRegulars] = useState(false)
|
||||
const busy = enabling || complete.isPending
|
||||
|
||||
async function finish() {
|
||||
@@ -63,13 +66,19 @@ export function NotificationsStep({ enableNotifications = notificationsUnavailab
|
||||
await complete.mutateAsync()
|
||||
} catch (error) {
|
||||
setProblem(`Could not finish setting up: ${(error as Error).message}`)
|
||||
setFewRegulars(needsMoreRegulars(error as Error))
|
||||
return
|
||||
}
|
||||
void navigate('/', { replace: true })
|
||||
}
|
||||
|
||||
async function turnOn() {
|
||||
function clearProblem() {
|
||||
setProblem(null)
|
||||
setFewRegulars(false)
|
||||
}
|
||||
|
||||
async function turnOn() {
|
||||
clearProblem()
|
||||
setEnabling(true)
|
||||
try {
|
||||
await enableNotifications()
|
||||
@@ -83,7 +92,7 @@ export function NotificationsStep({ enableNotifications = notificationsUnavailab
|
||||
}
|
||||
|
||||
function later() {
|
||||
setProblem(null)
|
||||
clearProblem()
|
||||
void finish()
|
||||
}
|
||||
|
||||
@@ -124,6 +133,11 @@ export function NotificationsStep({ enableNotifications = notificationsUnavailab
|
||||
{problem}
|
||||
</p>
|
||||
)}
|
||||
{fewRegulars && (
|
||||
<Button variant="outline" className={styles.recover} onClick={() => void navigate('/onboarding/regulars')}>
|
||||
Add Regulars
|
||||
</Button>
|
||||
)}
|
||||
</OnboardingStep>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -229,6 +229,11 @@
|
||||
color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
/* Step 3: back to step 2 when the server wants more Regulars. */
|
||||
.recover {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
@@ -146,6 +146,14 @@ describe('onboarding step 2 (mock 1h)', () => {
|
||||
expect(declaredStyle(pills[0]!).height).toBe('6px')
|
||||
})
|
||||
|
||||
it('does not offer a common pick the server would refuse as a duplicate of a Regular', async () => {
|
||||
await regularsStep([dinnerBody('d1', 'Homemade chili'), dinnerBody('d2', 'Easy Chicken Stir Fry')])
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Add Chili' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Add Chicken stir-fry' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add Beef tacos' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('sizes the input pill, "+" button, picks and Next as the mock does', async () => {
|
||||
await regularsStep()
|
||||
expect(hitArea(input()).height).toBe(48)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, type FormEvent } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useAddRegular, useRegulars, useRemoveRegular, type Regular } from '../dinners/api.ts'
|
||||
import { similarNames } from '../dinners/names.ts'
|
||||
import { useRefusals } from '../dinners/useRefusals.ts'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import { Chip } from '../ui/Chip.tsx'
|
||||
@@ -16,7 +17,8 @@ import styles from './Onboarding.module.css'
|
||||
// household moves on.
|
||||
export const MIN_REGULARS = 3
|
||||
|
||||
// commonPicks are offered as one-tap Regulars until the household has them.
|
||||
// commonPicks are offered as one-tap Regulars until the household has them,
|
||||
// or a Regular the server would count as the same dinner.
|
||||
export const commonPicks = [
|
||||
'Beef tacos',
|
||||
'Spaghetti bolognese',
|
||||
@@ -28,10 +30,6 @@ export const commonPicks = [
|
||||
'Fried rice',
|
||||
]
|
||||
|
||||
function sameName(a: string, b: string): boolean {
|
||||
return a.trim().toLowerCase() === b.trim().toLowerCase()
|
||||
}
|
||||
|
||||
// RegularsStep is onboarding step 2, handoff mock 1h: the household adds
|
||||
// the dinners it makes on repeat. Each add and remove shows at once and is
|
||||
// undone if the server refuses it.
|
||||
@@ -43,7 +41,7 @@ export function RegularsStep() {
|
||||
const [draft, setDraft] = useState('')
|
||||
const { refusals, track } = useRefusals()
|
||||
const list = regulars.data ?? []
|
||||
const picks = commonPicks.filter((pick) => !list.some((regular) => sameName(regular.name, pick)))
|
||||
const picks = commonPicks.filter((pick) => !list.some((regular) => similarNames(regular.name, pick)))
|
||||
// Next waits for every add to be answered, so a refused add's error shows
|
||||
// here and an add the server takes back never counts towards the three.
|
||||
const adding = list.some((regular) => regular.pending)
|
||||
|
||||
@@ -441,6 +441,18 @@ export function householdSettingsProblems(error: Error): HouseholdSettingsProble
|
||||
return problems
|
||||
}
|
||||
|
||||
// needsMoreRegulars reports whether error is the server refusing to finish
|
||||
// onboarding because the household has fewer than three Regulars
|
||||
// (internal/household MinRegulars) — the only refusal of
|
||||
// {"onboarding_completed": true}.
|
||||
export function needsMoreRegulars(error: Error): boolean {
|
||||
return (
|
||||
error instanceof ApiError &&
|
||||
error.code === 'invalid_settings' &&
|
||||
error.message.startsWith(`${invalidHouseholdPrefix}onboarding_completed: `)
|
||||
)
|
||||
}
|
||||
|
||||
// aiStatus is the AI assistant row's status line: the provider and the
|
||||
// model's display name, or that no model is chosen yet (the seed).
|
||||
export function aiStatus(settings: Pick<AISettings, 'provider' | 'model'>): string {
|
||||
|
||||
Reference in New Issue
Block a user