From fd835829e2fff1bb35e421dfb2e42f80c22264b9 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Fri, 9 Oct 2026 14:52:02 -0400 Subject: [PATCH] Add notification preferences API and screen (1n) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET/PUT /api/v1/settings/notifications read and replace the event x {push, chat} matrix for the five events and the reminder time. The PUT validates every event exactly once and an HH:MM time, refuses unknown events and fields, and writes the preferences and the household settings' reminder_time (the one copy) in one transaction, publishing the new settings to the scheduler as a household settings change does. /settings/notifications renders mock 1n: the matrix with Toggle switches, including the next-day "Keep it in rotation?" Rotation check (D-021); the reminder time edited in a sheet; the push permission row using pushStatus/subscribePush/unsubscribePush; the iOS InstallHint; the Google Chat webhook card with the masked URL, "Connected · {space}" and Send test showing success or the HTTP status; and the preview bubble. The desktop 2e Notifications & AI tab shows the matrix and webhook cards in its left column. The settings home row now reads Web Push on this device and the Google Chat space, and the Household form takes a reminder time saved elsewhere without undoing it. Maestro task 308a460d-b22c-4179-acba-d5ea0968ccd5. Co-Authored-By: Claude Opus 5.5 --- internal/db/notifications.sql.go | 61 +++ internal/db/querier.go | 4 + internal/household/notifications.go | 158 ++++++ internal/household/settings.go | 109 ++-- internal/server/notifications.go | 88 ++++ internal/server/notifications_test.go | 152 ++++++ internal/server/server.go | 5 +- internal/server/server_test.go | 5 +- migrations/queries/notifications.sql | 10 + web/src/App.tsx | 2 + web/src/push/subscribe.test.ts | 29 ++ web/src/push/subscribe.ts | 18 + web/src/push/usePush.ts | 9 +- web/src/settings/HouseholdSettingsPage.tsx | 16 + web/src/settings/NotificationsPage.module.css | 289 +++++++++++ web/src/settings/NotificationsPage.test.tsx | 317 ++++++++++++ web/src/settings/NotificationsPage.tsx | 481 ++++++++++++++++++ web/src/settings/SettingsPage.test.tsx | 18 +- web/src/settings/SettingsPage.tsx | 20 +- web/src/settings/SettingsTabs.test.tsx | 49 +- web/src/settings/SettingsTabs.tsx | 11 +- web/src/settings/api.ts | 46 +- web/src/settings/notificationsApi.ts | 183 +++++++ web/src/test/ApiProviders.tsx | 33 +- web/src/test/push.ts | 68 +++ 25 files changed, 2068 insertions(+), 113 deletions(-) create mode 100644 internal/household/notifications.go create mode 100644 internal/server/notifications.go create mode 100644 internal/server/notifications_test.go create mode 100644 web/src/settings/NotificationsPage.module.css create mode 100644 web/src/settings/NotificationsPage.test.tsx create mode 100644 web/src/settings/NotificationsPage.tsx create mode 100644 web/src/settings/notificationsApi.ts create mode 100644 web/src/test/push.ts diff --git a/internal/db/notifications.sql.go b/internal/db/notifications.sql.go index 3fea8f9..41d92f2 100644 --- a/internal/db/notifications.sql.go +++ b/internal/db/notifications.sql.go @@ -133,6 +133,39 @@ func (q *Queries) InsertNotification(ctx context.Context, arg InsertNotification return result.RowsAffected() } +const listNotificationPrefs = `-- name: ListNotificationPrefs :many +SELECT event, push, chat, created_at, updated_at FROM notification_prefs +` + +func (q *Queries) ListNotificationPrefs(ctx context.Context) ([]NotificationPref, error) { + rows, err := q.db.QueryContext(ctx, listNotificationPrefs) + if err != nil { + return nil, err + } + defer rows.Close() + var items []NotificationPref + for rows.Next() { + var i NotificationPref + if err := rows.Scan( + &i.Event, + &i.Push, + &i.Chat, + &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 markNotificationSent = `-- name: MarkNotificationSent :execrows UPDATE notification_outbox SET status = 'sent', attempts = attempts + 1, next_attempt_at = NULL, updated_at = ?1 @@ -200,3 +233,31 @@ func (q *Queries) RetryNotification(ctx context.Context, arg RetryNotificationPa } return result.RowsAffected() } + +const updateNotificationPref = `-- name: UpdateNotificationPref :execrows +UPDATE notification_prefs +SET push = ?, chat = ?, updated_at = ? +WHERE event = ? +` + +type UpdateNotificationPrefParams struct { + Push int64 + Chat int64 + UpdatedAt string + Event string +} + +// Sets which channels an event goes to; affects no row when the event has +// no preference row. +func (q *Queries) UpdateNotificationPref(ctx context.Context, arg UpdateNotificationPrefParams) (int64, error) { + result, err := q.db.ExecContext(ctx, updateNotificationPref, + arg.Push, + arg.Chat, + arg.UpdatedAt, + arg.Event, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/internal/db/querier.go b/internal/db/querier.go index 8d3e14c..e7d9a8c 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -176,6 +176,7 @@ type Querier interface { // Regular, and every Quest log dinner no other plan for the week starting // from_week_start or later has on any day. ListDraftPool(ctx context.Context, arg ListDraftPoolParams) ([]Dinner, error) + ListNotificationPrefs(ctx context.Context) ([]NotificationPref, error) // Every name the household turned down, oldest decision first: the name // each declined suggestion was suggested under, which renaming its dinner // does not change, and the name each dinner a Rotation check dropped @@ -300,6 +301,9 @@ type Querier interface { ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavoriteParams) (Dinner, error) // An unknown id returns sql.ErrNoRows. UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Dinner, error) + // Sets which channels an event goes to; affects no row when the event has + // no preference row. + UpdateNotificationPref(ctx context.Context, arg UpdateNotificationPrefParams) (int64, error) // Creates the single row (id 1) or replaces every column except id and // created_at. UpsertAISettings(ctx context.Context, arg UpsertAISettingsParams) (AiSetting, error) diff --git a/internal/household/notifications.go b/internal/household/notifications.go new file mode 100644 index 0000000..53811b5 --- /dev/null +++ b/internal/household/notifications.go @@ -0,0 +1,158 @@ +package household + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// NotificationPref is which channels one event's notifications go to. +type NotificationPref struct { + Event string `json:"event"` + Push bool `json:"push"` + Chat bool `json:"chat"` +} + +// NotificationSettings are the household's notification preferences: one +// NotificationPref for every event, in NotificationEvents order, and the +// day-of dinner reminder's time, which is the household settings' +// reminder_time. +type NotificationSettings struct { + Prefs []NotificationPref `json:"prefs"` + ReminderTime string `json:"reminder_time"` +} + +// NotificationEvents are the events notification_prefs holds a row for, in +// the order the Notifications screen lists them. +var NotificationEvents = func() []string { + events := make([]string, len(seedNotificationPrefs)) + for i, p := range seedNotificationPrefs { + events[i] = p.Event + } + return events +}() + +// Notifications returns the notification preferences. A missing event row is +// an error: SeedNotificationPrefs creates every one at boot. +func (s *SettingsService) Notifications(ctx context.Context) (NotificationSettings, error) { + return readNotifications(ctx, db.New(s.st.Reader)) +} + +// UpdateNotifications validates ns, which must name every event of +// NotificationEvents exactly once and a 24-hour HH:MM reminder time, and +// replaces the notification preferences and reminder_time with it in one +// transaction, recording updatedBy (which must not be empty). The new +// household settings are published to every subscriber, as Update does. An +// invalid ns is a *ValidationError and changes nothing. +func (s *SettingsService) UpdateNotifications(ctx context.Context, ns NotificationSettings, updatedBy string) (NotificationSettings, error) { + if updatedBy == "" { + return NotificationSettings{}, errors.New("household: update notifications: no member to record as updated_by") + } + if err := ns.validate(); err != nil { + return NotificationSettings{}, err + } + + s.updateMu.Lock() + defer s.updateMu.Unlock() + + var ( + row db.HouseholdSetting + out NotificationSettings + ) + err := s.st.WithTx(ctx, func(tx *sql.Tx) error { + q := db.New(tx) + now := store.Now().String() + for _, p := range ns.Prefs { + n, err := q.UpdateNotificationPref(ctx, db.UpdateNotificationPrefParams{ + Push: boolInt(p.Push), Chat: boolInt(p.Chat), UpdatedAt: now, Event: p.Event, + }) + if err != nil { + return fmt.Errorf("household: update notification preference %s: %w", p.Event, err) + } + if n != 1 { + return fmt.Errorf("household: no notification preference for %s", p.Event) + } + } + var err error + row, err = applyPatch(ctx, q, SettingsPatch{ReminderTime: &ns.ReminderTime}, updatedBy) + if err != nil { + return err + } + out, err = readNotifications(ctx, q) + return err + }) + if err != nil { + return NotificationSettings{}, err + } + + s.publish(fromRow(row)) + return out, nil +} + +func readNotifications(ctx context.Context, q *db.Queries) (NotificationSettings, error) { + rows, err := q.ListNotificationPrefs(ctx) + if err != nil { + return NotificationSettings{}, fmt.Errorf("household: read notification preferences: %w", err) + } + byEvent := make(map[string]db.NotificationPref, len(rows)) + for _, r := range rows { + byEvent[r.Event] = r + } + prefs := make([]NotificationPref, len(NotificationEvents)) + for i, event := range NotificationEvents { + r, ok := byEvent[event] + if !ok { + return NotificationSettings{}, fmt.Errorf("household: no notification preference for %s", event) + } + prefs[i] = NotificationPref{Event: event, Push: r.Push == 1, Chat: r.Chat == 1} + } + settings, err := q.GetHouseholdSettings(ctx) + if err != nil { + return NotificationSettings{}, fmt.Errorf("household: read settings: %w", err) + } + return NotificationSettings{Prefs: prefs, ReminderTime: settings.ReminderTime}, nil +} + +func (ns NotificationSettings) validate() error { + var problems []Problem + fail := func(field, reason string) { + problems = append(problems, Problem{Field: field, Reason: reason}) + } + known := make(map[string]bool, len(NotificationEvents)) + for _, event := range NotificationEvents { + known[event] = true + } + seen := make(map[string]bool, len(ns.Prefs)) + for _, p := range ns.Prefs { + switch { + case !known[p.Event]: + fail("prefs", fmt.Sprintf("unknown event %q", p.Event)) + case seen[p.Event]: + fail("prefs", fmt.Sprintf("event %q is listed more than once", p.Event)) + } + seen[p.Event] = true + } + for _, event := range NotificationEvents { + if !seen[event] { + fail("prefs", fmt.Sprintf("event %q is missing", event)) + } + } + if !clockTime.MatchString(ns.ReminderTime) { + fail("reminder_time", fmt.Sprintf("invalid time %q (want 24-hour HH:MM, 00:00-23:59)", ns.ReminderTime)) + } + if len(problems) > 0 { + return &ValidationError{Problems: problems} + } + return nil +} + +func boolInt(b bool) int64 { + if b { + return 1 + } + return 0 +} diff --git a/internal/household/settings.go b/internal/household/settings.go index 345e87a..88b8586 100644 --- a/internal/household/settings.go +++ b/internal/household/settings.go @@ -187,56 +187,9 @@ func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updat var row db.HouseholdSetting err := s.st.WithTx(ctx, func(tx *sql.Tx) error { - q := db.New(tx) - cur, err := q.GetHouseholdSettings(ctx) - if err != nil { - return fmt.Errorf("household: read settings: %w", err) - } - p := db.UpsertHouseholdSettingsParams{ - Timezone: cur.Timezone, - DinnerTime: cur.DinnerTime, - ReminderTime: cur.ReminderTime, - ReviewTime: cur.ReviewTime, - PlanDraftDow: cur.PlanDraftDow, - PlanDraftTime: cur.PlanDraftTime, - SuggestionsDow: cur.SuggestionsDow, - SuggestionsTime: cur.SuggestionsTime, - ChatAutosendDow: cur.ChatAutosendDow, - ChatAutosendTime: cur.ChatAutosendTime, - OnboardingCompletedAt: cur.OnboardingCompletedAt, - UpdatesSeenAt: cur.UpdatesSeenAt, - UpdatedBy: sql.NullString{String: updatedBy, Valid: true}, - CreatedAt: cur.CreatedAt, - UpdatedAt: store.Now().String(), - } - setString(&p.Timezone, patch.Timezone) - setString(&p.DinnerTime, patch.DinnerTime) - setString(&p.ReminderTime, patch.ReminderTime) - setString(&p.ReviewTime, patch.ReviewTime) - setDow(&p.PlanDraftDow, patch.PlanDraftDow) - setString(&p.PlanDraftTime, patch.PlanDraftTime) - setDow(&p.SuggestionsDow, patch.SuggestionsDow) - setString(&p.SuggestionsTime, patch.SuggestionsTime) - setDow(&p.ChatAutosendDow, patch.ChatAutosendDow) - setString(&p.ChatAutosendTime, patch.ChatAutosendTime) - if patch.OnboardingCompleted != nil && !p.OnboardingCompletedAt.Valid { - n, err := q.CountRegularDinners(ctx) - if err != nil { - return fmt.Errorf("household: count Regulars: %w", err) - } - if n < MinRegulars { - return &ValidationError{Problems: []Problem{{ - Field: "onboarding_completed", - Reason: fmt.Sprintf("add at least %d Regulars first (have %d)", MinRegulars, n), - }}} - } - p.OnboardingCompletedAt = sql.NullString{String: p.UpdatedAt, Valid: true} - } - row, err = q.UpsertHouseholdSettings(ctx, p) - if err != nil { - return fmt.Errorf("household: update settings: %w", err) - } - return nil + var err error + row, err = applyPatch(ctx, db.New(tx), patch, updatedBy) + return err }) if err != nil { return Settings{}, err @@ -247,8 +200,62 @@ func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updat return settings, nil } +// applyPatch applies a validated patch to the household settings row in q's +// transaction, recording updatedBy, and returns the new row. +func applyPatch(ctx context.Context, q *db.Queries, patch SettingsPatch, updatedBy string) (db.HouseholdSetting, error) { + cur, err := q.GetHouseholdSettings(ctx) + if err != nil { + return db.HouseholdSetting{}, fmt.Errorf("household: read settings: %w", err) + } + p := db.UpsertHouseholdSettingsParams{ + Timezone: cur.Timezone, + DinnerTime: cur.DinnerTime, + ReminderTime: cur.ReminderTime, + ReviewTime: cur.ReviewTime, + PlanDraftDow: cur.PlanDraftDow, + PlanDraftTime: cur.PlanDraftTime, + SuggestionsDow: cur.SuggestionsDow, + SuggestionsTime: cur.SuggestionsTime, + ChatAutosendDow: cur.ChatAutosendDow, + ChatAutosendTime: cur.ChatAutosendTime, + OnboardingCompletedAt: cur.OnboardingCompletedAt, + UpdatesSeenAt: cur.UpdatesSeenAt, + UpdatedBy: sql.NullString{String: updatedBy, Valid: true}, + CreatedAt: cur.CreatedAt, + UpdatedAt: store.Now().String(), + } + setString(&p.Timezone, patch.Timezone) + setString(&p.DinnerTime, patch.DinnerTime) + setString(&p.ReminderTime, patch.ReminderTime) + setString(&p.ReviewTime, patch.ReviewTime) + setDow(&p.PlanDraftDow, patch.PlanDraftDow) + setString(&p.PlanDraftTime, patch.PlanDraftTime) + setDow(&p.SuggestionsDow, patch.SuggestionsDow) + setString(&p.SuggestionsTime, patch.SuggestionsTime) + setDow(&p.ChatAutosendDow, patch.ChatAutosendDow) + setString(&p.ChatAutosendTime, patch.ChatAutosendTime) + if patch.OnboardingCompleted != nil && !p.OnboardingCompletedAt.Valid { + n, err := q.CountRegularDinners(ctx) + if err != nil { + return db.HouseholdSetting{}, fmt.Errorf("household: count Regulars: %w", err) + } + if n < MinRegulars { + return db.HouseholdSetting{}, &ValidationError{Problems: []Problem{{ + Field: "onboarding_completed", + Reason: fmt.Sprintf("add at least %d Regulars first (have %d)", MinRegulars, n), + }}} + } + p.OnboardingCompletedAt = sql.NullString{String: p.UpdatedAt, Valid: true} + } + row, err := q.UpsertHouseholdSettings(ctx, p) + if err != nil { + return db.HouseholdSetting{}, fmt.Errorf("household: update settings: %w", err) + } + return row, nil +} + // Subscribe registers fn to receive the new settings after every successful -// Update. fn runs synchronously on the updating goroutine, so it must return +// Update and UpdateNotifications. fn runs synchronously on the updating goroutine, so it must return // promptly and must not call Update. The returned function unsubscribes fn. func (s *SettingsService) Subscribe(fn func(Settings)) (unsubscribe func()) { s.subsMu.Lock() diff --git a/internal/server/notifications.go b/internal/server/notifications.go new file mode 100644 index 0000000..db3946f --- /dev/null +++ b/internal/server/notifications.go @@ -0,0 +1,88 @@ +package server + +import ( + "errors" + "fmt" + "log/slog" + "net/http" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" +) + +// notificationsBody is the PUT /settings/notifications body: every event's +// channels and the reminder time, all required. Unknown fields and null +// values are refused. +type notificationsBody struct { + Prefs *[]notificationPrefBody `json:"prefs"` + ReminderTime *string `json:"reminder_time"` +} + +type notificationPrefBody struct { + Event *string `json:"event"` + Push *bool `json:"push"` + Chat *bool `json:"chat"` +} + +// settings is the body as household notification settings, or why it is +// incomplete. +func (b notificationsBody) settings() (household.NotificationSettings, error) { + if b.Prefs == nil || b.ReminderTime == nil { + return household.NotificationSettings{}, errors.New("prefs and reminder_time are required") + } + prefs := make([]household.NotificationPref, len(*b.Prefs)) + for i, p := range *b.Prefs { + if p.Event == nil || p.Push == nil || p.Chat == nil { + return household.NotificationSettings{}, fmt.Errorf("prefs[%d]: event, push and chat are required", i) + } + prefs[i] = household.NotificationPref{Event: *p.Event, Push: *p.Push, Chat: *p.Chat} + } + return household.NotificationSettings{Prefs: prefs, ReminderTime: *b.ReminderTime}, nil +} + +func getNotificationSettings(logger *slog.Logger, svc *household.SettingsService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + settings, err := svc.Notifications(r.Context()) + if err != nil { + logger.ErrorContext(r.Context(), "get notification settings", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + writeJSON(w, r, logger, http.StatusOK, settings) + } +} + +// putNotificationSettings replaces the notification preferences and the +// reminder time, recording the session's actor subject as updated_by. +func putNotificationSettings(logger *slog.Logger, svc *household.SettingsService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + actor, ok := auth.ActorFrom(r.Context()) + if !ok { + logger.ErrorContext(r.Context(), "PUT /api/v1/settings/notifications without a session") + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + body, err := decodePatch[notificationsBody](http.MaxBytesReader(w, r.Body, maxSettingsBody)) + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + want, err := body.settings() + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + settings, err := svc.UpdateNotifications(r.Context(), want, actor.Subject) + var invalid *household.ValidationError + if errors.As(err, &invalid) { + writeError(w, r, logger, http.StatusBadRequest, "invalid_settings", invalid.Error()) + return + } + if err != nil { + logger.ErrorContext(r.Context(), "update notification settings", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + writeJSON(w, r, logger, http.StatusOK, settings) + } +} diff --git a/internal/server/notifications_test.go b/internal/server/notifications_test.go new file mode 100644 index 0000000..f042f7c --- /dev/null +++ b/internal/server/notifications_test.go @@ -0,0 +1,152 @@ +package server + +import ( + "net/http" + "reflect" + "strings" + "testing" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" +) + +const notificationsPath = "/api/v1/settings/notifications" + +// seedPrefs is the seed notification preferences as GET answers them. +var seedPrefs = []any{ + map[string]any{"event": "plan_ready", "push": true, "chat": true}, + map[string]any{"event": "list_ready", "push": true, "chat": true}, + map[string]any{"event": "day_of_reminder", "push": true, "chat": false}, + map[string]any{"event": "new_suggestions", "push": true, "chat": false}, + map[string]any{"event": "rotation_review", "push": true, "chat": false}, +} + +func TestGetNotificationSettings(t *testing.T) { + t.Parallel() + router := newTestRouterWith(t, "dev", newTestSettings(t), newTestRunner(t)) + + rec, body := serve(t, router, signedIn(http.MethodGet, notificationsPath, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body) + } + want := map[string]any{"prefs": seedPrefs, "reminder_time": "16:30"} + if !reflect.DeepEqual(body, want) { + t.Errorf("body = %v, want %v", body, want) + } +} + +const allChanged = `{"prefs":[ + {"event":"plan_ready","push":false,"chat":true}, + {"event":"list_ready","push":true,"chat":false}, + {"event":"day_of_reminder","push":true,"chat":true}, + {"event":"new_suggestions","push":false,"chat":false}, + {"event":"rotation_review","push":false,"chat":true} +],"reminder_time":"15:45"}` + +func TestPutNotificationSettings(t *testing.T) { + t.Parallel() + settings := newTestSettings(t) + router := newTestRouterWith(t, "dev", settings, newTestRunner(t)) + + var notified []household.Settings + settings.Subscribe(func(s household.Settings) { notified = append(notified, s) }) + + // The events come in any order and are answered in screen order. + rec, body := serve(t, router, signedIn(http.MethodPut, notificationsPath, strings.NewReader(`{"reminder_time":"15:45","prefs":[ + {"event":"rotation_review","push":false,"chat":true}, + {"event":"new_suggestions","push":false,"chat":false}, + {"event":"day_of_reminder","push":true,"chat":true}, + {"event":"list_ready","push":true,"chat":false}, + {"event":"plan_ready","push":false,"chat":true} + ]}`))) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body) + } + want := map[string]any{ + "prefs": []any{ + map[string]any{"event": "plan_ready", "push": false, "chat": true}, + map[string]any{"event": "list_ready", "push": true, "chat": false}, + map[string]any{"event": "day_of_reminder", "push": true, "chat": true}, + map[string]any{"event": "new_suggestions", "push": false, "chat": false}, + map[string]any{"event": "rotation_review", "push": false, "chat": true}, + }, + "reminder_time": "15:45", + } + if !reflect.DeepEqual(body, want) { + t.Errorf("PUT body = %v, want %v", body, want) + } + if _, got := serve(t, router, signedIn(http.MethodGet, notificationsPath, nil)); !reflect.DeepEqual(got, want) { + t.Errorf("GET after PUT = %v, want %v", got, want) + } + + // reminder_time is the household setting: there is one copy of it. + _, hs := serve(t, router, signedIn(http.MethodGet, settingsPath, nil)) + if hs["reminder_time"] != "15:45" || hs["updated_by"] != testSession.Subject { + t.Errorf("household settings after PUT = %v, want reminder_time 15:45 updated by %s", hs, testSession.Subject) + } + if len(notified) != 1 || notified[0].ReminderTime != "15:45" { + t.Errorf("subscriber notified with %+v, want one change to 15:45", notified) + } + + // A household settings change to reminder_time is what GET answers. + if rec, _ := serve(t, router, signedIn(http.MethodPatch, settingsPath, strings.NewReader(`{"reminder_time":"17:05"}`))); rec.Code != http.StatusOK { + t.Fatalf("PATCH household status = %d: %s", rec.Code, rec.Body) + } + if _, got := serve(t, router, signedIn(http.MethodGet, notificationsPath, nil)); got["reminder_time"] != "17:05" { + t.Errorf("GET reminder_time after PATCH household = %v, want 17:05", got["reminder_time"]) + } +} + +func TestPutNotificationSettingsRejected(t *testing.T) { + t.Parallel() + settings := newTestSettings(t) + router := newTestRouterWith(t, "dev", settings, newTestRunner(t)) + + const four = `{"event":"plan_ready","push":true,"chat":true}, + {"event":"list_ready","push":true,"chat":true}, + {"event":"day_of_reminder","push":true,"chat":true}, + {"event":"new_suggestions","push":true,"chat":true}` + for _, tc := range []struct { + name, body, code, message string + }{ + {"bad time", strings.Replace(allChanged, "15:45", "24:00", 1), "invalid_settings", `reminder_time: invalid time "24:00"`}, + {"unpadded time", strings.Replace(allChanged, "15:45", "9:00", 1), "invalid_settings", `reminder_time: invalid time "9:00"`}, + {"missing event", `{"prefs":[` + four + `],"reminder_time":"16:30"}`, "invalid_settings", `prefs: event "rotation_review" is missing`}, + {"unknown event", `{"prefs":[` + four + `,{"event":"rotation_review","push":true,"chat":true},{"event":"weather","push":true,"chat":true}],"reminder_time":"16:30"}`, "invalid_settings", `prefs: unknown event "weather"`}, + {"duplicate event", `{"prefs":[` + four + `,{"event":"plan_ready","push":true,"chat":true}],"reminder_time":"16:30"}`, "invalid_settings", `prefs: event "plan_ready" is listed more than once`}, + {"unknown field", strings.Replace(allChanged, `"reminder_time"`, `"review_time":"09:00","reminder_time"`, 1), "invalid_request", `unknown field "review_time"`}, + {"unknown pref field", strings.Replace(allChanged, `"chat":true}`, `"chat":true,"email":true}`, 1), "invalid_request", `unknown field "email"`}, + {"missing reminder_time", `{"prefs":[]}`, "invalid_request", "prefs and reminder_time are required"}, + {"missing channel", strings.Replace(allChanged, `"push":false,"chat":true}`, `"push":false}`, 1), "invalid_request", "prefs[0]: event, push and chat are required"}, + {"null channel", strings.Replace(allChanged, `"chat":true}`, `"chat":null}`, 1), "invalid_request", "prefs[0]: event, push and chat are required"}, + {"null field", `{"prefs":null,"reminder_time":"16:30"}`, "invalid_request", "prefs: must not be null"}, + {"wrong type", strings.Replace(allChanged, `"push":false`, `"push":"no"`, 1), "invalid_request", "want a JSON bool"}, + } { + t.Run(tc.name, func(t *testing.T) { + rec, body := serve(t, router, signedIn(http.MethodPut, notificationsPath, strings.NewReader(tc.body))) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400: %s", rec.Code, rec.Body) + } + e, _ := body["error"].(map[string]any) + if e["code"] != tc.code || !strings.Contains(e["message"].(string), tc.message) { + t.Errorf("error = %v, want code %s with %q", e, tc.code, tc.message) + } + }) + } + + // Nothing was changed. + _, got := serve(t, router, signedIn(http.MethodGet, notificationsPath, nil)) + if want := (map[string]any{"prefs": seedPrefs, "reminder_time": "16:30"}); !reflect.DeepEqual(got, want) { + t.Errorf("GET after rejected PUTs = %v, want the seed %v", got, want) + } +} + +func TestPutNotificationSettingsNeedsCSRF(t *testing.T) { + t.Parallel() + router := newTestRouterWith(t, "dev", newTestSettings(t), newTestRunner(t)) + + req := signedIn(http.MethodPut, notificationsPath, strings.NewReader(allChanged)) + req.Header.Del(CSRFHeader) + if rec, _ := serve(t, router, req); rec.Code != http.StatusForbidden { + t.Errorf("status = %d, want 403: %s", rec.Code, rec.Body) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 9413989..1464b69 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -38,7 +38,8 @@ type RouteMounter interface { // has its client address and scheme resolved, honouring forwarded headers // only from a peer in trusted (see resolveOrigin). version is reported by // GET /health; auth mounts the /auth routes; sessions guards /api/v1 and -// /media; settings serves /api/v1/settings/household; search serves +// /media; settings serves /api/v1/settings/household and +// /api/v1/settings/notifications; search serves // /api/v1/settings/recipe-search and its /test; ai serves // /api/v1/settings/ai, its /api-key, /verify, /models and /reset-prompt; // chatSvc serves GET and PUT /api/v1/settings/chat-webhook and its /test; @@ -130,6 +131,8 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a apiV1.Get("/me", me(logger)) apiV1.Get("/settings/household", getHouseholdSettings(logger, settings)) apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings)) + apiV1.Get("/settings/notifications", getNotificationSettings(logger, settings)) + apiV1.Put("/settings/notifications", putNotificationSettings(logger, settings)) apiV1.Get("/settings/recipe-search", getRecipeSearchSettings(logger, search)) apiV1.Patch("/settings/recipe-search", patchRecipeSearchSettings(logger, search)) apiV1.Post("/settings/recipe-search/test", postRecipeSearchTest(logger, search)) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index acf75cb..a67a081 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -107,13 +107,16 @@ func newTestStore(t *testing.T) *store.Store { } // newTestSettings returns a settings service over a migrated temp database -// seeded with zone America/New_York. +// seeded with zone America/New_York and the seed notification preferences. func newTestSettings(t *testing.T) *household.SettingsService { t.Helper() st := newTestStore(t) if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil { t.Fatal(err) } + if err := household.SeedNotificationPrefs(context.Background(), st); err != nil { + t.Fatal(err) + } return household.NewSettingsService(st) } diff --git a/migrations/queries/notifications.sql b/migrations/queries/notifications.sql index bda9d0b..c9deb42 100644 --- a/migrations/queries/notifications.sql +++ b/migrations/queries/notifications.sql @@ -56,3 +56,13 @@ SELECT next_attempt_at FROM notification_outbox WHERE status = 'pending' ORDER BY next_attempt_at LIMIT 1; + +-- name: ListNotificationPrefs :many +SELECT * FROM notification_prefs; + +-- name: UpdateNotificationPref :execrows +-- Sets which channels an event goes to; affects no row when the event has +-- no preference row. +UPDATE notification_prefs +SET push = ?, chat = ?, updated_at = ? +WHERE event = ?; diff --git a/web/src/App.tsx b/web/src/App.tsx index 59c2b85..8d85ba6 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -13,6 +13,7 @@ import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx' import { AISettingsPage } from './settings/AISettingsPage.tsx' import { CalendarSettingsPage } from './settings/CalendarSettingsPage.tsx' import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx' +import { NotificationsPage } from './settings/NotificationsPage.tsx' import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx' import { SettingsPage } from './settings/SettingsPage.tsx' import { AppShell } from './shell/AppShell.tsx' @@ -46,6 +47,7 @@ const screens: Partial> = { '/plan/shopping/archive': , '/discover': , '/settings': , + '/settings/notifications': , '/settings/ai': , '/settings/household': , '/settings/regulars': , diff --git a/web/src/push/subscribe.test.ts b/web/src/push/subscribe.test.ts index 5d26de3..42d7a73 100644 --- a/web/src/push/subscribe.test.ts +++ b/web/src/push/subscribe.test.ts @@ -6,6 +6,7 @@ import { decodeVapidKey, encodeBase64url, PermissionGestureError, + pushStatus, subscribePush, subscriptionBody, unsubscribePush, @@ -306,3 +307,31 @@ describe('unsubscribePush', () => { await expect(unsubscribePush(api, env)).rejects.toThrow(/did not remove its push subscription/) }) }) + +describe('pushStatus', () => { + it('is on with permission granted and a subscription', async () => { + const { env } = setup({ permission: 'granted', current: subscription() }) + expect(await pushStatus(env)).toBe('on') + }) + + it.each([ + ['not subscribed', { permission: 'granted' as const }], + ['permission not yet asked', { permission: 'default' as const }], + ['no service worker registered', { permission: 'granted' as const, registered: false }], + ])('is off when %s', async (_, options) => { + const { env, notification } = setup(options) + expect(await pushStatus(env)).toBe('off') + expect(notification.requestPermission).not.toHaveBeenCalled() + }) + + it('is denied when the browser blocks notifications', async () => { + const { env } = setup({ permission: 'denied', current: subscription() }) + expect(await pushStatus(env)).toBe('denied') + }) + + it('is unsupported without Web Push', async () => { + const { env } = setup() + expect(await pushStatus({ ...env, pushManager: false })).toBe('unsupported') + expect(await pushStatus({ ...env, notification: undefined })).toBe('unsupported') + }) +}) diff --git a/web/src/push/subscribe.ts b/web/src/push/subscribe.ts index 7bd182e..a14b799 100644 --- a/web/src/push/subscribe.ts +++ b/web/src/push/subscribe.ts @@ -19,6 +19,11 @@ export type SubscribePushResult = 'subscribed' | 'denied' | 'dismissed' | 'unsup // to remove, or a browser without Web Push. Any other failure rejects. export type UnsubscribePushResult = 'unsubscribed' | 'not-subscribed' | 'unsupported' +// PushStatus is whether Web Push is on for this device: subscribed with +// permission granted, off (not subscribed, or permission not yet given), +// blocked by the browser's permission, or a browser without Web Push. +export type PushStatus = 'on' | 'off' | 'denied' | 'unsupported' + // The parts of the browser's Push API this module uses. export type BrowserPushSubscription = { readonly endpoint: string @@ -207,3 +212,16 @@ export async function unsubscribePush(api: PushApi, env: PushEnvironment): Promi await removeSubscription(api, subscription) return 'unsubscribed' } + +// pushStatus reads whether Web Push is on for this device, without asking for +// permission or calling the server. A page with no service worker registered +// is off: subscribePush then says why it cannot turn on. +export async function pushStatus(env: PushEnvironment): Promise { + const { serviceWorker, notification } = env + if (serviceWorker === undefined || !env.pushManager || notification === undefined) return 'unsupported' + if (notification.permission === 'denied') return 'denied' + const found = await serviceWorker.getRegistration() + if (found === undefined) return 'off' + const subscription = await found.pushManager.getSubscription() + return subscription !== null && notification.permission === 'granted' ? 'on' : 'off' +} diff --git a/web/src/push/usePush.ts b/web/src/push/usePush.ts index 65078fd..6590083 100644 --- a/web/src/push/usePush.ts +++ b/web/src/push/usePush.ts @@ -2,25 +2,30 @@ import { useMemo } from 'react' import { useApi } from '../api/ApiContext.tsx' import { browserPushEnvironment, + pushStatus, subscribePush, unsubscribePush, + type PushStatus, type SubscribePushResult, type UnsubscribePushResult, } from './subscribe.ts' export type Push = { + // pushStatus reads whether Web Push is on for this device. + pushStatus(): Promise // subscribePush turns on Web Push on this device; call it only from a tap. subscribePush(): Promise // unsubscribePush turns off Web Push on this device. unsubscribePush(): Promise } -// usePush binds subscribePush and unsubscribePush to the app's API client -// and this browser. +// usePush binds pushStatus, subscribePush and unsubscribePush to the app's +// API client and this browser. export function usePush(): Push { const api = useApi() return useMemo( () => ({ + pushStatus: () => pushStatus(browserPushEnvironment()), subscribePush: () => subscribePush(api, browserPushEnvironment()), unsubscribePush: () => unsubscribePush(api, browserPushEnvironment()), }), diff --git a/web/src/settings/HouseholdSettingsPage.tsx b/web/src/settings/HouseholdSettingsPage.tsx index 34907fd..9143f6e 100644 --- a/web/src/settings/HouseholdSettingsPage.tsx +++ b/web/src/settings/HouseholdSettingsPage.tsx @@ -91,8 +91,24 @@ type HouseholdSettingsFormProps = HouseholdSettingsProps & { saved: HouseholdSettings } +// rebase moves draft from before to saved, the settings now stored: a field +// the household has not edited takes its stored value, so a change saved +// elsewhere (the Notifications screen's reminder time) is shown, not undone. +function rebase(before: HouseholdSettings, saved: HouseholdSettings, draft: HouseholdSettings): HouseholdSettings { + const next: Record = { ...draft } + for (const field of Object.keys(saved) as (keyof HouseholdSettings)[]) { + if (draft[field] === before[field]) next[field] = saved[field] + } + return next as HouseholdSettings +} + function HouseholdSettingsForm({ saved, onDirtyChange }: HouseholdSettingsFormProps) { const [draft, setDraft] = useState(saved) + const [base, setBase] = useState(saved) + if (base !== saved) { + setBase(saved) + setDraft(rebase(base, saved, draft)) + } const save = useSaveHouseholdSettings() const patch = patchOf(saved, draft) const dirty = Object.keys(patch).length > 0 diff --git a/web/src/settings/NotificationsPage.module.css b/web/src/settings/NotificationsPage.module.css new file mode 100644 index 0000000..6af81c6 --- /dev/null +++ b/web/src/settings/NotificationsPage.module.css @@ -0,0 +1,289 @@ +/* Notifications & Google Chat, handoff mock 1n; its cards also sit in the + desktop settings' left column (mock 2e). */ +.page { + box-sizing: border-box; + max-width: 560px; + display: flex; + flex-direction: column; + gap: 22px; + padding-top: 6px; +} + +.header { + display: flex; + align-items: center; + gap: 16px; +} + +.title { + margin: 0; + font-family: var(--font-heading); + font-size: 32px; + font-weight: 400; +} + +.loading { + height: 260px; + border-radius: var(--radius-lg); +} + +.card { + display: flex; + flex-direction: column; + gap: 14px; +} + +.cardTitle { + margin: 0; + font-family: var(--font-heading); + font-size: 22px; + font-weight: 400; +} + +.cardLoading { + height: 120px; + border-radius: var(--radius-lg); +} + +/* Event | PUSH | CHAT, the channel columns 46 px wide. */ +.matrix { + display: grid; + grid-template-columns: 1fr 46px 46px; + align-items: center; + row-gap: 18px; +} + +.head { + font-family: var(--font-body); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-neutral-700); +} + +.head:not(:first-child) { + justify-self: center; +} + +.event { + display: flex; + flex-direction: column; + align-items: flex-start; + min-width: 0; +} + +.eventName { + font-family: var(--font-body); + font-size: 16px; + font-weight: 700; +} + +.toggle { + justify-self: center; +} + +/* The reminder time: tappable text with a 44 px hit box. */ +.time { + min-width: 44px; + min-height: 44px; + margin: -10px 0 -12px; + padding: 0; + border: 0; + background: transparent; + color: var(--color-neutral-700); + font-family: var(--font-body); + font-size: 14px; + text-align: left; + text-decoration: underline dotted; + text-underline-offset: 3px; + cursor: pointer; +} + +.time:disabled { + cursor: not-allowed; +} + +.permission { + display: flex; + flex-direction: column; + gap: 8px; +} + +.permissionRow { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 10px; + min-height: 52px; + padding: 6px 6px 6px 18px; + border-radius: 20px; + background: var(--color-neutral-100); +} + +.permissionText { + margin: 0; + font-family: var(--font-body); + font-size: 14px; +} + +.permissionRow button { + min-height: 44px; +} + +.statusSkeleton { + display: inline-block; + width: 3em; + height: 1em; + vertical-align: middle; +} + +.input { + background: var(--color-bg); +} + +.mono { + overflow: hidden; + font-family: var(--font-mono); + font-size: 14px; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* The status shares its line with Send test, wrapping its own text first. */ +.webhookRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.webhookRow .connected { + flex: 1; + min-width: 0; +} + +.webhookRow button { + flex: none; +} + +.webhookRow button, +.webhookActions button { + min-height: 48px; +} + +.webhookActions { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.connected, +.testOk { + margin: 0; + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; + color: var(--color-accent-2-800); +} + +.note, +.error { + margin: 0; + font-family: var(--font-body); + font-size: 13px; + line-height: 1.4; +} + +.note { + color: var(--color-neutral-700); +} + +.error { + font-weight: 700; + color: var(--color-accent-700); +} + +.link { + align-self: flex-start; + min-width: 44px; + min-height: 44px; + padding: 0 4px; + border: 0; + background: transparent; + color: var(--color-accent-700); + font-family: var(--font-body); + font-size: 13px; + font-weight: 700; + text-decoration: underline; + cursor: pointer; +} + +/* The webhook form while its save is pending; it lays out as if it were + not there. */ +.lock { + display: contents; +} + +.preview { + display: flex; + gap: 12px; + margin: 0; + padding: 14px 16px; + border-radius: 20px; + background: var(--color-neutral-100); +} + +.avatar { + flex: none; + display: grid; + place-items: center; + width: 32px; + height: 32px; + border-radius: var(--radius-pill); + background: var(--color-accent); + color: var(--on-accent); + font-family: var(--font-heading); + font-size: 16px; +} + +.previewText { + margin: 0; + font-family: var(--font-body); + font-size: 14px; + line-height: 1.5; +} + +.sheet { + display: flex; + flex-direction: column; + gap: 14px; + max-width: 560px; + margin: 0 auto; +} + +.sheetTitle { + margin: 0; + font-family: var(--font-heading); + font-size: 22px; + font-weight: 400; +} + +.sheetText { + margin: 0; + font-family: var(--font-body); + font-size: 14px; + line-height: 1.45; + color: var(--color-neutral-700); +} + +.sheetActions { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.sheetActions button { + min-height: 48px; +} diff --git a/web/src/settings/NotificationsPage.test.tsx b/web/src/settings/NotificationsPage.test.tsx new file mode 100644 index 0000000..d48dc33 --- /dev/null +++ b/web/src/settings/NotificationsPage.test.tsx @@ -0,0 +1,317 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' +import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' +import { createNetworkStatus } from '../pwa/networkStatus.ts' +import { CHAT_WEBHOOK, CHAT_WEBHOOK_NONE, NOTIFICATION_SETTINGS } from '../test/ApiProviders.tsx' +import { InDataRouter } from '../test/dataRouter.tsx' +import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' +import { expectMinHitArea } from '../test/hitArea.ts' +import { PUSH_ENDPOINT, restorePushEnvironment, stubPushEnvironment, VAPID_PUBLIC_KEY } from '../test/push.ts' +import { NotificationsPage } from './NotificationsPage.tsx' + +type Responder = (body: unknown) => Response + +// stubAPI answers "METHOD path" from responders, each called with the +// request's decoded JSON body; the settings the page loads answer their +// fixtures unless overridden. Any other request fails the test. +function stubAPI(responders: Record = {}): Mock { + const all: Record = { + 'GET /api/v1/me': () => jsonResponse(200, ME), + 'GET /api/v1/settings/notifications': () => jsonResponse(200, NOTIFICATION_SETTINGS), + 'GET /api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK), + ...responders, + } + const fetchMock = vi.fn((input, init) => { + const key = `${init?.method ?? 'GET'} ${String(input)}` + const respond = Object.hasOwn(all, key) ? all[key] : undefined + if (respond === undefined) throw new Error(`unexpected fetch: ${key}`) + const body = typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined + return Promise.resolve(respond(body)) + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +// bodiesOf is the decoded JSON body of every "METHOD path" request sent. +function bodiesOf(fetchMock: Mock, method: string, path: string): unknown[] { + return fetchMock.mock.calls + .filter(([input, init]) => String(input) === path && (init?.method ?? 'GET') === method) + .map(([, init]) => JSON.parse(String(init?.body)) as unknown) +} + +afterEach(() => { + vi.unstubAllGlobals() + restorePushEnvironment() +}) + +async function renderPage() { + const api = createApiClient( + fakeLocation('/settings/notifications'), + createNetworkStatus(window), + createVerifiedSession(localStorage), + ) + await api.loadMe() + render( + + + + + + + , + ) +} + +const toggle = (name: string) => screen.getByRole('switch', { name }) + +// withChange is the seed notification settings with one change. +function withChange(event: string, channel: 'push' | 'chat', on: boolean) { + return { + ...NOTIFICATION_SETTINGS, + prefs: NOTIFICATION_SETTINGS.prefs.map((p) => (p.event === event ? { ...p, [channel]: on } : p)), + } +} + +describe('NotificationsPage (mock 1n)', () => { + it('shows the back button, the title and the five events in order with their channels', async () => { + stubAPI() + await renderPage() + expect(screen.getByRole('button', { name: 'Back to Settings' })).toBeInTheDocument() + expect(screen.getByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument() + + const matrix = await screen.findByRole('group', { name: 'Notification channels' }) + expect( + Array.from(matrix.children) + .slice(0, 3) + .map((cell) => cell.textContent), + ).toEqual(['Event', 'Push', 'Chat']) + const switches = within(matrix).getAllByRole('switch') + expect(switches.map((s) => s.getAttribute('aria-label'))).toEqual([ + 'New weekly plan ready: Push', + 'New weekly plan ready: Google Chat', + 'Shopping list ready: Push', + 'Shopping list ready: Google Chat', + 'Day-of dinner reminder: Push', + 'Day-of dinner reminder: Google Chat', + 'New suggestions to swipe: Push', + 'New suggestions to swipe: Google Chat', + 'Keep it in rotation?: Push', + 'Keep it in rotation?: Google Chat', + ]) + expect(switches.map((s) => s.getAttribute('aria-checked'))).toEqual([ + 'true', 'true', 'true', 'true', 'true', 'false', 'true', 'false', 'true', 'false', + ]) + switches.forEach(expectMinHitArea) + const time = within(matrix).getByRole('button', { name: 'Reminder time, 4:30 pm. Change' }) + expect(time).toHaveTextContent('at 4:30 pm') + expectMinHitArea(time) + }) + + it('saves a toggled channel with every other event unchanged', async () => { + const fetchMock = stubAPI({ + 'PUT /api/v1/settings/notifications': (body) => jsonResponse(200, body), + }) + await renderPage() + fireEvent.click(await screen.findByRole('switch', { name: 'Keep it in rotation?: Google Chat' })) + + await waitFor(() => expect(toggle('Keep it in rotation?: Google Chat')).toHaveAttribute('aria-checked', 'true')) + expect(bodiesOf(fetchMock, 'PUT', '/api/v1/settings/notifications')).toEqual([ + withChange('rotation_review', 'chat', true), + ]) + await waitFor(() => expect(toggle('Keep it in rotation?: Google Chat')).toBeEnabled()) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('shows the stored channels and why when a save fails, and retries it', async () => { + let fail = true + const fetchMock = stubAPI({ + 'PUT /api/v1/settings/notifications': (body) => + fail ? errorResponse(500, 'internal') : jsonResponse(200, body), + }) + await renderPage() + fireEvent.click(await screen.findByRole('switch', { name: 'New weekly plan ready: Push' })) + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent('Notification settings were not saved') + expect(toggle('New weekly plan ready: Push')).toHaveAttribute('aria-checked', 'true') + + fail = false + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(toggle('New weekly plan ready: Push')).toHaveAttribute('aria-checked', 'false')) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(bodiesOf(fetchMock, 'PUT', '/api/v1/settings/notifications')).toEqual([ + withChange('plan_ready', 'push', false), + withChange('plan_ready', 'push', false), + ]) + }) + + it('edits the reminder time in a sheet', async () => { + const fetchMock = stubAPI({ + 'PUT /api/v1/settings/notifications': (body) => jsonResponse(200, body), + }) + await renderPage() + fireEvent.click(await screen.findByRole('button', { name: 'Reminder time, 4:30 pm. Change' })) + + const sheet = screen.getByRole('dialog', { name: 'Day-of dinner reminder' }) + const input = within(sheet).getByLabelText('Reminder time') + expect(input).toHaveValue('16:30') + expect(within(sheet).getByRole('button', { name: 'Save' })).toBeDisabled() + fireEvent.change(input, { target: { value: '18:15' } }) + fireEvent.click(within(sheet).getByRole('button', { name: 'Save' })) + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(screen.getByRole('button', { name: 'Reminder time, 6:15 pm. Change' })).toHaveTextContent('at 6:15 pm') + expect(bodiesOf(fetchMock, 'PUT', '/api/v1/settings/notifications')).toEqual([ + { ...NOTIFICATION_SETTINGS, reminder_time: '18:15' }, + ]) + }) + + it('keeps the reminder sheet open and says why when the time is refused', async () => { + stubAPI({ + 'PUT /api/v1/settings/notifications': () => errorResponse(400, 'invalid_settings'), + }) + await renderPage() + fireEvent.click(await screen.findByRole('button', { name: 'Reminder time, 4:30 pm. Change' })) + const sheet = screen.getByRole('dialog', { name: 'Day-of dinner reminder' }) + fireEvent.change(within(sheet).getByLabelText('Reminder time'), { target: { value: '07:00' } }) + fireEvent.click(within(sheet).getByRole('button', { name: 'Save' })) + + expect(await within(sheet).findByRole('alert')).toHaveTextContent('The reminder time was not saved') + expect(screen.getByRole('button', { name: 'Reminder time, 4:30 pm. Change' })).toBeInTheDocument() + }) + + it('shows the preview bubble', async () => { + stubAPI() + await renderPage() + const preview = screen.getByRole('figure', { name: 'Google Chat message preview' }) + expect(preview).toHaveTextContent('MealQuest · preview') + expect(preview).toHaveTextContent( + "This week's plan is ready: Tacos, Bolognese, Tikka masala + 4 more. Open the plan →", + ) + }) +}) + +describe('NotificationsPage Google Chat webhook', () => { + it('shows the masked URL and the connected space', async () => { + stubAPI() + await renderPage() + const card = screen.getByRole('region', { name: 'Google Chat webhook' }) + expect(await within(card).findByRole('textbox', { name: 'Webhook URL' })).toHaveValue(CHAT_WEBHOOK.url_masked) + expect(within(card).getByRole('textbox', { name: 'Webhook URL' })).toHaveAttribute('readonly') + expect(within(card).getByText('Connected · Dinner Crew space')).toBeInTheDocument() + }) + + it.each([ + ['success', () => jsonResponse(200, { http_status: 200, ...CHAT_WEBHOOK, last_test_status: 'ok' }), 'Test message sent.'], + [ + 'the HTTP status Google Chat refused it with', + () => jsonResponse(200, { http_status: 403, ...CHAT_WEBHOOK, last_test_status: 'failed' }), + 'Google Chat refused the test message: HTTP 403.', + ], + [ + 'why it was not sent', + () => errorResponse(502, 'chat_unreachable'), + 'Test message not sent', + ], + ])('shows %s under the field after Send test', async (_, respond, said) => { + const fetchMock = stubAPI({ 'POST /api/v1/settings/chat-webhook/test': respond }) + await renderPage() + const card = screen.getByRole('region', { name: 'Google Chat webhook' }) + fireEvent.click(await within(card).findByRole('button', { name: 'Send test' })) + + const result = await within(card).findByText(said, { exact: false }) + // Under the field: the result follows the URL input. + const input = within(card).getByRole('textbox', { name: 'Webhook URL' }) + expect(input.compareDocumentPosition(result) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + expect(fetchMock.mock.calls.filter(([input]) => String(input) === '/api/v1/settings/chat-webhook/test')).toHaveLength(1) + }) + + it('asks for a webhook when none is stored and saves it', async () => { + const fetchMock = stubAPI({ + 'GET /api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK_NONE), + 'PUT /api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK), + }) + await renderPage() + const card = screen.getByRole('region', { name: 'Google Chat webhook' }) + const save = await within(card).findByRole('button', { name: 'Save webhook' }) + expect(save).toBeDisabled() + fireEvent.change(within(card).getByRole('textbox', { name: 'Webhook URL' }), { + target: { value: ' https://chat.googleapis.com/v1/spaces/AAQA/messages?key=k&token=t ' }, + }) + fireEvent.change(within(card).getByRole('textbox', { name: 'Space name' }), { target: { value: 'Dinner Crew space' } }) + fireEvent.click(save) + + expect(await within(card).findByText('Connected · Dinner Crew space')).toBeInTheDocument() + expect(bodiesOf(fetchMock, 'PUT', '/api/v1/settings/chat-webhook')).toEqual([ + { url: 'https://chat.googleapis.com/v1/spaces/AAQA/messages?key=k&token=t', space_label: 'Dinner Crew space' }, + ]) + }) +}) + +describe('NotificationsPage push permission row', () => { + const row = () => screen.getByRole('group', { name: 'Notifications on this device' }) + + it('says a browser without Web Push is not supported, with no button', async () => { + stubAPI() + await renderPage() + expect(await within(row()).findByText('Not supported')).toBeInTheDocument() + expect(within(row()).queryByRole('button')).not.toBeInTheDocument() + expect(screen.getByText("This browser can't receive notifications.")).toBeInTheDocument() + }) + + it('turns push on from the tap: asks permission, subscribes and stores the subscription', async () => { + const browser = stubPushEnvironment({ permission: 'default', answer: 'granted' }) + const fetchMock = stubAPI({ + 'GET /api/v1/push/vapid-public-key': () => jsonResponse(200, VAPID_PUBLIC_KEY), + 'POST /api/v1/push/subscriptions': () => new Response(null, { status: 201 }), + }) + await renderPage() + expect(await within(row()).findByText('Off')).toBeInTheDocument() + fireEvent.click(within(row()).getByRole('button', { name: 'Turn on' })) + + expect(await within(row()).findByText('On')).toBeInTheDocument() + expect(browser.notification.requestPermission).toHaveBeenCalledTimes(1) + expect(browser.pushManager.subscribe).toHaveBeenCalledTimes(1) + const [stored] = bodiesOf(fetchMock, 'POST', '/api/v1/push/subscriptions') + expect(stored).toMatchObject({ endpoint: PUSH_ENDPOINT }) + expect(within(row()).getByRole('button', { name: 'Turn off' })).toBeInTheDocument() + }) + + it('says the prompt was dismissed and push is still off', async () => { + stubPushEnvironment({ permission: 'default', answer: 'default' }) + stubAPI() + await renderPage() + fireEvent.click(await within(row()).findByRole('button', { name: 'Turn on' })) + expect(await screen.findByText(/closed without an answer/)).toBeInTheDocument() + expect(within(row()).getByText('Off')).toBeInTheDocument() + }) + + it('shows Blocked once the browser denies permission, with no button', async () => { + stubPushEnvironment({ permission: 'default', answer: 'denied' }) + stubAPI() + await renderPage() + fireEvent.click(await within(row()).findByRole('button', { name: 'Turn on' })) + expect(await within(row()).findByText('Blocked')).toBeInTheDocument() + expect(within(row()).queryByRole('button')).not.toBeInTheDocument() + expect(screen.getByText(/blocks notifications from MealQuest/)).toBeInTheDocument() + }) + + it('shows why turning on failed', async () => { + stubPushEnvironment({ permission: 'granted' }) + stubAPI({ 'GET /api/v1/push/vapid-public-key': () => errorResponse(503, 'push_unavailable') }) + await renderPage() + fireEvent.click(await within(row()).findByRole('button', { name: 'Turn on' })) + expect(await screen.findByRole('alert')).toHaveTextContent('Notifications could not be turned on') + expect(within(row()).getByText('Off')).toBeInTheDocument() + }) + + it('turns push off on this device', async () => { + stubPushEnvironment({ permission: 'granted', subscribed: true }) + const fetchMock = stubAPI({ 'DELETE /api/v1/push/subscriptions': () => new Response(null, { status: 204 }) }) + await renderPage() + fireEvent.click(await within(row()).findByRole('button', { name: 'Turn off' })) + expect(await within(row()).findByText('Off')).toBeInTheDocument() + expect(bodiesOf(fetchMock, 'DELETE', '/api/v1/push/subscriptions')).toEqual([{ endpoint: PUSH_ENDPOINT }]) + }) +}) diff --git a/web/src/settings/NotificationsPage.tsx b/web/src/settings/NotificationsPage.tsx new file mode 100644 index 0000000..b4f2579 --- /dev/null +++ b/web/src/settings/NotificationsPage.tsx @@ -0,0 +1,481 @@ +import { useQueryClient } from '@tanstack/react-query' +import { ChevronLeft } from 'lucide-react' +import { Fragment, useEffect, useId, useState } from 'react' +import { useNavigate } from 'react-router' +import { InstallHint } from '../pwa/InstallHint.tsx' +import type { PushStatus, SubscribePushResult, UnsubscribePushResult } from '../push/subscribe.ts' +import { usePush } from '../push/usePush.ts' +import { Button } from '../ui/Button.tsx' +import { Card } from '../ui/Card.tsx' +import { ErrorBanner } from '../ui/ErrorBanner.tsx' +import { Icon } from '../ui/Icon.tsx' +import { IconButton } from '../ui/IconButton.tsx' +import { Sheet } from '../ui/Sheet.tsx' +import { Skeleton } from '../ui/Skeleton.tsx' +import { TextInput } from '../ui/TextInput.tsx' +import { Toggle } from '../ui/Toggle.tsx' +import { clockTime, formatClockTime } from './api.ts' +import { LeaveGuard } from './LeaveGuard.tsx' +import { + PUSH_STATUS_KEY, + useChatWebhook, + useNotificationSettings, + usePushStatus, + useSaveChatWebhook, + useSaveNotificationSettings, + useTestChatWebhook, + type ChatWebhook, + type NotificationEvent, + type NotificationSettings, +} from './notificationsApi.ts' +import styles from './NotificationsPage.module.css' + +// eventLabels names each event as the Notifications screen lists it. +// rotation_review is the next-day Rotation check. +const eventLabels: Record = { + plan_ready: 'New weekly plan ready', + list_ready: 'Shopping list ready', + day_of_reminder: 'Day-of dinner reminder', + new_suggestions: 'New suggestions to swipe', + rotation_review: 'Keep it in rotation?', +} + +// Notifications & Google Chat (handoff mock 1n): which events go by push +// and to Google Chat, when the day-of reminder is sent, Web Push on this +// device, and the household's Google Chat webhook. Each change is saved as +// it is made; only a webhook being replaced is a draft. +export function NotificationsPage() { + const [dirty, setDirty] = useState(false) + return ( +
+
+ + + + +
+ ) +} + +function Header() { + const navigate = useNavigate() + return ( +
+ void navigate('/settings')}> + + +

Notifications

+
+ ) +} + +// NotificationsCard is the notification preferences as a card, for the +// desktop settings (handoff mock 2e). +export function NotificationsCard() { + const titleId = useId() + return ( + +

