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') +}