feat: add websocket
This commit is contained in:
@@ -77,6 +77,18 @@ type Config struct {
|
||||
GoogleOAuthClientSecret string
|
||||
FacebookOAuthClientID string
|
||||
FacebookOAuthClientSecret string
|
||||
|
||||
// Soketi (Pusher-compatible WebSocket server) — used to push realtime
|
||||
// notifications. Empty App credentials disable realtime delivery (the
|
||||
// DB row + email still happen). SoketiHost is the HTTP-API origin the
|
||||
// BFF triggers events against (e.g. "ws.prabhupadasays.org"); the
|
||||
// frontend connects to the same host over WSS.
|
||||
SoketiAppID string
|
||||
SoketiAppKey string
|
||||
SoketiAppSecret string
|
||||
SoketiHost string
|
||||
SoketiPort int
|
||||
SoketiUseTLS bool
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables (with .env fallback).
|
||||
@@ -135,6 +147,13 @@ func Load() (*Config, error) {
|
||||
GoogleOAuthClientSecret: getEnv("GOOGLE_OAUTH_CLIENT_SECRET", ""),
|
||||
FacebookOAuthClientID: getEnv("FACEBOOK_OAUTH_CLIENT_ID", ""),
|
||||
FacebookOAuthClientSecret: getEnv("FACEBOOK_OAUTH_CLIENT_SECRET", ""),
|
||||
|
||||
SoketiAppID: getEnv("SOKETI_APP_ID", ""),
|
||||
SoketiAppKey: getEnv("SOKETI_APP_KEY", ""),
|
||||
SoketiAppSecret: getEnv("SOKETI_APP_SECRET", ""),
|
||||
SoketiHost: getEnv("SOKETI_HOST", "ws.prabhupadasays.org"),
|
||||
SoketiPort: getEnvInt("SOKETI_PORT", 443),
|
||||
SoketiUseTLS: getEnvBool("SOKETI_USE_TLS", true),
|
||||
}
|
||||
|
||||
if cfg.MongoURI == "" {
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/auth"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/oauth"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/realtime"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/shorturl"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -197,7 +198,11 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
||||
blockRepo := repository.NewBlockRepo(s.pg)
|
||||
reportRepo := repository.NewReportRepo(s.pg)
|
||||
notifRepo := repository.NewNotificationRepo(s.pg)
|
||||
notifSvc := service.NewNotificationService(notifRepo, userRepo, mail, logger, cfg.FrontendURL)
|
||||
rtClient := realtime.NewSoketiClient(
|
||||
cfg.SoketiAppID, cfg.SoketiAppKey, cfg.SoketiAppSecret,
|
||||
cfg.SoketiHost, cfg.SoketiPort, cfg.SoketiUseTLS, logger,
|
||||
)
|
||||
notifSvc := service.NewNotificationService(notifRepo, userRepo, mail, rtClient, logger, cfg.FrontendURL)
|
||||
graphSvc := service.NewSocialGraphService(followRepo, blockRepo, reportRepo, postRepo, userRepo, notifSvc, mail, cfg.FeedbackAdminEmails, logger)
|
||||
s.relationH = handler.NewRelationshipHandler(graphSvc)
|
||||
s.notificationH = handler.NewNotificationHandler(notifSvc)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user