Notification preferences API and screen (1n) (Feature 19) (#166)
CI / ci (push) Successful in 5m53s
CI / ci (push) Successful in 5m53s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #166.
This commit is contained in:
@@ -133,6 +133,39 @@ func (q *Queries) InsertNotification(ctx context.Context, arg InsertNotification
|
|||||||
return result.RowsAffected()
|
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
|
const markNotificationSent = `-- name: MarkNotificationSent :execrows
|
||||||
UPDATE notification_outbox
|
UPDATE notification_outbox
|
||||||
SET status = 'sent', attempts = attempts + 1, next_attempt_at = NULL, updated_at = ?1
|
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()
|
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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ type Querier interface {
|
|||||||
// Regular, and every Quest log dinner no other plan for the week starting
|
// Regular, and every Quest log dinner no other plan for the week starting
|
||||||
// from_week_start or later has on any day.
|
// from_week_start or later has on any day.
|
||||||
ListDraftPool(ctx context.Context, arg ListDraftPoolParams) ([]Dinner, error)
|
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
|
// Every name the household turned down, oldest decision first: the name
|
||||||
// each declined suggestion was suggested under, which renaming its dinner
|
// each declined suggestion was suggested under, which renaming its dinner
|
||||||
// does not change, and the name each dinner a Rotation check dropped
|
// 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)
|
ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavoriteParams) (Dinner, error)
|
||||||
// An unknown id returns sql.ErrNoRows.
|
// An unknown id returns sql.ErrNoRows.
|
||||||
UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Dinner, error)
|
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
|
// Creates the single row (id 1) or replaces every column except id and
|
||||||
// created_at.
|
// created_at.
|
||||||
UpsertAISettings(ctx context.Context, arg UpsertAISettingsParams) (AiSetting, error)
|
UpsertAISettings(ctx context.Context, arg UpsertAISettingsParams) (AiSetting, error)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -187,56 +187,9 @@ func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updat
|
|||||||
|
|
||||||
var row db.HouseholdSetting
|
var row db.HouseholdSetting
|
||||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||||
q := db.New(tx)
|
var err error
|
||||||
cur, err := q.GetHouseholdSettings(ctx)
|
row, err = applyPatch(ctx, db.New(tx), patch, updatedBy)
|
||||||
if err != nil {
|
return err
|
||||||
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
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Settings{}, err
|
return Settings{}, err
|
||||||
@@ -247,8 +200,62 @@ func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updat
|
|||||||
return settings, nil
|
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
|
// 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.
|
// promptly and must not call Update. The returned function unsubscribes fn.
|
||||||
func (s *SettingsService) Subscribe(fn func(Settings)) (unsubscribe func()) {
|
func (s *SettingsService) Subscribe(fn func(Settings)) (unsubscribe func()) {
|
||||||
s.subsMu.Lock()
|
s.subsMu.Lock()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,8 @@ type RouteMounter interface {
|
|||||||
// has its client address and scheme resolved, honouring forwarded headers
|
// has its client address and scheme resolved, honouring forwarded headers
|
||||||
// only from a peer in trusted (see resolveOrigin). version is reported by
|
// only from a peer in trusted (see resolveOrigin). version is reported by
|
||||||
// GET /health; auth mounts the /auth routes; sessions guards /api/v1 and
|
// 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/recipe-search and its /test; ai serves
|
||||||
// /api/v1/settings/ai, its /api-key, /verify, /models and /reset-prompt;
|
// /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;
|
// 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("/me", me(logger))
|
||||||
apiV1.Get("/settings/household", getHouseholdSettings(logger, settings))
|
apiV1.Get("/settings/household", getHouseholdSettings(logger, settings))
|
||||||
apiV1.Patch("/settings/household", patchHouseholdSettings(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.Get("/settings/recipe-search", getRecipeSearchSettings(logger, search))
|
||||||
apiV1.Patch("/settings/recipe-search", patchRecipeSearchSettings(logger, search))
|
apiV1.Patch("/settings/recipe-search", patchRecipeSearchSettings(logger, search))
|
||||||
apiV1.Post("/settings/recipe-search/test", postRecipeSearchTest(logger, search))
|
apiV1.Post("/settings/recipe-search/test", postRecipeSearchTest(logger, search))
|
||||||
|
|||||||
@@ -107,13 +107,16 @@ func newTestStore(t *testing.T) *store.Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// newTestSettings returns a settings service over a migrated temp database
|
// 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 {
|
func newTestSettings(t *testing.T) *household.SettingsService {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
st := newTestStore(t)
|
st := newTestStore(t)
|
||||||
if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
|
if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if err := household.SeedNotificationPrefs(context.Background(), st); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
return household.NewSettingsService(st)
|
return household.NewSettingsService(st)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,3 +56,13 @@ SELECT next_attempt_at FROM notification_outbox
|
|||||||
WHERE status = 'pending'
|
WHERE status = 'pending'
|
||||||
ORDER BY next_attempt_at
|
ORDER BY next_attempt_at
|
||||||
LIMIT 1;
|
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 = ?;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx'
|
|||||||
import { AISettingsPage } from './settings/AISettingsPage.tsx'
|
import { AISettingsPage } from './settings/AISettingsPage.tsx'
|
||||||
import { CalendarSettingsPage } from './settings/CalendarSettingsPage.tsx'
|
import { CalendarSettingsPage } from './settings/CalendarSettingsPage.tsx'
|
||||||
import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx'
|
import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx'
|
||||||
|
import { NotificationsPage } from './settings/NotificationsPage.tsx'
|
||||||
import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx'
|
import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx'
|
||||||
import { SettingsPage } from './settings/SettingsPage.tsx'
|
import { SettingsPage } from './settings/SettingsPage.tsx'
|
||||||
import { AppShell } from './shell/AppShell.tsx'
|
import { AppShell } from './shell/AppShell.tsx'
|
||||||
@@ -46,6 +47,7 @@ const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
|||||||
'/plan/shopping/archive': <SupplyArchivePage />,
|
'/plan/shopping/archive': <SupplyArchivePage />,
|
||||||
'/discover': <DiscoverPage />,
|
'/discover': <DiscoverPage />,
|
||||||
'/settings': <SettingsPage />,
|
'/settings': <SettingsPage />,
|
||||||
|
'/settings/notifications': <NotificationsPage />,
|
||||||
'/settings/ai': <AISettingsPage />,
|
'/settings/ai': <AISettingsPage />,
|
||||||
'/settings/household': <HouseholdSettingsPage />,
|
'/settings/household': <HouseholdSettingsPage />,
|
||||||
'/settings/regulars': <RegularsSettingsPage />,
|
'/settings/regulars': <RegularsSettingsPage />,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
decodeVapidKey,
|
decodeVapidKey,
|
||||||
encodeBase64url,
|
encodeBase64url,
|
||||||
PermissionGestureError,
|
PermissionGestureError,
|
||||||
|
pushStatus,
|
||||||
subscribePush,
|
subscribePush,
|
||||||
subscriptionBody,
|
subscriptionBody,
|
||||||
unsubscribePush,
|
unsubscribePush,
|
||||||
@@ -306,3 +307,31 @@ describe('unsubscribePush', () => {
|
|||||||
await expect(unsubscribePush(api, env)).rejects.toThrow(/did not remove its push subscription/)
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ export type SubscribePushResult = 'subscribed' | 'denied' | 'dismissed' | 'unsup
|
|||||||
// to remove, or a browser without Web Push. Any other failure rejects.
|
// to remove, or a browser without Web Push. Any other failure rejects.
|
||||||
export type UnsubscribePushResult = 'unsubscribed' | 'not-subscribed' | 'unsupported'
|
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.
|
// The parts of the browser's Push API this module uses.
|
||||||
export type BrowserPushSubscription = {
|
export type BrowserPushSubscription = {
|
||||||
readonly endpoint: string
|
readonly endpoint: string
|
||||||
@@ -207,3 +212,16 @@ export async function unsubscribePush(api: PushApi, env: PushEnvironment): Promi
|
|||||||
await removeSubscription(api, subscription)
|
await removeSubscription(api, subscription)
|
||||||
return 'unsubscribed'
|
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<PushStatus> {
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,25 +2,30 @@ import { useMemo } from 'react'
|
|||||||
import { useApi } from '../api/ApiContext.tsx'
|
import { useApi } from '../api/ApiContext.tsx'
|
||||||
import {
|
import {
|
||||||
browserPushEnvironment,
|
browserPushEnvironment,
|
||||||
|
pushStatus,
|
||||||
subscribePush,
|
subscribePush,
|
||||||
unsubscribePush,
|
unsubscribePush,
|
||||||
|
type PushStatus,
|
||||||
type SubscribePushResult,
|
type SubscribePushResult,
|
||||||
type UnsubscribePushResult,
|
type UnsubscribePushResult,
|
||||||
} from './subscribe.ts'
|
} from './subscribe.ts'
|
||||||
|
|
||||||
export type Push = {
|
export type Push = {
|
||||||
|
// pushStatus reads whether Web Push is on for this device.
|
||||||
|
pushStatus(): Promise<PushStatus>
|
||||||
// subscribePush turns on Web Push on this device; call it only from a tap.
|
// subscribePush turns on Web Push on this device; call it only from a tap.
|
||||||
subscribePush(): Promise<SubscribePushResult>
|
subscribePush(): Promise<SubscribePushResult>
|
||||||
// unsubscribePush turns off Web Push on this device.
|
// unsubscribePush turns off Web Push on this device.
|
||||||
unsubscribePush(): Promise<UnsubscribePushResult>
|
unsubscribePush(): Promise<UnsubscribePushResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
// usePush binds subscribePush and unsubscribePush to the app's API client
|
// usePush binds pushStatus, subscribePush and unsubscribePush to the app's
|
||||||
// and this browser.
|
// API client and this browser.
|
||||||
export function usePush(): Push {
|
export function usePush(): Push {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
pushStatus: () => pushStatus(browserPushEnvironment()),
|
||||||
subscribePush: () => subscribePush(api, browserPushEnvironment()),
|
subscribePush: () => subscribePush(api, browserPushEnvironment()),
|
||||||
unsubscribePush: () => unsubscribePush(api, browserPushEnvironment()),
|
unsubscribePush: () => unsubscribePush(api, browserPushEnvironment()),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -91,8 +91,24 @@ type HouseholdSettingsFormProps = HouseholdSettingsProps & {
|
|||||||
saved: HouseholdSettings
|
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<string, unknown> = { ...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) {
|
function HouseholdSettingsForm({ saved, onDirtyChange }: HouseholdSettingsFormProps) {
|
||||||
const [draft, setDraft] = useState(saved)
|
const [draft, setDraft] = useState(saved)
|
||||||
|
const [base, setBase] = useState(saved)
|
||||||
|
if (base !== saved) {
|
||||||
|
setBase(saved)
|
||||||
|
setDraft(rebase(base, saved, draft))
|
||||||
|
}
|
||||||
const save = useSaveHouseholdSettings()
|
const save = useSaveHouseholdSettings()
|
||||||
const patch = patchOf(saved, draft)
|
const patch = patchOf(saved, draft)
|
||||||
const dirty = Object.keys(patch).length > 0
|
const dirty = Object.keys(patch).length > 0
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<string, Responder> = {}): Mock<typeof fetch> {
|
||||||
|
const all: Record<string, Responder> = {
|
||||||
|
'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<typeof fetch>((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<typeof fetch>, 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(
|
||||||
|
<ApiProvider client={api}>
|
||||||
|
<QueryClientProvider client={createQueryClient()}>
|
||||||
|
<InDataRouter path="/settings/notifications">
|
||||||
|
<NotificationsPage />
|
||||||
|
</InDataRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ApiProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<NotificationEvent, string> = {
|
||||||
|
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 (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<Header />
|
||||||
|
<NotificationPrefs />
|
||||||
|
<ChatWebhookCard onDirtyChange={setDirty} />
|
||||||
|
<ChatPreview />
|
||||||
|
<LeaveGuard dirty={dirty} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Header() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
return (
|
||||||
|
<header className={styles.header}>
|
||||||
|
<IconButton label="Back to Settings" onClick={() => void navigate('/settings')}>
|
||||||
|
<Icon icon={ChevronLeft} size="nav" />
|
||||||
|
</IconButton>
|
||||||
|
<h1 className={styles.title}>Notifications</h1>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationsCard is the notification preferences as a card, for the
|
||||||
|
// desktop settings (handoff mock 2e).
|
||||||
|
export function NotificationsCard() {
|
||||||
|
const titleId = useId()
|
||||||
|
return (
|
||||||
|
<Card className={styles.card} aria-labelledby={titleId} role="region">
|
||||||
|
<h2 id={titleId} className={styles.cardTitle}>
|
||||||
|
Notifications
|
||||||
|
</h2>
|
||||||
|
<NotificationPrefs />
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationPrefs is the event × channel matrix once it loads, then Web
|
||||||
|
// Push on this device.
|
||||||
|
function NotificationPrefs() {
|
||||||
|
const settings = useNotificationSettings()
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{settings.isPending && <Skeleton className={styles.loading} />}
|
||||||
|
{settings.isError && (
|
||||||
|
<ErrorBanner onRetry={() => void settings.refetch()}>
|
||||||
|
Notification settings could not load: {settings.error.message}
|
||||||
|
</ErrorBanner>
|
||||||
|
)}
|
||||||
|
{settings.isSuccess && <NotificationMatrix saved={settings.data} />}
|
||||||
|
<PushPermission />
|
||||||
|
<InstallHint />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Channel = 'push' | 'chat'
|
||||||
|
|
||||||
|
const channelNames: Record<Channel, string> = { 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 (
|
||||||
|
<>
|
||||||
|
<div className={styles.matrix} role="group" aria-label="Notification channels">
|
||||||
|
<span className={styles.head}>Event</span>
|
||||||
|
<span className={styles.head} aria-hidden="true">
|
||||||
|
Push
|
||||||
|
</span>
|
||||||
|
<span className={styles.head} aria-hidden="true">
|
||||||
|
Chat
|
||||||
|
</span>
|
||||||
|
{shown.prefs.map(({ event, push, chat }) => (
|
||||||
|
<Fragment key={event}>
|
||||||
|
<div className={styles.event}>
|
||||||
|
<span id={`${id}-${event}`} className={styles.eventName}>
|
||||||
|
{eventLabels[event]}
|
||||||
|
</span>
|
||||||
|
{event === 'day_of_reminder' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.time}
|
||||||
|
aria-label={`Reminder time, ${reminder}. Change`}
|
||||||
|
disabled={save.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
save.reset()
|
||||||
|
setEditingTime(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
at {reminder}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(['push', 'chat'] as const).map((channel) => (
|
||||||
|
<Toggle
|
||||||
|
key={channel}
|
||||||
|
className={styles.toggle}
|
||||||
|
aria-label={`${eventLabels[event]}: ${channelNames[channel]}`}
|
||||||
|
checked={channel === 'push' ? push : chat}
|
||||||
|
disabled={save.isPending}
|
||||||
|
onCheckedChange={(on) => toggle(event, channel, on)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{save.isError && !editingTime && (
|
||||||
|
<ErrorBanner onRetry={() => save.mutate(save.variables)}>
|
||||||
|
Notification settings were not saved: {save.error.message}
|
||||||
|
</ErrorBanner>
|
||||||
|
)}
|
||||||
|
<ReminderTimeSheet
|
||||||
|
open={editingTime}
|
||||||
|
saved={saved}
|
||||||
|
busy={save.isPending}
|
||||||
|
error={save.isError ? save.error.message : null}
|
||||||
|
onClose={() => {
|
||||||
|
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 (
|
||||||
|
<Sheet open={open} onClose={onClose} aria-labelledby={titleId}>
|
||||||
|
<div className={styles.sheet}>
|
||||||
|
<h2 id={titleId} className={styles.sheetTitle}>
|
||||||
|
Day-of dinner reminder
|
||||||
|
</h2>
|
||||||
|
<p className={styles.sheetText}>When the reminder is sent on each planned day. This changes it for everyone in the household.</p>
|
||||||
|
<TextInput
|
||||||
|
label="Reminder time"
|
||||||
|
type="time"
|
||||||
|
value={time}
|
||||||
|
required
|
||||||
|
onChange={(event) => setTime(event.target.value)}
|
||||||
|
/>
|
||||||
|
{!valid && <p className={styles.note}>Choose a time.</p>}
|
||||||
|
{error !== null && (
|
||||||
|
<p className={styles.error} role="alert">
|
||||||
|
The reminder time was not saved: {error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className={styles.sheetActions}>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button disabled={busy || !valid || time === saved.reminder_time} onClick={() => onSave(time)}>
|
||||||
|
{busy ? 'Saving…' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pushStatusText: Record<PushStatus, string> = {
|
||||||
|
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<PushOutcome | null>(null)
|
||||||
|
const [failure, setFailure] = useState<{ action: 'on' | 'off'; message: string } | null>(null)
|
||||||
|
const labelId = useId()
|
||||||
|
|
||||||
|
function run(action: 'on' | 'off', pending: Promise<PushOutcome>) {
|
||||||
|
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 (
|
||||||
|
<div className={styles.permission}>
|
||||||
|
<div className={styles.permissionRow} role="group" aria-labelledby={labelId}>
|
||||||
|
<p className={styles.permissionText}>
|
||||||
|
<span id={labelId}>Notifications on this device</span>
|
||||||
|
{': '}
|
||||||
|
{status.isPending && <Skeleton className={styles.statusSkeleton} />}
|
||||||
|
{status.isSuccess && <strong>{pushStatusText[status.data]}</strong>}
|
||||||
|
</p>
|
||||||
|
{status.data === 'off' && (
|
||||||
|
<Button variant="outline" disabled={busy} onClick={() => run('on', push.subscribePush())}>
|
||||||
|
{busy ? 'Turning on…' : 'Turn on'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{status.data === 'on' && (
|
||||||
|
<Button variant="outline" disabled={busy} onClick={() => run('off', push.unsubscribePush())}>
|
||||||
|
{busy ? 'Turning off…' : 'Turn off'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{status.data === 'denied' && (
|
||||||
|
<p className={styles.note}>This browser blocks notifications from MealQuest. Allow them in the browser's site settings.</p>
|
||||||
|
)}
|
||||||
|
{status.data === 'unsupported' && (
|
||||||
|
<p className={styles.note}>This browser can't receive notifications.</p>
|
||||||
|
)}
|
||||||
|
{outcome === 'dismissed' && (
|
||||||
|
<p className={styles.note} role="status">
|
||||||
|
The permission prompt was closed without an answer, so notifications are still off.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{status.isError && (
|
||||||
|
<ErrorBanner onRetry={() => void status.refetch()}>
|
||||||
|
Could not check notifications on this device: {status.error.message}
|
||||||
|
</ErrorBanner>
|
||||||
|
)}
|
||||||
|
{failure !== null && (
|
||||||
|
<ErrorBanner>
|
||||||
|
Notifications could not be turned {failure.action}: {failure.message}
|
||||||
|
</ErrorBanner>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Card className={styles.card} aria-labelledby={titleId} role="region">
|
||||||
|
<h2 id={titleId} className={styles.cardTitle}>
|
||||||
|
Google Chat webhook
|
||||||
|
</h2>
|
||||||
|
{webhook.isPending && <Skeleton className={styles.cardLoading} />}
|
||||||
|
{webhook.isError && (
|
||||||
|
<ErrorBanner onRetry={() => void webhook.refetch()}>
|
||||||
|
The Google Chat webhook could not load: {webhook.error.message}
|
||||||
|
</ErrorBanner>
|
||||||
|
)}
|
||||||
|
{webhook.isSuccess && <ChatWebhookForm saved={webhook.data} onDirtyChange={onDirtyChange} />}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<fieldset className={styles.lock} disabled={save.isPending}>
|
||||||
|
<TextInput
|
||||||
|
label="Webhook URL"
|
||||||
|
type="url"
|
||||||
|
className={`${styles.input} ${styles.mono}`}
|
||||||
|
value={url}
|
||||||
|
placeholder="https://chat.googleapis.com/v1/spaces/…"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
onChange={(event) => setUrl(event.target.value)}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Space name"
|
||||||
|
className={styles.input}
|
||||||
|
value={space}
|
||||||
|
placeholder="Dinner Crew"
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={(event) => setSpace(event.target.value)}
|
||||||
|
/>
|
||||||
|
{save.isError && (
|
||||||
|
<p className={styles.error} role="alert">
|
||||||
|
The webhook was not saved: {save.error.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className={styles.webhookActions}>
|
||||||
|
{connected && (
|
||||||
|
<Button variant="outline" onClick={close}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
disabled={url.trim() === '' || space.trim() === '' || save.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
test.reset()
|
||||||
|
save.mutate({ url: url.trim(), space_label: space.trim() }, { onSuccess: close })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{save.isPending ? 'Saving…' : 'Save webhook'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
label="Webhook URL"
|
||||||
|
labelHidden
|
||||||
|
className={`${styles.input} ${styles.mono}`}
|
||||||
|
value={saved.url_masked ?? ''}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
<TestResult test={test} />
|
||||||
|
<div className={styles.webhookRow}>
|
||||||
|
<p className={styles.connected}>Connected · {saved.space_label}</p>
|
||||||
|
<Button variant="outline" disabled={test.isPending} onClick={() => test.mutate()}>
|
||||||
|
{test.isPending ? 'Sending…' : 'Send test'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.link}
|
||||||
|
onClick={() => {
|
||||||
|
test.reset()
|
||||||
|
setReplacing(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Replace webhook
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<typeof useTestChatWebhook> }) {
|
||||||
|
if (test.isSuccess) {
|
||||||
|
const status = test.data.http_status
|
||||||
|
const ok = status >= 200 && status < 300
|
||||||
|
return (
|
||||||
|
<p className={ok ? styles.testOk : styles.error} role="status">
|
||||||
|
{ok ? 'Test message sent.' : `Google Chat refused the test message: HTTP ${status}.`}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (test.isError) {
|
||||||
|
return (
|
||||||
|
<p className={styles.error} role="alert">
|
||||||
|
Test message not sent: {test.error.message}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatPreview shows what a Google Chat message from MealQuest looks like.
|
||||||
|
function ChatPreview() {
|
||||||
|
return (
|
||||||
|
<figure className={styles.preview} aria-label="Google Chat message preview">
|
||||||
|
<span className={styles.avatar} aria-hidden="true">
|
||||||
|
Q
|
||||||
|
</span>
|
||||||
|
<p className={styles.previewText}>
|
||||||
|
<strong>MealQuest</strong> · preview
|
||||||
|
<br />
|
||||||
|
This week's plan is ready: Tacos, Bolognese, Tikka masala + 4 more. Open the plan →
|
||||||
|
</p>
|
||||||
|
</figure>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,12 +4,20 @@ import { Route, Routes, useLocation } from 'react-router'
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||||
import { createNetworkStatus } from '../pwa/networkStatus.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 { declaredStyle } from '../test/cssRules.ts'
|
||||||
import { InDataRouter } from '../test/dataRouter.tsx'
|
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||||
import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
|
import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
|
||||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||||
import { mockViewport } from '../test/matchMedia.ts'
|
import { mockViewport } from '../test/matchMedia.ts'
|
||||||
|
import { restorePushEnvironment, stubPushEnvironment } from '../test/push.ts'
|
||||||
import { formatClockTime, modelDisplayName } from './api.ts'
|
import { formatClockTime, modelDisplayName } from './api.ts'
|
||||||
import { SettingsPage } from './SettingsPage.tsx'
|
import { SettingsPage } from './SettingsPage.tsx'
|
||||||
|
|
||||||
@@ -18,12 +26,14 @@ const AI = AI_SETTINGS
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
|
restorePushEnvironment()
|
||||||
Reflect.deleteProperty(window, 'matchMedia')
|
Reflect.deleteProperty(window, 'matchMedia')
|
||||||
})
|
})
|
||||||
|
|
||||||
const REGULARS_PATH = '/api/v1/dinners?status=regular'
|
const REGULARS_PATH = '/api/v1/dinners?status=regular'
|
||||||
const CALENDAR_PATH = '/api/v1/integrations/google'
|
const CALENDAR_PATH = '/api/v1/integrations/google'
|
||||||
const NOTIFICATIONS_PATH = '/api/v1/settings/notifications'
|
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
|
// stubSettings answers as today's server does (see SETTINGS_API), with
|
||||||
// overrides for the paths a test changes.
|
// overrides for the paths a test changes.
|
||||||
@@ -99,6 +109,7 @@ describe('SettingsPage', () => {
|
|||||||
CALENDAR_PATH,
|
CALENDAR_PATH,
|
||||||
'/api/v1/settings/ai',
|
'/api/v1/settings/ai',
|
||||||
'/api/v1/settings/ai/models',
|
'/api/v1/settings/ai/models',
|
||||||
|
CHAT_WEBHOOK_PATH,
|
||||||
'/api/v1/settings/household',
|
'/api/v1/settings/household',
|
||||||
NOTIFICATIONS_PATH,
|
NOTIFICATIONS_PATH,
|
||||||
'/api/v1/settings/recipe-search',
|
'/api/v1/settings/recipe-search',
|
||||||
@@ -120,8 +131,9 @@ describe('SettingsPage', () => {
|
|||||||
[REGULARS_PATH]: () => jsonResponse(200, { dinners: Array.from({ length: 7 }, (_, i) => ({ id: `d${i}` })) }),
|
[REGULARS_PATH]: () => jsonResponse(200, { dinners: Array.from({ length: 7 }, (_, i) => ({ id: `d${i}` })) }),
|
||||||
[CALENDAR_PATH]: () =>
|
[CALENDAR_PATH]: () =>
|
||||||
jsonResponse(200, { ...GOOGLE_NOT_CONNECTED, status: 'connected', calendar_id: 'c1', calendar_name: 'Dinners calendar' }),
|
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()
|
renderSettings()
|
||||||
expect(await within(row('Regular dinners')).findByText('7 on rotation')).toBeInTheDocument()
|
expect(await within(row('Regular dinners')).findByText('7 on rotation')).toBeInTheDocument()
|
||||||
expect(await within(row('Google Calendar')).findByText('Connected · Dinners calendar')).toBeInTheDocument()
|
expect(await within(row('Google Calendar')).findByText('Connected · Dinners calendar')).toBeInTheDocument()
|
||||||
@@ -134,7 +146,7 @@ describe('SettingsPage', () => {
|
|||||||
stubSettings({
|
stubSettings({
|
||||||
[REGULARS_PATH]: () => jsonResponse(200, { dinners: [] }),
|
[REGULARS_PATH]: () => jsonResponse(200, { dinners: [] }),
|
||||||
[CALENDAR_PATH]: () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
|
[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()
|
renderSettings()
|
||||||
expect(await within(row('Regular dinners')).findByText('None on rotation yet')).toBeInTheDocument()
|
expect(await within(row('Regular dinners')).findByText('None on rotation yet')).toBeInTheDocument()
|
||||||
|
|||||||
@@ -12,14 +12,13 @@ import { useBreakpoint } from '../shell/useBreakpoint.ts'
|
|||||||
import {
|
import {
|
||||||
aiStatus,
|
aiStatus,
|
||||||
formatClockTime,
|
formatClockTime,
|
||||||
notificationsStatus,
|
|
||||||
regularDinnersStatus,
|
regularDinnersStatus,
|
||||||
useAISettings,
|
useAISettings,
|
||||||
useHouseholdSettings,
|
useHouseholdSettings,
|
||||||
useNotificationsStatus,
|
|
||||||
useRegularDinnersCount,
|
useRegularDinnersCount,
|
||||||
} from './api.ts'
|
} from './api.ts'
|
||||||
import { calendarStatus, useGoogleConnection } from './calendarApi.ts'
|
import { calendarStatus, useGoogleConnection } from './calendarApi.ts'
|
||||||
|
import { notificationsStatus, useChatWebhook, usePushStatus } from './notificationsApi.ts'
|
||||||
import styles from './SettingsPage.module.css'
|
import styles from './SettingsPage.module.css'
|
||||||
import { SettingsTabs } from './SettingsTabs.tsx'
|
import { SettingsTabs } from './SettingsTabs.tsx'
|
||||||
|
|
||||||
@@ -38,16 +37,17 @@ export function SettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The mobile settings list (handoff mock 1p). Each row opens its settings
|
// The mobile settings list (handoff mock 1p). Each row opens its settings
|
||||||
// page and shows a live status line. Regular dinners and Notifications &
|
// page and shows a live status line. Regular dinners reads an endpoint its
|
||||||
// Google Chat read endpoints their features have not served yet; until then
|
// feature has not served yet; until then it shows its not-yet-available
|
||||||
// each shows its not-yet-available state (see api.ts).
|
// state (see api.ts).
|
||||||
function SettingsList() {
|
function SettingsList() {
|
||||||
const household = useHouseholdSettings()
|
const household = useHouseholdSettings()
|
||||||
const ai = useAISettings()
|
const ai = useAISettings()
|
||||||
const regulars = useRegularDinnersCount()
|
const regulars = useRegularDinnersCount()
|
||||||
const calendar = useGoogleConnection()
|
const calendar = useGoogleConnection()
|
||||||
const notifications = useNotificationsStatus()
|
const push = usePushStatus()
|
||||||
const failed = [regulars, calendar, household, notifications, ai].filter((query) => query.isError)
|
const chat = useChatWebhook()
|
||||||
|
const failed = [regulars, calendar, household, push, chat, ai].filter((query) => query.isError)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div className={styles.page}>
|
||||||
@@ -93,7 +93,11 @@ function SettingsList() {
|
|||||||
to="/settings/notifications"
|
to="/settings/notifications"
|
||||||
icon={Bell}
|
icon={Bell}
|
||||||
title="Notifications & Google Chat"
|
title="Notifications & Google Chat"
|
||||||
status={status(notifications, notificationsStatus)}
|
status={
|
||||||
|
push.isSuccess
|
||||||
|
? status(chat, (webhook) => notificationsStatus(push.data, webhook))
|
||||||
|
: status(push, () => '')
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Group label="Admin" admin>
|
<Group label="Admin" admin>
|
||||||
|
|||||||
@@ -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 { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||||
import { createNetworkStatus } from '../pwa/networkStatus.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 { InDataRouter } from '../test/dataRouter.tsx'
|
||||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||||
@@ -22,6 +30,8 @@ function stubAPI(responders: Record<string, Responder> = {}): Mock<typeof fetch>
|
|||||||
'GET /api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
|
'GET /api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
|
||||||
'GET /api/v1/settings/ai/models': () => jsonResponse(200, AI_MODELS),
|
'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/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,
|
...responders,
|
||||||
}
|
}
|
||||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||||
@@ -84,7 +94,12 @@ describe.each([1440, 1180])('SettingsPage at %i px (mock 2e)', (width) => {
|
|||||||
const columns = panel('Notifications & AI')
|
const columns = panel('Notifications & AI')
|
||||||
expect(columns).toBeVisible()
|
expect(columns).toBeVisible()
|
||||||
const [left, right] = Array.from(columns.children)
|
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' })
|
const ai = within(right as HTMLElement).getByRole('region', { name: 'AI assistant' })
|
||||||
expect(within(ai).getByText('Admin')).toBeInTheDocument()
|
expect(within(ai).getByText('Admin')).toBeInTheDocument()
|
||||||
expect(await within(ai).findByRole('combobox', { name: 'Provider' })).toHaveValue('anthropic')
|
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', () => {
|
describe('SettingsPage across the 768 px breakpoint', () => {
|
||||||
it('shows the mobile list below 768 px and the sub-tabs from 768 px', async () => {
|
it('shows the mobile list below 768 px and the sub-tabs from 768 px', async () => {
|
||||||
stubAPI({
|
stubAPI({
|
||||||
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
||||||
'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
|
'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
|
||||||
'GET /api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
|
|
||||||
})
|
})
|
||||||
const resize = await renderAt(767)
|
const resize = await renderAt(767)
|
||||||
expect(screen.getByRole('link', { name: /^AI assistant/ })).toHaveAttribute('href', '/settings/ai')
|
expect(screen.getByRole('link', { name: /^AI assistant/ })).toHaveAttribute('href', '/settings/ai')
|
||||||
@@ -170,7 +212,6 @@ describe('SettingsPage across the 768 px breakpoint', () => {
|
|||||||
stubAPI({
|
stubAPI({
|
||||||
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
||||||
'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
|
'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
|
||||||
'GET /api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
|
|
||||||
})
|
})
|
||||||
const resize = await renderAt(1180)
|
const resize = await renderAt(1180)
|
||||||
fireEvent.change(await screen.findByRole('textbox', { name: 'System prompt' }), {
|
fireEvent.change(await screen.findByRole('textbox', { name: 'System prompt' }), {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Card } from '../ui/Card.tsx'
|
|||||||
import { AIAssistantCard } from './AISettingsPage.tsx'
|
import { AIAssistantCard } from './AISettingsPage.tsx'
|
||||||
import { HouseholdSettingsSections } from './HouseholdSettingsPage.tsx'
|
import { HouseholdSettingsSections } from './HouseholdSettingsPage.tsx'
|
||||||
import { LeaveGuard } from './LeaveGuard.tsx'
|
import { LeaveGuard } from './LeaveGuard.tsx'
|
||||||
|
import { ChatWebhookCard, NotificationsCard } from './NotificationsPage.tsx'
|
||||||
import { RecipeSearchCard } from './RecipeSearchCard.tsx'
|
import { RecipeSearchCard } from './RecipeSearchCard.tsx'
|
||||||
import styles from './SettingsTabs.module.css'
|
import styles from './SettingsTabs.module.css'
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
|
|||||||
const [householdDirty, setHouseholdDirty] = useState(false)
|
const [householdDirty, setHouseholdDirty] = useState(false)
|
||||||
const [aiDirty, setAIDirty] = useState(false)
|
const [aiDirty, setAIDirty] = useState(false)
|
||||||
const [recipeSearchDirty, setRecipeSearchDirty] = useState(false)
|
const [recipeSearchDirty, setRecipeSearchDirty] = useState(false)
|
||||||
|
const [chatDirty, setChatDirty] = useState(false)
|
||||||
const refs = useRef<(HTMLButtonElement | null)[]>([])
|
const refs = useRef<(HTMLButtonElement | null)[]>([])
|
||||||
const id = useId()
|
const id = useId()
|
||||||
const tabId = (value: Tab) => `${id}-tab-${value}`
|
const tabId = (value: Tab) => `${id}-tab-${value}`
|
||||||
@@ -105,11 +107,8 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
|
|||||||
styles.columns,
|
styles.columns,
|
||||||
<>
|
<>
|
||||||
<div className={styles.column}>
|
<div className={styles.column}>
|
||||||
{/* TODO(Feature 19): the Notifications matrix and Google Chat
|
<NotificationsCard />
|
||||||
webhook cards replace this placeholder. */}
|
<ChatWebhookCard onDirtyChange={setChatDirty} />
|
||||||
<Placeholder title="Notifications & Google Chat">
|
|
||||||
Notification and Google Chat webhook settings arrive in a later update.
|
|
||||||
</Placeholder>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.column}>
|
<div className={styles.column}>
|
||||||
<AIAssistantCard onDirtyChange={setAIDirty} />
|
<AIAssistantCard onDirtyChange={setAIDirty} />
|
||||||
@@ -118,7 +117,7 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
|
|||||||
</>,
|
</>,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<LeaveGuard dirty={householdDirty || aiDirty || recipeSearchDirty} />
|
<LeaveGuard dirty={householdDirty || aiDirty || recipeSearchDirty || chatDirty} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-34
@@ -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 read API. Until it lands the server answers 404 and the Regular
|
||||||
// dinners row says the count is unavailable.
|
// dinners row says the count is unavailable.
|
||||||
export const REGULAR_DINNERS_PATH = '/api/v1/dinners?status=regular'
|
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
|
// 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
|
// 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,
|
// parseHouseholdSettings refuses a body that is not the household settings,
|
||||||
// or has a time that is not HH:MM, so formatClockTime never sees one.
|
// 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() {
|
export function useSaveHouseholdSettings() {
|
||||||
const { apiFetch } = useApi()
|
const { apiFetch } = useApi()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -386,7 +389,10 @@ export function useSaveHouseholdSettings() {
|
|||||||
`PATCH ${HOUSEHOLD_SETTINGS_PATH}`,
|
`PATCH ${HOUSEHOLD_SETTINGS_PATH}`,
|
||||||
await apiFetch<unknown>(HOUSEHOLD_SETTINGS_PATH, { method: 'PATCH', body: patch }),
|
await apiFetch<unknown>(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`
|
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
|
// maskedKey shows a stored key as dots and its last four characters, all
|
||||||
// the client ever knows of it.
|
// the client ever knows of it.
|
||||||
export function maskedKey(last4: string): string {
|
export function maskedKey(last4: string): string {
|
||||||
|
|||||||
@@ -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<unknown>(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<unknown>(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<unknown>(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<unknown>(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<ChatWebhookTest> => {
|
||||||
|
const where = `POST ${CHAT_WEBHOOK_TEST_PATH}`
|
||||||
|
const body = await apiFetch<unknown>(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'}`
|
||||||
|
}
|
||||||
@@ -61,14 +61,41 @@ export const GOOGLE_NOT_CONNECTED = {
|
|||||||
last_sync_error: null,
|
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
|
// SETTINGS_API answers the settings home's requests, for stubFetchByPath, as
|
||||||
// today's server does: the dinners and notifications status endpoints do not
|
// today's server does: the dinners endpoint does not exist yet, and neither
|
||||||
// exist yet, and Google Calendar is not connected. The home's sub-tabs stay mounted on mobile, so
|
// 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.
|
// it also reads the AI models and recipe search settings.
|
||||||
export const SETTINGS_API = {
|
export const SETTINGS_API = {
|
||||||
'/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
'/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
||||||
'/api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
|
'/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/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
||||||
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
|
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
|
||||||
'/api/v1/settings/ai/models': () => jsonResponse(200, AI_MODELS),
|
'/api/v1/settings/ai/models': () => jsonResponse(200, AI_MODELS),
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { vi } from 'vitest'
|
||||||
|
import { encodeBase64url, type BrowserPushSubscription } from '../push/subscribe.ts'
|
||||||
|
|
||||||
|
function bytes(length: number, first: number): Uint8Array<ArrayBuffer> {
|
||||||
|
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')
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user