feat: notification enhancement

This commit is contained in:
Krispire Admin
2026-06-11 00:31:35 +05:30
parent cb62adf2de
commit 187bcd1442
11 changed files with 441 additions and 79 deletions
+43
View File
@@ -2,6 +2,7 @@ package handler
import ( import (
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"github.com/prabhupadasays/spsays-bff/internal/middleware" "github.com/prabhupadasays/spsays-bff/internal/middleware"
@@ -51,6 +52,48 @@ func (h *NotificationHandler) MarkAllRead(w http.ResponseWriter, r *http.Request
response.NoContent(w) 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 { type emailPrefsResp struct {
LikeEmails bool `json:"like_emails"` LikeEmails bool `json:"like_emails"`
} }
+29 -4
View File
@@ -1,6 +1,7 @@
package handler package handler
import ( import (
"encoding/base64"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/url" "net/url"
@@ -19,6 +20,11 @@ const (
maxEndpointLen = 2048 maxEndpointLen = 2048
maxKeyLen = 256 maxKeyLen = 256
maxDevicesPerUser = 10 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 // validatePushEndpoint requires an https URL belonging to a known browser push
@@ -46,6 +52,21 @@ func validatePushEndpoint(endpoint string) bool {
return false 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 // PushHandler serves Web Push subscription management. The VAPID public key
// is handed to the browser so it can subscribe; subscriptions are stored per // is handed to the browser so it can subscribe; subscriptions are stored per
// user. All routes require auth. // 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") response.Error(w, http.StatusBadRequest, "invalid push endpoint")
return return
} }
if len(req.Keys.P256dh) > maxKeyLen || len(req.Keys.Auth) > maxKeyLen { if !validateSubscriptionKey(req.Keys.P256dh, p256dhLen) ||
response.Error(w, http.StatusBadRequest, "subscription keys too long") !validateSubscriptionKey(req.Keys.Auth, authLen) {
response.Error(w, http.StatusBadRequest, "invalid subscription keys")
return return
} }
if err := h.repo.Upsert(r.Context(), uid, req.Endpoint, req.Keys.P256dh, req.Keys.Auth, r.UserAgent()); err != nil { 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"` 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) { func (h *PushHandler) Unsubscribe(w http.ResponseWriter, r *http.Request) {
if h.repo == nil { if h.repo == nil {
response.NoContent(w) response.NoContent(w)
return return
} }
uid := middleware.UserID(r.Context())
var req unsubscribeReq var req unsubscribeReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Endpoint) == "" { if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Endpoint) == "" {
response.Error(w, http.StatusBadRequest, "missing endpoint") response.Error(w, http.StatusBadRequest, "missing endpoint")
return 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") response.Error(w, http.StatusInternalServerError, "could not remove subscription")
return return
} }
@@ -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
}
+15 -2
View File
@@ -74,8 +74,9 @@ func (r *PushRepo) PruneToMostRecent(ctx context.Context, userID string, keep in
return nil return nil
} }
// DeleteByEndpoint removes a subscription (on unsubscribe or when the push // DeleteByEndpoint removes a subscription (when the push service reports it
// service reports it gone). // 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 { func (r *PushRepo) DeleteByEndpoint(ctx context.Context, endpoint string) error {
_, err := r.pg.Pool.Exec(ctx, _, err := r.pg.Pool.Exec(ctx,
`DELETE FROM push_subscriptions WHERE endpoint = $1`, endpoint) `DELETE FROM push_subscriptions WHERE endpoint = $1`, endpoint)
@@ -84,3 +85,15 @@ func (r *PushRepo) DeleteByEndpoint(ctx context.Context, endpoint string) error
} }
return nil 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
}
+9 -22
View File
@@ -44,30 +44,14 @@ func (r *ReminderRepo) SetTimezone(ctx context.Context, userID, tz string) error
return nil 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 // DueCandidates returns reminder-enabled users who haven't already had BOTH
// reminders today, with the data the worker needs to decide. Defaults are // reminders today, with the data the worker needs to decide. Defaults are
// folded in (06:00 chant, 21:30 quote, UTC tz). // 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) { func (r *ReminderRepo) DueCandidates(ctx context.Context) ([]ReminderCandidate, error) {
rows, err := r.pg.Pool.Query(ctx, ` rows, err := r.pg.Pool.Query(ctx, `
SELECT SELECT
@@ -92,7 +76,10 @@ func (r *ReminderRepo) DueCandidates(ctx context.Context) ([]ReminderCandidate,
COALESCE(u.daily_chanting_rounds, 16), COALESCE(u.daily_chanting_rounds, 16),
u.last_chant_incomplete_reminder_on u.last_chant_incomplete_reminder_on
FROM users u 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 ( AND (
u.last_chant_reminder_on IS NULL OR u.last_chant_reminder_on < (now() AT TIME ZONE 'UTC')::date 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 OR u.last_quote_reminder_on IS NULL OR u.last_quote_reminder_on < (now() AT TIME ZONE 'UTC')::date
+17 -25
View File
@@ -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 // ListByUser returns a recipient's notifications, newest first, with the
// actor's display fields joined in for rendering (nil for system // 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) { func (r *NotificationRepo) ListByUser(ctx context.Context, userID string, limit, offset int) ([]model.Notification, error) {
rows, err := r.pg.Pool.Query(ctx, ` rows, err := r.pg.Pool.Query(ctx, `
SELECT n.id, n.user_id, n.actor_id, n.kind, n.post_id, n.actor_count, 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 FROM notifications n
LEFT JOIN users u ON u.id = n.actor_id LEFT JOIN users u ON u.id = n.actor_id
WHERE n.user_id = $1 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 ORDER BY n.created_at DESC
LIMIT $2 OFFSET $3`, userID, limit, offset, LIMIT $2 OFFSET $3`, userID, limit, offset,
) )
@@ -394,32 +400,18 @@ func (r *NotificationRepo) CreateMention(ctx context.Context, userID, actorID, p
return nil return nil
} }
// LikeEmailsEnabled reports whether the user wants aggregated like emails. // CountUnread returns how many of a recipient's notifications are unread,
func (r *NotificationRepo) LikeEmailsEnabled(ctx context.Context, userID string) (bool, error) { // excluding kinds the user has turned off for the in-app channel (matching
var enabled bool // ListByUser, so the badge never counts rows the feed won't show).
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.
func (r *NotificationRepo) CountUnread(ctx context.Context, userID string) (int, error) { func (r *NotificationRepo) CountUnread(ctx context.Context, userID string) (int, error) {
var n int var n int
err := r.pg.Pool.QueryRow(ctx, err := r.pg.Pool.QueryRow(ctx, `
`SELECT count(*) FROM notifications WHERE user_id = $1 AND read_at IS NULL`, userID, 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) ).Scan(&n)
if err != nil { if err != nil {
return 0, fmt.Errorf("count unread notifications: %w", err) return 0, fmt.Errorf("count unread notifications: %w", err)
+7 -2
View File
@@ -213,7 +213,8 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
pushRepo := repository.NewPushRepo(s.pg) pushRepo := repository.NewPushRepo(s.pg)
pushSender := service.NewPushSender(pushRepo, cfg.VAPIDPublicKey, cfg.VAPIDPrivateKey, cfg.VAPIDSubject, logger) pushSender := service.NewPushSender(pushRepo, cfg.VAPIDPublicKey, cfg.VAPIDPrivateKey, cfg.VAPIDSubject, logger)
s.pushH = handler.NewPushHandler(pushSender, pushRepo) 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 // Wire the notification seam into the post service so a new like notifies
// the author (push + realtime; not the liker themselves). // the author (push + realtime; not the liker themselves).
postSvc.SetNotifier(notifSvc) 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 // websocket) to chant / post a quote at their local time. Shares the
// notification seam above so all three channels fire. // notification seam above so all three channels fire.
reminderRepo := repository.NewReminderRepo(s.pg) 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) s.reminderH = handler.NewReminderHandler(reminderSvc)
wctx := s.workerCtx wctx := s.workerCtx
if wctx == nil { if wctx == nil {
@@ -799,6 +800,10 @@ func (s *Server) setupRouter() {
r.Get("/", s.notificationH.List) r.Get("/", s.notificationH.List)
r.Get("/unread-count", s.notificationH.UnreadCount) r.Get("/unread-count", s.notificationH.UnreadCount)
r.Post("/read", s.notificationH.MarkAllRead) 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.Get("/email-prefs", s.notificationH.GetEmailPrefs)
r.Put("/email-prefs", s.notificationH.UpdateEmailPrefs) r.Put("/email-prefs", s.notificationH.UpdateEmailPrefs)
}) })
+148 -20
View File
@@ -15,8 +15,10 @@ package service
import ( import (
"bytes" "bytes"
"context" "context"
"errors"
"fmt" "fmt"
"html/template" "html/template"
"slices"
"strings" "strings"
"time" "time"
@@ -41,8 +43,23 @@ const (
NotifChantIncomplete = "chant_incomplete_reminder" 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 { type NotificationService struct {
notifs *repository.NotificationRepo notifs *repository.NotificationRepo
prefs *repository.NotificationPrefsRepo
users *repository.UserRepo users *repository.UserRepo
mail mailer.Mailer mail mailer.Mailer
rt *realtime.SoketiClient // optional — nil disables WebSocket push rt *realtime.SoketiClient // optional — nil disables WebSocket push
@@ -51,9 +68,10 @@ type NotificationService struct {
frontendURL string 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{ return &NotificationService{
notifs: notifs, notifs: notifs,
prefs: prefs,
users: users, users: users,
mail: mail, mail: mail,
rt: rt, 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. // List returns the caller's notifications, newest first.
func (s *NotificationService) List(ctx context.Context, userID string, limit, offset int) ([]model.Notification, error) { func (s *NotificationService) List(ctx context.Context, userID string, limit, offset int) ([]model.Notification, error) {
if limit <= 0 || limit > 100 { 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) return s.notifs.MarkAllRead(ctx, userID)
} }
// LikeEmailPref / SetLikeEmailPref back the Settings → Notifications toggle // ErrUnknownPref marks a PrefMatrix/SetPref call with an unrecognized
// for aggregated like emails. // 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) { 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 { 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 // 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 { if authorID == "" || likerID == "" || authorID == likerID {
return return
} }
// Aggregate into the single (author, post) like-notification row. Returns off := s.disabledPrefs(ctx, authorID)
// the new liker count and whether an aggregated email is due (throttled).
// 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) count, emailDue, err := s.notifs.UpsertLike(ctx, authorID, likerID, postID, likeEmailThrottle)
if err != nil { if err != nil {
s.logger.Warn("aggregate like notification failed", s.logger.Warn("aggregate like notification failed",
@@ -136,7 +246,7 @@ func (s *NotificationService) NotifyPostLike(ctx context.Context, postID, author
body := likeMessage(who, count) body := likeMessage(who, count)
// Realtime nudge — carry actor_count so the toast can read "and N others". // 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} payload := map[string]any{"kind": NotifPostLike, "actor_count": count}
if actorUser != nil { if actorUser != nil {
payload["actor_name"] = actorDisplay(actorUser) 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. // 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{ s.push.SendToUser(ctx, authorID, PushPayload{
Title: "New Like", Title: "New Like",
Body: body, 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) == "" { if !emailDue || author == nil || author.Email == nil || strings.TrimSpace(*author.Email) == "" {
return return
} }
if enabled, perr := s.notifs.LikeEmailsEnabled(ctx, authorID); perr != nil || !enabled { if prefOff(off, NotifPostLike, repository.ChannelEmail) {
return return
} }
var buf bytes.Buffer var buf bytes.Buffer
@@ -200,14 +310,17 @@ func (s *NotificationService) NotifyPostMention(ctx context.Context, mentionedID
if mentionedID == "" || actorID == "" || mentionedID == actorID { if mentionedID == "" || actorID == "" || mentionedID == actorID {
return return
} }
off := s.disabledPrefs(ctx, mentionedID)
if err := s.notifs.CreateMention(ctx, mentionedID, actorID, postID); err != nil { if err := s.notifs.CreateMention(ctx, mentionedID, actorID, postID); err != nil {
s.logger.Warn("persist mention notification failed", s.logger.Warn("persist mention notification failed",
zap.String("recipient", mentionedID), zap.Error(err)) zap.String("recipient", mentionedID), zap.Error(err))
} }
actorUser, _ := s.users.FindByID(ctx, actorID) 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 return
} }
who := "Someone" 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) return fmt.Sprintf("%s and %d %s liked your post", who, others, noun)
} }
// dispatch persists the notification and sends the matching email. All // dispatch persists the notification and sends it over every channel the
// failures are logged, never propagated. // recipient hasn't opted out of. All failures are logged, never propagated.
func (s *NotificationService) dispatch(ctx context.Context, recipientID, actorID, kind, subjectFmt string) { 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 actor := actorID
if _, err := s.notifs.Create(ctx, recipientID, &actor, kind); err != nil { if _, err := s.notifs.Create(ctx, recipientID, &actor, kind); err != nil {
s.logger.Warn("persist notification failed", 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 // Realtime push (best-effort) — a lightweight nudge so the client can
// bump the bell + toast immediately. Fires regardless of whether the // bump the bell + toast immediately. Part of the in-app channel.
// recipient has an email; authoritative data still comes from REST. if !prefOff(off, kind, repository.ChannelInApp) {
s.publishRealtime(ctx, recipientID, kind, actorUser) s.publishRealtime(ctx, recipientID, kind, actorUser)
}
// Web Push (best-effort) — reaches the recipient even with the app // Web Push (best-effort) — reaches the recipient even with the app
// closed. Same seam as realtime/email; nil sender is a no-op. // 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). // Email (best-effort).
if prefOff(off, kind, repository.ChannelEmail) {
return
}
recipient, err := s.users.FindByID(ctx, recipientID) recipient, err := s.users.FindByID(ctx, recipientID)
if err != nil { if err != nil {
s.logger.Warn("notification recipient lookup failed", zap.String("recipient", recipientID), zap.Error(err)) 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 // and an email. Best-effort; nothing is persisted to the notification
// center. `url` is where tapping the notification lands. // center. `url` is where tapping the notification lands.
func (s *NotificationService) SendReminder(ctx context.Context, userID, kind, title, body, url string) { 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 // Realtime toast — a distinct "reminder" event so the client toasts
// without bumping the unread badge (reminders have no DB row). // 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{ _ = s.rt.Trigger(ctx, "notifications."+userID, "reminder", map[string]any{
"kind": kind, "title": title, "body": body, "url": url, "kind": kind, "title": title, "body": body, "url": url,
}) })
} }
// Web Push (reaches closed apps). // 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}) 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). // Email (only if there's an address on file).
if prefOff(off, kind, repository.ChannelEmail) {
return
}
recipient, err := s.users.FindByID(ctx, userID) recipient, err := s.users.FindByID(ctx, userID)
if err != nil || recipient == nil || recipient.Email == nil || strings.TrimSpace(*recipient.Email) == "" { if err != nil || recipient == nil || recipient.Email == nil || strings.TrimSpace(*recipient.Email) == "" {
return return
+30 -4
View File
@@ -50,27 +50,53 @@ const (
type ReminderService struct { type ReminderService struct {
repo *repository.ReminderRepo repo *repository.ReminderRepo
prefs *repository.NotificationPrefsRepo
notif *NotificationService notif *NotificationService
logger *zap.Logger logger *zap.Logger
sadhnaURL string 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{ return &ReminderService{
repo: repo, repo: repo,
prefs: prefs,
notif: notif, notif: notif,
logger: logger, logger: logger,
sadhnaURL: strings.TrimRight(frontendURL, "/") + "/sadhna-streak", 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) { 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 { 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 // SetTimezone validates the IANA zone before storing so a bad value can never
@@ -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;
+41
View File
@@ -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.