diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 40baf62..d475990 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -5,7 +5,9 @@ # The Makefile is the single build definition: CI runs `make test` and # `make build` so it fails whenever either target would, and the binary # carries the Makefile's -trimpath and version-stamped -ldflags (the -# `make build` log shows the -X main.version=... value). +# `make build` log shows the -X main.version=... value). `make sqlc-check` +# fails when the committed internal/db code is stale against sqlc.yaml, +# migrations/ and migrations/queries/. name: CI on: [push, pull_request] @@ -28,5 +30,10 @@ jobs: - name: make test run: make test + - name: make sqlc-check + run: | + make sqlc-install + make sqlc-check SQLC="$(go env GOPATH)/bin/sqlc" + - name: make build run: make build diff --git a/Makefile b/Makefile index e9f157a..4356184 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,9 @@ # make test gofmt check, go vet, go test -race, npm typecheck and test. # make build Build the PWA, embed it, build the static binary bin/mealquest. # make docker Build the container image from ./Dockerfile. +# make sqlc Regenerate internal/db from sqlc.yaml with the pinned sqlc. +# make sqlc-check Regenerate; fail if internal/db differs from the commit. +# make sqlc-install Install the pinned sqlc into the Go bin directory. FRONTEND_DIST := internal/frontend/dist # Dirty means a tracked change outside the generated embed output, which @@ -14,7 +17,13 @@ VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev)$(if $(G IMAGE ?= mealquest:$(VERSION) LDFLAGS := -s -w -X main.version=$(VERSION) -.PHONY: dev test go-test web-test build web embed check-embed-clean docker +# sqlc is a dev tool only, never a go.mod dependency. Generated code records +# the version that wrote it, so every run must use exactly this one. +SQLC_VERSION := v1.31.1 +SQLC ?= sqlc +SQLC_OUT := internal/db + +.PHONY: dev test go-test web-test build web embed check-embed-clean docker sqlc sqlc-check sqlc-install # Run both servers; Ctrl-C (or either exiting) stops both. The binary is built # first and run directly because `go run` would orphan its child on kill. @@ -58,3 +67,22 @@ check-embed-clean: docker: docker build --build-arg VERSION=$(VERSION) -t $(IMAGE) . + +# sqlc $(SQLC_VERSION) needs Go 1.26 to build; GOTOOLCHAIN=auto lets go fetch +# it for this install only, even where the environment pins the local Go. +sqlc-install: + GOTOOLCHAIN=auto go install github.com/sqlc-dev/sqlc/cmd/sqlc@$(SQLC_VERSION) + +# Previously generated files are removed first so a dropped query file does +# not leave its stale output behind; hand-written files are kept. +sqlc: + @v="$$($(SQLC) version)"; \ + if [ "$$v" != "$(SQLC_VERSION)" ]; then echo "sqlc $(SQLC_VERSION) required, $(SQLC) is $$v"; exit 1; fi + find $(SQLC_OUT) -maxdepth 1 -name '*.go' -exec grep -l -x '// Code generated by sqlc. DO NOT EDIT.' {} + | xargs -r rm -- + $(SQLC) generate + +# Fails on any difference from the committed generated code, including new +# or deleted files. +sqlc-check: sqlc + @drift="$$(git status --porcelain --untracked-files=all -- $(SQLC_OUT))"; \ + if [ -n "$$drift" ]; then echo "internal/db is stale; run make sqlc and commit:"; echo "$$drift"; git diff -- $(SQLC_OUT); exit 1; fi diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..f43598b --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package db + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/db/db_test.go b/internal/db/db_test.go new file mode 100644 index 0000000..bdbba38 --- /dev/null +++ b/internal/db/db_test.go @@ -0,0 +1,564 @@ +package db_test + +import ( + "context" + "database/sql" + "errors" + "reflect" + "testing" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +const ( + created = "2026-10-03T12:00:00.000Z" + updated = "2026-10-04T12:00:00.000Z" +) + +// openQueries returns queries on the writer of a freshly migrated database +// in a temp directory. +func openQueries(t *testing.T) (*store.Store, *db.Queries) { + t.Helper() + ctx := context.Background() + s, err := store.Open(ctx, t.TempDir()) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { + if err := s.Close(); err != nil { + t.Errorf("Close: %v", err) + } + }) + if err := store.Migrate(ctx, s.Writer, migrations.FS); err != nil { + t.Fatalf("Migrate: %v", err) + } + return s, db.New(s.Writer) +} + +func str(s string) sql.NullString { return sql.NullString{String: s, Valid: true} } +func num(n int64) sql.NullInt64 { return sql.NullInt64{Int64: n, Valid: true} } + +func mustNoRows(t *testing.T, what string, err error) { + t.Helper() + if !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("%s: got %v, want sql.ErrNoRows", what, err) + } +} + +func createPhoto(t *testing.T, q *db.Queries, sha string) db.Photo { + t.Helper() + p, err := q.CreatePhoto(context.Background(), db.CreatePhotoParams{ + ID: store.NewID(), + SourceUrl: "https://example.com/" + sha + ".jpg", + LocalPath: "photos/" + sha + ".jpg", + Width: 800, + Height: 600, + Sha256: sha, + FetchedAt: created, + CreatedAt: created, + UpdatedAt: created, + }) + if err != nil { + t.Fatalf("CreatePhoto: %v", err) + } + return p +} + +func upsertRecipe(t *testing.T, q *db.Queries, id, url, title, at string) db.Recipe { + t.Helper() + r, err := q.UpsertRecipe(context.Background(), db.UpsertRecipeParams{ + ID: id, + SourceUrl: url, + SiteName: str("Example"), + Title: title, + TotalMinutes: num(30), + PrepMinutes: num(10), + CookMinutes: num(20), + Yield: str("4 servings"), + RawJsonld: `{"@type":"Recipe"}`, + FetchedAt: at, + CreatedAt: at, + UpdatedAt: at, + }) + if err != nil { + t.Fatalf("UpsertRecipe: %v", err) + } + return r +} + +func TestDinners(t *testing.T) { + ctx := context.Background() + _, q := openQueries(t) + recipe := upsertRecipe(t, q, store.NewID(), "https://example.com/tacos", "Tacos", created) + photo := createPhoto(t, q, "aa11") + + create := db.CreateDinnerParams{ + ID: store.NewID(), + Name: "Taco Tuesday", + NormalizedName: "taco tuesday", + Status: "regular", + Origin: "regular", + Method: str("stovetop"), + MinutesTotal: num(30), + Difficulty: str("easy"), + Cuisine: str("Mexican"), + IsFavorite: 0, + RecipeID: str(recipe.ID), + PhotoID: str(photo.ID), + GroundingStatus: "grounded", + UpdatedBy: str("alex@example.com"), + CreatedAt: created, + UpdatedAt: created, + } + want := db.Dinner(create) + got, err := q.CreateDinner(ctx, create) + if err != nil { + t.Fatalf("CreateDinner: %v", err) + } + if got != want { + t.Fatalf("CreateDinner returned %+v, want %+v", got, want) + } + if got, err = q.GetDinner(ctx, create.ID); err != nil || got != want { + t.Fatalf("GetDinner = %+v, %v; want %+v", got, err, want) + } + _, err = q.GetDinner(ctx, "missing") + mustNoRows(t, "GetDinner missing", err) + + // Second dinner sorts before the first by normalized name, in a + // different status so the status filter is exercised. + other, err := q.CreateDinner(ctx, db.CreateDinnerParams{ + ID: store.NewID(), + Name: "Apple Pork Chops", + NormalizedName: "apple pork chops", + Status: "quest_log", + Origin: "side_quest", + GroundingStatus: "pending", + CreatedAt: created, + UpdatedAt: created, + }) + if err != nil { + t.Fatalf("CreateDinner other: %v", err) + } + third, err := q.CreateDinner(ctx, db.CreateDinnerParams{ + ID: store.NewID(), + Name: "Beef Stew", + NormalizedName: "beef stew", + Status: "regular", + Origin: "manual", + GroundingStatus: "manual", + CreatedAt: created, + UpdatedAt: created, + }) + if err != nil { + t.Fatalf("CreateDinner third: %v", err) + } + listIDs := func(status string) []string { + t.Helper() + ds, err := q.ListDinnersByStatus(ctx, status) + if err != nil { + t.Fatalf("ListDinnersByStatus(%s): %v", status, err) + } + ids := []string{} + for _, d := range ds { + ids = append(ids, d.ID) + } + return ids + } + assertIDs(t, "regular", listIDs("regular"), third.ID, create.ID) + assertIDs(t, "quest_log", listIDs("quest_log"), other.ID) + assertIDs(t, "tried", listIDs("tried")) + + update := db.UpdateDinnerParams{ + ID: create.ID, + Name: "Fish Tacos", + NormalizedName: "fish tacos", + Status: "tried", + Origin: "side_quest", + Method: str("grill"), + MinutesTotal: num(25), + Difficulty: str("medium"), + Cuisine: sql.NullString{}, + RecipeID: sql.NullString{}, + PhotoID: str(photo.ID), + GroundingStatus: "failed", + UpdatedBy: str("sam@example.com"), + UpdatedAt: updated, + } + got, err = q.UpdateDinner(ctx, update) + if err != nil { + t.Fatalf("UpdateDinner: %v", err) + } + want = db.Dinner{ + ID: create.ID, Name: update.Name, NormalizedName: update.NormalizedName, + Status: update.Status, Origin: update.Origin, Method: update.Method, + MinutesTotal: update.MinutesTotal, Difficulty: update.Difficulty, + Cuisine: update.Cuisine, IsFavorite: 0, RecipeID: update.RecipeID, + PhotoID: update.PhotoID, GroundingStatus: update.GroundingStatus, + UpdatedBy: update.UpdatedBy, CreatedAt: created, UpdatedAt: updated, + } + if got != want { + t.Fatalf("UpdateDinner returned %+v, want %+v", got, want) + } + if got, err = q.GetDinner(ctx, create.ID); err != nil || got != want { + t.Fatalf("GetDinner after update = %+v, %v; want %+v", got, err, want) + } + update.ID = "missing" + _, err = q.UpdateDinner(ctx, update) + mustNoRows(t, "UpdateDinner missing", err) + + got, err = q.SetDinnerFavorite(ctx, db.SetDinnerFavoriteParams{ + ID: create.ID, IsFavorite: 1, UpdatedBy: str("alex@example.com"), UpdatedAt: updated, + }) + if err != nil { + t.Fatalf("SetDinnerFavorite: %v", err) + } + want.IsFavorite, want.UpdatedBy = 1, str("alex@example.com") + if got != want { + t.Fatalf("SetDinnerFavorite returned %+v, want %+v", got, want) + } + got, err = q.SetDinnerFavorite(ctx, db.SetDinnerFavoriteParams{ + ID: create.ID, IsFavorite: 0, UpdatedBy: str("sam@example.com"), UpdatedAt: updated, + }) + if err != nil || got.IsFavorite != 0 || got.UpdatedBy != str("sam@example.com") { + t.Fatalf("SetDinnerFavorite off = %+v, %v", got, err) + } + _, err = q.SetDinnerFavorite(ctx, db.SetDinnerFavoriteParams{ID: "missing", IsFavorite: 1, UpdatedAt: updated}) + mustNoRows(t, "SetDinnerFavorite missing", err) + + const archivedAt = "2026-10-05T12:00:00.000Z" + got, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{ + ID: create.ID, UpdatedBy: str("alex@example.com"), UpdatedAt: archivedAt, + }) + if err != nil { + t.Fatalf("ArchiveDinner: %v", err) + } + want.IsFavorite, want.Status, want.UpdatedBy, want.UpdatedAt = 0, "archived", str("alex@example.com"), archivedAt + if got != want { + t.Fatalf("ArchiveDinner returned %+v, want %+v", got, want) + } + assertIDs(t, "archived", listIDs("archived"), create.ID) + assertIDs(t, "tried after archive", listIDs("tried")) + _, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{ID: "missing", UpdatedAt: archivedAt}) + mustNoRows(t, "ArchiveDinner missing", err) +} + +func assertIDs(t *testing.T, what string, got []string, want ...string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("%s: got ids %v, want %v", what, got, want) + } + for i := range got { + if got[i] != want[i] { + t.Fatalf("%s: got ids %v, want %v", what, got, want) + } + } +} + +func TestRecipeUpsertWithIngredientsAndSteps(t *testing.T) { + ctx := context.Background() + s, q := openQueries(t) + const url = "https://example.com/chili" + + _, err := q.GetRecipeBySourceURL(ctx, url) + mustNoRows(t, "GetRecipeBySourceURL before insert", err) + + // saveRecipe is how a caller stores a fetched recipe: upsert by + // source_url, then replace the children, all in one transaction. + saveRecipe := func(title, at string, ingredients, steps []string) db.Recipe { + t.Helper() + var r db.Recipe + err := s.WithTx(ctx, func(tx *sql.Tx) error { + qtx := q.WithTx(tx) + var err error + r, err = qtx.UpsertRecipe(ctx, db.UpsertRecipeParams{ + ID: store.NewID(), SourceUrl: url, SiteName: str("Example"), Title: title, + TotalMinutes: num(60), RawJsonld: `{"name":"` + title + `"}`, + FetchedAt: at, CreatedAt: at, UpdatedAt: at, + }) + if err != nil { + return err + } + if err := qtx.DeleteRecipeIngredients(ctx, r.ID); err != nil { + return err + } + if err := qtx.DeleteRecipeSteps(ctx, r.ID); err != nil { + return err + } + for i, text := range ingredients { + if err := qtx.CreateRecipeIngredient(ctx, db.CreateRecipeIngredientParams{ + ID: store.NewID(), RecipeID: r.ID, Position: int64(i), RawText: text, + Name: str(text), Qty: str("1"), Unit: str("cup"), Note: sql.NullString{}, + CreatedAt: at, UpdatedAt: at, + }); err != nil { + return err + } + } + for i, text := range steps { + if err := qtx.CreateRecipeStep(ctx, db.CreateRecipeStepParams{ + ID: store.NewID(), RecipeID: r.ID, Position: int64(i), Text: text, + CreatedAt: at, UpdatedAt: at, + }); err != nil { + return err + } + } + return nil + }) + if err != nil { + t.Fatalf("save recipe %q: %v", title, err) + } + return r + } + assertRecipe := func(id, title, createdAt, updatedAt string, ingredients, steps []string) { + t.Helper() + r, err := q.GetRecipe(ctx, id) + if err != nil { + t.Fatalf("GetRecipe: %v", err) + } + if r.Title != title || r.SourceUrl != url || r.CreatedAt != createdAt || r.UpdatedAt != updatedAt || r.FetchedAt != updatedAt { + t.Fatalf("GetRecipe = %+v; want title %q, created %s, updated %s", r, title, createdAt, updatedAt) + } + byURL, err := q.GetRecipeBySourceURL(ctx, url) + if err != nil || byURL != r { + t.Fatalf("GetRecipeBySourceURL = %+v, %v; want %+v", byURL, err, r) + } + ings, err := q.ListRecipeIngredients(ctx, id) + if err != nil { + t.Fatalf("ListRecipeIngredients: %v", err) + } + gotIngs := []string{} + for i, ing := range ings { + if ing.RecipeID != id || ing.Position != int64(i) || ing.Name != str(ing.RawText) { + t.Fatalf("ingredient %d = %+v", i, ing) + } + gotIngs = append(gotIngs, ing.RawText) + } + assertIDs(t, "ingredients", gotIngs, ingredients...) + sts, err := q.ListRecipeSteps(ctx, id) + if err != nil { + t.Fatalf("ListRecipeSteps: %v", err) + } + gotSteps := []string{} + for i, st := range sts { + if st.RecipeID != id || st.Position != int64(i) { + t.Fatalf("step %d = %+v", i, st) + } + gotSteps = append(gotSteps, st.Text) + } + assertIDs(t, "steps", gotSteps, steps...) + } + + first := saveRecipe("Chili", created, []string{"beans", "beef", "tomatoes"}, []string{"Brown the beef.", "Simmer everything."}) + assertRecipe(first.ID, "Chili", created, created, []string{"beans", "beef", "tomatoes"}, []string{"Brown the beef.", "Simmer everything."}) + + // Re-fetching the same URL keeps the id and created_at and replaces the + // fields and children. + second := saveRecipe("White Chili", updated, []string{"chicken", "white beans"}, []string{"Simmer."}) + if second.ID != first.ID { + t.Fatalf("upsert changed id: %s -> %s", first.ID, second.ID) + } + assertRecipe(first.ID, "White Chili", created, updated, []string{"chicken", "white beans"}, []string{"Simmer."}) + + // A different recipe's children are not listed. + upsertRecipe(t, q, store.NewID(), "https://example.com/other", "Other", created) + var n int + if err := s.Reader.QueryRowContext(ctx, "SELECT count(*) FROM recipes").Scan(&n); err != nil || n != 2 { + t.Fatalf("recipes count = %d, %v; want 2", n, err) + } + _, err = q.GetRecipe(ctx, "missing") + mustNoRows(t, "GetRecipe missing", err) +} + +func TestPhotos(t *testing.T) { + ctx := context.Background() + _, q := openQueries(t) + + _, err := q.GetPhotoBySHA256(ctx, "bb22") + mustNoRows(t, "GetPhotoBySHA256 before insert", err) + + first := createPhoto(t, q, "bb22") + createPhoto(t, q, "cc33") + if first.Sha256 != "bb22" || first.Width != 800 || first.Height != 600 || first.CreatedAt != created { + t.Fatalf("CreatePhoto returned %+v", first) + } + got, err := q.GetPhotoBySHA256(ctx, "bb22") + if err != nil || got != first { + t.Fatalf("GetPhotoBySHA256 = %+v, %v; want %+v", got, err, first) + } + + // sha256 is not unique in the schema; the earliest photo is returned. + createPhoto(t, q, "bb22") + if got, err = q.GetPhotoBySHA256(ctx, "bb22"); err != nil || got != first { + t.Fatalf("GetPhotoBySHA256 with duplicate = %+v, %v; want %+v", got, err, first) + } +} + +func TestHouseholdSettings(t *testing.T) { + ctx := context.Background() + _, q := openQueries(t) + _, err := q.GetHouseholdSettings(ctx) + mustNoRows(t, "GetHouseholdSettings before upsert", err) + + p := db.UpsertHouseholdSettingsParams{ + Timezone: "America/New_York", DinnerTime: "17:30", ReminderTime: "16:30", + ReviewTime: "09:00", PlanDraftDow: 5, PlanDraftTime: "09:00", + SuggestionsDow: 0, SuggestionsTime: "08:00", ChatAutosendDow: 6, + ChatAutosendTime: "08:00", CreatedAt: created, UpdatedAt: created, + } + want := db.HouseholdSetting{ + ID: 1, Timezone: p.Timezone, DinnerTime: p.DinnerTime, ReminderTime: p.ReminderTime, + ReviewTime: p.ReviewTime, PlanDraftDow: p.PlanDraftDow, PlanDraftTime: p.PlanDraftTime, + SuggestionsDow: p.SuggestionsDow, SuggestionsTime: p.SuggestionsTime, + ChatAutosendDow: p.ChatAutosendDow, ChatAutosendTime: p.ChatAutosendTime, + CreatedAt: created, UpdatedAt: created, + } + assertSettings(t, "insert", q.UpsertHouseholdSettings, q.GetHouseholdSettings, p, want) + + p = db.UpsertHouseholdSettingsParams{ + Timezone: "America/Chicago", DinnerTime: "18:00", ReminderTime: "17:00", + ReviewTime: "10:00", PlanDraftDow: 4, PlanDraftTime: "20:00", + SuggestionsDow: 1, SuggestionsTime: "07:00", ChatAutosendDow: 0, + ChatAutosendTime: "09:30", OnboardingCompletedAt: str(updated), + UpdatesSeenAt: str(updated), CreatedAt: updated, UpdatedAt: updated, + } + want = db.HouseholdSetting{ + ID: 1, Timezone: p.Timezone, DinnerTime: p.DinnerTime, ReminderTime: p.ReminderTime, + ReviewTime: p.ReviewTime, PlanDraftDow: p.PlanDraftDow, PlanDraftTime: p.PlanDraftTime, + SuggestionsDow: p.SuggestionsDow, SuggestionsTime: p.SuggestionsTime, + ChatAutosendDow: p.ChatAutosendDow, ChatAutosendTime: p.ChatAutosendTime, + OnboardingCompletedAt: p.OnboardingCompletedAt, UpdatesSeenAt: p.UpdatesSeenAt, + CreatedAt: created, UpdatedAt: updated, + } + assertSettings(t, "update", q.UpsertHouseholdSettings, q.GetHouseholdSettings, p, want) +} + +func TestAISettings(t *testing.T) { + ctx := context.Background() + _, q := openQueries(t) + _, err := q.GetAISettings(ctx) + mustNoRows(t, "GetAISettings before upsert", err) + + p := db.UpsertAISettingsParams{ + Provider: "ollama", BaseUrl: "http://ollama:11434", Model: "llama3", + Temperature: 0.7, CreatedAt: created, UpdatedAt: created, + } + want := db.AiSetting{ + ID: 1, Provider: p.Provider, BaseUrl: p.BaseUrl, Model: p.Model, + Temperature: p.Temperature, CreatedAt: created, UpdatedAt: created, + } + assertSettings(t, "insert", q.UpsertAISettings, q.GetAISettings, p, want) + + p = db.UpsertAISettingsParams{ + Provider: "anthropic", BaseUrl: "https://api.anthropic.com", Model: "claude", + Temperature: 0.2, SystemPrompt: str("Be brief."), ApiKeyCt: []byte{1, 2, 3}, + ApiKeyLast4: str("wxyz"), KeyVerifiedAt: str(updated), CreatedAt: updated, UpdatedAt: updated, + } + want = db.AiSetting{ + ID: 1, Provider: p.Provider, BaseUrl: p.BaseUrl, Model: p.Model, + Temperature: p.Temperature, SystemPrompt: p.SystemPrompt, ApiKeyCt: p.ApiKeyCt, + ApiKeyLast4: p.ApiKeyLast4, KeyVerifiedAt: p.KeyVerifiedAt, + CreatedAt: created, UpdatedAt: updated, + } + assertSettings(t, "update", q.UpsertAISettings, q.GetAISettings, p, want) +} + +func TestRecipeSearchSettings(t *testing.T) { + ctx := context.Background() + _, q := openQueries(t) + _, err := q.GetRecipeSearchSettings(ctx) + mustNoRows(t, "GetRecipeSearchSettings before upsert", err) + + p := db.UpsertRecipeSearchSettingsParams{ + Provider: "searxng", BaseUrl: str("http://searxng:8080"), CreatedAt: created, UpdatedAt: created, + } + want := db.RecipeSearchSetting{ + ID: 1, Provider: p.Provider, BaseUrl: p.BaseUrl, CreatedAt: created, UpdatedAt: created, + } + assertSettings(t, "insert", q.UpsertRecipeSearchSettings, q.GetRecipeSearchSettings, p, want) + + p = db.UpsertRecipeSearchSettingsParams{ + Provider: "brave", ApiKeyCt: []byte{9, 8, 7}, CreatedAt: updated, UpdatedAt: updated, + } + want = db.RecipeSearchSetting{ + ID: 1, Provider: p.Provider, ApiKeyCt: p.ApiKeyCt, CreatedAt: created, UpdatedAt: updated, + } + assertSettings(t, "update", q.UpsertRecipeSearchSettings, q.GetRecipeSearchSettings, p, want) +} + +func TestIntegrationsGoogle(t *testing.T) { + ctx := context.Background() + _, q := openQueries(t) + _, err := q.GetIntegrationsGoogle(ctx) + mustNoRows(t, "GetIntegrationsGoogle before upsert", err) + + p := db.UpsertIntegrationsGoogleParams{ + RefreshTokenCt: []byte{1}, ConnectedAt: created, CreatedAt: created, UpdatedAt: created, + } + want := db.IntegrationsGoogle{ + ID: 1, RefreshTokenCt: p.RefreshTokenCt, ConnectedAt: created, CreatedAt: created, UpdatedAt: created, + } + assertSettings(t, "insert", q.UpsertIntegrationsGoogle, q.GetIntegrationsGoogle, p, want) + + p = db.UpsertIntegrationsGoogleParams{ + RefreshTokenCt: []byte{2, 3}, CalendarID: str("family@group.calendar.google.com"), + CalendarName: str("Family"), ConnectedAt: updated, LastSyncError: str("quota exceeded"), + CreatedAt: updated, UpdatedAt: updated, + } + want = db.IntegrationsGoogle{ + ID: 1, RefreshTokenCt: p.RefreshTokenCt, CalendarID: p.CalendarID, + CalendarName: p.CalendarName, ConnectedAt: updated, LastSyncError: p.LastSyncError, + CreatedAt: created, UpdatedAt: updated, + } + assertSettings(t, "update", q.UpsertIntegrationsGoogle, q.GetIntegrationsGoogle, p, want) +} + +func TestChatSettings(t *testing.T) { + ctx := context.Background() + _, q := openQueries(t) + _, err := q.GetChatSettings(ctx) + mustNoRows(t, "GetChatSettings before upsert", err) + + p := db.UpsertChatSettingsParams{ + WebhookUrlCt: []byte{4, 5}, CreatedAt: created, UpdatedAt: created, + } + want := db.ChatSetting{ + ID: 1, WebhookUrlCt: p.WebhookUrlCt, CreatedAt: created, UpdatedAt: created, + } + assertSettings(t, "insert", q.UpsertChatSettings, q.GetChatSettings, p, want) + + p = db.UpsertChatSettingsParams{ + WebhookUrlCt: []byte{6}, SpaceLabel: str("Kitchen"), LastTestAt: str(updated), + LastTestStatus: str("ok"), CreatedAt: updated, UpdatedAt: updated, + } + want = db.ChatSetting{ + ID: 1, WebhookUrlCt: p.WebhookUrlCt, SpaceLabel: p.SpaceLabel, LastTestAt: p.LastTestAt, + LastTestStatus: p.LastTestStatus, CreatedAt: created, UpdatedAt: updated, + } + assertSettings(t, "update", q.UpsertChatSettings, q.GetChatSettings, p, want) +} + +// assertSettings upserts p and checks that both the returned row and a +// fresh Get equal want. On update, want keeps the original created_at. +func assertSettings[P, R any]( + t *testing.T, what string, + upsert func(context.Context, P) (R, error), get func(context.Context) (R, error), + p P, want R, +) { + t.Helper() + ctx := context.Background() + got, err := upsert(ctx, p) + if err != nil { + t.Fatalf("%s upsert: %v", what, err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("%s upsert returned %+v, want %+v", what, got, want) + } + got, err = get(ctx) + if err != nil { + t.Fatalf("%s get: %v", what, err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("%s get = %+v, want %+v", what, got, want) + } +} diff --git a/internal/db/dinners.sql.go b/internal/db/dinners.sql.go new file mode 100644 index 0000000..3b7b213 --- /dev/null +++ b/internal/db/dinners.sql.go @@ -0,0 +1,321 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: dinners.sql + +package db + +import ( + "context" + "database/sql" +) + +const archiveDinner = `-- name: ArchiveDinner :one +UPDATE dinners +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 +` + +type ArchiveDinnerParams struct { + UpdatedBy sql.NullString + UpdatedAt string + ID string +} + +// An unknown id returns sql.ErrNoRows. +func (q *Queries) ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (Dinner, error) { + row := q.db.QueryRowContext(ctx, archiveDinner, arg.UpdatedBy, arg.UpdatedAt, arg.ID) + var i Dinner + err := row.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, + ) + return i, err +} + +const createDinner = `-- name: CreateDinner :one +INSERT INTO dinners ( + 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 +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, + ?, ?, ? +) +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 +` + +type CreateDinnerParams 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 +} + +func (q *Queries) CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error) { + row := q.db.QueryRowContext(ctx, createDinner, + arg.ID, + arg.Name, + arg.NormalizedName, + arg.Status, + arg.Origin, + arg.Method, + arg.MinutesTotal, + arg.Difficulty, + arg.Cuisine, + arg.IsFavorite, + arg.RecipeID, + arg.PhotoID, + arg.GroundingStatus, + arg.UpdatedBy, + arg.CreatedAt, + arg.UpdatedAt, + ) + var i Dinner + err := row.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, + ) + return i, err +} + +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 FROM dinners +WHERE id = ? +` + +func (q *Queries) GetDinner(ctx context.Context, id string) (Dinner, error) { + row := q.db.QueryRowContext(ctx, getDinner, id) + var i Dinner + err := row.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, + ) + return i, err +} + +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 FROM dinners +WHERE status = ? +ORDER BY normalized_name, id +` + +func (q *Queries) ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error) { + rows, err := q.db.QueryContext(ctx, listDinnersByStatus, status) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Dinner + for rows.Next() { + var i Dinner + 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, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const setDinnerFavorite = `-- name: SetDinnerFavorite :one +UPDATE dinners +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 +` + +type SetDinnerFavoriteParams struct { + IsFavorite int64 + UpdatedBy sql.NullString + UpdatedAt string + ID string +} + +// An unknown id returns sql.ErrNoRows. +func (q *Queries) SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error) { + row := q.db.QueryRowContext(ctx, setDinnerFavorite, + arg.IsFavorite, + arg.UpdatedBy, + arg.UpdatedAt, + arg.ID, + ) + var i Dinner + err := row.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, + ) + return i, err +} + +const updateDinner = `-- name: UpdateDinner :one +UPDATE dinners +SET name = ?, + normalized_name = ?, + status = ?, + origin = ?, + method = ?, + minutes_total = ?, + difficulty = ?, + cuisine = ?, + recipe_id = ?, + photo_id = ?, + grounding_status = ?, + 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 +` + +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 + PhotoID sql.NullString + GroundingStatus string + UpdatedBy sql.NullString + UpdatedAt string + ID string +} + +// An unknown id returns sql.ErrNoRows. +func (q *Queries) UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Dinner, error) { + row := q.db.QueryRowContext(ctx, updateDinner, + arg.Name, + arg.NormalizedName, + arg.Status, + arg.Origin, + arg.Method, + arg.MinutesTotal, + arg.Difficulty, + arg.Cuisine, + arg.RecipeID, + arg.PhotoID, + arg.GroundingStatus, + arg.UpdatedBy, + arg.UpdatedAt, + arg.ID, + ) + var i Dinner + err := row.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, + ) + return i, err +} diff --git a/internal/db/models.go b/internal/db/models.go new file mode 100644 index 0000000..78206b0 --- /dev/null +++ b/internal/db/models.go @@ -0,0 +1,309 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package db + +import ( + "database/sql" +) + +type AiRun struct { + ID string + Skill string + SkillVersion string + Provider string + Model string + Temperature float64 + Status string + LatencyMs sql.NullInt64 + TokensIn sql.NullInt64 + TokensOut sql.NullInt64 + Error sql.NullString + RequestRedacted string + ResponseRedacted sql.NullString + CreatedAt string + UpdatedAt string +} + +type AiSetting struct { + ID int64 + Provider string + BaseUrl string + Model string + Temperature float64 + SystemPrompt sql.NullString + ApiKeyCt []byte + ApiKeyLast4 sql.NullString + KeyVerifiedAt sql.NullString + CreatedAt string + UpdatedAt string +} + +type ChatSetting struct { + ID int64 + WebhookUrlCt []byte + SpaceLabel sql.NullString + LastTestAt sql.NullString + LastTestStatus sql.NullString + CreatedAt string + UpdatedAt string +} + +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 +} + +type HouseholdSetting struct { + ID int64 + Timezone string + DinnerTime string + ReminderTime string + ReviewTime string + PlanDraftDow int64 + PlanDraftTime string + SuggestionsDow int64 + SuggestionsTime string + ChatAutosendDow int64 + ChatAutosendTime string + OnboardingCompletedAt sql.NullString + UpdatesSeenAt sql.NullString + CreatedAt string + UpdatedAt string +} + +type IntegrationsGoogle struct { + ID int64 + RefreshTokenCt []byte + CalendarID sql.NullString + CalendarName sql.NullString + ConnectedAt string + LastSyncError sql.NullString + CreatedAt string + UpdatedAt string +} + +type Job struct { + ID string + Kind string + Status string + Payload string + Result sql.NullString + Error sql.NullString + StartedAt sql.NullString + FinishedAt sql.NullString + CreatedAt string + UpdatedAt string +} + +type NotificationOutbox struct { + ID string + EventKey string + Channel string + Payload string + Status string + Attempts int64 + NextAttemptAt sql.NullString + LastError sql.NullString + CreatedAt string + UpdatedAt string +} + +type NotificationPref struct { + Event string + Push int64 + Chat int64 + CreatedAt string + UpdatedAt string +} + +type Photo struct { + ID string + SourceUrl string + LocalPath string + Width int64 + Height int64 + Sha256 string + FetchedAt string + CreatedAt string + UpdatedAt string +} + +type PlanDay struct { + ID string + PlanID string + Date string + DinnerID sql.NullString + IsNew int64 + CalendarEventID sql.NullString + CreatedAt string + UpdatedAt string +} + +type PushSubscription struct { + ID string + Endpoint string + P256dh string + Auth string + UserAgent sql.NullString + FailureCount int64 + LastSuccessAt sql.NullString + CreatedAt string + UpdatedAt string +} + +type Recipe struct { + ID string + SourceUrl string + SiteName sql.NullString + Title string + TotalMinutes sql.NullInt64 + PrepMinutes sql.NullInt64 + CookMinutes sql.NullInt64 + Yield sql.NullString + RawJsonld string + FetchedAt string + CreatedAt string + UpdatedAt string +} + +type RecipeIngredient struct { + ID string + RecipeID string + Position int64 + RawText string + Name sql.NullString + Qty sql.NullString + Unit sql.NullString + Note sql.NullString + CreatedAt string + UpdatedAt string +} + +type RecipeSearchSetting struct { + ID int64 + Provider string + BaseUrl sql.NullString + ApiKeyCt []byte + CreatedAt string + UpdatedAt string +} + +type RecipeStep struct { + ID string + RecipeID string + Position int64 + Text string + CreatedAt string + UpdatedAt string +} + +type RotationReview struct { + ID string + DinnerID string + PlanDate string + Status string + Decision sql.NullString + DecidedAt sql.NullString + UpdatedBy sql.NullString + CreatedAt string + UpdatedAt string +} + +type ScheduleRun struct { + ID string + Schedule string + PeriodKey string + CreatedAt string +} + +type Session struct { + IDHash string + Subject string + Email string + Name string + Groups string + CsrfToken string + CreatedAt string + LastSeenAt string + ExpiresAt string +} + +type ShoppingGroup struct { + ID string + ListID string + DinnerID string + Position int64 + CreatedAt string + UpdatedAt string +} + +type ShoppingItem struct { + ID string + GroupID string + Name string + Qty sql.NullString + Needed int64 + Position int64 + CreatedAt string + UpdatedAt string +} + +type ShoppingList struct { + ID string + WeekStart string + PlanID string + Title string + Status string + SentToChatAt sql.NullString + FrozenAt sql.NullString + CreatedAt string + UpdatedAt string +} + +type Suggestion struct { + ID string + DinnerID string + BatchID string + Reason string + CitedRegularIds string + Status string + DecidedAt sql.NullString + CreatedAt string + UpdatedAt string +} + +type SuggestionBatch struct { + ID string + Trigger string + Status string + AiRunID sql.NullString + CreatedAt string + UpdatedAt string +} + +type WeekPlan struct { + ID string + WeekStart string + Status string + AiNote sql.NullString + AiRunID sql.NullString + CalendarSyncedAt sql.NullString + CreatedAt string + UpdatedAt string +} diff --git a/internal/db/photos.sql.go b/internal/db/photos.sql.go new file mode 100644 index 0000000..884ba4d --- /dev/null +++ b/internal/db/photos.sql.go @@ -0,0 +1,86 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: photos.sql + +package db + +import ( + "context" +) + +const createPhoto = `-- name: CreatePhoto :one +INSERT INTO photos ( + id, source_url, local_path, width, height, sha256, fetched_at, + created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, + ?, ? +) +RETURNING id, source_url, local_path, width, height, sha256, fetched_at, created_at, updated_at +` + +type CreatePhotoParams struct { + ID string + SourceUrl string + LocalPath string + Width int64 + Height int64 + Sha256 string + FetchedAt string + CreatedAt string + UpdatedAt string +} + +func (q *Queries) CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error) { + row := q.db.QueryRowContext(ctx, createPhoto, + arg.ID, + arg.SourceUrl, + arg.LocalPath, + arg.Width, + arg.Height, + arg.Sha256, + arg.FetchedAt, + arg.CreatedAt, + arg.UpdatedAt, + ) + var i Photo + err := row.Scan( + &i.ID, + &i.SourceUrl, + &i.LocalPath, + &i.Width, + &i.Height, + &i.Sha256, + &i.FetchedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getPhotoBySHA256 = `-- name: GetPhotoBySHA256 :one +SELECT id, source_url, local_path, width, height, sha256, fetched_at, created_at, updated_at FROM photos +WHERE sha256 = ? +ORDER BY id +LIMIT 1 +` + +// Finds stored image bytes by content hash. The schema does not make sha256 +// unique, so the earliest-created (lowest id) match is returned. +func (q *Queries) GetPhotoBySHA256(ctx context.Context, sha256 string) (Photo, error) { + row := q.db.QueryRowContext(ctx, getPhotoBySHA256, sha256) + var i Photo + err := row.Scan( + &i.ID, + &i.SourceUrl, + &i.LocalPath, + &i.Width, + &i.Height, + &i.Sha256, + &i.FetchedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/db/querier.go b/internal/db/querier.go new file mode 100644 index 0000000..bc63ad5 --- /dev/null +++ b/internal/db/querier.go @@ -0,0 +1,65 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package db + +import ( + "context" +) + +type Querier interface { + // An unknown id returns sql.ErrNoRows. + ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (Dinner, error) + CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error) + CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error) + CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error + CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error + DeleteRecipeIngredients(ctx context.Context, recipeID string) error + DeleteRecipeSteps(ctx context.Context, recipeID string) error + // Returns sql.ErrNoRows until the row exists. + GetAISettings(ctx context.Context) (AiSetting, error) + // Returns sql.ErrNoRows until the row exists. + GetChatSettings(ctx context.Context) (ChatSetting, error) + GetDinner(ctx context.Context, id string) (Dinner, error) + // Returns sql.ErrNoRows until the row exists. + GetHouseholdSettings(ctx context.Context) (HouseholdSetting, error) + // Returns sql.ErrNoRows until the row exists. + GetIntegrationsGoogle(ctx context.Context) (IntegrationsGoogle, error) + // Finds stored image bytes by content hash. The schema does not make sha256 + // unique, so the earliest-created (lowest id) match is returned. + GetPhotoBySHA256(ctx context.Context, sha256 string) (Photo, error) + GetRecipe(ctx context.Context, id string) (Recipe, error) + GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error) + // Returns sql.ErrNoRows until the row exists. + GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error) + ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error) + ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error) + ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error) + // An unknown id returns sql.ErrNoRows. + SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error) + // An unknown id returns sql.ErrNoRows. + UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Dinner, error) + // Creates the single row (id 1) or replaces every column except id and + // created_at. + UpsertAISettings(ctx context.Context, arg UpsertAISettingsParams) (AiSetting, error) + // Creates the single row (id 1) or replaces every column except id and + // created_at. + UpsertChatSettings(ctx context.Context, arg UpsertChatSettingsParams) (ChatSetting, error) + // Creates the single row (id 1) or replaces every column except id and + // created_at. + UpsertHouseholdSettings(ctx context.Context, arg UpsertHouseholdSettingsParams) (HouseholdSetting, error) + // Creates the single row (id 1) or replaces every column except id and + // created_at. + UpsertIntegrationsGoogle(ctx context.Context, arg UpsertIntegrationsGoogleParams) (IntegrationsGoogle, error) + // A recipe is keyed by source_url: re-fetching the same URL updates the + // existing row and keeps its id and created_at, which the returned row + // carries. Replace its ingredients and steps in the same transaction with + // DeleteRecipeIngredients/DeleteRecipeSteps then the Create queries. + UpsertRecipe(ctx context.Context, arg UpsertRecipeParams) (Recipe, error) + // Creates the single row (id 1) or replaces every column except id and + // created_at. + UpsertRecipeSearchSettings(ctx context.Context, arg UpsertRecipeSearchSettingsParams) (RecipeSearchSetting, error) +} + +var _ Querier = (*Queries)(nil) diff --git a/internal/db/recipes.sql.go b/internal/db/recipes.sql.go new file mode 100644 index 0000000..6255443 --- /dev/null +++ b/internal/db/recipes.sql.go @@ -0,0 +1,298 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: recipes.sql + +package db + +import ( + "context" + "database/sql" +) + +const createRecipeIngredient = `-- name: CreateRecipeIngredient :exec +INSERT INTO recipe_ingredients ( + id, recipe_id, position, raw_text, name, qty, unit, note, + created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, + ?, ? +) +` + +type CreateRecipeIngredientParams struct { + ID string + RecipeID string + Position int64 + RawText string + Name sql.NullString + Qty sql.NullString + Unit sql.NullString + Note sql.NullString + CreatedAt string + UpdatedAt string +} + +func (q *Queries) CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error { + _, err := q.db.ExecContext(ctx, createRecipeIngredient, + arg.ID, + arg.RecipeID, + arg.Position, + arg.RawText, + arg.Name, + arg.Qty, + arg.Unit, + arg.Note, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} + +const createRecipeStep = `-- name: CreateRecipeStep :exec +INSERT INTO recipe_steps ( + id, recipe_id, position, text, created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ? +) +` + +type CreateRecipeStepParams struct { + ID string + RecipeID string + Position int64 + Text string + CreatedAt string + UpdatedAt string +} + +func (q *Queries) CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error { + _, err := q.db.ExecContext(ctx, createRecipeStep, + arg.ID, + arg.RecipeID, + arg.Position, + arg.Text, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} + +const deleteRecipeIngredients = `-- name: DeleteRecipeIngredients :exec +DELETE FROM recipe_ingredients +WHERE recipe_id = ? +` + +func (q *Queries) DeleteRecipeIngredients(ctx context.Context, recipeID string) error { + _, err := q.db.ExecContext(ctx, deleteRecipeIngredients, recipeID) + return err +} + +const deleteRecipeSteps = `-- name: DeleteRecipeSteps :exec +DELETE FROM recipe_steps +WHERE recipe_id = ? +` + +func (q *Queries) DeleteRecipeSteps(ctx context.Context, recipeID string) error { + _, err := q.db.ExecContext(ctx, deleteRecipeSteps, recipeID) + return err +} + +const getRecipe = `-- name: GetRecipe :one +SELECT id, source_url, site_name, title, total_minutes, prep_minutes, cook_minutes, yield, raw_jsonld, fetched_at, created_at, updated_at FROM recipes +WHERE id = ? +` + +func (q *Queries) GetRecipe(ctx context.Context, id string) (Recipe, error) { + row := q.db.QueryRowContext(ctx, getRecipe, id) + var i Recipe + err := row.Scan( + &i.ID, + &i.SourceUrl, + &i.SiteName, + &i.Title, + &i.TotalMinutes, + &i.PrepMinutes, + &i.CookMinutes, + &i.Yield, + &i.RawJsonld, + &i.FetchedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getRecipeBySourceURL = `-- name: GetRecipeBySourceURL :one +SELECT id, source_url, site_name, title, total_minutes, prep_minutes, cook_minutes, yield, raw_jsonld, fetched_at, created_at, updated_at FROM recipes +WHERE source_url = ? +` + +func (q *Queries) GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error) { + row := q.db.QueryRowContext(ctx, getRecipeBySourceURL, sourceUrl) + var i Recipe + err := row.Scan( + &i.ID, + &i.SourceUrl, + &i.SiteName, + &i.Title, + &i.TotalMinutes, + &i.PrepMinutes, + &i.CookMinutes, + &i.Yield, + &i.RawJsonld, + &i.FetchedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listRecipeIngredients = `-- name: ListRecipeIngredients :many +SELECT id, recipe_id, position, raw_text, name, qty, unit, note, created_at, updated_at FROM recipe_ingredients +WHERE recipe_id = ? +ORDER BY position, id +` + +func (q *Queries) ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error) { + rows, err := q.db.QueryContext(ctx, listRecipeIngredients, recipeID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []RecipeIngredient + for rows.Next() { + var i RecipeIngredient + if err := rows.Scan( + &i.ID, + &i.RecipeID, + &i.Position, + &i.RawText, + &i.Name, + &i.Qty, + &i.Unit, + &i.Note, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listRecipeSteps = `-- name: ListRecipeSteps :many +SELECT id, recipe_id, position, text, created_at, updated_at FROM recipe_steps +WHERE recipe_id = ? +ORDER BY position, id +` + +func (q *Queries) ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error) { + rows, err := q.db.QueryContext(ctx, listRecipeSteps, recipeID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []RecipeStep + for rows.Next() { + var i RecipeStep + if err := rows.Scan( + &i.ID, + &i.RecipeID, + &i.Position, + &i.Text, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertRecipe = `-- name: UpsertRecipe :one +INSERT INTO recipes ( + id, source_url, site_name, title, total_minutes, prep_minutes, + cook_minutes, yield, raw_jsonld, fetched_at, created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ? +) +ON CONFLICT (source_url) DO UPDATE SET + site_name = excluded.site_name, + title = excluded.title, + total_minutes = excluded.total_minutes, + prep_minutes = excluded.prep_minutes, + cook_minutes = excluded.cook_minutes, + yield = excluded.yield, + raw_jsonld = excluded.raw_jsonld, + fetched_at = excluded.fetched_at, + updated_at = excluded.updated_at +RETURNING id, source_url, site_name, title, total_minutes, prep_minutes, cook_minutes, yield, raw_jsonld, fetched_at, created_at, updated_at +` + +type UpsertRecipeParams struct { + ID string + SourceUrl string + SiteName sql.NullString + Title string + TotalMinutes sql.NullInt64 + PrepMinutes sql.NullInt64 + CookMinutes sql.NullInt64 + Yield sql.NullString + RawJsonld string + FetchedAt string + CreatedAt string + UpdatedAt string +} + +// A recipe is keyed by source_url: re-fetching the same URL updates the +// existing row and keeps its id and created_at, which the returned row +// carries. Replace its ingredients and steps in the same transaction with +// DeleteRecipeIngredients/DeleteRecipeSteps then the Create queries. +func (q *Queries) UpsertRecipe(ctx context.Context, arg UpsertRecipeParams) (Recipe, error) { + row := q.db.QueryRowContext(ctx, upsertRecipe, + arg.ID, + arg.SourceUrl, + arg.SiteName, + arg.Title, + arg.TotalMinutes, + arg.PrepMinutes, + arg.CookMinutes, + arg.Yield, + arg.RawJsonld, + arg.FetchedAt, + arg.CreatedAt, + arg.UpdatedAt, + ) + var i Recipe + err := row.Scan( + &i.ID, + &i.SourceUrl, + &i.SiteName, + &i.Title, + &i.TotalMinutes, + &i.PrepMinutes, + &i.CookMinutes, + &i.Yield, + &i.RawJsonld, + &i.FetchedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/db/settings.sql.go b/internal/db/settings.sql.go new file mode 100644 index 0000000..5bdc156 --- /dev/null +++ b/internal/db/settings.sql.go @@ -0,0 +1,428 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: settings.sql + +package db + +import ( + "context" + "database/sql" +) + +const getAISettings = `-- name: GetAISettings :one +SELECT id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at FROM ai_settings +WHERE id = 1 +` + +// Returns sql.ErrNoRows until the row exists. +func (q *Queries) GetAISettings(ctx context.Context) (AiSetting, error) { + row := q.db.QueryRowContext(ctx, getAISettings) + var i AiSetting + err := row.Scan( + &i.ID, + &i.Provider, + &i.BaseUrl, + &i.Model, + &i.Temperature, + &i.SystemPrompt, + &i.ApiKeyCt, + &i.ApiKeyLast4, + &i.KeyVerifiedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getChatSettings = `-- name: GetChatSettings :one +SELECT id, webhook_url_ct, space_label, last_test_at, last_test_status, created_at, updated_at FROM chat_settings +WHERE id = 1 +` + +// Returns sql.ErrNoRows until the row exists. +func (q *Queries) GetChatSettings(ctx context.Context) (ChatSetting, error) { + row := q.db.QueryRowContext(ctx, getChatSettings) + var i ChatSetting + err := row.Scan( + &i.ID, + &i.WebhookUrlCt, + &i.SpaceLabel, + &i.LastTestAt, + &i.LastTestStatus, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getHouseholdSettings = `-- name: GetHouseholdSettings :one +SELECT id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow, plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow, chat_autosend_time, onboarding_completed_at, updates_seen_at, created_at, updated_at FROM household_settings +WHERE id = 1 +` + +// Returns sql.ErrNoRows until the row exists. +func (q *Queries) GetHouseholdSettings(ctx context.Context) (HouseholdSetting, error) { + row := q.db.QueryRowContext(ctx, getHouseholdSettings) + var i HouseholdSetting + err := row.Scan( + &i.ID, + &i.Timezone, + &i.DinnerTime, + &i.ReminderTime, + &i.ReviewTime, + &i.PlanDraftDow, + &i.PlanDraftTime, + &i.SuggestionsDow, + &i.SuggestionsTime, + &i.ChatAutosendDow, + &i.ChatAutosendTime, + &i.OnboardingCompletedAt, + &i.UpdatesSeenAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getIntegrationsGoogle = `-- name: GetIntegrationsGoogle :one +SELECT id, refresh_token_ct, calendar_id, calendar_name, connected_at, last_sync_error, created_at, updated_at FROM integrations_google +WHERE id = 1 +` + +// Returns sql.ErrNoRows until the row exists. +func (q *Queries) GetIntegrationsGoogle(ctx context.Context) (IntegrationsGoogle, error) { + row := q.db.QueryRowContext(ctx, getIntegrationsGoogle) + var i IntegrationsGoogle + err := row.Scan( + &i.ID, + &i.RefreshTokenCt, + &i.CalendarID, + &i.CalendarName, + &i.ConnectedAt, + &i.LastSyncError, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getRecipeSearchSettings = `-- name: GetRecipeSearchSettings :one +SELECT id, provider, base_url, api_key_ct, created_at, updated_at FROM recipe_search_settings +WHERE id = 1 +` + +// Returns sql.ErrNoRows until the row exists. +func (q *Queries) GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error) { + row := q.db.QueryRowContext(ctx, getRecipeSearchSettings) + var i RecipeSearchSetting + err := row.Scan( + &i.ID, + &i.Provider, + &i.BaseUrl, + &i.ApiKeyCt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const upsertAISettings = `-- name: UpsertAISettings :one +INSERT INTO ai_settings ( + id, provider, base_url, model, temperature, system_prompt, api_key_ct, + api_key_last4, key_verified_at, created_at, updated_at +) VALUES ( + 1, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ? +) +ON CONFLICT (id) DO UPDATE SET + provider = excluded.provider, + base_url = excluded.base_url, + model = excluded.model, + temperature = excluded.temperature, + system_prompt = excluded.system_prompt, + api_key_ct = excluded.api_key_ct, + api_key_last4 = excluded.api_key_last4, + key_verified_at = excluded.key_verified_at, + updated_at = excluded.updated_at +RETURNING id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at +` + +type UpsertAISettingsParams struct { + Provider string + BaseUrl string + Model string + Temperature float64 + SystemPrompt sql.NullString + ApiKeyCt []byte + ApiKeyLast4 sql.NullString + KeyVerifiedAt sql.NullString + CreatedAt string + UpdatedAt string +} + +// Creates the single row (id 1) or replaces every column except id and +// created_at. +func (q *Queries) UpsertAISettings(ctx context.Context, arg UpsertAISettingsParams) (AiSetting, error) { + row := q.db.QueryRowContext(ctx, upsertAISettings, + arg.Provider, + arg.BaseUrl, + arg.Model, + arg.Temperature, + arg.SystemPrompt, + arg.ApiKeyCt, + arg.ApiKeyLast4, + arg.KeyVerifiedAt, + arg.CreatedAt, + arg.UpdatedAt, + ) + var i AiSetting + err := row.Scan( + &i.ID, + &i.Provider, + &i.BaseUrl, + &i.Model, + &i.Temperature, + &i.SystemPrompt, + &i.ApiKeyCt, + &i.ApiKeyLast4, + &i.KeyVerifiedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const upsertChatSettings = `-- name: UpsertChatSettings :one +INSERT INTO chat_settings ( + id, webhook_url_ct, space_label, last_test_at, last_test_status, + created_at, updated_at +) VALUES ( + 1, ?, ?, ?, ?, + ?, ? +) +ON CONFLICT (id) DO UPDATE SET + webhook_url_ct = excluded.webhook_url_ct, + space_label = excluded.space_label, + last_test_at = excluded.last_test_at, + last_test_status = excluded.last_test_status, + updated_at = excluded.updated_at +RETURNING id, webhook_url_ct, space_label, last_test_at, last_test_status, created_at, updated_at +` + +type UpsertChatSettingsParams struct { + WebhookUrlCt []byte + SpaceLabel sql.NullString + LastTestAt sql.NullString + LastTestStatus sql.NullString + CreatedAt string + UpdatedAt string +} + +// Creates the single row (id 1) or replaces every column except id and +// created_at. +func (q *Queries) UpsertChatSettings(ctx context.Context, arg UpsertChatSettingsParams) (ChatSetting, error) { + row := q.db.QueryRowContext(ctx, upsertChatSettings, + arg.WebhookUrlCt, + arg.SpaceLabel, + arg.LastTestAt, + arg.LastTestStatus, + arg.CreatedAt, + arg.UpdatedAt, + ) + var i ChatSetting + err := row.Scan( + &i.ID, + &i.WebhookUrlCt, + &i.SpaceLabel, + &i.LastTestAt, + &i.LastTestStatus, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const upsertHouseholdSettings = `-- name: UpsertHouseholdSettings :one +INSERT INTO household_settings ( + id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow, + plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow, + chat_autosend_time, onboarding_completed_at, updates_seen_at, + created_at, updated_at +) VALUES ( + 1, ?, ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, ?, + ?, ? +) +ON CONFLICT (id) DO UPDATE SET + timezone = excluded.timezone, + dinner_time = excluded.dinner_time, + reminder_time = excluded.reminder_time, + review_time = excluded.review_time, + plan_draft_dow = excluded.plan_draft_dow, + plan_draft_time = excluded.plan_draft_time, + suggestions_dow = excluded.suggestions_dow, + suggestions_time = excluded.suggestions_time, + chat_autosend_dow = excluded.chat_autosend_dow, + chat_autosend_time = excluded.chat_autosend_time, + onboarding_completed_at = excluded.onboarding_completed_at, + updates_seen_at = excluded.updates_seen_at, + updated_at = excluded.updated_at +RETURNING id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow, plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow, chat_autosend_time, onboarding_completed_at, updates_seen_at, created_at, updated_at +` + +type UpsertHouseholdSettingsParams struct { + Timezone string + DinnerTime string + ReminderTime string + ReviewTime string + PlanDraftDow int64 + PlanDraftTime string + SuggestionsDow int64 + SuggestionsTime string + ChatAutosendDow int64 + ChatAutosendTime string + OnboardingCompletedAt sql.NullString + UpdatesSeenAt sql.NullString + CreatedAt string + UpdatedAt string +} + +// Creates the single row (id 1) or replaces every column except id and +// created_at. +func (q *Queries) UpsertHouseholdSettings(ctx context.Context, arg UpsertHouseholdSettingsParams) (HouseholdSetting, error) { + row := q.db.QueryRowContext(ctx, upsertHouseholdSettings, + arg.Timezone, + arg.DinnerTime, + arg.ReminderTime, + arg.ReviewTime, + arg.PlanDraftDow, + arg.PlanDraftTime, + arg.SuggestionsDow, + arg.SuggestionsTime, + arg.ChatAutosendDow, + arg.ChatAutosendTime, + arg.OnboardingCompletedAt, + arg.UpdatesSeenAt, + arg.CreatedAt, + arg.UpdatedAt, + ) + var i HouseholdSetting + err := row.Scan( + &i.ID, + &i.Timezone, + &i.DinnerTime, + &i.ReminderTime, + &i.ReviewTime, + &i.PlanDraftDow, + &i.PlanDraftTime, + &i.SuggestionsDow, + &i.SuggestionsTime, + &i.ChatAutosendDow, + &i.ChatAutosendTime, + &i.OnboardingCompletedAt, + &i.UpdatesSeenAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const upsertIntegrationsGoogle = `-- name: UpsertIntegrationsGoogle :one +INSERT INTO integrations_google ( + id, refresh_token_ct, calendar_id, calendar_name, connected_at, + last_sync_error, created_at, updated_at +) VALUES ( + 1, ?, ?, ?, ?, + ?, ?, ? +) +ON CONFLICT (id) DO UPDATE SET + refresh_token_ct = excluded.refresh_token_ct, + calendar_id = excluded.calendar_id, + calendar_name = excluded.calendar_name, + connected_at = excluded.connected_at, + last_sync_error = excluded.last_sync_error, + updated_at = excluded.updated_at +RETURNING id, refresh_token_ct, calendar_id, calendar_name, connected_at, last_sync_error, created_at, updated_at +` + +type UpsertIntegrationsGoogleParams struct { + RefreshTokenCt []byte + CalendarID sql.NullString + CalendarName sql.NullString + ConnectedAt string + LastSyncError sql.NullString + CreatedAt string + UpdatedAt string +} + +// Creates the single row (id 1) or replaces every column except id and +// created_at. +func (q *Queries) UpsertIntegrationsGoogle(ctx context.Context, arg UpsertIntegrationsGoogleParams) (IntegrationsGoogle, error) { + row := q.db.QueryRowContext(ctx, upsertIntegrationsGoogle, + arg.RefreshTokenCt, + arg.CalendarID, + arg.CalendarName, + arg.ConnectedAt, + arg.LastSyncError, + arg.CreatedAt, + arg.UpdatedAt, + ) + var i IntegrationsGoogle + err := row.Scan( + &i.ID, + &i.RefreshTokenCt, + &i.CalendarID, + &i.CalendarName, + &i.ConnectedAt, + &i.LastSyncError, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const upsertRecipeSearchSettings = `-- name: UpsertRecipeSearchSettings :one +INSERT INTO recipe_search_settings ( + id, provider, base_url, api_key_ct, created_at, updated_at +) VALUES ( + 1, ?, ?, ?, ?, ? +) +ON CONFLICT (id) DO UPDATE SET + provider = excluded.provider, + base_url = excluded.base_url, + api_key_ct = excluded.api_key_ct, + updated_at = excluded.updated_at +RETURNING id, provider, base_url, api_key_ct, created_at, updated_at +` + +type UpsertRecipeSearchSettingsParams struct { + Provider string + BaseUrl sql.NullString + ApiKeyCt []byte + CreatedAt string + UpdatedAt string +} + +// Creates the single row (id 1) or replaces every column except id and +// created_at. +func (q *Queries) UpsertRecipeSearchSettings(ctx context.Context, arg UpsertRecipeSearchSettingsParams) (RecipeSearchSetting, error) { + row := q.db.QueryRowContext(ctx, upsertRecipeSearchSettings, + arg.Provider, + arg.BaseUrl, + arg.ApiKeyCt, + arg.CreatedAt, + arg.UpdatedAt, + ) + var i RecipeSearchSetting + err := row.Scan( + &i.ID, + &i.Provider, + &i.BaseUrl, + &i.ApiKeyCt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/migrations/queries/dinners.sql b/migrations/queries/dinners.sql new file mode 100644 index 0000000..6b4d39b --- /dev/null +++ b/migrations/queries/dinners.sql @@ -0,0 +1,57 @@ +-- name: CreateDinner :one +INSERT INTO dinners ( + 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 +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, + ?, ?, ? +) +RETURNING *; + +-- name: GetDinner :one +SELECT * FROM dinners +WHERE id = ?; + +-- name: ListDinnersByStatus :many +SELECT * FROM dinners +WHERE status = ? +ORDER BY normalized_name, id; + +-- name: UpdateDinner :one +-- An unknown id returns sql.ErrNoRows. +UPDATE dinners +SET name = ?, + normalized_name = ?, + status = ?, + origin = ?, + method = ?, + minutes_total = ?, + difficulty = ?, + cuisine = ?, + recipe_id = ?, + photo_id = ?, + grounding_status = ?, + updated_by = ?, + updated_at = ? +WHERE id = ? +RETURNING *; + +-- name: ArchiveDinner :one +-- An unknown id returns sql.ErrNoRows. +UPDATE dinners +SET status = 'archived', + updated_by = ?, + updated_at = ? +WHERE id = ? +RETURNING *; + +-- name: SetDinnerFavorite :one +-- An unknown id returns sql.ErrNoRows. +UPDATE dinners +SET is_favorite = ?, + updated_by = ?, + updated_at = ? +WHERE id = ? +RETURNING *; diff --git a/migrations/queries/photos.sql b/migrations/queries/photos.sql new file mode 100644 index 0000000..983d5ab --- /dev/null +++ b/migrations/queries/photos.sql @@ -0,0 +1,17 @@ +-- name: CreatePhoto :one +INSERT INTO photos ( + id, source_url, local_path, width, height, sha256, fetched_at, + created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, + ?, ? +) +RETURNING *; + +-- name: GetPhotoBySHA256 :one +-- Finds stored image bytes by content hash. The schema does not make sha256 +-- unique, so the earliest-created (lowest id) match is returned. +SELECT * FROM photos +WHERE sha256 = ? +ORDER BY id +LIMIT 1; diff --git a/migrations/queries/recipes.sql b/migrations/queries/recipes.sql new file mode 100644 index 0000000..3ed7a3c --- /dev/null +++ b/migrations/queries/recipes.sql @@ -0,0 +1,65 @@ +-- name: UpsertRecipe :one +-- A recipe is keyed by source_url: re-fetching the same URL updates the +-- existing row and keeps its id and created_at, which the returned row +-- carries. Replace its ingredients and steps in the same transaction with +-- DeleteRecipeIngredients/DeleteRecipeSteps then the Create queries. +INSERT INTO recipes ( + id, source_url, site_name, title, total_minutes, prep_minutes, + cook_minutes, yield, raw_jsonld, fetched_at, created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ? +) +ON CONFLICT (source_url) DO UPDATE SET + site_name = excluded.site_name, + title = excluded.title, + total_minutes = excluded.total_minutes, + prep_minutes = excluded.prep_minutes, + cook_minutes = excluded.cook_minutes, + yield = excluded.yield, + raw_jsonld = excluded.raw_jsonld, + fetched_at = excluded.fetched_at, + updated_at = excluded.updated_at +RETURNING *; + +-- name: GetRecipe :one +SELECT * FROM recipes +WHERE id = ?; + +-- name: GetRecipeBySourceURL :one +SELECT * FROM recipes +WHERE source_url = ?; + +-- name: CreateRecipeIngredient :exec +INSERT INTO recipe_ingredients ( + id, recipe_id, position, raw_text, name, qty, unit, note, + created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, + ?, ? +); + +-- name: ListRecipeIngredients :many +SELECT * FROM recipe_ingredients +WHERE recipe_id = ? +ORDER BY position, id; + +-- name: DeleteRecipeIngredients :exec +DELETE FROM recipe_ingredients +WHERE recipe_id = ?; + +-- name: CreateRecipeStep :exec +INSERT INTO recipe_steps ( + id, recipe_id, position, text, created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ? +); + +-- name: ListRecipeSteps :many +SELECT * FROM recipe_steps +WHERE recipe_id = ? +ORDER BY position, id; + +-- name: DeleteRecipeSteps :exec +DELETE FROM recipe_steps +WHERE recipe_id = ?; diff --git a/migrations/queries/settings.sql b/migrations/queries/settings.sql new file mode 100644 index 0000000..2b39e69 --- /dev/null +++ b/migrations/queries/settings.sql @@ -0,0 +1,128 @@ +-- name: GetHouseholdSettings :one +-- Returns sql.ErrNoRows until the row exists. +SELECT * FROM household_settings +WHERE id = 1; + +-- name: UpsertHouseholdSettings :one +-- Creates the single row (id 1) or replaces every column except id and +-- created_at. +INSERT INTO household_settings ( + id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow, + plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow, + chat_autosend_time, onboarding_completed_at, updates_seen_at, + created_at, updated_at +) VALUES ( + 1, ?, ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, ?, + ?, ? +) +ON CONFLICT (id) DO UPDATE SET + timezone = excluded.timezone, + dinner_time = excluded.dinner_time, + reminder_time = excluded.reminder_time, + review_time = excluded.review_time, + plan_draft_dow = excluded.plan_draft_dow, + plan_draft_time = excluded.plan_draft_time, + suggestions_dow = excluded.suggestions_dow, + suggestions_time = excluded.suggestions_time, + chat_autosend_dow = excluded.chat_autosend_dow, + chat_autosend_time = excluded.chat_autosend_time, + onboarding_completed_at = excluded.onboarding_completed_at, + updates_seen_at = excluded.updates_seen_at, + updated_at = excluded.updated_at +RETURNING *; + +-- name: GetAISettings :one +-- Returns sql.ErrNoRows until the row exists. +SELECT * FROM ai_settings +WHERE id = 1; + +-- name: UpsertAISettings :one +-- Creates the single row (id 1) or replaces every column except id and +-- created_at. +INSERT INTO ai_settings ( + id, provider, base_url, model, temperature, system_prompt, api_key_ct, + api_key_last4, key_verified_at, created_at, updated_at +) VALUES ( + 1, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ? +) +ON CONFLICT (id) DO UPDATE SET + provider = excluded.provider, + base_url = excluded.base_url, + model = excluded.model, + temperature = excluded.temperature, + system_prompt = excluded.system_prompt, + api_key_ct = excluded.api_key_ct, + api_key_last4 = excluded.api_key_last4, + key_verified_at = excluded.key_verified_at, + updated_at = excluded.updated_at +RETURNING *; + +-- name: GetRecipeSearchSettings :one +-- Returns sql.ErrNoRows until the row exists. +SELECT * FROM recipe_search_settings +WHERE id = 1; + +-- name: UpsertRecipeSearchSettings :one +-- Creates the single row (id 1) or replaces every column except id and +-- created_at. +INSERT INTO recipe_search_settings ( + id, provider, base_url, api_key_ct, created_at, updated_at +) VALUES ( + 1, ?, ?, ?, ?, ? +) +ON CONFLICT (id) DO UPDATE SET + provider = excluded.provider, + base_url = excluded.base_url, + api_key_ct = excluded.api_key_ct, + updated_at = excluded.updated_at +RETURNING *; + +-- name: GetIntegrationsGoogle :one +-- Returns sql.ErrNoRows until the row exists. +SELECT * FROM integrations_google +WHERE id = 1; + +-- name: UpsertIntegrationsGoogle :one +-- Creates the single row (id 1) or replaces every column except id and +-- created_at. +INSERT INTO integrations_google ( + id, refresh_token_ct, calendar_id, calendar_name, connected_at, + last_sync_error, created_at, updated_at +) VALUES ( + 1, ?, ?, ?, ?, + ?, ?, ? +) +ON CONFLICT (id) DO UPDATE SET + refresh_token_ct = excluded.refresh_token_ct, + calendar_id = excluded.calendar_id, + calendar_name = excluded.calendar_name, + connected_at = excluded.connected_at, + last_sync_error = excluded.last_sync_error, + updated_at = excluded.updated_at +RETURNING *; + +-- name: GetChatSettings :one +-- Returns sql.ErrNoRows until the row exists. +SELECT * FROM chat_settings +WHERE id = 1; + +-- name: UpsertChatSettings :one +-- Creates the single row (id 1) or replaces every column except id and +-- created_at. +INSERT INTO chat_settings ( + id, webhook_url_ct, space_label, last_test_at, last_test_status, + created_at, updated_at +) VALUES ( + 1, ?, ?, ?, ?, + ?, ? +) +ON CONFLICT (id) DO UPDATE SET + webhook_url_ct = excluded.webhook_url_ct, + space_label = excluded.space_label, + last_test_at = excluded.last_test_at, + last_test_status = excluded.last_test_status, + updated_at = excluded.updated_at +RETURNING *; diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000..bbdc793 --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,15 @@ +# sqlc code generation. Regenerate with `make sqlc` (pinned sqlc version in the +# Makefile); `make sqlc-check` fails when internal/db is stale. sqlc reads only +# the top-level *.sql files of the schema directory, so migrations/queries/ is +# not treated as schema. +version: "2" +sql: + - engine: sqlite + schema: migrations + queries: migrations/queries + gen: + go: + package: db + out: internal/db + sql_package: database/sql + emit_interface: true