Files
spsays-bff/internal/service/notification_service.go
T
2026-06-10 16:28:57 +05:30

493 lines
20 KiB
Go

// Package service — NotificationService is the single seam every
// user-facing event flows through ("the notification plugin"). Today it
// does two things per event:
//
// 1. Persists a row in `notifications` (so an in-app notification center
// and, later, websocket + push delivery can read the same history).
// 2. Sends an email to the recipient via the shared mailer.
//
// Everything here is fire-and-forget from the caller's perspective: a
// follow request must succeed even if the email bounces or the insert
// fails, so Notify* never returns an error — failures are logged. When
// websocket/push land they hook in right here alongside the email send.
package service
import (
"bytes"
"context"
"fmt"
"html/template"
"strings"
"time"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
"github.com/prabhupadasays/spsays-bff/pkg/realtime"
"go.uber.org/zap"
)
// Notification kinds — short machine strings stored in notifications.kind.
const (
NotifFollowRequest = "follow_request"
NotifFollowAccepted = "follow_accepted"
NotifPostLike = "post_like"
NotifPostMention = "post_mention"
// Reminder kinds are delivered live (email + push + a websocket toast)
// but NOT persisted — they're ephemeral nudges, not history.
NotifChantReminder = "chant_reminder"
NotifQuoteReminder = "quote_reminder"
NotifChantIncomplete = "chant_incomplete_reminder"
)
type NotificationService struct {
notifs *repository.NotificationRepo
users *repository.UserRepo
mail mailer.Mailer
rt *realtime.SoketiClient // optional — nil disables WebSocket push
push *PushSender // optional — nil disables Web Push
logger *zap.Logger
frontendURL string
}
func NewNotificationService(notifs *repository.NotificationRepo, users *repository.UserRepo, mail mailer.Mailer, rt *realtime.SoketiClient, push *PushSender, logger *zap.Logger, frontendURL string) *NotificationService {
return &NotificationService{
notifs: notifs,
users: users,
mail: mail,
rt: rt,
push: push,
logger: logger,
frontendURL: strings.TrimRight(frontendURL, "/"),
}
}
// 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 {
limit = 30
}
if offset < 0 {
offset = 0
}
return s.notifs.ListByUser(ctx, userID, limit, offset)
}
// UnreadCount returns the caller's unread notification count (for the bell
// badge).
func (s *NotificationService) UnreadCount(ctx context.Context, userID string) (int, error) {
return s.notifs.CountUnread(ctx, userID)
}
// MarkAllRead marks all of the caller's notifications read.
func (s *NotificationService) MarkAllRead(ctx context.Context, userID string) error {
return s.notifs.MarkAllRead(ctx, userID)
}
// LikeEmailPref / SetLikeEmailPref back the Settings → Notifications toggle
// for aggregated like emails.
func (s *NotificationService) LikeEmailPref(ctx context.Context, userID string) (bool, error) {
return s.notifs.LikeEmailsEnabled(ctx, userID)
}
func (s *NotificationService) SetLikeEmailPref(ctx context.Context, userID string, enabled bool) error {
return s.notifs.SetLikeEmailsEnabled(ctx, userID, enabled)
}
// NotifyFollowRequest tells `recipientID` that `actorID` asked to follow
// them. Persists the row and emails the recipient (if they have an email
// on file).
func (s *NotificationService) NotifyFollowRequest(ctx context.Context, recipientID, actorID string) {
s.dispatch(ctx, recipientID, actorID, NotifFollowRequest,
"%s requested to follow you — Prabhupāda Says")
}
// NotifyFollowAccepted tells `recipientID` that `actorID` accepted their
// follow request (i.e. the recipient can now see actor's activity).
func (s *NotificationService) NotifyFollowAccepted(ctx context.Context, recipientID, actorID string) {
s.dispatch(ctx, recipientID, actorID, NotifFollowAccepted,
"%s accepted your follow request — Prabhupāda Says")
}
// NotifyPostLike tells the post author that `likerID` liked their post. Sent
// only when the liker isn't the author (callers also guard). Unlike follows,
// likes are high-volume, so this delivers a persisted row + realtime nudge +
// Web Push but NO email. The push is tagged per-post so repeated likes on the
// same post collapse into one notification rather than stacking.
func (s *NotificationService) NotifyPostLike(ctx context.Context, postID, authorID, likerID string) {
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).
count, emailDue, err := s.notifs.UpsertLike(ctx, authorID, likerID, postID, likeEmailThrottle)
if err != nil {
s.logger.Warn("aggregate like notification failed",
zap.String("recipient", authorID), zap.Error(err))
count, emailDue = 1, false
}
actorUser, _ := s.users.FindByID(ctx, likerID)
who := "Someone"
if actorUser != nil {
who = actorDisplay(actorUser)
}
body := likeMessage(who, count)
// Realtime nudge — carry actor_count so the toast can read "and N others".
if s.rt != nil {
payload := map[string]any{"kind": NotifPostLike, "actor_count": count}
if actorUser != nil {
payload["actor_name"] = actorDisplay(actorUser)
payload["actor_handle"] = actorUser.Handle
payload["actor_avatar_url"] = actorUser.AvatarURL
}
if err := s.rt.Trigger(ctx, "notifications."+authorID, "notification", payload); err != nil {
s.logger.Warn("realtime like publish failed", zap.String("recipient", authorID), zap.Error(err))
}
}
// Tapping a notification opens the author's own feed anchored to the post.
author, _ := s.users.FindByID(ctx, authorID)
url := s.frontendURL + "/notifications"
if author != nil {
url = s.frontendURL + "/u/" + author.Handle + "/posts#post-" + postID
}
// Web Push — tagged per-post so repeated likes collapse into one.
if s.push != nil {
s.push.SendToUser(ctx, authorID, PushPayload{
Title: "New Like",
Body: body,
URL: url,
Icon: s.iconURL(),
Tag: NotifPostLike + ":" + postID,
})
}
// Aggregated email — only when the throttle says one is due (so a burst of
// likes yields a single email), the author has an address, and they
// haven't opted out. `body` already aggregates ("X and N others …").
if !emailDue || author == nil || author.Email == nil || strings.TrimSpace(*author.Email) == "" {
return
}
if enabled, perr := s.notifs.LikeEmailsEnabled(ctx, authorID); perr != nil || !enabled {
return
}
var buf bytes.Buffer
if err := likeEmailTpl.Execute(&buf, map[string]any{
"RecipientName": author.Name,
"Message": body,
"URL": url,
}); err != nil {
s.logger.Warn("render like email failed", zap.Error(err))
return
}
if err := s.mail.Send(strings.TrimSpace(*author.Email), "New likes on your post — Prabhupāda Says", buf.String()); err != nil {
s.logger.Warn("like email send failed", zap.String("to", *author.Email), zap.Error(err))
}
}
// likeEmailThrottle is the minimum gap between aggregated like emails for a
// single post — a burst of likes within the window produces one email.
const likeEmailThrottle = 6 * time.Hour
// NotifyPostMention tells `mentionedID` that `actorID` (the post author)
// tagged them in a post. Persisted row + realtime + Web Push; no email
// (push/in-app is enough, and a post can tag many people). Skips self-tags.
func (s *NotificationService) NotifyPostMention(ctx context.Context, mentionedID, actorID, postID string) {
if mentionedID == "" || actorID == "" || mentionedID == actorID {
return
}
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 s.push == nil {
return
}
who := "Someone"
url := s.frontendURL + "/notifications"
if actorUser != nil {
who = actorDisplay(actorUser)
url = s.frontendURL + "/u/" + actorUser.Handle + "/posts#post-" + postID
}
s.push.SendToUser(ctx, mentionedID, PushPayload{
Title: "You were mentioned",
Body: who + " mentioned you in a post",
URL: url,
Icon: s.iconURL(),
Tag: NotifPostMention + ":" + postID,
})
}
// likeMessage renders the aggregated like text: "X liked your post" or
// "X and N others liked your post".
func likeMessage(who string, count int) string {
if count <= 1 {
return who + " liked your post"
}
others := count - 1
noun := "others"
if others == 1 {
noun = "other"
}
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.
func (s *NotificationService) dispatch(ctx context.Context, recipientID, actorID, kind, subjectFmt string) {
actor := actorID
if _, err := s.notifs.Create(ctx, recipientID, &actor, kind); err != nil {
s.logger.Warn("persist notification failed",
zap.String("recipient", recipientID), zap.String("kind", kind), zap.Error(err))
// Persistence failed, but still try the other channels.
}
// Look up the actor once — both the realtime push and the email use it.
actorUser, aerr := s.users.FindByID(ctx, actorID)
if aerr != nil {
s.logger.Warn("notification actor lookup failed", zap.String("actor", actorID), zap.Error(aerr))
}
// 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)
// 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)
// Email (best-effort).
recipient, err := s.users.FindByID(ctx, recipientID)
if err != nil {
s.logger.Warn("notification recipient lookup failed", zap.String("recipient", recipientID), zap.Error(err))
return
}
if recipient.Email == nil || strings.TrimSpace(*recipient.Email) == "" {
// No email on file (OAuth-only account that never added one) —
// the persisted row + realtime push are the notification.
return
}
if actorUser == nil {
// Can't render the email body without the actor's name.
return
}
body, err := s.render(kind, recipient, actorUser)
if err != nil {
s.logger.Warn("render notification email failed", zap.String("kind", kind), zap.Error(err))
return
}
subject := strings.Replace(subjectFmt, "%s", actorDisplay(actorUser), 1)
if err := s.mail.Send(strings.TrimSpace(*recipient.Email), subject, body); err != nil {
s.logger.Warn("notification email send failed",
zap.String("to", *recipient.Email), zap.String("kind", kind), zap.Error(err))
}
}
// publishRealtime pushes a lightweight notification event to the
// recipient's Soketi channel. PUBLIC channel for now ("notifications.{id}")
// — switch to a "private-" prefix once Soketi App auth is wired up. The
// payload carries just enough for a toast; the client refetches the
// authoritative unread count + list from REST.
func (s *NotificationService) publishRealtime(ctx context.Context, recipientID, kind string, actor *model.User) {
if s.rt == nil {
return
}
payload := map[string]any{"kind": kind}
if actor != nil {
payload["actor_name"] = actorDisplay(actor)
payload["actor_handle"] = actor.Handle
payload["actor_avatar_url"] = actor.AvatarURL
}
if err := s.rt.Trigger(ctx, "notifications."+recipientID, "notification", payload); err != nil {
s.logger.Warn("realtime notification publish failed",
zap.String("recipient", recipientID), zap.String("kind", kind), zap.Error(err))
}
}
// SendReminder delivers an ephemeral sādhana reminder across all live
// channels — a websocket toast (no unread bump, not persisted), a Web Push,
// 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) {
// Realtime toast — a distinct "reminder" event so the client toasts
// without bumping the unread badge (reminders have no DB row).
if s.rt != nil {
_ = 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 {
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).
recipient, err := s.users.FindByID(ctx, userID)
if err != nil || recipient == nil || recipient.Email == nil || strings.TrimSpace(*recipient.Email) == "" {
return
}
var buf bytes.Buffer
if err := reminderEmailTpl.Execute(&buf, map[string]any{
"RecipientName": recipient.Name,
"Message": body,
"URL": url,
}); err != nil {
s.logger.Warn("render reminder email failed", zap.String("kind", kind), zap.Error(err))
return
}
if err := s.mail.Send(strings.TrimSpace(*recipient.Email), title, buf.String()); err != nil {
s.logger.Warn("reminder email send failed", zap.String("to", *recipient.Email), zap.String("kind", kind), zap.Error(err))
}
}
// publishPush delivers a Web Push notification to the recipient's devices.
// The body quotes the actor; tapping it opens their profile (where a pending
// follow request can be accepted). Best-effort; nil sender is a no-op.
func (s *NotificationService) publishPush(ctx context.Context, recipientID, kind string, actor *model.User) {
if s.push == nil {
return
}
who := "Someone"
url := s.frontendURL + "/notifications"
if actor != nil {
who = actorDisplay(actor)
url = s.frontendURL + "/u/" + actor.Handle
}
// Title must be meaningful, not the app name: iOS renders installed-PWA
// notifications as "<title> from <app name>", so a generic "Prabhupāda
// Says" title reads as "Prabhupāda Says from Prabhupāda Says".
var title, body string
switch kind {
case NotifFollowRequest:
title = "New Friend Request"
body = who + " requested to follow you"
case NotifFollowAccepted:
title = "Friend Request Accepted"
body = who + " accepted your follow request"
default:
title = "New notification"
body = "You have a new notification"
}
s.push.SendToUser(ctx, recipientID, PushPayload{
Title: title,
Body: body,
URL: url,
Icon: s.iconURL(),
Tag: kind,
})
}
// iconURL is the absolute URL of the notification icon (the app's square
// icon), so it renders regardless of the client's base path.
func (s *NotificationService) iconURL() string {
return strings.TrimRight(s.frontendURL, "/") + "/icon-192.png"
}
func (s *NotificationService) render(kind string, recipient, actor *model.User) (string, error) {
data := map[string]any{
"RecipientName": recipient.Name,
"ActorName": actorDisplay(actor),
"ActorHandle": actor.Handle,
"ProfileURL": s.frontendURL + "/u/" + actor.Handle,
}
var tpl *template.Template
switch kind {
case NotifFollowRequest:
tpl = followRequestEmailTpl
case NotifFollowAccepted:
tpl = followAcceptedEmailTpl
default:
tpl = followRequestEmailTpl
}
var buf bytes.Buffer
if err := tpl.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
// actorDisplay prefers the display name, falling back to @handle.
func actorDisplay(u *model.User) string {
if n := strings.TrimSpace(u.Name); n != "" {
return n
}
return "@" + u.Handle
}
// Vaishnava-styled, 17px base to match the feedback emails. Both quote
// the actor and link to their profile (where the recipient can accept a
// pending request).
var followRequestEmailTpl = template.Must(template.New("followRequest").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
<p><strong>{{.ActorName}}</strong> (<a href="{{.ProfileURL}}" style="color:#9a6b2f;font-weight:600">@{{.ActorHandle}}</a>)
has requested to follow you on Prabhupāda Says.</p>
<p>You can review and accept or decline the request from their profile:</p>
<p style="margin:1.25em 0">
<a href="{{.ProfileURL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">View request</a>
</p>
<p style="margin-top:1.5em">Your servants,<br>
<strong>The Prabhupāda Says team</strong></p>
</div>`))
var followAcceptedEmailTpl = template.Must(template.New("followAccepted").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
<p><strong>{{.ActorName}}</strong> (<a href="{{.ProfileURL}}" style="color:#9a6b2f;font-weight:600">@{{.ActorHandle}}</a>)
has accepted your follow request. You're now following them on Prabhupāda Says.</p>
<p style="margin:1.25em 0">
<a href="{{.ProfileURL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Visit profile</a>
</p>
<p style="margin-top:1.5em">Your servants,<br>
<strong>The Prabhupāda Says team</strong></p>
</div>`))
// Daily sādhana reminder. The Message is the recency-aware nudge; the CTA
// opens the streak page.
var reminderEmailTpl = template.Must(template.New("reminder").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
<p>{{.Message}}</p>
<p style="margin:1.25em 0">
<a href="{{.URL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open your sādhana</a>
</p>
<p style="margin-top:1.5em;font-size:14px;color:#7a6a55">You can turn these reminders off any time in Settings → Notifications.</p>
<p style="margin-top:1em">Your servants,<br>
<strong>The Prabhupāda Says team</strong></p>
</div>`))
// Aggregated like email. Message is "X and N others liked your post"; the CTA
// opens the author's post.
var likeEmailTpl = template.Must(template.New("postLike").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
<p>{{.Message}}.</p>
<p style="margin:1.25em 0">
<a href="{{.URL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">View your post</a>
</p>
<p style="margin-top:1.5em;font-size:14px;color:#7a6a55">You can turn off like emails any time in Settings → Notifications.</p>
<p style="margin-top:1em">Your servants,<br>
<strong>The Prabhupāda Says team</strong></p>
</div>`))