feat: notification enhancement
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user