From dbb6310112a9af7b6d5be23e733d91aa4402bcb2 Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Fri, 9 Oct 2026 20:52:29 -0400 Subject: [PATCH] Home API (Feature 21) (#173) Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> --- cmd/server/main.go | 8 +- docs/ARCHITECTURE.md | 28 ++ internal/db/notifications.sql.go | 16 ++ internal/db/querier.go | 3 + internal/home/export_test.go | 6 + internal/home/home.go | 197 ++++++++++++++ internal/home/home_test.go | 372 +++++++++++++++++++++++++++ internal/notify/notify.go | 16 ++ internal/server/aisettings_test.go | 2 +- internal/server/chat_test.go | 4 +- internal/server/dinners_test.go | 2 +- internal/server/google_test.go | 2 +- internal/server/home.go | 22 ++ internal/server/home_test.go | 69 +++++ internal/server/ideaactions_test.go | 2 +- internal/server/ideas_test.go | 2 +- internal/server/login_test.go | 2 +- internal/server/media_test.go | 2 +- internal/server/origin_test.go | 2 +- internal/server/plans_test.go | 2 +- internal/server/push_test.go | 4 +- internal/server/recipesearch_test.go | 2 +- internal/server/server.go | 9 +- internal/server/server_test.go | 94 ++++++- internal/server/settings_test.go | 2 +- internal/server/suggestions_test.go | 2 +- migrations/queries/notifications.sql | 8 + 27 files changed, 849 insertions(+), 31 deletions(-) create mode 100644 internal/home/export_test.go create mode 100644 internal/home/home.go create mode 100644 internal/home/home_test.go create mode 100644 internal/server/home.go create mode 100644 internal/server/home_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 7103695..c472419 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -23,6 +23,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" @@ -171,6 +172,11 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } + homeSvc, err := home.NewService(settings, planSvc, suggestSvc, shopSvc, notifier) + if err != nil { + return err + } + sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax) if err != nil { return err @@ -187,7 +193,7 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } - router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, chatSvc, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, ideasSvc, runner, photos, frontend.Assets()) + router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, chatSvc, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, ideasSvc, homeSvc, runner, photos, frontend.Assets()) if err != nil { return err } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0ebae68..07e3b92 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1089,6 +1089,34 @@ Runs are recorded in `schedule_runs` and executed through `jobs`. for any other `by`. A frozen list is read through `GET /api/v1/shopping/{weekStart}` with `status` `archived`. +## Home API (Feature 21) + +- `GET /api/v1/home` (`home.Service`) aggregates the Home screen from the + services that own each value, at "now" in the household's zone (never + UTC), and answers: + + ```json + { + "dateLabel": "Friday, Oct 2", + "tonight": {"id": "…", "name": "…", "photo": "/media/…", "origin": "regular", "minutes_total": 40}, + "sideQuestCount": 10, + "shoppingList": {"weekStart": "2026-10-05", "label": "Week 41 · Oct 5 – 11", "ready": true, "sentToChatAt": null}, + "recentUpdates": {"unread": true} + } + ``` + + `tonight` is today's day of the current week's plan (`plan.Get`), null + when the week has no plan; it changes at the household's local + midnight. `photo` and `minutes_total` are null when unknown. + `sideQuestCount` is the pending cards of the deck (`suggest.Deck`). + `shoppingList` is the upcoming week's Supply run when it has one, else + the current week's (`shopping.Get`); with neither, it is the current + week with `ready` false. `recentUpdates.unread` is true when a + notification was stored in `notification_outbox` after + `household_settings.updates_seen_at`, or at all while that is null + (`notify.StoredSince`); an event whose channels are all switched off is + never stored and never counts. Nothing writes `updates_seen_at` yet. + ## Frontend - React 19, TypeScript, Vite, React Router 7, TanStack Query 5, diff --git a/internal/db/notifications.sql.go b/internal/db/notifications.sql.go index 41d92f2..3758e5d 100644 --- a/internal/db/notifications.sql.go +++ b/internal/db/notifications.sql.go @@ -97,6 +97,22 @@ func (q *Queries) GetNotificationPref(ctx context.Context, event string) (Notifi return i, err } +const hasNotificationSince = `-- name: HasNotificationSince :one +SELECT EXISTS ( + SELECT 1 FROM notification_outbox + WHERE CAST(?1 AS TEXT) IS NULL OR created_at > CAST(?1 AS TEXT) +) AS found +` + +// Reports whether a notification was stored after since, or at all when +// since is NULL. +func (q *Queries) HasNotificationSince(ctx context.Context, since sql.NullString) (bool, error) { + row := q.db.QueryRowContext(ctx, hasNotificationSince, since) + var found bool + err := row.Scan(&found) + return found, err +} + const insertNotification = `-- name: InsertNotification :execrows INSERT INTO notification_outbox ( id, event_key, channel, payload, status, attempts, next_attempt_at, created_at, updated_at diff --git a/internal/db/querier.go b/internal/db/querier.go index c626259..2213f67 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -179,6 +179,9 @@ type Querier interface { // them only when a member edited them while it was grounding. Like // GroundDinner it clears the pasted recipe_url: the recipe replaces the link. GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error) + // Reports whether a notification was stored after since, or at all when + // since is NULL. + HasNotificationSince(ctx context.Context, since sql.NullString) (bool, error) // Stores a pending notification for one channel, due at once. A row already // stored for the event key and channel, in any status, is left unchanged and // no row is inserted. diff --git a/internal/home/export_test.go b/internal/home/export_test.go new file mode 100644 index 0000000..c3db366 --- /dev/null +++ b/internal/home/export_test.go @@ -0,0 +1,6 @@ +package home + +import "time" + +// SetClock makes now the clock s reads "now" and "tonight" on. +func SetClock(s *Service, now func() time.Time) { s.now = now } diff --git a/internal/home/home.go b/internal/home/home.go new file mode 100644 index 0000000..73f21fb --- /dev/null +++ b/internal/home/home.go @@ -0,0 +1,197 @@ +// Package home reads the Home screen: tonight's dinner, the Side quests to +// swipe, the Supply run and whether there are recent updates. It owns no +// data: every value is read through the service that owns it, and "now" +// and "tonight" are in the household's zone, never UTC. +package home + +import ( + "context" + "errors" + "fmt" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest" +) + +// Service reads the Home screen. +type Service struct { + settings *household.SettingsService + plans *plan.Service + suggest *suggest.Service + shopping *shopping.Service + notifier *notify.Service + // now is the clock "now" and "tonight" are read on; tests replace it. + now func() time.Time +} + +// NewService returns a service reading through the given services. +func NewService(settings *household.SettingsService, plans *plan.Service, suggestSvc *suggest.Service, shop *shopping.Service, notifier *notify.Service) (*Service, error) { + switch { + case settings == nil: + return nil, errors.New("home: nil household settings service") + case plans == nil: + return nil, errors.New("home: nil plan service") + case suggestSvc == nil: + return nil, errors.New("home: nil suggestions service") + case shop == nil: + return nil, errors.New("home: nil shopping service") + case notifier == nil: + return nil, errors.New("home: nil notifier") + } + return &Service{settings: settings, plans: plans, suggest: suggestSvc, shopping: shop, notifier: notifier, now: time.Now}, nil +} + +// Home is the Home screen as the API shows it. +type Home struct { + // DateLabel is today in the household's zone, e.g. "Friday, Oct 2". + DateLabel string `json:"dateLabel"` + // Tonight is the dinner planned for today on the current week's plan; + // nil when the week has no plan. + Tonight *Dinner `json:"tonight"` + // SideQuestCount is how many Side quests are pending on the deck. + SideQuestCount int `json:"sideQuestCount"` + // ShoppingList is the Supply run of the upcoming week when it has one, + // else of the current week. + ShoppingList ShoppingStatus `json:"shoppingList"` + RecentUpdates RecentUpdates `json:"recentUpdates"` +} + +// Dinner is tonight's dinner. +type Dinner struct { + ID string `json:"id"` + Name string `json:"name"` + Photo *string `json:"photo"` + // Origin is regular or side_quest. + Origin string `json:"origin"` + MinutesTotal *int64 `json:"minutes_total"` +} + +// ShoppingStatus is whether a week's Supply run is ready. +type ShoppingStatus struct { + // WeekStart is the Monday of the week, YYYY-MM-DD. + WeekStart string `json:"weekStart"` + // Label is the week label, e.g. "Week 41 · Oct 5 – 11". + Label string `json:"label"` + // Ready is true when the week has a Supply run. + Ready bool `json:"ready"` + // SentToChatAt is when the Supply run was sent to Google Chat; nil + // until it is, and when there is no Supply run. + SentToChatAt *string `json:"sentToChatAt"` +} + +// RecentUpdates says whether there is something new for the bell. +type RecentUpdates struct { + // Unread is true when a notification was stored after the household + // last saw its updates (household_settings.updates_seen_at), or at all + // when it never has. + Unread bool `json:"unread"` +} + +// Get reads the Home screen at the service's "now". +func (s *Service) Get(ctx context.Context) (Home, error) { + settings, err := s.settings.Get(ctx) + if err != nil { + return Home{}, fmt.Errorf("home: read household settings: %w", err) + } + zone, err := household.LoadZone(settings.Timezone) + if err != nil { + return Home{}, fmt.Errorf("home: household settings: %w", err) + } + now := s.now().In(zone) + today := household.Today(now, zone) + thisWeek := household.WeekStart(today) + + tonight, err := s.tonight(ctx, thisWeek, today) + if err != nil { + return Home{}, err + } + deck, err := s.suggest.Deck(ctx) + if err != nil { + return Home{}, fmt.Errorf("home: read deck: %w", err) + } + list, err := s.shoppingStatus(ctx, thisWeek) + if err != nil { + return Home{}, err + } + unread, err := s.unread(ctx, settings.UpdatesSeenAt) + if err != nil { + return Home{}, err + } + return Home{ + DateLabel: now.Format("Monday, Jan 2"), + Tonight: tonight, + SideQuestCount: len(deck.Suggestions), + ShoppingList: list, + RecentUpdates: RecentUpdates{Unread: unread}, + }, nil +} + +// tonight returns the dinner planned on today in the plan of the week +// starting thisWeek, or nil when the week has no plan. +func (s *Service) tonight(ctx context.Context, thisWeek, today store.LocalDate) (*Dinner, error) { + p, err := s.plans.Get(ctx, thisWeek) + if errors.Is(err, plan.ErrNotFound) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("home: read plan: %w", err) + } + for _, d := range p.Days { + if d.Date == today.String() { + return &Dinner{ + ID: d.Dinner.ID, Name: d.Dinner.Name, Photo: d.Dinner.Photo, + Origin: d.Dinner.Origin, MinutesTotal: d.Dinner.Minutes, + }, nil + } + } + return nil, fmt.Errorf("home: plan of week %s has no day %s", thisWeek, today) +} + +// shoppingStatus returns the status of the upcoming week's Supply run when +// it has one, else of the current week's, ready or not. +func (s *Service) shoppingStatus(ctx context.Context, thisWeek store.LocalDate) (ShoppingStatus, error) { + y, m, d := thisWeek.Date() + nextWeek := store.LocalDateOf(time.Date(y, m, d+7, 0, 0, 0, 0, time.UTC)) + for _, week := range []store.LocalDate{nextWeek, thisWeek} { + l, err := s.shopping.Get(ctx, week) + if errors.Is(err, shopping.ErrListNotFound) { + continue + } + if err != nil { + return ShoppingStatus{}, fmt.Errorf("home: read Supply run: %w", err) + } + return weekStatus(week, true, l.SentToChatAt) + } + return weekStatus(thisWeek, false, nil) +} + +func weekStatus(week store.LocalDate, ready bool, sentToChatAt *string) (ShoppingStatus, error) { + label, err := household.WeekLabel(week) + if err != nil { + return ShoppingStatus{}, fmt.Errorf("home: %w", err) + } + return ShoppingStatus{WeekStart: week.String(), Label: label, Ready: ready, SentToChatAt: sentToChatAt}, nil +} + +// unread reports whether a notification was stored after seenAt, the +// stored updates_seen_at, or at all when it is nil. +func (s *Service) unread(ctx context.Context, seenAt *string) (bool, error) { + var since *time.Time + if seenAt != nil { + var ts store.Timestamp + if err := ts.Scan(*seenAt); err != nil { + return false, fmt.Errorf("home: household settings updates_seen_at: %w", err) + } + since = &ts.Time + } + unread, err := s.notifier.StoredSince(ctx, since) + if err != nil { + return false, fmt.Errorf("home: %w", err) + } + return unread, nil +} diff --git a/internal/home/home_test.go b/internal/home/home_test.go new file mode 100644 index 0000000..4742350 --- /dev/null +++ b/internal/home/home_test.go @@ -0,0 +1,372 @@ +package home_test + +import ( + "context" + "database/sql" + "encoding/base64" + "errors" + "io" + "log/slog" + "net/http" + "net/url" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +// zone is the household's zone in these tests: not UTC, so a late local +// evening is already the next day in UTC. +const zone = "America/Chicago" + +// weekDates is the week of 2026-10-12, Monday first. +var weekDates = []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"} + +type env struct { + svc *home.Service + st *store.Store + notifier *notify.Service + loc *time.Location +} + +// newEnv returns a home service over a migrated temp database whose +// household is in zone, reading "now" from a clock set to at. +func newEnv(t *testing.T) *env { + t.Helper() + ctx := t.Context() + st, err := store.Open(ctx, t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := st.Close(); err != nil { + t.Error(err) + } + }) + if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + if err := household.SeedSettings(ctx, st, zone); err != nil { + t.Fatal(err) + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + runner, err := jobs.NewRunner(st, logger, 1) + if err != nil { + t.Fatal(err) + } + notifier := notifytest.New(t, st) + pub := &url.URL{Scheme: "https", Host: "mealquest.example"} + + box, err := secretbox.New(base64.StdEncoding.EncodeToString(make([]byte, secretbox.KeySize))) + if err != nil { + t.Fatal(err) + } + google := gcaltest.New(t, "https://mealquest.example"+gcal.CallbackPath) + googleSvc, err := gcal.NewService(st, box, gcal.Config{ + ClientID: gcaltest.ClientID, ClientSecret: gcaltest.ClientSecret, PublicURL: pub, + SecretKey: []byte("server-secret-key"), Endpoint: google.Endpoint(), RevokeURL: google.RevokeURL(), + CalendarAPI: google.CalendarAPI(), + }) + if err != nil { + t.Fatal(err) + } + draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) { + return skills.Week{}, errors.New("no model in this test") + } + plans, err := plan.NewService(st, draft, runner, googleSvc, notifier, pub, logger) + if err != nil { + t.Fatal(err) + } + g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{}) + sideQuests := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) { + return skills.SideQuests{}, errors.New("no model in this test") + } + suggestSvc, err := suggest.NewService(st, g, sideQuests, runner, notifier, logger) + if err != nil { + t.Fatal(err) + } + chatSvc, err := chat.NewService(st, box, pub, http.DefaultTransport) + if err != nil { + t.Fatal(err) + } + title := func(context.Context, string, skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) { + return skills.ArchiveTitle{}, errors.New("no model in this test") + } + shop, err := shopping.NewService(st, title, runner, notifier, chatSvc, logger) + if err != nil { + t.Fatal(err) + } + svc, err := home.NewService(household.NewSettingsService(st), plans, suggestSvc, shop, notifier) + if err != nil { + t.Fatal(err) + } + loc, err := time.LoadLocation(zone) + if err != nil { + t.Fatal(err) + } + return &env{svc: svc, st: st, notifier: notifier, loc: loc} +} + +// at reads the Home screen with the clock at the household-local wall +// time "YYYY-MM-DD HH:MM". +func (e *env) at(t *testing.T, local string) home.Home { + t.Helper() + now, err := time.ParseInLocation("2006-01-02 15:04", local, e.loc) + if err != nil { + t.Fatal(err) + } + home.SetClock(e.svc, func() time.Time { return now }) + h, err := e.svc.Get(t.Context()) + if err != nil { + t.Fatal(err) + } + return h +} + +// dinner stores a dinner and returns its id. +func (e *env) dinner(t *testing.T, name, status, origin string, minutes int64) string { + t.Helper() + now := store.Now().String() + d, err := db.New(e.st.Writer).CreateDinner(t.Context(), db.CreateDinnerParams{ + ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: status, Origin: origin, + MinutesTotal: sql.NullInt64{Int64: minutes, Valid: minutes > 0}, + GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now, + }) + if err != nil { + t.Fatal(err) + } + return d.ID +} + +// planWeek stores the plan of the week of 2026-10-12: the dinner named for +// its date each day, a Regular of 30 minutes, but Wednesday a Side quest of +// unknown minutes. It returns the plan's id. +func (e *env) planWeek(t *testing.T) string { + t.Helper() + ctx := t.Context() + q := db.New(e.st.Writer) + now := store.Now().String() + planID := store.NewID() + if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{ID: planID, WeekStart: weekDates[0], CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + for _, date := range weekDates { + id := e.dinner(t, "Dinner "+date, "regular", "regular", 30) + if date == "2026-10-14" { + id = e.dinner(t, "Dinner "+date, "quest_log", "side_quest", 0) + } + if err := q.PutPlanDay(ctx, db.PutPlanDayParams{ID: store.NewID(), PlanID: planID, Date: date, + DinnerID: sql.NullString{String: id, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + } + return planID +} + +// Tonight's dinner and the date label follow the household's local +// midnight, not UTC's: late on Tuesday evening in Chicago it is already +// Wednesday in UTC, and Home still shows Tuesday's dinner. +func TestTonightSwitchesAtLocalMidnight(t *testing.T) { + t.Parallel() + e := newEnv(t) + e.planWeek(t) + + for _, tc := range []struct { + local, label, dinner string + }{ + // 23:59 CDT is 04:59 UTC the next day. + {"2026-10-13 23:59", "Tuesday, Oct 13", "Dinner 2026-10-13"}, + {"2026-10-14 00:00", "Wednesday, Oct 14", "Dinner 2026-10-14"}, + // 19:00 CDT on Sunday is Monday 00:00 UTC, a week with no plan. + {"2026-10-18 19:00", "Sunday, Oct 18", "Dinner 2026-10-18"}, + } { + h := e.at(t, tc.local) + if h.DateLabel != tc.label { + t.Errorf("at %s: dateLabel = %q, want %q", tc.local, h.DateLabel, tc.label) + } + if h.Tonight == nil || h.Tonight.Name != tc.dinner { + t.Errorf("at %s: tonight = %+v, want %s", tc.local, h.Tonight, tc.dinner) + } + } + + // Late Sunday evening of the week before, the plan's week has not + // begun locally, though in UTC it has. + if h := e.at(t, "2026-10-11 23:30"); h.Tonight != nil || h.DateLabel != "Sunday, Oct 11" { + t.Errorf("at 2026-10-11 23:30: tonight = %+v, dateLabel = %q, want no dinner on Sunday, Oct 11", h.Tonight, h.DateLabel) + } +} + +// Tonight carries the dinner's id, name, photo, origin and minutes. +func TestTonightDinner(t *testing.T) { + t.Parallel() + e := newEnv(t) + e.planWeek(t) + + h := e.at(t, "2026-10-13 18:00") + if h.Tonight == nil || h.Tonight.ID == "" || h.Tonight.Origin != "regular" || h.Tonight.Photo != nil || + h.Tonight.MinutesTotal == nil || *h.Tonight.MinutesTotal != 30 { + t.Errorf("tonight = %+v, want Tuesday's 30-minute Regular with no photo", h.Tonight) + } + h = e.at(t, "2026-10-14 18:00") + if h.Tonight == nil || h.Tonight.Origin != "side_quest" || h.Tonight.MinutesTotal != nil { + t.Errorf("tonight = %+v, want Wednesday's Side quest of unknown minutes", h.Tonight) + } +} + +// With no plan, no suggestions, no Supply run and no notifications, Home +// says so: no dinner tonight, nothing to swipe, the current week's Supply +// run not ready and nothing unread. +func TestHomeEmpty(t *testing.T) { + t.Parallel() + e := newEnv(t) + + h := e.at(t, "2026-10-13 23:59") + want := home.Home{ + DateLabel: "Tuesday, Oct 13", + ShoppingList: home.ShoppingStatus{ + WeekStart: "2026-10-12", Label: "Week 42 · Oct 12 – 18", + }, + } + if h != want { + t.Errorf("home = %+v, want %+v", h, want) + } +} + +// The Side quest count is the pending cards of the deck: a decided +// suggestion is not counted. +func TestSideQuestCount(t *testing.T) { + t.Parallel() + e := newEnv(t) + ctx := t.Context() + q := db.New(e.st.Writer) + now := store.Now().String() + batch, err := q.CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now}) + if err != nil { + t.Fatal(err) + } + if _, err := q.FinishSuggestionBatch(ctx, db.FinishSuggestionBatchParams{Status: "done", UpdatedAt: now, ID: batch.ID}); err != nil { + t.Fatal(err) + } + for _, name := range []string{"Pho", "Ramen", "Tagine"} { + id := e.dinner(t, name, "suggested", "side_quest", 0) + if err := q.CreateSuggestion(ctx, db.CreateSuggestionParams{ + ID: store.NewID(), DinnerID: id, BatchID: batch.ID, Name: name, Reason: "Because", CitedRegularIds: "[]", + CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatal(err) + } + } + if h := e.at(t, "2026-10-13 12:00"); h.SideQuestCount != 3 { + t.Errorf("sideQuestCount = %d, want 3", h.SideQuestCount) + } + if _, err := e.st.Writer.ExecContext(ctx, `UPDATE suggestions SET status = 'declined', decided_at = ? WHERE name = 'Pho'`, now); err != nil { + t.Fatal(err) + } + if h := e.at(t, "2026-10-13 12:00"); h.SideQuestCount != 2 { + t.Errorf("sideQuestCount after a Pass = %d, want 2", h.SideQuestCount) + } +} + +// The Supply run shown is the upcoming week's when it has one, else the +// current week's; the weeks are the household's local weeks. +func TestShoppingStatus(t *testing.T) { + t.Parallel() + e := newEnv(t) + ctx := t.Context() + planID := e.planWeek(t) + list := func(weekStart string, sent sql.NullString) { + t.Helper() + now := store.Now().String() + if _, err := e.st.Writer.ExecContext(ctx, + `INSERT INTO shopping_lists (id, week_start, plan_id, status, sent_to_chat_at, created_at, updated_at) VALUES (?, ?, ?, 'current', ?, ?, ?)`, + store.NewID(), weekStart, planID, sent, now, now); err != nil { + t.Fatal(err) + } + } + + list("2026-10-12", sql.NullString{}) + want := home.ShoppingStatus{WeekStart: "2026-10-12", Label: "Week 42 · Oct 12 – 18", Ready: true} + if h := e.at(t, "2026-10-16 09:00"); h.ShoppingList != want { + t.Errorf("shoppingList = %+v, want %+v", h.ShoppingList, want) + } + + sent := "2026-10-17T13:00:00.000Z" + list("2026-10-19", sql.NullString{String: sent, Valid: true}) + h := e.at(t, "2026-10-16 09:00") + if s := h.ShoppingList; s.WeekStart != "2026-10-19" || s.Label != "Week 43 · Oct 19 – 25" || !s.Ready || s.SentToChatAt == nil || *s.SentToChatAt != sent { + t.Errorf("shoppingList = %+v, want the upcoming week's, sent at %s", s, sent) + } + + // Late Sunday evening locally is Monday in UTC: the current week is + // still the week of 2026-10-12, so its upcoming week's list shows. + if s := e.at(t, "2026-10-18 23:30").ShoppingList; s.WeekStart != "2026-10-19" { + t.Errorf("late Sunday: shoppingList week = %s, want 2026-10-19", s.WeekStart) + } + // On Monday the week of 2026-10-19 is current and has no upcoming + // list. + if s := e.at(t, "2026-10-19 08:00").ShoppingList; s.WeekStart != "2026-10-19" || !s.Ready { + t.Errorf("Monday: shoppingList = %+v, want the current week's", s) + } + // A week later neither the current nor the upcoming week has a list. + want = home.ShoppingStatus{WeekStart: "2026-10-26", Label: "Week 44 · Oct 26 – Nov 1"} + if s := e.at(t, "2026-10-26 08:00").ShoppingList; s != want { + t.Errorf("no list: shoppingList = %+v, want %+v", s, want) + } +} + +// Recent updates are unread when a notification was stored after the +// household last saw its updates, or ever when it never has. +func TestRecentUpdatesUnread(t *testing.T) { + t.Parallel() + e := newEnv(t) + ctx := t.Context() + + if h := e.at(t, "2026-10-13 12:00"); h.RecentUpdates.Unread { + t.Error("unread with no notifications") + } + if err := e.notifier.Emit(ctx, notify.Event{Type: notify.NewSuggestions, Key: "new_suggestions:1", Title: "New Side quests", URL: "/discover"}); err != nil { + t.Fatal(err) + } + if h := e.at(t, "2026-10-13 12:00"); !h.RecentUpdates.Unread { + t.Error("not unread with a notification never seen") + } + seen := func(at time.Time) { + t.Helper() + if _, err := e.st.Writer.ExecContext(ctx, `UPDATE household_settings SET updates_seen_at = ?`, store.Timestamp{Time: at.UTC().Truncate(time.Millisecond)}.String()); err != nil { + t.Fatal(err) + } + } + seen(time.Now().Add(time.Hour)) + if h := e.at(t, "2026-10-13 12:00"); h.RecentUpdates.Unread { + t.Error("unread with every notification seen") + } + seen(time.Now().Add(-time.Hour)) + if h := e.at(t, "2026-10-13 12:00"); !h.RecentUpdates.Unread { + t.Error("not unread with a notification stored after updates were seen") + } +} + +func TestNewServiceRequiresServices(t *testing.T) { + t.Parallel() + if _, err := home.NewService(nil, nil, nil, nil, nil); err == nil { + t.Fatal("NewService accepted nil services") + } +} diff --git a/internal/notify/notify.go b/internal/notify/notify.go index 4254e5b..ae73a5a 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -189,6 +189,22 @@ func (s *Service) EmitTx(ctx context.Context, tx *sql.Tx, ev Event) error { }) } +// StoredSince reports whether a notification was stored after since, or +// at all when since is nil. An event counts once it is stored for any +// channel, whatever became of its delivery; one whose preference switches +// every channel off is never stored and never counts. +func (s *Service) StoredSince(ctx context.Context, since *time.Time) (bool, error) { + var after sql.NullString + if since != nil { + after = sql.NullString{String: timestamp(*since), Valid: true} + } + found, err := db.New(s.st.Reader).HasNotificationSince(ctx, after) + if err != nil { + return false, fmt.Errorf("notify: read notifications since %s: %w", after.String, err) + } + return found, nil +} + // emitTx stores ev in tx for each channel that channels chooses from the // preference of ev's type. func (s *Service) emitTx(ctx context.Context, tx *sql.Tx, ev Event, channels func(db.NotificationPref) ([]Channel, error)) error { diff --git a/internal/server/aisettings_test.go b/internal/server/aisettings_test.go index e5b4a1f..2679a39 100644 --- a/internal/server/aisettings_test.go +++ b/internal/server/aisettings_test.go @@ -29,7 +29,7 @@ func newAIRouter(t *testing.T) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), - newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/chat_test.go b/internal/server/chat_test.go index d1b6c7b..a55cfef 100644 --- a/internal/server/chat_test.go +++ b/internal/server/chat_test.go @@ -86,7 +86,7 @@ func (b *lockedBuffer) String() string { func newChatRouter(t *testing.T, svc *chat.Service, logs io.Writer) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(logs, &slog.HandlerOptions{Level: slog.LevelDebug})) - r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), svc, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), + r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), svc, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) @@ -233,7 +233,7 @@ func TestChatWebhookNeedsCSRF(t *testing.T) { func TestNewRouterRequiresChat(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil Google Chat service") } } diff --git a/internal/server/dinners_test.go b/internal/server/dinners_test.go index 237a256..4a13e76 100644 --- a/internal/server/dinners_test.go +++ b/internal/server/dinners_test.go @@ -98,7 +98,7 @@ func newDinnersEnv(t *testing.T) *dinnersEnv { t.Fatal(err) } e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), - svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), runner, photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), runner, photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/google_test.go b/internal/server/google_test.go index ccdc4e0..c09c1ae 100644 --- a/internal/server/google_test.go +++ b/internal/server/google_test.go @@ -69,7 +69,7 @@ func newGoogleEnv(t *testing.T) *googleEnv { } logger := slog.New(slog.NewJSONHandler(e.logs, &slog.HandlerOptions{Level: slog.LevelDebug})) e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), - newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), + newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/home.go b/internal/server/home.go new file mode 100644 index 0000000..f54e7fb --- /dev/null +++ b/internal/server/home.go @@ -0,0 +1,22 @@ +package server + +import ( + "log/slog" + "net/http" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home" +) + +// getHome answers GET /home with the Home screen at the household's local +// "now". +func getHome(logger *slog.Logger, svc *home.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + h, err := svc.Get(r.Context()) + if err != nil { + logger.ErrorContext(r.Context(), "read home", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + writeJSON(w, r, logger, http.StatusOK, h) + } +} diff --git a/internal/server/home_test.go b/internal/server/home_test.go new file mode 100644 index 0000000..f84d5ff --- /dev/null +++ b/internal/server/home_test.go @@ -0,0 +1,69 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" +) + +// GET /home answers the Home screen in its JSON shape; newTestHome's +// household (America/New_York) has no plan, no suggestions, no Supply run +// and no notifications. +func TestGetHome(t *testing.T) { + t.Parallel() + h := newTestRouter(t, "dev") + loc, err := time.LoadLocation("America/New_York") + if err != nil { + t.Fatal(err) + } + // want is the answer at instant now; the request is read between two + // instants, so it matches the answer at one of them. + want := func(now time.Time) string { + week := household.WeekStart(household.Today(now, loc)) + label, err := household.WeekLabel(week) + if err != nil { + t.Fatal(err) + } + body, err := json.Marshal(map[string]any{ + "dateLabel": now.In(loc).Format("Monday, Jan 2"), + "tonight": nil, + "sideQuestCount": 0, + "shoppingList": map[string]any{"weekStart": week.String(), "label": label, "ready": false, "sentToChatAt": nil}, + "recentUpdates": map[string]any{"unread": false}, + }) + if err != nil { + t.Fatal(err) + } + return string(body) + } + before := time.Now() + rec, _ := serve(t, h, signedIn(http.MethodGet, "/api/v1/home", nil)) + after := time.Now() + if rec.Code != http.StatusOK { + t.Fatalf("GET /api/v1/home = %d: %s", rec.Code, rec.Body) + } + var got any + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + canonical, err := json.Marshal(got) + if err != nil { + t.Fatal(err) + } + if string(canonical) != want(before) && string(canonical) != want(after) { + t.Errorf("GET /api/v1/home = %s, want %s", canonical, want(after)) + } +} + +func TestGetHomeNeedsSession(t *testing.T) { + t.Parallel() + rec := httptest.NewRecorder() + newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/home", nil)) + if rec.Code != http.StatusUnauthorized { + t.Errorf("GET /api/v1/home without a session = %d, want 401", rec.Code) + } +} diff --git a/internal/server/ideaactions_test.go b/internal/server/ideaactions_test.go index 238156a..c6dbf4c 100644 --- a/internal/server/ideaactions_test.go +++ b/internal/server/ideaactions_test.go @@ -78,7 +78,7 @@ func newIdeaActionsRouter(t *testing.T, model, normalize *llm.Fake, search *reci t.Fatal(err) } h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), - dinnerSvc, newTestSuggest(t), planSvc, newTestShopping(t), newTestGoogle(t), newTestPush(t), ideasSvc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + dinnerSvc, newTestSuggest(t), planSvc, newTestShopping(t), newTestGoogle(t), newTestPush(t), ideasSvc, newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/ideas_test.go b/internal/server/ideas_test.go index 73b2d1c..35c77cd 100644 --- a/internal/server/ideas_test.go +++ b/internal/server/ideas_test.go @@ -66,7 +66,7 @@ func newIdeasRouter(t *testing.T, model, normalize *llm.Fake, search *recipestes t.Fatal(err) } h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), - newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), svc, newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/login_test.go b/internal/server/login_test.go index ceb4c6f..3bd7234 100644 --- a/internal/server/login_test.go +++ b/internal/server/login_test.go @@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler { t.Fatal(err) } r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t), - newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/media_test.go b/internal/server/media_test.go index 77490c7..d527d3b 100644 --- a/internal/server/media_test.go +++ b/internal/server/media_test.go @@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) { t.Fatal(err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), + r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/origin_test.go b/internal/server/origin_test.go index 20bc97d..de89969 100644 --- a/internal/server/origin_test.go +++ b/internal/server/origin_test.go @@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler { } logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil))) r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t), - newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/plans_test.go b/internal/server/plans_test.go index 4b7224d..9910272 100644 --- a/internal/server/plans_test.go +++ b/internal/server/plans_test.go @@ -107,7 +107,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv { t.Fatal(err) } h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), chatSvc, - newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/push_test.go b/internal/server/push_test.go index cf1779d..5ffdcff 100644 --- a/internal/server/push_test.go +++ b/internal/server/push_test.go @@ -55,7 +55,7 @@ func newTestPushWith(t *testing.T) (*push.Service, *store.Store, vapid.KeyPair) func newPushRouter(t *testing.T, svc *push.Service) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), svc, newTestIdeas(t), newTestRunner(t), + r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), svc, newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) @@ -103,7 +103,7 @@ func TestNewRouterRequiresPush(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), nil, newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), nil, newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil push service") } } diff --git a/internal/server/recipesearch_test.go b/internal/server/recipesearch_test.go index 028887d..caf61fd 100644 --- a/internal/server/recipesearch_test.go +++ b/internal/server/recipesearch_test.go @@ -22,7 +22,7 @@ const ( func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), + r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/server.go b/internal/server/server.go index dcff31f..c5f5850 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -16,6 +16,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" @@ -66,12 +67,12 @@ type RouteMounter interface { // /api/v1/settings/notifications/test-push; ideasSvc serves POST // /api/v1/ideas/from-ingredients and POST /api/v1/ideas/quick, and with // dinnerSvc POST /api/v1/ideas/{jobId}/items/{i}/quest-log and, with -// planSvc too, /cook-tonight; runner serves +// planSvc too, /cook-tonight; homeSvc serves GET /api/v1/home; runner serves // POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves // GET /media/photos/{id}-{size}.jpg; assets is the web build // served for every other GET. /health, /auth and the web build are public. // Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader. -func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, chatSvc *chat.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, pushSvc *push.Service, ideasSvc *ideas.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) { +func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, chatSvc *chat.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, pushSvc *push.Service, ideasSvc *ideas.Service, homeSvc *home.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) { if settings == nil { return nil, errors.New("server: nil household settings service") } @@ -105,6 +106,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a if ideasSvc == nil { return nil, errors.New("server: nil ideas service") } + if homeSvc == nil { + return nil, errors.New("server: nil home service") + } if runner == nil { return nil, errors.New("server: nil job runner") } @@ -137,6 +141,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a sub.NotFound(notFound) } apiV1.Get("/me", me(logger)) + apiV1.Get("/home", getHome(logger, homeSvc)) apiV1.Get("/settings/household", getHouseholdSettings(logger, settings)) apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings)) apiV1.Get("/settings/notifications", getNotificationSettings(logger, settings)) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index e6c75ae..2ea9637 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -22,8 +22,10 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" @@ -56,7 +58,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting "index.html": {Data: []byte(fixtureIndex)}, "assets/index-AbC123.js": {Data: []byte(fixtureAsset)}, } - r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), runner, newTestPhotos(t), assets) + r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), assets) if err != nil { t.Fatal(err) } @@ -257,6 +259,65 @@ func newTestShopping(t *testing.T) *shopping.Service { return svc } +// newTestHome returns a home service whose services all share one +// migrated temp database with zone America/New_York, with an unstarted job +// runner, no model, and Google and Google Chat endpoints that refuse every +// connection. One database keeps the routers that never ask for Home cheap. +func newTestHome(t *testing.T) *home.Service { + t.Helper() + st := newTestStore(t) + if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil { + t.Fatal(err) + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + runner, err := jobs.NewRunner(st, logger, 1) + if err != nil { + t.Fatal(err) + } + notifier := notifytest.New(t, st) + draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) { + return skills.Week{}, errors.New("no model in this test") + } + pub := &url.URL{Scheme: "https", Host: "mealquest.example"} + googleSvc, err := gcal.NewService(st, newTestBox(t), gcal.Config{ + ClientID: "client-id", + ClientSecret: "client-secret", + PublicURL: pub, + SecretKey: []byte("server-secret-key"), + Endpoint: oauth2.Endpoint{AuthURL: "http://127.0.0.1:1/auth", TokenURL: "http://127.0.0.1:1/token"}, + RevokeURL: "http://127.0.0.1:1/revoke", + CalendarAPI: "http://127.0.0.1:1/calendar/v3", + }) + if err != nil { + t.Fatal(err) + } + planSvc, err := plan.NewService(st, draft, runner, googleSvc, notifier, pub, logger) + if err != nil { + t.Fatal(err) + } + g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{}) + sideQuests := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) { + return skills.SideQuests{}, errors.New("no model in this test") + } + suggestSvc, err := suggest.NewService(st, g, sideQuests, runner, notifier, logger) + if err != nil { + t.Fatal(err) + } + chatSvc, err := chat.NewService(st, newTestBox(t), pub, refuseTransport{}) + if err != nil { + t.Fatal(err) + } + shop, err := shopping.NewService(st, untitled, runner, notifier, chatSvc, logger) + if err != nil { + t.Fatal(err) + } + svc, err := home.NewService(household.NewSettingsService(st), planSvc, suggestSvc, shop, notifier) + if err != nil { + t.Fatal(err) + } + return svc +} + // untitled is a shopping.Titler with no model: every Supply run it is // asked to title stays untitled. func untitled(context.Context, string, skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) { @@ -317,7 +378,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil AI settings service") } } @@ -326,7 +387,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil recipe search settings service") } } @@ -335,7 +396,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted nil photo files") } } @@ -344,7 +405,7 @@ func TestNewRouterRequiresSettings(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil settings service") } } @@ -353,7 +414,7 @@ func TestNewRouterRequiresDinners(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil dinners service") } } @@ -362,7 +423,7 @@ func TestNewRouterRequiresSuggestions(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil suggestions service") } } @@ -371,7 +432,7 @@ func TestNewRouterRequiresPlan(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil plan service") } } @@ -380,7 +441,7 @@ func TestNewRouterRequiresShopping(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil shopping service") } } @@ -389,16 +450,25 @@ func TestNewRouterRequiresIdeas(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), nil, newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil ideas service") } } +func TestNewRouterRequiresHome(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + t.Fatal("NewRouter accepted a nil home service") + } +} + func TestNewRouterRequiresGoogle(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil Google Calendar service") } } @@ -407,7 +477,7 @@ func TestNewRouterRequiresJobRunner(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil job runner") } } diff --git a/internal/server/settings_test.go b/internal/server/settings_test.go index 84bd11f..bb8f67b 100644 --- a/internal/server/settings_test.go +++ b/internal/server/settings_test.go @@ -277,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) { } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st), - newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/suggestions_test.go b/internal/server/suggestions_test.go index aaba602..aeac339 100644 --- a/internal/server/suggestions_test.go +++ b/internal/server/suggestions_test.go @@ -46,7 +46,7 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S t.Fatal(err) } h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), - newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/migrations/queries/notifications.sql b/migrations/queries/notifications.sql index c9deb42..83d0ac9 100644 --- a/migrations/queries/notifications.sql +++ b/migrations/queries/notifications.sql @@ -66,3 +66,11 @@ SELECT * FROM notification_prefs; UPDATE notification_prefs SET push = ?, chat = ?, updated_at = ? WHERE event = ?; + +-- name: HasNotificationSince :one +-- Reports whether a notification was stored after since, or at all when +-- since is NULL. +SELECT EXISTS ( + SELECT 1 FROM notification_outbox + WHERE CAST(sqlc.narg(since) AS TEXT) IS NULL OR created_at > CAST(sqlc.narg(since) AS TEXT) +) AS found;