feat: chat notification
This commit is contained in:
@@ -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(`<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>{{.SenderName}}</strong> (@{{.SenderHandle}}) has sent you a message.
|
||||
Reply to them on Prabhupāda Says.</p>
|
||||
|
||||
<p style="margin:1.6em 0">
|
||||
<a href="{{.ChatURL}}" style="background:#9a6b2f;color:#fff;text-decoration:none;padding:12px 22px;border-radius:8px;font-weight:600;display:inline-block">Open the conversation</a>
|
||||
</p>
|
||||
|
||||
<p style="font-size:14px;color:#7a6a55">You are receiving this because someone started a conversation with you.
|
||||
You can turn these off under Settings → Notifications.</p>
|
||||
</div>`))
|
||||
|
||||
// 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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user