886 lines
35 KiB
Go
886 lines
35 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"
|
||
"errors"
|
||
"fmt"
|
||
"html/template"
|
||
"slices"
|
||
"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"
|
||
NotifPostComment = "post_comment"
|
||
NotifCommentReply = "comment_reply"
|
||
NotifCommentMention = "comment_mention"
|
||
|
||
// NotifDiscipleJoined is broadcast to the whole community when an admin
|
||
// recognises an account as one of Śrīla Prabhupāda's own initiated
|
||
// disciples. The actor is the disciple, so it renders and links exactly
|
||
// like the other actor-driven kinds.
|
||
NotifDiscipleJoined = "disciple_joined"
|
||
|
||
// 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"
|
||
)
|
||
|
||
// 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, NotifPostComment, NotifCommentReply},
|
||
"sadhna_reminders": {NotifChantReminder, NotifQuoteReminder, NotifChantIncomplete},
|
||
"community": {NotifDiscipleJoined},
|
||
}
|
||
|
||
// 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
|
||
push *PushSender // optional — nil disables Web Push
|
||
logger *zap.Logger
|
||
frontendURL string
|
||
}
|
||
|
||
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,
|
||
push: push,
|
||
logger: logger,
|
||
frontendURL: strings.TrimRight(frontendURL, "/"),
|
||
}
|
||
}
|
||
|
||
// 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 {
|
||
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)
|
||
}
|
||
|
||
// 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 {
|
||
// The handler sanitizes this into a generic 500 — log the real
|
||
// cause here or it's invisible in production.
|
||
s.logger.Error("notification prefs: load failed", zap.String("user", userID), zap.Error(err))
|
||
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 {
|
||
err = s.prefs.SyncLegacyColumns(ctx, userID)
|
||
}
|
||
if err != nil {
|
||
s.logger.Error("notification prefs: write failed",
|
||
zap.String("user", userID), zap.String("category", category),
|
||
zap.String("channel", channel), zap.Error(err))
|
||
}
|
||
return err
|
||
}
|
||
|
||
// 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) {
|
||
off, err := s.prefs.DisabledSet(ctx, userID)
|
||
if err != nil {
|
||
s.logger.Error("notification prefs: load failed", zap.String("user", userID), zap.Error(err))
|
||
return false, err
|
||
}
|
||
return !prefOff(off, NotifPostLike, repository.ChannelEmail), nil
|
||
}
|
||
|
||
func (s *NotificationService) SetLikeEmailPref(ctx context.Context, userID string, enabled bool) error {
|
||
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 {
|
||
err = s.prefs.SyncLegacyColumns(ctx, userID)
|
||
}
|
||
if err != nil {
|
||
s.logger.Error("notification prefs: like-email write failed",
|
||
zap.String("user", userID), zap.Error(err))
|
||
}
|
||
return err
|
||
}
|
||
|
||
// 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
|
||
}
|
||
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",
|
||
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 && !prefOff(off, NotifPostLike, repository.ChannelInApp) {
|
||
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.TriggerUser(ctx, 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 && !prefOff(off, NotifPostLike, repository.ChannelPush) {
|
||
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 prefOff(off, NotifPostLike, repository.ChannelEmail) {
|
||
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
|
||
}
|
||
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)
|
||
if !prefOff(off, NotifPostMention, repository.ChannelInApp) {
|
||
s.publishRealtime(ctx, mentionedID, NotifPostMention, actorUser)
|
||
}
|
||
|
||
if s.push == nil || prefOff(off, NotifPostMention, repository.ChannelPush) {
|
||
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,
|
||
})
|
||
}
|
||
|
||
// NotifyPostComment tells the post author that `commenterID` commented on
|
||
// their post. NotifyCommentReply tells `recipientID` (the author of the
|
||
// comment being replied to) that `replierID` replied. Both: persisted row +
|
||
// realtime + Web Push, no email (comments are interactive, not digest-worthy).
|
||
// Self-actions are skipped.
|
||
func (s *NotificationService) NotifyPostComment(ctx context.Context, postID, authorID, commenterID string) {
|
||
s.notifyComment(ctx, NotifPostComment, postID, authorID, commenterID)
|
||
}
|
||
|
||
func (s *NotificationService) NotifyCommentReply(ctx context.Context, postID, recipientID, replierID string) {
|
||
s.notifyComment(ctx, NotifCommentReply, postID, recipientID, replierID)
|
||
}
|
||
|
||
func (s *NotificationService) notifyComment(ctx context.Context, kind, postID, recipientID, actorID string) {
|
||
if recipientID == "" || actorID == "" || recipientID == actorID {
|
||
return
|
||
}
|
||
off := s.disabledPrefs(ctx, recipientID)
|
||
if err := s.notifs.CreateComment(ctx, recipientID, actorID, postID, kind); err != nil {
|
||
s.logger.Warn("persist comment notification failed",
|
||
zap.String("recipient", recipientID), zap.String("kind", kind), zap.Error(err))
|
||
}
|
||
actorUser, _ := s.users.FindByID(ctx, actorID)
|
||
if !prefOff(off, kind, repository.ChannelInApp) {
|
||
s.publishRealtime(ctx, recipientID, kind, actorUser)
|
||
}
|
||
|
||
if s.push == nil || prefOff(off, kind, repository.ChannelPush) {
|
||
return
|
||
}
|
||
who := "Someone"
|
||
if actorUser != nil {
|
||
who = actorDisplay(actorUser)
|
||
}
|
||
// Tapping it opens the post (via its author's handle — the actor isn't the
|
||
// post owner on a reply). Falls back to the notification center.
|
||
url := s.frontendURL + "/notifications"
|
||
if h, err := s.notifs.PostAuthorHandle(ctx, postID); err == nil && h != "" {
|
||
url = s.frontendURL + "/u/" + h + "/posts#post-" + postID
|
||
}
|
||
title, body := "New comment", who+" commented on your post"
|
||
switch kind {
|
||
case NotifCommentReply:
|
||
title, body = "New reply", who+" replied to your comment"
|
||
case NotifCommentMention:
|
||
title, body = "You were mentioned", who+" mentioned you in a comment"
|
||
}
|
||
s.push.SendToUser(ctx, recipientID, PushPayload{
|
||
Title: title,
|
||
Body: body,
|
||
URL: url,
|
||
Icon: s.iconURL(),
|
||
Tag: kind + ":" + postID,
|
||
})
|
||
}
|
||
|
||
// NotifyCommentMentions tells each user named by `handles` that `actorID`
|
||
// mentioned them in a comment on `postID`. Handles are resolved to users,
|
||
// deduped, and the actor plus any `skip` ids (e.g. the post/parent author who
|
||
// already got a comment/reply notification) are excluded so nobody is
|
||
// double-pinged. Reuses the comment notification path (row + realtime + push).
|
||
func (s *NotificationService) NotifyCommentMentions(ctx context.Context, postID, actorID string, handles []string, skip ...string) {
|
||
if actorID == "" || len(handles) == 0 {
|
||
return
|
||
}
|
||
skipSet := map[string]bool{actorID: true}
|
||
for _, id := range skip {
|
||
if id != "" {
|
||
skipSet[id] = true
|
||
}
|
||
}
|
||
for _, h := range handles {
|
||
u, err := s.users.FindByHandle(ctx, h)
|
||
if err != nil || u == nil || skipSet[u.ID] {
|
||
continue
|
||
}
|
||
skipSet[u.ID] = true // dedupe repeats of the same mention
|
||
s.notifyComment(ctx, NotifCommentMention, postID, u.ID, actorID)
|
||
}
|
||
}
|
||
|
||
// 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 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",
|
||
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. 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.
|
||
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))
|
||
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.TriggerUser(ctx, 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) {
|
||
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 && !prefOff(off, kind, repository.ChannelInApp) {
|
||
_ = s.rt.TriggerUser(ctx, userID, "reminder", map[string]any{
|
||
"kind": kind, "title": title, "body": body, "url": url,
|
||
})
|
||
}
|
||
|
||
// Web Push (reaches closed apps).
|
||
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
|
||
}
|
||
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,
|
||
})
|
||
}
|
||
|
||
// NotifyAdminMessage delivers a free-text message from an admin to one user:
|
||
// a persisted in-app notification (kind 'admin_message') plus best-effort
|
||
// realtime, Web Push, and email. Admin messages bypass the per-kind opt-out
|
||
// matrix (there's no category for them) — they're deliberate, low-volume, and
|
||
// operationally important. Returns an error only if the row couldn't be
|
||
// persisted; the three delivery channels are best-effort.
|
||
func (s *NotificationService) NotifyAdminMessage(ctx context.Context, recipientID, title, body string) error {
|
||
if err := s.notifs.CreateAdminMessage(ctx, recipientID, body); err != nil {
|
||
return err
|
||
}
|
||
url := s.frontendURL + "/notifications"
|
||
|
||
if s.rt != nil {
|
||
if err := s.rt.TriggerUser(ctx, recipientID, "notification", map[string]any{
|
||
"kind": "admin_message",
|
||
"body": body,
|
||
}); err != nil {
|
||
s.logger.Warn("realtime admin message publish failed",
|
||
zap.String("recipient", recipientID), zap.Error(err))
|
||
}
|
||
}
|
||
|
||
if s.push != nil {
|
||
pushTitle := strings.TrimSpace(title)
|
||
if pushTitle == "" {
|
||
pushTitle = "Prabhupāda Says"
|
||
}
|
||
s.push.SendToUser(ctx, recipientID, PushPayload{
|
||
Title: pushTitle,
|
||
Body: body,
|
||
URL: url,
|
||
Icon: s.iconURL(),
|
||
Tag: "admin_message",
|
||
})
|
||
}
|
||
|
||
// Email — best-effort, only when the recipient has a verified-or-not address.
|
||
recipient, _ := s.users.FindByID(ctx, recipientID)
|
||
if recipient != nil && recipient.Email != nil && strings.TrimSpace(*recipient.Email) != "" {
|
||
var buf bytes.Buffer
|
||
if err := adminMessageEmailTpl.Execute(&buf, map[string]any{
|
||
"RecipientName": recipient.Name,
|
||
"Title": strings.TrimSpace(title),
|
||
"Body": body,
|
||
"URL": url,
|
||
}); err != nil {
|
||
s.logger.Warn("render admin message email failed", zap.Error(err))
|
||
} else {
|
||
subject := strings.TrimSpace(title)
|
||
if subject == "" {
|
||
subject = "A message from Prabhupāda Says"
|
||
}
|
||
if err := s.mail.Send(strings.TrimSpace(*recipient.Email), subject, buf.String()); err != nil {
|
||
s.logger.Warn("admin message email send failed", zap.Error(err))
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// pushBroadcastDelay paces the per-user push fan-out. Web Push is one HTTP
|
||
// request per subscription to an external service (FCM/Mozilla/Apple), so a
|
||
// tight loop over hundreds of devotees would hammer those endpoints and spike
|
||
// outbound connections. A small gap keeps the broadcast a background trickle.
|
||
const pushBroadcastDelay = 50 * time.Millisecond
|
||
|
||
// BroadcastDiscipleJoined tells the community that `actor` — an account an
|
||
// admin has just recognised as one of Śrīla Prabhupāda's own initiated
|
||
// disciples — has joined.
|
||
//
|
||
// Two channels, deliberately decoupled by cost:
|
||
//
|
||
// - In-app: a single bulk INSERT for every user. Cheap and instant, so it
|
||
// happens inline and the row is durable the moment this returns.
|
||
// - Push + realtime: one call per recipient against external services, so it
|
||
// runs paced. The caller is expected to invoke this on a detached,
|
||
// time-limited context (not the admin's request context, which dies with
|
||
// the response).
|
||
//
|
||
// No email — announcements are in-app/push only by design; see the
|
||
// deliverability reasoning in the disciple-link notes.
|
||
//
|
||
// Errors from the fan-out are logged, not returned: the announcement is
|
||
// already durably recorded in-app, and a failed push to one devotee must not
|
||
// abort the rest.
|
||
func (s *NotificationService) BroadcastDiscipleJoined(ctx context.Context, actor *model.User) error {
|
||
if actor == nil {
|
||
return nil
|
||
}
|
||
|
||
written, err := s.notifs.BroadcastToAllUsers(ctx, NotifDiscipleJoined, actor.ID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
s.logger.Info("disciple-joined broadcast: in-app rows written",
|
||
zap.String("actor", actor.Handle), zap.Int64("recipients", written))
|
||
|
||
// Everything below is best-effort delivery on top of the durable row.
|
||
recipients, err := s.notifs.PushRecipientsForKind(ctx, NotifDiscipleJoined, actor.ID)
|
||
if err != nil {
|
||
s.logger.Warn("disciple-joined broadcast: push recipient lookup failed", zap.Error(err))
|
||
return nil
|
||
}
|
||
if len(recipients) == 0 || s.push == nil {
|
||
return nil
|
||
}
|
||
|
||
name := actorDisplay(actor)
|
||
payload := PushPayload{
|
||
Title: "A direct disciple has joined",
|
||
Body: name + " — an initiated disciple of Śrīla Prabhupāda — is now on Prabhupāda Says.",
|
||
URL: s.frontendURL + "/u/" + actor.Handle,
|
||
Icon: s.iconURL(),
|
||
Tag: NotifDiscipleJoined,
|
||
}
|
||
|
||
sent := 0
|
||
for _, uid := range recipients {
|
||
// Honour cancellation so a shutdown (or the caller's timeout) stops
|
||
// the fan-out cleanly rather than running to completion detached.
|
||
if ctx.Err() != nil {
|
||
s.logger.Warn("disciple-joined broadcast: push fan-out cut short",
|
||
zap.Int("sent", sent), zap.Int("total", len(recipients)), zap.Error(ctx.Err()))
|
||
return nil
|
||
}
|
||
if s.rt != nil {
|
||
if err := s.rt.TriggerUser(ctx, uid, "notification", map[string]any{
|
||
"kind": NotifDiscipleJoined,
|
||
"actor_name": name,
|
||
"actor_handle": actor.Handle,
|
||
}); err != nil {
|
||
s.logger.Debug("disciple-joined realtime publish failed",
|
||
zap.String("recipient", uid), zap.Error(err))
|
||
}
|
||
}
|
||
s.push.SendToUser(ctx, uid, payload)
|
||
sent++
|
||
time.Sleep(pushBroadcastDelay)
|
||
}
|
||
s.logger.Info("disciple-joined broadcast: push fan-out complete",
|
||
zap.String("actor", actor.Handle), zap.Int("recipients", sent))
|
||
return nil
|
||
}
|
||
|
||
// 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>`))
|
||
|
||
// adminMessageEmailTpl renders an admin → user direct message. Body is
|
||
// auto-escaped by html/template, so admin-entered text can't inject markup.
|
||
var adminMessageEmailTpl = template.Must(template.New("adminMessage").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>
|
||
|
||
{{if .Title}}<p style="font-weight:600;font-size:19px;margin-bottom:0.5em">{{.Title}}</p>{{end}}
|
||
|
||
<p style="white-space:pre-line">{{.Body}}</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 Prabhupāda Says</a>
|
||
</p>
|
||
|
||
<p style="margin-top:1em">Your servants,<br>
|
||
<strong>The Prabhupāda Says team</strong></p>
|
||
</div>`))
|