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
+148 -20
View File
@@ -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