+ Notifications +

+ +
+ ) +} + +// NotificationPrefs is the event × channel matrix once it loads, then Web +// Push on this device. +function NotificationPrefs() { + const settings = useNotificationSettings() + return ( + <> + {settings.isPending && } + {settings.isError && ( + void settings.refetch()}> + Notification settings could not load: {settings.error.message} + + )} + {settings.isSuccess && } + + + + ) +} + +type Channel = 'push' | 'chat' + +const channelNames: Record = { push: 'Push', chat: 'Google Chat' } + +// NotificationMatrix saves every change at once. While a save is pending it +// shows the settings being saved and takes no other change; a failed save +// shows the stored settings again and says why. +function NotificationMatrix({ saved }: { saved: NotificationSettings }) { + const save = useSaveNotificationSettings() + const [editingTime, setEditingTime] = useState(false) + const shown = save.isPending ? save.variables : saved + const id = useId() + const reminder = formatClockTime(shown.reminder_time) + + function toggle(event: NotificationEvent, channel: Channel, on: boolean) { + save.mutate({ ...shown, prefs: shown.prefs.map((p) => (p.event === event ? { ...p, [channel]: on } : p)) }) + } + + return ( + <> +
+ Event + + + {shown.prefs.map(({ event, push, chat }) => ( + +
+ + {eventLabels[event]} + + {event === 'day_of_reminder' && ( + + )} +
+ {(['push', 'chat'] as const).map((channel) => ( + toggle(event, channel, on)} + /> + ))} +
+ ))} +
+ {save.isError && !editingTime && ( + save.mutate(save.variables)}> + Notification settings were not saved: {save.error.message} + + )} + { + save.reset() + setEditingTime(false) + }} + onSave={(reminder_time) => + save.mutate({ ...saved, reminder_time }, { onSuccess: () => setEditingTime(false) }) + } + /> + + ) +} + +type ReminderTimeSheetProps = { + open: boolean + saved: NotificationSettings + busy: boolean + // Why the last save failed, or null. + error: string | null + onClose: () => void + onSave: (reminderTime: string) => void +} + +// ReminderTimeSheet edits when the day-of dinner reminder is sent. +function ReminderTimeSheet({ open, saved, busy, error, onClose, onSave }: ReminderTimeSheetProps) { + const titleId = useId() + const [time, setTime] = useState(saved.reminder_time) + useEffect(() => { + if (open) setTime(saved.reminder_time) + }, [open, saved.reminder_time]) + const valid = clockTime.test(time) + + return ( + +
+

+ Day-of dinner reminder +

+

When the reminder is sent on each planned day. This changes it for everyone in the household.

+ setTime(event.target.value)} + /> + {!valid &&

Choose a time.

} + {error !== null && ( +

+ The reminder time was not saved: {error} +

+ )} +
+ + +
+
+
+ ) +} + +const pushStatusText: Record = { + on: 'On', + off: 'Off', + denied: 'Blocked', + unsupported: 'Not supported', +} + +type PushOutcome = SubscribePushResult | UnsubscribePushResult + +// PushPermission is whether Web Push is on for this device, with a button +// to turn it on or off. Turning on asks for notification permission, which +// browsers allow only from a tap, so subscribePush is called straight from +// the button's click. +function PushPermission() { + const status = usePushStatus() + const push = usePush() + const queryClient = useQueryClient() + const [busy, setBusy] = useState(false) + const [outcome, setOutcome] = useState(null) + const [failure, setFailure] = useState<{ action: 'on' | 'off'; message: string } | null>(null) + const labelId = useId() + + function run(action: 'on' | 'off', pending: Promise) { + setBusy(true) + setOutcome(null) + setFailure(null) + void pending + .then(setOutcome, (error: unknown) => + setFailure({ action, message: error instanceof Error ? error.message : String(error) }), + ) + .finally(() => { + setBusy(false) + void queryClient.invalidateQueries({ queryKey: PUSH_STATUS_KEY }) + }) + } + + return ( +
+
+

