// 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" "html/template" "strings" "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" // 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" ) 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) } // 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") } // 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, Tag: kind}) } // 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 } var body string switch kind { case NotifFollowRequest: body = who + " requested to follow you" case NotifFollowAccepted: body = who + " accepted your follow request" default: body = "You have a new notification" } s.push.SendToUser(ctx, recipientID, PushPayload{ Title: "Prabhupāda Says", Body: body, URL: url, Tag: kind, }) } 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(`
Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏
{{.ActorName}} (@{{.ActorHandle}}) has requested to follow you on Prabhupāda Says.
You can review and accept or decline the request from their profile:
Your servants,
The Prabhupāda Says team
Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏
{{.ActorName}} (@{{.ActorHandle}}) has accepted your follow request. You're now following them on Prabhupāda Says.
Your servants,
The Prabhupāda Says team
Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏
{{.Message}}
You can turn these reminders off any time in Settings → Notifications.
Your servants,
The Prabhupāda Says team