From 187bcd144285f60e9520ca8c66ded04c67af1b50 Mon Sep 17 00:00:00 2001 From: Krispire Admin Date: Thu, 11 Jun 2026 00:31:35 +0530 Subject: [PATCH] feat: notification enhancement --- internal/handler/notification.go | 43 +++++ internal/handler/push.go | 33 +++- .../repository/notification_prefs_repo.go | 99 +++++++++++ internal/repository/push_repo.go | 17 +- internal/repository/reminder_repo.go | 31 +--- internal/repository/social_graph_repo.go | 42 ++--- internal/server/server.go | 9 +- internal/service/notification_service.go | 168 +++++++++++++++--- internal/service/reminder_service.go | 34 +++- migrations/0034_notification_prefs.down.sql | 3 + migrations/0034_notification_prefs.up.sql | 41 +++++ 11 files changed, 441 insertions(+), 79 deletions(-) create mode 100644 internal/repository/notification_prefs_repo.go create mode 100644 migrations/0034_notification_prefs.down.sql create mode 100644 migrations/0034_notification_prefs.up.sql diff --git a/internal/handler/notification.go b/internal/handler/notification.go index bebd144..deca349 100644 --- a/internal/handler/notification.go +++ b/internal/handler/notification.go @@ -2,6 +2,7 @@ package handler import ( "encoding/json" + "errors" "net/http" "github.com/prabhupadasays/spsays-bff/internal/middleware" @@ -51,6 +52,48 @@ func (h *NotificationHandler) MarkAllRead(w http.ResponseWriter, r *http.Request response.NoContent(w) } +// GetPrefs — GET /notifications/prefs. The resolved Settings matrix: +// {"prefs": {"follows": {"in_app": true, "email": true, "push": true}, …}}. +func (h *NotificationHandler) GetPrefs(w http.ResponseWriter, r *http.Request) { + uid := middleware.UserID(r.Context()) + matrix, err := h.svc.PrefMatrix(r.Context(), uid) + if err != nil { + response.Error(w, http.StatusInternalServerError, "could not load notification preferences") + return + } + response.JSON(w, http.StatusOK, map[string]any{"prefs": matrix}) +} + +// UpdatePrefs — PUT /notifications/prefs { event, channel, enabled }. Writes +// one matrix cell; returns the full re-resolved matrix so the client can +// render authoritative state. +func (h *NotificationHandler) UpdatePrefs(w http.ResponseWriter, r *http.Request) { + uid := middleware.UserID(r.Context()) + var req struct { + Event string `json:"event"` + Channel string `json:"channel"` + Enabled bool `json:"enabled"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + response.Error(w, http.StatusBadRequest, "invalid JSON body") + return + } + if err := h.svc.SetPref(r.Context(), uid, req.Event, req.Channel, req.Enabled); err != nil { + if errors.Is(err, service.ErrUnknownPref) { + response.Error(w, http.StatusBadRequest, "unknown event or channel") + return + } + response.Error(w, http.StatusInternalServerError, "could not update notification preferences") + return + } + matrix, err := h.svc.PrefMatrix(r.Context(), uid) + if err != nil { + response.Error(w, http.StatusInternalServerError, "could not load notification preferences") + return + } + response.JSON(w, http.StatusOK, map[string]any{"prefs": matrix}) +} + type emailPrefsResp struct { LikeEmails bool `json:"like_emails"` } diff --git a/internal/handler/push.go b/internal/handler/push.go index 13bbd02..7dcb534 100644 --- a/internal/handler/push.go +++ b/internal/handler/push.go @@ -1,6 +1,7 @@ package handler import ( + "encoding/base64" "encoding/json" "net/http" "net/url" @@ -19,6 +20,11 @@ const ( maxEndpointLen = 2048 maxKeyLen = 256 maxDevicesPerUser = 10 + + // RFC 8291 fixes the key sizes: p256dh is an uncompressed P-256 point, + // auth a 16-byte secret. Anything else is guaranteed to fail at send time. + p256dhLen = 65 + authLen = 16 ) // validatePushEndpoint requires an https URL belonging to a known browser push @@ -46,6 +52,21 @@ func validatePushEndpoint(endpoint string) bool { return false } +// validateSubscriptionKey checks that a client key is base64(url)-decodable +// to exactly `want` bytes — the same decoding webpush-go applies at send +// time, so anything accepted here is actually deliverable. +func validateSubscriptionKey(key string, want int) bool { + if l := len(key); l == 0 || l > maxKeyLen { + return false + } + trimmed := strings.TrimRight(key, "=") + b, err := base64.RawURLEncoding.DecodeString(trimmed) + if err != nil { + b, err = base64.RawStdEncoding.DecodeString(trimmed) + } + return err == nil && len(b) == want +} + // PushHandler serves Web Push subscription management. The VAPID public key // is handed to the browser so it can subscribe; subscriptions are stored per // user. All routes require auth. @@ -98,8 +119,9 @@ func (h *PushHandler) Subscribe(w http.ResponseWriter, r *http.Request) { response.Error(w, http.StatusBadRequest, "invalid push endpoint") return } - if len(req.Keys.P256dh) > maxKeyLen || len(req.Keys.Auth) > maxKeyLen { - response.Error(w, http.StatusBadRequest, "subscription keys too long") + if !validateSubscriptionKey(req.Keys.P256dh, p256dhLen) || + !validateSubscriptionKey(req.Keys.Auth, authLen) { + response.Error(w, http.StatusBadRequest, "invalid subscription keys") return } if err := h.repo.Upsert(r.Context(), uid, req.Endpoint, req.Keys.P256dh, req.Keys.Auth, r.UserAgent()); err != nil { @@ -115,18 +137,21 @@ type unsubscribeReq struct { Endpoint string `json:"endpoint"` } -// Unsubscribe — POST /push/unsubscribe. Removes a subscription by endpoint. +// Unsubscribe — POST /push/unsubscribe. Removes one of the caller's own +// subscriptions by endpoint (scoped to the user so nobody can delete another +// account's subscription by guessing/replaying an endpoint). func (h *PushHandler) Unsubscribe(w http.ResponseWriter, r *http.Request) { if h.repo == nil { response.NoContent(w) return } + uid := middleware.UserID(r.Context()) var req unsubscribeReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Endpoint) == "" { response.Error(w, http.StatusBadRequest, "missing endpoint") return } - if err := h.repo.DeleteByEndpoint(r.Context(), req.Endpoint); err != nil { + if err := h.repo.DeleteByEndpointForUser(r.Context(), uid, req.Endpoint); err != nil { response.Error(w, http.StatusInternalServerError, "could not remove subscription") return } diff --git a/internal/repository/notification_prefs_repo.go b/internal/repository/notification_prefs_repo.go new file mode 100644 index 0000000..e06b819 --- /dev/null +++ b/internal/repository/notification_prefs_repo.go @@ -0,0 +1,99 @@ +package repository + +import ( + "context" + "fmt" +) + +// Notification delivery channels (notification_prefs.channel values). +const ( + ChannelInApp = "in_app" + ChannelEmail = "email" + ChannelPush = "push" +) + +// PrefKey is the lookup key for a (kind, channel) cell in a DisabledSet. +func PrefKey(kind, channel string) string { return kind + "/" + channel } + +// NotificationPrefsRepo stores per-user notification opt-outs: one row per +// (kind, channel) the user has turned OFF. Absence of a row means enabled, +// so the table only ever holds overrides and new kinds/channels default on. +type NotificationPrefsRepo struct{ pg *Postgres } + +func NewNotificationPrefsRepo(pg *Postgres) *NotificationPrefsRepo { + return &NotificationPrefsRepo{pg: pg} +} + +// DisabledSet returns the user's opt-outs keyed by PrefKey(kind, channel). +func (r *NotificationPrefsRepo) DisabledSet(ctx context.Context, userID string) (map[string]struct{}, error) { + rows, err := r.pg.Pool.Query(ctx, ` + SELECT kind, channel FROM notification_prefs + WHERE user_id = $1 AND enabled = FALSE`, userID) + if err != nil { + return nil, fmt.Errorf("list notification prefs: %w", err) + } + defer rows.Close() + out := map[string]struct{}{} + for rows.Next() { + var kind, channel string + if err := rows.Scan(&kind, &channel); err != nil { + return nil, fmt.Errorf("scan notification pref: %w", err) + } + out[PrefKey(kind, channel)] = struct{}{} + } + return out, rows.Err() +} + +// Disable records opt-out rows for every kind × channel pair. +func (r *NotificationPrefsRepo) Disable(ctx context.Context, userID string, kinds, channels []string) error { + _, err := r.pg.Pool.Exec(ctx, ` + INSERT INTO notification_prefs (user_id, kind, channel, enabled) + SELECT $1, k, c, FALSE + FROM unnest($2::text[]) AS k, unnest($3::text[]) AS c + ON CONFLICT (user_id, kind, channel) + DO UPDATE SET enabled = FALSE, updated_at = now()`, + userID, kinds, channels) + if err != nil { + return fmt.Errorf("disable notification prefs: %w", err) + } + return nil +} + +// Enable removes opt-out rows (absence = enabled) for every kind × channel +// pair. +func (r *NotificationPrefsRepo) Enable(ctx context.Context, userID string, kinds, channels []string) error { + _, err := r.pg.Pool.Exec(ctx, ` + DELETE FROM notification_prefs + WHERE user_id = $1 AND kind = ANY($2::text[]) AND channel = ANY($3::text[])`, + userID, kinds, channels) + if err != nil { + return fmt.Errorf("enable notification prefs: %w", err) + } + return nil +} + +// SyncLegacyColumns recomputes users.like_emails_enabled and +// users.sadhna_reminders_enabled from the prefs table, so an old frontend +// (or a rollback) reading the legacy columns stays correct. Call after any +// prefs write; drop together with the columns once fully rolled out. +func (r *NotificationPrefsRepo) SyncLegacyColumns(ctx context.Context, userID string) error { + _, err := r.pg.Pool.Exec(ctx, ` + UPDATE users SET + like_emails_enabled = NOT EXISTS ( + SELECT 1 FROM notification_prefs np + WHERE np.user_id = users.id + AND np.kind = 'post_like' AND np.channel = 'email' + AND np.enabled = FALSE), + -- The legacy reminder toggle was all-or-nothing: it reads as OFF + -- only when every reminder kind × channel cell is opted out. + sadhna_reminders_enabled = ( + SELECT count(*) FROM notification_prefs np + WHERE np.user_id = users.id + AND np.kind IN ('chant_reminder', 'quote_reminder', 'chant_incomplete_reminder') + AND np.enabled = FALSE) < 9 + WHERE id = $1`, userID) + if err != nil { + return fmt.Errorf("sync legacy notification prefs: %w", err) + } + return nil +} diff --git a/internal/repository/push_repo.go b/internal/repository/push_repo.go index 0c983dd..22119b9 100644 --- a/internal/repository/push_repo.go +++ b/internal/repository/push_repo.go @@ -74,8 +74,9 @@ func (r *PushRepo) PruneToMostRecent(ctx context.Context, userID string, keep in return nil } -// DeleteByEndpoint removes a subscription (on unsubscribe or when the push -// service reports it gone). +// DeleteByEndpoint removes a subscription (when the push service reports it +// gone). Server-internal — user-initiated unsubscribes must go through +// DeleteByEndpointForUser so one user can't remove another's subscription. func (r *PushRepo) DeleteByEndpoint(ctx context.Context, endpoint string) error { _, err := r.pg.Pool.Exec(ctx, `DELETE FROM push_subscriptions WHERE endpoint = $1`, endpoint) @@ -84,3 +85,15 @@ func (r *PushRepo) DeleteByEndpoint(ctx context.Context, endpoint string) error } return nil } + +// DeleteByEndpointForUser removes a subscription only if it belongs to the +// given user — the ownership-checked variant for the unsubscribe endpoint. +func (r *PushRepo) DeleteByEndpointForUser(ctx context.Context, userID, endpoint string) error { + _, err := r.pg.Pool.Exec(ctx, + `DELETE FROM push_subscriptions WHERE endpoint = $1 AND user_id = $2`, + endpoint, userID) + if err != nil { + return fmt.Errorf("delete push subscription: %w", err) + } + return nil +} diff --git a/internal/repository/reminder_repo.go b/internal/repository/reminder_repo.go index ff724b8..13139d8 100644 --- a/internal/repository/reminder_repo.go +++ b/internal/repository/reminder_repo.go @@ -44,30 +44,14 @@ func (r *ReminderRepo) SetTimezone(ctx context.Context, userID, tz string) error return nil } -// GetEnabled returns whether the user wants sādhana reminders. -func (r *ReminderRepo) GetEnabled(ctx context.Context, userID string) (bool, error) { - var enabled bool - err := r.pg.Pool.QueryRow(ctx, - `SELECT sadhna_reminders_enabled FROM users WHERE id = $1`, userID).Scan(&enabled) - if err != nil { - return false, fmt.Errorf("get reminder pref: %w", err) - } - return enabled, nil -} - -// SetEnabled flips the user's reminder opt-in. -func (r *ReminderRepo) SetEnabled(ctx context.Context, userID string, enabled bool) error { - _, err := r.pg.Pool.Exec(ctx, - `UPDATE users SET sadhna_reminders_enabled = $1 WHERE id = $2`, enabled, userID) - if err != nil { - return fmt.Errorf("set reminder pref: %w", err) - } - return nil -} - // DueCandidates returns reminder-enabled users who haven't already had BOTH // reminders today, with the data the worker needs to decide. Defaults are // folded in (06:00 chant, 21:30 quote, UTC tz). +// +// "Reminder-enabled" now reads notification_prefs: a user drops out of the +// scan only when every reminder kind × channel cell (3 × 3) is opted out. +// Finer-grained opt-outs (e.g. email off, push on) still match here — +// SendReminder enforces the per-channel prefs on delivery. func (r *ReminderRepo) DueCandidates(ctx context.Context) ([]ReminderCandidate, error) { rows, err := r.pg.Pool.Query(ctx, ` SELECT @@ -92,7 +76,10 @@ func (r *ReminderRepo) DueCandidates(ctx context.Context) ([]ReminderCandidate, COALESCE(u.daily_chanting_rounds, 16), u.last_chant_incomplete_reminder_on FROM users u - WHERE u.sadhna_reminders_enabled = TRUE + WHERE (SELECT count(*) FROM notification_prefs np + WHERE np.user_id = u.id + AND np.kind IN ('chant_reminder', 'quote_reminder', 'chant_incomplete_reminder') + AND np.enabled = FALSE) < 9 AND ( u.last_chant_reminder_on IS NULL OR u.last_chant_reminder_on < (now() AT TIME ZONE 'UTC')::date OR u.last_quote_reminder_on IS NULL OR u.last_quote_reminder_on < (now() AT TIME ZONE 'UTC')::date diff --git a/internal/repository/social_graph_repo.go b/internal/repository/social_graph_repo.go index 4cd6f6c..cdf1120 100644 --- a/internal/repository/social_graph_repo.go +++ b/internal/repository/social_graph_repo.go @@ -317,7 +317,9 @@ func (r *NotificationRepo) Create(ctx context.Context, userID string, actorID *s // ListByUser returns a recipient's notifications, newest first, with the // actor's display fields joined in for rendering (nil for system -// notifications or since-deleted actors). +// notifications or since-deleted actors). Kinds the user has turned off for +// the in-app channel are filtered at read time — the rows still persist (they +// back like-aggregation and reappear if the pref is re-enabled). func (r *NotificationRepo) ListByUser(ctx context.Context, userID string, limit, offset int) ([]model.Notification, error) { rows, err := r.pg.Pool.Query(ctx, ` SELECT n.id, n.user_id, n.actor_id, n.kind, n.post_id, n.actor_count, @@ -326,6 +328,10 @@ func (r *NotificationRepo) ListByUser(ctx context.Context, userID string, limit, FROM notifications n LEFT JOIN users u ON u.id = n.actor_id WHERE n.user_id = $1 + AND NOT EXISTS ( + SELECT 1 FROM notification_prefs np + WHERE np.user_id = n.user_id AND np.kind = n.kind + AND np.channel = 'in_app' AND np.enabled = FALSE) ORDER BY n.created_at DESC LIMIT $2 OFFSET $3`, userID, limit, offset, ) @@ -394,32 +400,18 @@ func (r *NotificationRepo) CreateMention(ctx context.Context, userID, actorID, p return nil } -// LikeEmailsEnabled reports whether the user wants aggregated like emails. -func (r *NotificationRepo) LikeEmailsEnabled(ctx context.Context, userID string) (bool, error) { - var enabled bool - if err := r.pg.Pool.QueryRow(ctx, - `SELECT like_emails_enabled FROM users WHERE id = $1`, userID, - ).Scan(&enabled); err != nil { - return false, fmt.Errorf("get like-email pref: %w", err) - } - return enabled, nil -} - -// SetLikeEmailsEnabled flips the user's like-email opt-in. -func (r *NotificationRepo) SetLikeEmailsEnabled(ctx context.Context, userID string, enabled bool) error { - if _, err := r.pg.Pool.Exec(ctx, - `UPDATE users SET like_emails_enabled = $1 WHERE id = $2`, enabled, userID, - ); err != nil { - return fmt.Errorf("set like-email pref: %w", err) - } - return nil -} - -// CountUnread returns how many of a recipient's notifications are unread. +// CountUnread returns how many of a recipient's notifications are unread, +// excluding kinds the user has turned off for the in-app channel (matching +// ListByUser, so the badge never counts rows the feed won't show). func (r *NotificationRepo) CountUnread(ctx context.Context, userID string) (int, error) { var n int - err := r.pg.Pool.QueryRow(ctx, - `SELECT count(*) FROM notifications WHERE user_id = $1 AND read_at IS NULL`, userID, + err := r.pg.Pool.QueryRow(ctx, ` + SELECT count(*) FROM notifications n + WHERE n.user_id = $1 AND n.read_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM notification_prefs np + WHERE np.user_id = n.user_id AND np.kind = n.kind + AND np.channel = 'in_app' AND np.enabled = FALSE)`, userID, ).Scan(&n) if err != nil { return 0, fmt.Errorf("count unread notifications: %w", err) diff --git a/internal/server/server.go b/internal/server/server.go index f7b3e39..20e7f4e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -213,7 +213,8 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) { pushRepo := repository.NewPushRepo(s.pg) pushSender := service.NewPushSender(pushRepo, cfg.VAPIDPublicKey, cfg.VAPIDPrivateKey, cfg.VAPIDSubject, logger) s.pushH = handler.NewPushHandler(pushSender, pushRepo) - notifSvc := service.NewNotificationService(notifRepo, userRepo, mail, rtClient, pushSender, logger, cfg.FrontendURL) + notifPrefsRepo := repository.NewNotificationPrefsRepo(s.pg) + notifSvc := service.NewNotificationService(notifRepo, notifPrefsRepo, userRepo, mail, rtClient, pushSender, logger, cfg.FrontendURL) // Wire the notification seam into the post service so a new like notifies // the author (push + realtime; not the liker themselves). postSvc.SetNotifier(notifSvc) @@ -222,7 +223,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) { // websocket) to chant / post a quote at their local time. Shares the // notification seam above so all three channels fire. reminderRepo := repository.NewReminderRepo(s.pg) - reminderSvc := service.NewReminderService(reminderRepo, notifSvc, logger, cfg.FrontendURL) + reminderSvc := service.NewReminderService(reminderRepo, notifPrefsRepo, notifSvc, logger, cfg.FrontendURL) s.reminderH = handler.NewReminderHandler(reminderSvc) wctx := s.workerCtx if wctx == nil { @@ -799,6 +800,10 @@ func (s *Server) setupRouter() { r.Get("/", s.notificationH.List) r.Get("/unread-count", s.notificationH.UnreadCount) r.Post("/read", s.notificationH.MarkAllRead) + r.Get("/prefs", s.notificationH.GetPrefs) + r.Put("/prefs", s.notificationH.UpdatePrefs) + // Legacy single-toggle endpoints — shims over /prefs, kept until + // old clients are gone. r.Get("/email-prefs", s.notificationH.GetEmailPrefs) r.Put("/email-prefs", s.notificationH.UpdateEmailPrefs) }) diff --git a/internal/service/notification_service.go b/internal/service/notification_service.go index cd5921b..ed01ba0 100644 --- a/internal/service/notification_service.go +++ b/internal/service/notification_service.go @@ -15,8 +15,10 @@ package service import ( "bytes" "context" + "errors" "fmt" "html/template" + "slices" "strings" "time" @@ -41,8 +43,23 @@ const ( NotifChantIncomplete = "chant_incomplete_reminder" ) +// PrefCategories groups notification kinds into the coarser event categories +// the Settings matrix exposes — storage stays kind-level (so backfilled +// opt-outs map exactly), but the UI reads/writes whole categories. +var PrefCategories = map[string][]string{ + "follows": {NotifFollowRequest, NotifFollowAccepted}, + "post_interactions": {NotifPostLike, NotifPostMention}, + "sadhna_reminders": {NotifChantReminder, NotifQuoteReminder, NotifChantIncomplete}, +} + +// PrefChannels is every delivery channel a matrix cell can address. +var PrefChannels = []string{ + repository.ChannelInApp, repository.ChannelEmail, repository.ChannelPush, +} + type NotificationService struct { notifs *repository.NotificationRepo + prefs *repository.NotificationPrefsRepo users *repository.UserRepo mail mailer.Mailer rt *realtime.SoketiClient // optional — nil disables WebSocket push @@ -51,9 +68,10 @@ type NotificationService struct { frontendURL string } -func NewNotificationService(notifs *repository.NotificationRepo, users *repository.UserRepo, mail mailer.Mailer, rt *realtime.SoketiClient, push *PushSender, logger *zap.Logger, frontendURL string) *NotificationService { +func NewNotificationService(notifs *repository.NotificationRepo, prefs *repository.NotificationPrefsRepo, users *repository.UserRepo, mail mailer.Mailer, rt *realtime.SoketiClient, push *PushSender, logger *zap.Logger, frontendURL string) *NotificationService { return &NotificationService{ notifs: notifs, + prefs: prefs, users: users, mail: mail, rt: rt, @@ -63,6 +81,27 @@ func NewNotificationService(notifs *repository.NotificationRepo, users *reposito } } +// disabledPrefs loads the recipient's notification opt-outs. Fails open +// (empty set): absence means enabled, and a prefs lookup hiccup must never +// silence delivery for users who opted out of nothing. +func (s *NotificationService) disabledPrefs(ctx context.Context, userID string) map[string]struct{} { + if s.prefs == nil { + return nil + } + set, err := s.prefs.DisabledSet(ctx, userID) + if err != nil { + s.logger.Warn("load notification prefs failed", zap.String("user", userID), zap.Error(err)) + return nil + } + return set +} + +// prefOff reports whether (kind, channel) is opted out in a disabled set. +func prefOff(set map[string]struct{}, kind, channel string) bool { + _, off := set[repository.PrefKey(kind, channel)] + return off +} + // List returns the caller's notifications, newest first. func (s *NotificationService) List(ctx context.Context, userID string, limit, offset int) ([]model.Notification, error) { if limit <= 0 || limit > 100 { @@ -85,14 +124,82 @@ func (s *NotificationService) MarkAllRead(ctx context.Context, userID string) er return s.notifs.MarkAllRead(ctx, userID) } -// LikeEmailPref / SetLikeEmailPref back the Settings → Notifications toggle -// for aggregated like emails. +// ErrUnknownPref marks a PrefMatrix/SetPref call with an unrecognized +// category or channel (a client bug — surfaced as a 400). +var ErrUnknownPref = errors.New("unknown notification preference") + +// PrefMatrix returns the resolved Settings matrix: category → channel → +// enabled. A cell reads enabled only when every kind in the category is +// enabled on that channel, so a backfilled single-kind opt-out (like emails) +// shows as the category being off rather than silently on. +func (s *NotificationService) PrefMatrix(ctx context.Context, userID string) (map[string]map[string]bool, error) { + off, err := s.prefs.DisabledSet(ctx, userID) + if err != nil { + return nil, err + } + matrix := make(map[string]map[string]bool, len(PrefCategories)) + for category, kinds := range PrefCategories { + row := make(map[string]bool, len(PrefChannels)) + for _, ch := range PrefChannels { + enabled := true + for _, kind := range kinds { + if prefOff(off, kind, ch) { + enabled = false + break + } + } + row[ch] = enabled + } + matrix[category] = row + } + return matrix, nil +} + +// SetPref writes one category × channel cell, fanning out to every kind in +// the category, and keeps the legacy users columns in sync for old clients. +func (s *NotificationService) SetPref(ctx context.Context, userID, category, channel string, enabled bool) error { + kinds, ok := PrefCategories[category] + if !ok { + return ErrUnknownPref + } + if !slices.Contains(PrefChannels, channel) { + return ErrUnknownPref + } + var err error + if enabled { + err = s.prefs.Enable(ctx, userID, kinds, []string{channel}) + } else { + err = s.prefs.Disable(ctx, userID, kinds, []string{channel}) + } + if err != nil { + return err + } + return s.prefs.SyncLegacyColumns(ctx, userID) +} + +// LikeEmailPref / SetLikeEmailPref back the legacy like-email endpoint — +// now a shim over the prefs table (post_like × email) that dual-writes the +// old users column. Kept until the matrix UI is fully rolled out. func (s *NotificationService) LikeEmailPref(ctx context.Context, userID string) (bool, error) { - return s.notifs.LikeEmailsEnabled(ctx, userID) + off, err := s.prefs.DisabledSet(ctx, userID) + if err != nil { + return false, err + } + return !prefOff(off, NotifPostLike, repository.ChannelEmail), nil } func (s *NotificationService) SetLikeEmailPref(ctx context.Context, userID string, enabled bool) error { - return s.notifs.SetLikeEmailsEnabled(ctx, userID, enabled) + kinds, channels := []string{NotifPostLike}, []string{repository.ChannelEmail} + var err error + if enabled { + err = s.prefs.Enable(ctx, userID, kinds, channels) + } else { + err = s.prefs.Disable(ctx, userID, kinds, channels) + } + if err != nil { + return err + } + return s.prefs.SyncLegacyColumns(ctx, userID) } // NotifyFollowRequest tells `recipientID` that `actorID` asked to follow @@ -119,8 +226,11 @@ func (s *NotificationService) NotifyPostLike(ctx context.Context, postID, author if authorID == "" || likerID == "" || authorID == likerID { return } - // Aggregate into the single (author, post) like-notification row. Returns - // the new liker count and whether an aggregated email is due (throttled). + off := s.disabledPrefs(ctx, authorID) + + // Aggregate into the single (author, post) like-notification row — always, + // even with in-app off: the row carries the like aggregation + email + // throttle state, and read-time filtering keeps it out of the feed. count, emailDue, err := s.notifs.UpsertLike(ctx, authorID, likerID, postID, likeEmailThrottle) if err != nil { s.logger.Warn("aggregate like notification failed", @@ -136,7 +246,7 @@ func (s *NotificationService) NotifyPostLike(ctx context.Context, postID, author body := likeMessage(who, count) // Realtime nudge — carry actor_count so the toast can read "and N others". - if s.rt != nil { + if s.rt != nil && !prefOff(off, NotifPostLike, repository.ChannelInApp) { payload := map[string]any{"kind": NotifPostLike, "actor_count": count} if actorUser != nil { payload["actor_name"] = actorDisplay(actorUser) @@ -156,7 +266,7 @@ func (s *NotificationService) NotifyPostLike(ctx context.Context, postID, author } // Web Push — tagged per-post so repeated likes collapse into one. - if s.push != nil { + if s.push != nil && !prefOff(off, NotifPostLike, repository.ChannelPush) { s.push.SendToUser(ctx, authorID, PushPayload{ Title: "New Like", Body: body, @@ -172,7 +282,7 @@ func (s *NotificationService) NotifyPostLike(ctx context.Context, postID, author if !emailDue || author == nil || author.Email == nil || strings.TrimSpace(*author.Email) == "" { return } - if enabled, perr := s.notifs.LikeEmailsEnabled(ctx, authorID); perr != nil || !enabled { + if prefOff(off, NotifPostLike, repository.ChannelEmail) { return } var buf bytes.Buffer @@ -200,14 +310,17 @@ func (s *NotificationService) NotifyPostMention(ctx context.Context, mentionedID if mentionedID == "" || actorID == "" || mentionedID == actorID { return } + off := s.disabledPrefs(ctx, mentionedID) if err := s.notifs.CreateMention(ctx, mentionedID, actorID, postID); err != nil { s.logger.Warn("persist mention notification failed", zap.String("recipient", mentionedID), zap.Error(err)) } actorUser, _ := s.users.FindByID(ctx, actorID) - s.publishRealtime(ctx, mentionedID, NotifPostMention, actorUser) + if !prefOff(off, NotifPostMention, repository.ChannelInApp) { + s.publishRealtime(ctx, mentionedID, NotifPostMention, actorUser) + } - if s.push == nil { + if s.push == nil || prefOff(off, NotifPostMention, repository.ChannelPush) { return } who := "Someone" @@ -239,9 +352,13 @@ func likeMessage(who string, count int) string { return fmt.Sprintf("%s and %d %s liked your post", who, others, noun) } -// dispatch persists the notification and sends the matching email. All -// failures are logged, never propagated. +// dispatch persists the notification and sends it over every channel the +// recipient hasn't opted out of. All failures are logged, never propagated. func (s *NotificationService) dispatch(ctx context.Context, recipientID, actorID, kind, subjectFmt string) { + off := s.disabledPrefs(ctx, recipientID) + + // Always persist — the in-app pref filters display at read time, and + // the row is the durable record either way. actor := actorID if _, err := s.notifs.Create(ctx, recipientID, &actor, kind); err != nil { s.logger.Warn("persist notification failed", @@ -256,15 +373,21 @@ func (s *NotificationService) dispatch(ctx context.Context, recipientID, actorID } // Realtime push (best-effort) — a lightweight nudge so the client can - // bump the bell + toast immediately. Fires regardless of whether the - // recipient has an email; authoritative data still comes from REST. - s.publishRealtime(ctx, recipientID, kind, actorUser) + // bump the bell + toast immediately. Part of the in-app channel. + if !prefOff(off, kind, repository.ChannelInApp) { + s.publishRealtime(ctx, recipientID, kind, actorUser) + } // Web Push (best-effort) — reaches the recipient even with the app // closed. Same seam as realtime/email; nil sender is a no-op. - s.publishPush(ctx, recipientID, kind, actorUser) + if !prefOff(off, kind, repository.ChannelPush) { + s.publishPush(ctx, recipientID, kind, actorUser) + } // Email (best-effort). + if prefOff(off, kind, repository.ChannelEmail) { + return + } recipient, err := s.users.FindByID(ctx, recipientID) if err != nil { s.logger.Warn("notification recipient lookup failed", zap.String("recipient", recipientID), zap.Error(err)) @@ -318,20 +441,25 @@ func (s *NotificationService) publishRealtime(ctx context.Context, recipientID, // and an email. Best-effort; nothing is persisted to the notification // center. `url` is where tapping the notification lands. func (s *NotificationService) SendReminder(ctx context.Context, userID, kind, title, body, url string) { + off := s.disabledPrefs(ctx, userID) + // Realtime toast — a distinct "reminder" event so the client toasts // without bumping the unread badge (reminders have no DB row). - if s.rt != nil { + if s.rt != nil && !prefOff(off, kind, repository.ChannelInApp) { _ = s.rt.Trigger(ctx, "notifications."+userID, "reminder", map[string]any{ "kind": kind, "title": title, "body": body, "url": url, }) } // Web Push (reaches closed apps). - if s.push != nil { + if s.push != nil && !prefOff(off, kind, repository.ChannelPush) { s.push.SendToUser(ctx, userID, PushPayload{Title: title, Body: body, URL: url, Icon: s.iconURL(), Tag: kind, RequireInteraction: true}) } // Email (only if there's an address on file). + if prefOff(off, kind, repository.ChannelEmail) { + return + } recipient, err := s.users.FindByID(ctx, userID) if err != nil || recipient == nil || recipient.Email == nil || strings.TrimSpace(*recipient.Email) == "" { return diff --git a/internal/service/reminder_service.go b/internal/service/reminder_service.go index 9877637..57e3e44 100644 --- a/internal/service/reminder_service.go +++ b/internal/service/reminder_service.go @@ -50,27 +50,53 @@ const ( type ReminderService struct { repo *repository.ReminderRepo + prefs *repository.NotificationPrefsRepo notif *NotificationService logger *zap.Logger sadhnaURL string } -func NewReminderService(repo *repository.ReminderRepo, notif *NotificationService, logger *zap.Logger, frontendURL string) *ReminderService { +func NewReminderService(repo *repository.ReminderRepo, prefs *repository.NotificationPrefsRepo, notif *NotificationService, logger *zap.Logger, frontendURL string) *ReminderService { return &ReminderService{ repo: repo, + prefs: prefs, notif: notif, logger: logger, sadhnaURL: strings.TrimRight(frontendURL, "/") + "/sadhna-streak", } } -// Enabled / SetEnabled / SetTimezone back the settings + tz-capture endpoints. +// Enabled / SetEnabled back the legacy all-or-nothing reminder toggle — now a +// shim over the per-kind/channel prefs (dual-writing the legacy users column) +// so old clients and the new matrix stay consistent. "Enabled" means at least +// one reminder kind × channel cell is still on. func (s *ReminderService) Enabled(ctx context.Context, userID string) (bool, error) { - return s.repo.GetEnabled(ctx, userID) + off, err := s.prefs.DisabledSet(ctx, userID) + if err != nil { + return false, err + } + for _, kind := range PrefCategories["sadhna_reminders"] { + for _, ch := range PrefChannels { + if _, disabled := off[repository.PrefKey(kind, ch)]; !disabled { + return true, nil + } + } + } + return false, nil } func (s *ReminderService) SetEnabled(ctx context.Context, userID string, enabled bool) error { - return s.repo.SetEnabled(ctx, userID, enabled) + kinds := PrefCategories["sadhna_reminders"] + var err error + if enabled { + err = s.prefs.Enable(ctx, userID, kinds, PrefChannels) + } else { + err = s.prefs.Disable(ctx, userID, kinds, PrefChannels) + } + if err != nil { + return err + } + return s.prefs.SyncLegacyColumns(ctx, userID) } // SetTimezone validates the IANA zone before storing so a bad value can never diff --git a/migrations/0034_notification_prefs.down.sql b/migrations/0034_notification_prefs.down.sql new file mode 100644 index 0000000..06110e9 --- /dev/null +++ b/migrations/0034_notification_prefs.down.sql @@ -0,0 +1,3 @@ +-- The legacy users columns were dual-written the whole time, so dropping the +-- table loses no opt-out state that the old code paths read. +DROP TABLE IF EXISTS notification_prefs; diff --git a/migrations/0034_notification_prefs.up.sql b/migrations/0034_notification_prefs.up.sql new file mode 100644 index 0000000..4bf1b68 --- /dev/null +++ b/migrations/0034_notification_prefs.up.sql @@ -0,0 +1,41 @@ +-- Per-event × per-channel notification preferences. +-- +-- One row per (user, kind, channel) the user has turned OFF. Absence of a row +-- means enabled — the table stores opt-outs only, so new kinds/channels are +-- enabled for everyone with no backfill, and existing users keep receiving +-- everything they receive today. +-- +-- `kind` is the notifications.kind machine string (follow_request, post_like, +-- chant_reminder, …); the Settings UI groups kinds into coarser categories +-- but storage stays kind-level so backfilled opt-outs map exactly. +CREATE TABLE IF NOT EXISTS notification_prefs ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + channel TEXT NOT NULL CHECK (channel IN ('in_app', 'email', 'push')), + enabled BOOLEAN NOT NULL DEFAULT FALSE, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, kind, channel) +); + +-- Backfill the existing like-email opt-out at exactly its old granularity: +-- post_like × email only (NOT the whole post-interactions category, so these +-- users don't accidentally lose anything else). +INSERT INTO notification_prefs (user_id, kind, channel, enabled) +SELECT id, 'post_like', 'email', FALSE +FROM users WHERE like_emails_enabled = FALSE +ON CONFLICT DO NOTHING; + +-- Backfill the reminder opt-out: the old toggle silenced all channels at +-- once, so it maps to every reminder kind × every channel. +INSERT INTO notification_prefs (user_id, kind, channel, enabled) +SELECT u.id, k.kind, c.channel, FALSE +FROM users u +CROSS JOIN (VALUES ('chant_reminder'), ('quote_reminder'), ('chant_incomplete_reminder')) AS k(kind) +CROSS JOIN (VALUES ('in_app'), ('email'), ('push')) AS c(channel) +WHERE u.sadhna_reminders_enabled = FALSE +ON CONFLICT DO NOTHING; + +-- users.like_emails_enabled and users.sadhna_reminders_enabled stay for now: +-- the prefs endpoints dual-write them so an old frontend (or a rollback) +-- keeps working. They get dropped in a later migration once the new +-- endpoints are fully rolled out.