+ Notifications on this device + {': '} + {status.isPending && } + {status.isSuccess && {pushStatusText[status.data]}} +

+ {status.data === 'off' && ( + + )} + {status.data === 'on' && ( + + )} +
+ {status.data === 'denied' && ( +

This browser blocks notifications from MealQuest. Allow them in the browser's site settings.

+ )} + {status.data === 'unsupported' && ( +

This browser can't receive notifications.

+ )} + {outcome === 'dismissed' && ( +

+ The permission prompt was closed without an answer, so notifications are still off. +

+ )} + {status.isError && ( + void status.refetch()}> + Could not check notifications on this device: {status.error.message} + + )} + {failure !== null && ( + + Notifications could not be turned {failure.action}: {failure.message} + + )} +
+ ) +} + +type ChatWebhookCardProps = { + // Called with whether a webhook being entered is unsaved. + onDirtyChange: (dirty: boolean) => void +} + +// ChatWebhookCard is the household's Google Chat webhook: its masked URL, +// the space it posts to, and a test message. +export function ChatWebhookCard({ onDirtyChange }: ChatWebhookCardProps) { + const webhook = useChatWebhook() + const titleId = useId() + return ( + +

+ Google Chat webhook +

+ {webhook.isPending && } + {webhook.isError && ( + void webhook.refetch()}> + The Google Chat webhook could not load: {webhook.error.message} + + )} + {webhook.isSuccess && } +
+ ) +} + +function ChatWebhookForm({ saved, onDirtyChange }: ChatWebhookCardProps & { saved: ChatWebhook }) { + const connected = saved.url_masked !== null + const [replacing, setReplacing] = useState(!connected) + const [url, setUrl] = useState('') + const [space, setSpace] = useState('') + const save = useSaveChatWebhook() + const test = useTestChatWebhook() + const dirty = replacing && (url !== '' || space !== '') + + useEffect(() => { + onDirtyChange(dirty) + }, [dirty, onDirtyChange]) + useEffect(() => () => onDirtyChange(false), [onDirtyChange]) + + function close() { + save.reset() + setReplacing(false) + setUrl('') + setSpace('') + } + + if (replacing) { + return ( +
+ setUrl(event.target.value)} + /> + setSpace(event.target.value)} + /> + {save.isError && ( +

+ The webhook was not saved: {save.error.message} +

+ )} +
+ {connected && ( + + )} + +
+
+ ) + } + + return ( + <> + + +
+

Connected · {saved.space_label}

+ +
+ + + ) +} + +// TestResult says how the last Send test ended, under the URL: sent, the +// HTTP status Google Chat refused it with, or why it was not sent. +function TestResult({ test }: { test: ReturnType }) { + if (test.isSuccess) { + const status = test.data.http_status + const ok = status >= 200 && status < 300 + return ( +

