224 lines
9.0 KiB
Go
224 lines
9.0 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"
|
|
"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"
|
|
)
|
|
|
|
type NotificationService struct {
|
|
notifs *repository.NotificationRepo
|
|
users *repository.UserRepo
|
|
mail mailer.Mailer
|
|
rt *realtime.SoketiClient // optional — nil disables WebSocket push
|
|
logger *zap.Logger
|
|
frontendURL string
|
|
}
|
|
|
|
func NewNotificationService(notifs *repository.NotificationRepo, users *repository.UserRepo, mail mailer.Mailer, rt *realtime.SoketiClient, logger *zap.Logger, frontendURL string) *NotificationService {
|
|
return &NotificationService{
|
|
notifs: notifs,
|
|
users: users,
|
|
mail: mail,
|
|
rt: rt,
|
|
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)
|
|
|
|
// 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))
|
|
}
|
|
}
|
|
|
|
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>`))
|