From ac92bc2cf3a9fcea6016dc68a65b5b9b8cd4deff Mon Sep 17 00:00:00 2001 From: Krispire Admin Date: Sun, 30 Aug 2026 11:13:33 +0530 Subject: [PATCH] feat: chat notification --- internal/server/server.go | 3 +- internal/service/chat_service.go | 98 +++++++++++++++++++++--- internal/service/chat_service_test.go | 4 +- internal/service/notification_service.go | 4 +- 4 files changed, 95 insertions(+), 14 deletions(-) diff --git a/internal/server/server.go b/internal/server/server.go index 63f7603..68804dc 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -266,7 +266,8 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) { // Chat — DMs, groups and the (still coming-soon) Kautilya threads. chatRepo := repository.NewChatRepo(s.pg) chatSvc := service.NewChatService(chatRepo, blockRepo, postRepo, - pushSender, notifPrefsRepo, userRepo, rtClient, r2, s.redis, logger) + pushSender, notifPrefsRepo, userRepo, mail, cfg.FrontendURL, + rtClient, r2, s.redis, logger) s.chatH = handler.NewChatHandler(chatSvc) // Soketi signs webhooks with the app secret. channel_occupied/vacated on a diff --git a/internal/service/chat_service.go b/internal/service/chat_service.go index 959535e..22cafe2 100644 --- a/internal/service/chat_service.go +++ b/internal/service/chat_service.go @@ -1,12 +1,14 @@ package service import ( + "bytes" "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" + "html/template" "net/url" "path" "strings" @@ -14,6 +16,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" "github.com/prabhupadasays/spsays-bff/pkg/unfurl" "go.uber.org/zap" @@ -69,14 +72,18 @@ type ChatService struct { posts *repository.PostRepo // push, prefs and users deliver out-of-band message alerts. All optional: // nil means no push, never a failed send. - push *PushSender - prefs *repository.NotificationPrefsRepo - users *repository.UserRepo - rt *realtime.SoketiClient - r2 *repository.R2 - links *unfurl.Fetcher - cache *repository.Redis - logger *zap.Logger + push *PushSender + prefs *repository.NotificationPrefsRepo + users *repository.UserRepo + // mail nudges someone who has never used this thread and isn't connected. + // Optional: nil disables first-contact email and nothing else. + mail mailer.Mailer + frontendURL string + rt *realtime.SoketiClient + r2 *repository.R2 + links *unfurl.Fetcher + cache *repository.Redis + logger *zap.Logger } func NewChatService( @@ -86,6 +93,8 @@ func NewChatService( push *PushSender, prefs *repository.NotificationPrefsRepo, users *repository.UserRepo, + mail mailer.Mailer, + frontendURL string, rt *realtime.SoketiClient, r2 *repository.R2, cache *repository.Redis, @@ -93,7 +102,8 @@ func NewChatService( ) *ChatService { return &ChatService{ chat: chat, blocks: blocks, posts: posts, - push: push, prefs: prefs, users: users, rt: rt, r2: r2, + push: push, prefs: prefs, users: users, mail: mail, + frontendURL: frontendURL, rt: rt, r2: r2, links: unfurl.New(), cache: cache, logger: logger, } } @@ -1926,6 +1936,14 @@ func (s *ChatService) notifyNewMessage(ctx context.Context, conv *model.Conversa if c.MutedUntil != nil && c.MutedUntil.After(now) { continue } + // First contact: push only reaches a device that has already opted + // in, which someone who has never used chat almost certainly hasn't. + // Email is the one channel that reaches them — and only the FIRST + // message qualifies, since after that the thread exists and push + // carries it. + if msg.Seq == 1 && !s.online(ctx, c.UserID) { + s.emailFirstContact(ctx, c.UserID, senderID, conv) + } s.pushTo(ctx, conv, c.UserID, title, body) } } @@ -1962,6 +1980,68 @@ func (s *ChatService) pushTo(ctx context.Context, conv *model.Conversation, user }) } +// emailFirstContact tells someone a conversation has been opened with them, +// when nothing else would reach them. +// +// Deliberately says nothing about the message itself: an email subject line is +// the least private surface in the product, and "you have a message" is enough +// to bring someone back. +func (s *ChatService) emailFirstContact(ctx context.Context, recipientID, senderID string, conv *model.Conversation) { + if s.mail == nil || s.users == nil { + return + } + // The same preference as chat push, on its email channel — so turning + // Messages off silences both rather than leaving one leaking through. + if s.prefs != nil { + if off, err := s.prefs.DisabledSet(ctx, recipientID); err == nil && + prefOff(off, NotifChatMessage, repository.ChannelEmail) { + return + } + } + + recipient, err := s.users.FindByID(ctx, recipientID) + if err != nil || recipient == nil || + recipient.Email == nil || strings.TrimSpace(*recipient.Email) == "" { + // OAuth-only accounts may have no address on file. + return + } + sender, err := s.users.FindByID(ctx, senderID) + if err != nil || sender == nil { + return + } + + var buf bytes.Buffer + if err := firstContactEmailTpl.Execute(&buf, map[string]any{ + "RecipientName": recipient.Name, + "SenderName": actorDisplay(sender), + "SenderHandle": sender.Handle, + "ChatURL": s.frontendURL + "/chats/" + conv.ID, + }); err != nil { + s.logger.Warn("chat: first-contact email render failed", zap.Error(err)) + return + } + subject := actorDisplay(sender) + " sent you a message — Prabhupāda Says" + if err := s.mail.Send(strings.TrimSpace(*recipient.Email), subject, buf.String()); err != nil { + s.logger.Warn("chat: first-contact email failed", + zap.String("recipient", recipientID), zap.Error(err)) + } +} + +// Styled to match the follow-request email so the two read as one product. +var firstContactEmailTpl = template.Must(template.New("firstContact").Parse(`
+

Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏

+ +

{{.SenderName}} (@{{.SenderHandle}}) has sent you a message. + Reply to them on Prabhupāda Says.

+ +

+ Open the conversation +

+ +

You are receiving this because someone started a conversation with you. + You can turn these off under Settings → Notifications.

+
`)) + // pushCopy builds the lock-screen text. func (s *ChatService) pushCopy(ctx context.Context, conv *model.Conversation, senderID string, msg *model.Message) (title, body string) { sender := "Someone" diff --git a/internal/service/chat_service_test.go b/internal/service/chat_service_test.go index 1f40dda..85bd6b2 100644 --- a/internal/service/chat_service_test.go +++ b/internal/service/chat_service_test.go @@ -44,7 +44,7 @@ func testChatService(t *testing.T) (*ChatService, *repository.ChatRepo, *reposit // no-ops — these tests are about the rules, and none of those services are // reachable from a test run. return NewChatService(chat, repository.NewBlockRepo(pg), repository.NewPostRepo(pg), - nil, nil, nil, nil, nil, nil, zap.NewNop()), chat, pg + nil, nil, nil, nil, "", nil, nil, nil, zap.NewNop()), chat, pg } // chatUserWithHandle returns both identifiers: ids for authoring, handles for @@ -1928,7 +1928,7 @@ func TestSharedPosts_ResolutionDoesNotScaleWithShareCount(t *testing.T) { pg := &repository.Postgres{Pool: pool} svc := NewChatService(repository.NewChatRepo(pg), repository.NewBlockRepo(pg), - repository.NewPostRepo(pg), nil, nil, nil, nil, nil, nil, zap.NewNop()) + repository.NewPostRepo(pg), nil, nil, nil, nil, "", nil, nil, nil, zap.NewNop()) // measure returns the statement count for reading a thread carrying // `shares` distinct shared posts. diff --git a/internal/service/notification_service.go b/internal/service/notification_service.go index d275ed7..3ea4fc4 100644 --- a/internal/service/notification_service.go +++ b/internal/service/notification_service.go @@ -65,8 +65,8 @@ var PrefCategories = map[string][]string{ "post_interactions": {NotifPostLike, NotifPostMention, NotifPostComment, NotifCommentReply}, "sadhna_reminders": {NotifChantReminder, NotifQuoteReminder, NotifChantIncomplete}, "community": {NotifDiscipleJoined}, - // Push only: in-app delivery for chat is the inbox itself, and there is - // no such thing as an emailed chat message. + // Push, plus email for FIRST contact only — see ChatService.emailFirstContact. + // In-app delivery for chat is the inbox itself, so it isn't offered here. "messages": {NotifChatMessage}, }