+ {ok ? 'Test message sent.' : `Google Chat refused the test message: HTTP ${status}.`} +

+ ) + } + if (test.isError) { + return ( +

+ Test message not sent: {test.error.message} +

+ ) + } + return null +} + +// ChatPreview shows what a Google Chat message from MealQuest looks like. +function ChatPreview() { + return ( +
+ +

+ MealQuest · preview +
+ This week's plan is ready: Tacos, Bolognese, Tikka masala + 4 more. Open the plan → +

+
+ ) +} diff --git a/web/src/settings/SettingsPage.test.tsx b/web/src/settings/SettingsPage.test.tsx index a039276..64b4f54 100644 --- a/web/src/settings/SettingsPage.test.tsx +++ b/web/src/settings/SettingsPage.test.tsx @@ -4,12 +4,20 @@ import { Route, Routes, useLocation } from 'react-router' import { afterEach, describe, expect, it, vi } from 'vitest' import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' import { createNetworkStatus } from '../pwa/networkStatus.ts' -import { AI_SETTINGS, GOOGLE_NOT_CONNECTED, HOUSEHOLD_SETTINGS, SETTINGS_API } from '../test/ApiProviders.tsx' +import { + AI_SETTINGS, + CHAT_WEBHOOK, + CHAT_WEBHOOK_NONE, + GOOGLE_NOT_CONNECTED, + HOUSEHOLD_SETTINGS, + SETTINGS_API, +} from '../test/ApiProviders.tsx' import { declaredStyle } from '../test/cssRules.ts' import { InDataRouter } from '../test/dataRouter.tsx' import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts' import { expectMinHitArea } from '../test/hitArea.ts' import { mockViewport } from '../test/matchMedia.ts' +import { restorePushEnvironment, stubPushEnvironment } from '../test/push.ts' import { formatClockTime, modelDisplayName } from './api.ts' import { SettingsPage } from './SettingsPage.tsx' @@ -18,12 +26,14 @@ const AI = AI_SETTINGS afterEach(() => { vi.unstubAllGlobals() + restorePushEnvironment() Reflect.deleteProperty(window, 'matchMedia') }) const REGULARS_PATH = '/api/v1/dinners?status=regular' const CALENDAR_PATH = '/api/v1/integrations/google' const NOTIFICATIONS_PATH = '/api/v1/settings/notifications' +const CHAT_WEBHOOK_PATH = '/api/v1/settings/chat-webhook' // stubSettings answers as today's server does (see SETTINGS_API), with // overrides for the paths a test changes. @@ -99,6 +109,7 @@ describe('SettingsPage', () => { CALENDAR_PATH, '/api/v1/settings/ai', '/api/v1/settings/ai/models', + CHAT_WEBHOOK_PATH, '/api/v1/settings/household', NOTIFICATIONS_PATH, '/api/v1/settings/recipe-search', @@ -120,8 +131,9 @@ describe('SettingsPage', () => { [REGULARS_PATH]: () => jsonResponse(200, { dinners: Array.from({ length: 7 }, (_, i) => ({ id: `d${i}` })) }), [CALENDAR_PATH]: () => jsonResponse(200, { ...GOOGLE_NOT_CONNECTED, status: 'connected', calendar_id: 'c1', calendar_name: 'Dinners calendar' }), - [NOTIFICATIONS_PATH]: () => jsonResponse(200, { push: true, chat_space: 'Dinner Crew space' }), + [CHAT_WEBHOOK_PATH]: () => jsonResponse(200, CHAT_WEBHOOK), }) + stubPushEnvironment({ permission: 'granted', subscribed: true }) renderSettings() expect(await within(row('Regular dinners')).findByText('7 on rotation')).toBeInTheDocument() expect(await within(row('Google Calendar')).findByText('Connected · Dinners calendar')).toBeInTheDocument() @@ -134,7 +146,7 @@ describe('SettingsPage', () => { stubSettings({ [REGULARS_PATH]: () => jsonResponse(200, { dinners: [] }), [CALENDAR_PATH]: () => jsonResponse(200, GOOGLE_NOT_CONNECTED), - [NOTIFICATIONS_PATH]: () => jsonResponse(200, { push: false, chat_space: null }), + [CHAT_WEBHOOK_PATH]: () => jsonResponse(200, CHAT_WEBHOOK_NONE), }) renderSettings() expect(await within(row('Regular dinners')).findByText('None on rotation yet')).toBeInTheDocument() diff --git a/web/src/settings/SettingsPage.tsx b/web/src/settings/SettingsPage.tsx index 425623a..c01b620 100644 --- a/web/src/settings/SettingsPage.tsx +++ b/web/src/settings/SettingsPage.tsx @@ -12,14 +12,13 @@ import { useBreakpoint } from '../shell/useBreakpoint.ts' import { aiStatus, formatClockTime, - notificationsStatus, regularDinnersStatus, useAISettings, useHouseholdSettings, - useNotificationsStatus, useRegularDinnersCount, } from './api.ts' import { calendarStatus, useGoogleConnection } from './calendarApi.ts' +import { notificationsStatus, useChatWebhook, usePushStatus } from './notificationsApi.ts' import styles from './SettingsPage.module.css' import { SettingsTabs } from './SettingsTabs.tsx' @@ -38,16 +37,17 @@ export function SettingsPage() { } // The mobile settings list (handoff mock 1p). Each row opens its settings -// page and shows a live status line. Regular dinners and Notifications & -// Google Chat read endpoints their features have not served yet; until then -// each shows its not-yet-available state (see api.ts). +// page and shows a live status line. Regular dinners reads an endpoint its +// feature has not served yet; until then it shows its not-yet-available +// state (see api.ts). function SettingsList() { const household = useHouseholdSettings() const ai = useAISettings() const regulars = useRegularDinnersCount() const calendar = useGoogleConnection() - const notifications = useNotificationsStatus() - const failed = [regulars, calendar, household, notifications, ai].filter((query) => query.isError) + const push = usePushStatus() + const chat = useChatWebhook() + const failed = [regulars, calendar, household, push, chat, ai].filter((query) => query.isError) return (
@@ -93,7 +93,11 @@ function SettingsList() { to="/settings/notifications" icon={Bell} title="Notifications & Google Chat" - status={status(notifications, notificationsStatus)} + status={ + push.isSuccess + ? status(chat, (webhook) => notificationsStatus(push.data, webhook)) + : status(push, () => '') + } /> diff --git a/web/src/settings/SettingsTabs.test.tsx b/web/src/settings/SettingsTabs.test.tsx index f3cdc36..eb9da10 100644 --- a/web/src/settings/SettingsTabs.test.tsx +++ b/web/src/settings/SettingsTabs.test.tsx @@ -3,7 +3,15 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' import { createNetworkStatus } from '../pwa/networkStatus.ts' -import { AI_MODELS, AI_SETTINGS, GOOGLE_NOT_CONNECTED, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS } from '../test/ApiProviders.tsx' +import { + AI_MODELS, + AI_SETTINGS, + CHAT_WEBHOOK, + GOOGLE_NOT_CONNECTED, + HOUSEHOLD_SETTINGS, + NOTIFICATION_SETTINGS, + RECIPE_SEARCH_SETTINGS, +} from '../test/ApiProviders.tsx' import { InDataRouter } from '../test/dataRouter.tsx' import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' import { expectMinHitArea } from '../test/hitArea.ts' @@ -22,6 +30,8 @@ function stubAPI(responders: Record = {}): Mock 'GET /api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS), 'GET /api/v1/settings/ai/models': () => jsonResponse(200, AI_MODELS), 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS), + 'GET /api/v1/settings/notifications': () => jsonResponse(200, NOTIFICATION_SETTINGS), + 'GET /api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK), ...responders, } const fetchMock = vi.fn((input, init) => { @@ -84,7 +94,12 @@ describe.each([1440, 1180])('SettingsPage at %i px (mock 2e)', (width) => { const columns = panel('Notifications & AI') expect(columns).toBeVisible() const [left, right] = Array.from(columns.children) - expect(within(left as HTMLElement).getByRole('region', { name: 'Notifications & Google Chat' })).toBeInTheDocument() + const notifications = within(left as HTMLElement).getByRole('region', { name: 'Notifications' }) + expect(await within(notifications).findAllByRole('switch')).toHaveLength(10) + expect(within(notifications).getByRole('button', { name: 'Reminder time, 4:30 pm. Change' })).toBeInTheDocument() + const chat = within(left as HTMLElement).getByRole('region', { name: 'Google Chat webhook' }) + expect(await within(chat).findByText('Connected · Dinner Crew space')).toBeInTheDocument() + expect(within(chat).getByRole('button', { name: 'Send test' })).toBeInTheDocument() const ai = within(right as HTMLElement).getByRole('region', { name: 'AI assistant' }) expect(within(ai).getByText('Admin')).toBeInTheDocument() expect(await within(ai).findByRole('combobox', { name: 'Provider' })).toHaveValue('anthropic') @@ -150,12 +165,39 @@ describe.each([1440, 1180])('SettingsPage at %i px (mock 2e)', (width) => { }) }) +describe('SettingsPage reminder time across tabs', () => { + it('shows a reminder time saved on Notifications in the Household tab, keeping its draft', async () => { + const saved = { ...HOUSEHOLD_SETTINGS } + stubAPI({ + 'GET /api/v1/settings/household': () => jsonResponse(200, saved), + 'PUT /api/v1/settings/notifications': (body) => { + saved.reminder_time = (body as { reminder_time: string }).reminder_time + return jsonResponse(200, body) + }, + }) + await renderAt(1440) + fireEvent.click(tab('Household')) + const household = panel('Household') + fireEvent.change(await within(household).findByLabelText('Dinner time'), { target: { value: '18:15' } }) + + fireEvent.click(tab('Notifications & AI')) + fireEvent.click(await screen.findByRole('button', { name: 'Reminder time, 4:30 pm. Change' })) + const sheet = screen.getByRole('dialog', { name: 'Day-of dinner reminder' }) + fireEvent.change(within(sheet).getByLabelText('Reminder time'), { target: { value: '15:00' } }) + fireEvent.click(within(sheet).getByRole('button', { name: 'Save' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + + fireEvent.click(tab('Household')) + await waitFor(() => expect(within(household).getByLabelText('Day-of reminder')).toHaveValue('15:00')) + expect(within(household).getByLabelText('Dinner time')).toHaveValue('18:15') + }) +}) + describe('SettingsPage across the 768 px breakpoint', () => { it('shows the mobile list below 768 px and the sub-tabs from 768 px', async () => { stubAPI({ 'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'), 'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED), - 'GET /api/v1/settings/notifications': () => errorResponse(404, 'not_found'), }) const resize = await renderAt(767) expect(screen.getByRole('link', { name: /^AI assistant/ })).toHaveAttribute('href', '/settings/ai') @@ -170,7 +212,6 @@ describe('SettingsPage across the 768 px breakpoint', () => { stubAPI({ 'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'), 'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED), - 'GET /api/v1/settings/notifications': () => errorResponse(404, 'not_found'), }) const resize = await renderAt(1180) fireEvent.change(await screen.findByRole('textbox', { name: 'System prompt' }), { diff --git a/web/src/settings/SettingsTabs.tsx b/web/src/settings/SettingsTabs.tsx index 1a4ffc9..2aaf975 100644 --- a/web/src/settings/SettingsTabs.tsx +++ b/web/src/settings/SettingsTabs.tsx @@ -3,6 +3,7 @@ import { Card } from '../ui/Card.tsx' import { AIAssistantCard } from './AISettingsPage.tsx' import { HouseholdSettingsSections } from './HouseholdSettingsPage.tsx' import { LeaveGuard } from './LeaveGuard.tsx' +import { ChatWebhookCard, NotificationsCard } from './NotificationsPage.tsx' import { RecipeSearchCard } from './RecipeSearchCard.tsx' import styles from './SettingsTabs.module.css' @@ -25,6 +26,7 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) { const [householdDirty, setHouseholdDirty] = useState(false) const [aiDirty, setAIDirty] = useState(false) const [recipeSearchDirty, setRecipeSearchDirty] = useState(false) + const [chatDirty, setChatDirty] = useState(false) const refs = useRef<(HTMLButtonElement | null)[]>([]) const id = useId() const tabId = (value: Tab) => `${id}-tab-${value}` @@ -105,11 +107,8 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) { styles.columns, <>
- {/* TODO(Feature 19): the Notifications matrix and Google Chat - webhook cards replace this placeholder. */} - - Notification and Google Chat webhook settings arrive in a later update. - + +
@@ -118,7 +117,7 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) { , )} - +
) } diff --git a/web/src/settings/api.ts b/web/src/settings/api.ts index bab2bcc..fbe4fa9 100644 --- a/web/src/settings/api.ts +++ b/web/src/settings/api.ts @@ -14,10 +14,6 @@ export const RECIPE_SEARCH_TEST_PATH = '/api/v1/settings/recipe-search/test' // dinners read API. Until it lands the server answers 404 and the Regular // dinners row says the count is unavailable. export const REGULAR_DINNERS_PATH = '/api/v1/dinners?status=regular' -// TODO(notifications feature): the Web Push and Google Chat status. Until it -// is served the server answers 404, which is the truth: push is off and no -// Google Chat space is connected. -export const NOTIFICATIONS_STATUS_PATH = '/api/v1/settings/notifications' // HouseholdSettings is the part of GET /api/v1/settings/household the // settings pages read. Times are zero-padded 24-hour HH:MM in timezone; days @@ -332,7 +328,8 @@ export function useTestRecipeSearch() { }) } -const clockTime = /^([01][0-9]|2[0-3]):([0-5][0-9])$/ +// clockTime matches a zero-padded 24-hour HH:MM time. +export const clockTime = /^([01][0-9]|2[0-3]):([0-5][0-9])$/ // parseHouseholdSettings refuses a body that is not the household settings, // or has a time that is not HH:MM, so formatClockTime never sees one. @@ -377,6 +374,12 @@ export function useHouseholdSettings() { }) } +// NOTIFICATIONS_KEY is the notification settings (notificationsApi.ts), +// whose reminder_time is the household settings'. +export const NOTIFICATIONS_KEY = ['settings', 'notifications'] as const + +// useSaveHouseholdSettings saves a change and reads the notification +// settings again, which show reminder_time. export function useSaveHouseholdSettings() { const { apiFetch } = useApi() const queryClient = useQueryClient() @@ -386,7 +389,10 @@ export function useSaveHouseholdSettings() { `PATCH ${HOUSEHOLD_SETTINGS_PATH}`, await apiFetch(HOUSEHOLD_SETTINGS_PATH, { method: 'PATCH', body: patch }), ), - onSuccess: (settings) => queryClient.setQueryData(HOUSEHOLD_SETTINGS_KEY, settings), + onSuccess: (settings) => { + queryClient.setQueryData(HOUSEHOLD_SETTINGS_KEY, settings) + void queryClient.invalidateQueries({ queryKey: NOTIFICATIONS_KEY }) + }, }) } @@ -513,34 +519,6 @@ export function regularDinnersStatus(count: number | null): string { return `${count} on rotation` } -// NotificationsStatus is whether Web Push is on and the connected Google -// Chat space's name, null when none is. -export type NotificationsStatus = { push: boolean; chat_space: string | null } - -export function useNotificationsStatus() { - const { apiFetch } = useApi() - return useQuery({ - queryKey: ['settings', 'notifications'], - queryFn: () => - fetchUnlessAbsent(apiFetch, NOTIFICATIONS_STATUS_PATH, (body): NotificationsStatus => { - if ( - !isRecord(body) || - typeof body.push !== 'boolean' || - (body.chat_space !== null && typeof body.chat_space !== 'string') - ) { - throw new Error(`GET ${NOTIFICATIONS_STATUS_PATH}: response is not a notifications status`) - } - return { push: body.push, chat_space: body.chat_space } - }), - }) -} - -// notificationsStatus is the handoff's "Push on · Dinner Crew space". -export function notificationsStatus(status: NotificationsStatus | null): string { - const push = status?.push === true ? 'Push on' : 'Push off' - return `${push} · ${status?.chat_space ?? 'Google Chat not connected'}` -} - // maskedKey shows a stored key as dots and its last four characters, all // the client ever knows of it. export function maskedKey(last4: string): string { diff --git a/web/src/settings/notificationsApi.ts b/web/src/settings/notificationsApi.ts new file mode 100644 index 0000000..a7a8978 --- /dev/null +++ b/web/src/settings/notificationsApi.ts @@ -0,0 +1,183 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useApi } from '../api/index.ts' +import { isRecord } from '../api/errors.ts' +import type { PushStatus } from '../push/subscribe.ts' +import { usePush } from '../push/usePush.ts' +import { clockTime, HOUSEHOLD_SETTINGS_KEY, NOTIFICATIONS_KEY } from './api.ts' + +export const NOTIFICATIONS_PATH = '/api/v1/settings/notifications' +export const CHAT_WEBHOOK_PATH = '/api/v1/settings/chat-webhook' +export const CHAT_WEBHOOK_TEST_PATH = '/api/v1/settings/chat-webhook/test' + +// notificationEvents are the events the household chooses channels for +// (internal/household NotificationEvents), in the order the Notifications +// screen lists them. rotation_review is the next-day Rotation check, "Keep it +// in rotation?". +export const notificationEvents = [ + 'plan_ready', + 'list_ready', + 'day_of_reminder', + 'new_suggestions', + 'rotation_review', +] as const + +export type NotificationEvent = (typeof notificationEvents)[number] + +export type NotificationPref = { event: NotificationEvent; push: boolean; chat: boolean } + +// NotificationSettings is GET and the PUT /api/v1/settings/notifications +// body: every event's channels, in notificationEvents order, and when the +// day-of dinner reminder is sent (the household settings' reminder_time, +// 24-hour HH:MM). +export type NotificationSettings = { prefs: NotificationPref[]; reminder_time: string } + +// parseNotificationSettings refuses a body that is not every event's +// channels, in order, with an HH:MM reminder time. +function parseNotificationSettings(where: string, body: unknown): NotificationSettings { + if (!isRecord(body) || !Array.isArray(body.prefs) || typeof body.reminder_time !== 'string') { + throw new Error(`${where}: response is not the notification settings`) + } + if (!clockTime.test(body.reminder_time)) { + throw new Error(`${where}: reminder_time ${JSON.stringify(body.reminder_time)} is not HH:MM`) + } + const prefs: unknown[] = body.prefs + if (prefs.length !== notificationEvents.length) { + throw new Error(`${where}: want ${notificationEvents.length} events, got ${prefs.length}`) + } + return { + prefs: notificationEvents.map((event, i) => { + const pref = prefs[i] + if (!isRecord(pref) || pref.event !== event || typeof pref.push !== 'boolean' || typeof pref.chat !== 'boolean') { + throw new Error(`${where}: event ${i} is not ${event}'s channels`) + } + return { event, push: pref.push, chat: pref.chat } + }), + reminder_time: body.reminder_time, + } +} + +export function useNotificationSettings() { + const { apiFetch } = useApi() + return useQuery({ + queryKey: NOTIFICATIONS_KEY, + queryFn: async () => + parseNotificationSettings(`GET ${NOTIFICATIONS_PATH}`, await apiFetch(NOTIFICATIONS_PATH)), + }) +} + +// useSaveNotificationSettings replaces every event's channels and the +// reminder time. reminder_time is a household setting, so the household +// settings are read again. +export function useSaveNotificationSettings() { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (settings: NotificationSettings) => + parseNotificationSettings( + `PUT ${NOTIFICATIONS_PATH}`, + await apiFetch(NOTIFICATIONS_PATH, { method: 'PUT', body: settings }), + ), + onSuccess: (settings) => { + queryClient.setQueryData(NOTIFICATIONS_KEY, settings) + void queryClient.invalidateQueries({ queryKey: HOUSEHOLD_SETTINGS_KEY }) + }, + }) +} + +// ChatWebhook is GET /api/v1/settings/chat-webhook. The URL itself is never +// sent: url_masked has no key and only the token's last few characters. +// Every field is null while no webhook is stored. +export type ChatWebhook = { + space_label: string | null + url_masked: string | null + // When a test message was last sent to this webhook and whether Google + // Chat accepted it; null until one is. + last_test_at: string | null + last_test_status: 'ok' | 'failed' | null +} + +// ChatWebhookTest is POST /api/v1/settings/chat-webhook/test: the status +// Google Chat answered the test message with, whatever it was. +export type ChatWebhookTest = { http_status: number; settings: ChatWebhook } + +function isStringOrNull(value: unknown): value is string | null { + return value === null || typeof value === 'string' +} + +function parseChatWebhook(where: string, body: unknown): ChatWebhook { + if ( + !isRecord(body) || + !isStringOrNull(body.space_label) || + !isStringOrNull(body.url_masked) || + !isStringOrNull(body.last_test_at) || + !(body.last_test_status === null || body.last_test_status === 'ok' || body.last_test_status === 'failed') + ) { + throw new Error(`${where}: response is not the Google Chat webhook settings`) + } + if ((body.url_masked === null) !== (body.space_label === null)) { + throw new Error(`${where}: a webhook URL and its space name must be stored together`) + } + return { + space_label: body.space_label, + url_masked: body.url_masked, + last_test_at: body.last_test_at, + last_test_status: body.last_test_status, + } +} + +const CHAT_WEBHOOK_KEY = ['settings', 'chat-webhook'] as const + +export function useChatWebhook() { + const { apiFetch } = useApi() + return useQuery({ + queryKey: CHAT_WEBHOOK_KEY, + queryFn: async () => parseChatWebhook(`GET ${CHAT_WEBHOOK_PATH}`, await apiFetch(CHAT_WEBHOOK_PATH)), + }) +} + +// useSaveChatWebhook stores a webhook URL and its space's name, replacing +// the stored one. +export function useSaveChatWebhook() { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (webhook: { url: string; space_label: string }) => + parseChatWebhook( + `PUT ${CHAT_WEBHOOK_PATH}`, + await apiFetch(CHAT_WEBHOOK_PATH, { method: 'PUT', body: webhook }), + ), + onSuccess: (webhook) => queryClient.setQueryData(CHAT_WEBHOOK_KEY, webhook), + }) +} + +// useTestChatWebhook sends a test message to the stored webhook. +export function useTestChatWebhook() { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (): Promise => { + const where = `POST ${CHAT_WEBHOOK_TEST_PATH}` + const body = await apiFetch(CHAT_WEBHOOK_TEST_PATH, { method: 'POST' }) + if (!isRecord(body) || typeof body.http_status !== 'number' || !Number.isInteger(body.http_status)) { + throw new Error(`${where}: response has no http_status`) + } + return { http_status: body.http_status, settings: parseChatWebhook(where, body) } + }, + onSuccess: (test) => queryClient.setQueryData(CHAT_WEBHOOK_KEY, test.settings), + }) +} + +export const PUSH_STATUS_KEY = ['push', 'status'] as const + +// usePushStatus is whether Web Push is on for this device. It asks only the +// browser, so it runs offline too. +export function usePushStatus() { + const push = usePush() + return useQuery({ queryKey: PUSH_STATUS_KEY, queryFn: () => push.pushStatus(), networkMode: 'always' }) +} + +// notificationsStatus is the settings home's "Push on · Dinner Crew space" +// line: whether Web Push is on for this device, and the Google Chat space. +export function notificationsStatus(push: PushStatus, chat: ChatWebhook): string { + return `${push === 'on' ? 'Push on' : 'Push off'} · ${chat.space_label ?? 'Google Chat not connected'}` +} diff --git a/web/src/test/ApiProviders.tsx b/web/src/test/ApiProviders.tsx index 3a377a6..4405812 100644 --- a/web/src/test/ApiProviders.tsx +++ b/web/src/test/ApiProviders.tsx @@ -61,14 +61,41 @@ export const GOOGLE_NOT_CONNECTED = { last_sync_error: null, } +// NOTIFICATION_SETTINGS is a GET /api/v1/settings/notifications body: the +// seed channels and reminder time. +export const NOTIFICATION_SETTINGS = { + prefs: [ + { event: 'plan_ready', push: true, chat: true }, + { event: 'list_ready', push: true, chat: true }, + { event: 'day_of_reminder', push: true, chat: false }, + { event: 'new_suggestions', push: true, chat: false }, + { event: 'rotation_review', push: true, chat: false }, + ], + reminder_time: '16:30', +} + +// CHAT_WEBHOOK_NONE is a GET /api/v1/settings/chat-webhook body: no webhook +// stored. +export const CHAT_WEBHOOK_NONE = { space_label: null, url_masked: null, last_test_at: null, last_test_status: null } + +// CHAT_WEBHOOK is a GET /api/v1/settings/chat-webhook body: a webhook +// stored and never tested. +export const CHAT_WEBHOOK = { + space_label: 'Dinner Crew space', + url_masked: 'https://chat.googleapis.com/v1/spaces/AAQA1b2C3d4/messages?token=…qQ%3D', + last_test_at: null, + last_test_status: null, +} + // SETTINGS_API answers the settings home's requests, for stubFetchByPath, as -// today's server does: the dinners and notifications status endpoints do not -// exist yet, and Google Calendar is not connected. The home's sub-tabs stay mounted on mobile, so +// today's server does: the dinners endpoint does not exist yet, and neither +// Google Calendar nor Google Chat is connected. The home's sub-tabs stay mounted on mobile, so // it also reads the AI models and recipe search settings. export const SETTINGS_API = { '/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'), '/api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED), - '/api/v1/settings/notifications': () => errorResponse(404, 'not_found'), + '/api/v1/settings/notifications': () => jsonResponse(200, NOTIFICATION_SETTINGS), + '/api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK_NONE), '/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS), '/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS), '/api/v1/settings/ai/models': () => jsonResponse(200, AI_MODELS), diff --git a/web/src/test/push.ts b/web/src/test/push.ts new file mode 100644 index 0000000..d8b933b --- /dev/null +++ b/web/src/test/push.ts @@ -0,0 +1,68 @@ +import { vi } from 'vitest' +import { encodeBase64url, type BrowserPushSubscription } from '../push/subscribe.ts' + +function bytes(length: number, first: number): Uint8Array { + const out = new Uint8Array(new ArrayBuffer(length)) + for (let i = 0; i < length; i++) out[i] = (first + i) % 256 + return out +} + +const vapidKey = bytes(65, 4) + +// VAPID_PUBLIC_KEY is a GET /api/v1/push/vapid-public-key body whose key +// the stubbed subscriptions were made with. +export const VAPID_PUBLIC_KEY = { public_key: encodeBase64url(vapidKey.buffer) } + +export const PUSH_ENDPOINT = 'https://push.example/send/abc' + +type BrowserPush = { + // The browser's notification permission before any prompt. + permission?: NotificationPermission + // How the permission prompt is answered. + answer?: NotificationPermission + // Whether this browser already holds a push subscription. + subscribed?: boolean +} + +// stubPushEnvironment gives jsdom, which has no Web Push, a browser with a +// registered service worker, PushManager, Notification and an active tap. +// Undo with restorePushEnvironment and vi.unstubAllGlobals. +export function stubPushEnvironment({ permission = 'default', answer = 'granted', subscribed = false }: BrowserPush = {}) { + const subscription: BrowserPushSubscription = { + endpoint: PUSH_ENDPOINT, + options: { applicationServerKey: vapidKey.buffer }, + getKey: (name) => (name === 'p256dh' ? bytes(65, 4).buffer : bytes(16, 200).buffer), + unsubscribe: vi.fn(async () => { + current = null + return true + }), + } + let current: BrowserPushSubscription | null = subscribed ? subscription : null + const pushManager = { + getSubscription: vi.fn(async () => current), + subscribe: vi.fn(async () => { + current = subscription + return subscription + }), + } + const notification = { + permission, + requestPermission: vi.fn(async () => { + notification.permission = answer + return answer + }), + } + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: { getRegistration: async () => ({ pushManager }) }, + }) + Object.defineProperty(navigator, 'userActivation', { configurable: true, value: { isActive: true } }) + vi.stubGlobal('PushManager', function PushManager() {}) + vi.stubGlobal('Notification', notification) + return { notification, pushManager } +} + +export function restorePushEnvironment() { + Reflect.deleteProperty(navigator, 'serviceWorker') + Reflect.deleteProperty(navigator, 'userActivation') +}