feat: add websocket

This commit is contained in:
Krispire Admin
2026-06-07 00:23:21 +05:30
parent efa1eaf23b
commit e352948fb1
5 changed files with 219 additions and 9 deletions
+41 -7
View File
@@ -21,6 +21,7 @@ import (
"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"
)
@@ -34,15 +35,17 @@ 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, logger *zap.Logger, frontendURL string) *NotificationService {
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, "/"),
}
@@ -92,10 +95,21 @@ func (s *NotificationService) dispatch(ctx context.Context, recipientID, 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 to email — the email is the
// only delivery channel that exists today.
// 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))
@@ -103,12 +117,11 @@ func (s *NotificationService) dispatch(ctx context.Context, recipientID, actorID
}
if recipient.Email == nil || strings.TrimSpace(*recipient.Email) == "" {
// No email on file (OAuth-only account that never added one) —
// the persisted row is the notification; nothing to send.
// the persisted row + realtime push are the notification.
return
}
actorUser, err := s.users.FindByID(ctx, actorID)
if err != nil {
s.logger.Warn("notification actor lookup failed", zap.String("actor", actorID), zap.Error(err))
if actorUser == nil {
// Can't render the email body without the actor's name.
return
}
@@ -124,6 +137,27 @@ func (s *NotificationService) dispatch(ctx context.Context, recipientID, actorID
}
}
// 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,