feat: add web push and sadhna tracker
This commit is contained in:
@@ -29,6 +29,11 @@ import (
|
||||
const (
|
||||
NotifFollowRequest = "follow_request"
|
||||
NotifFollowAccepted = "follow_accepted"
|
||||
|
||||
// Reminder kinds are delivered live (email + push + a websocket toast)
|
||||
// but NOT persisted — they're ephemeral nudges, not history.
|
||||
NotifChantReminder = "chant_reminder"
|
||||
NotifQuoteReminder = "quote_reminder"
|
||||
)
|
||||
|
||||
type NotificationService struct {
|
||||
@@ -36,16 +41,18 @@ type NotificationService struct {
|
||||
users *repository.UserRepo
|
||||
mail mailer.Mailer
|
||||
rt *realtime.SoketiClient // optional — nil disables WebSocket push
|
||||
push *PushSender // optional — nil disables Web 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 {
|
||||
func NewNotificationService(notifs *repository.NotificationRepo, users *repository.UserRepo, mail mailer.Mailer, rt *realtime.SoketiClient, push *PushSender, logger *zap.Logger, frontendURL string) *NotificationService {
|
||||
return &NotificationService{
|
||||
notifs: notifs,
|
||||
users: users,
|
||||
mail: mail,
|
||||
rt: rt,
|
||||
push: push,
|
||||
logger: logger,
|
||||
frontendURL: strings.TrimRight(frontendURL, "/"),
|
||||
}
|
||||
@@ -109,6 +116,10 @@ func (s *NotificationService) dispatch(ctx context.Context, recipientID, actorID
|
||||
// recipient has an email; authoritative data still comes from REST.
|
||||
s.publishRealtime(ctx, recipientID, kind, actorUser)
|
||||
|
||||
// Web Push (best-effort) — reaches the recipient even with the app
|
||||
// closed. Same seam as realtime/email; nil sender is a no-op.
|
||||
s.publishPush(ctx, recipientID, kind, actorUser)
|
||||
|
||||
// Email (best-effort).
|
||||
recipient, err := s.users.FindByID(ctx, recipientID)
|
||||
if err != nil {
|
||||
@@ -158,6 +169,73 @@ func (s *NotificationService) publishRealtime(ctx context.Context, recipientID,
|
||||
}
|
||||
}
|
||||
|
||||
// SendReminder delivers an ephemeral sādhana reminder across all live
|
||||
// channels — a websocket toast (no unread bump, not persisted), a Web Push,
|
||||
// and an email. Best-effort; nothing is persisted to the notification
|
||||
// center. `url` is where tapping the notification lands.
|
||||
func (s *NotificationService) SendReminder(ctx context.Context, userID, kind, title, body, url string) {
|
||||
// Realtime toast — a distinct "reminder" event so the client toasts
|
||||
// without bumping the unread badge (reminders have no DB row).
|
||||
if s.rt != nil {
|
||||
_ = s.rt.Trigger(ctx, "notifications."+userID, "reminder", map[string]any{
|
||||
"kind": kind, "title": title, "body": body, "url": url,
|
||||
})
|
||||
}
|
||||
|
||||
// Web Push (reaches closed apps).
|
||||
if s.push != nil {
|
||||
s.push.SendToUser(ctx, userID, PushPayload{Title: title, Body: body, URL: url, Tag: kind})
|
||||
}
|
||||
|
||||
// Email (only if there's an address on file).
|
||||
recipient, err := s.users.FindByID(ctx, userID)
|
||||
if err != nil || recipient == nil || recipient.Email == nil || strings.TrimSpace(*recipient.Email) == "" {
|
||||
return
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := reminderEmailTpl.Execute(&buf, map[string]any{
|
||||
"RecipientName": recipient.Name,
|
||||
"Message": body,
|
||||
"URL": url,
|
||||
}); err != nil {
|
||||
s.logger.Warn("render reminder email failed", zap.String("kind", kind), zap.Error(err))
|
||||
return
|
||||
}
|
||||
if err := s.mail.Send(strings.TrimSpace(*recipient.Email), title, buf.String()); err != nil {
|
||||
s.logger.Warn("reminder email send failed", zap.String("to", *recipient.Email), zap.String("kind", kind), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// publishPush delivers a Web Push notification to the recipient's devices.
|
||||
// The body quotes the actor; tapping it opens their profile (where a pending
|
||||
// follow request can be accepted). Best-effort; nil sender is a no-op.
|
||||
func (s *NotificationService) publishPush(ctx context.Context, recipientID, kind string, actor *model.User) {
|
||||
if s.push == nil {
|
||||
return
|
||||
}
|
||||
who := "Someone"
|
||||
url := s.frontendURL + "/notifications"
|
||||
if actor != nil {
|
||||
who = actorDisplay(actor)
|
||||
url = s.frontendURL + "/u/" + actor.Handle
|
||||
}
|
||||
var body string
|
||||
switch kind {
|
||||
case NotifFollowRequest:
|
||||
body = who + " requested to follow you"
|
||||
case NotifFollowAccepted:
|
||||
body = who + " accepted your follow request"
|
||||
default:
|
||||
body = "You have a new notification"
|
||||
}
|
||||
s.push.SendToUser(ctx, recipientID, PushPayload{
|
||||
Title: "Prabhupāda Says",
|
||||
Body: body,
|
||||
URL: url,
|
||||
Tag: kind,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *NotificationService) render(kind string, recipient, actor *model.User) (string, error) {
|
||||
data := map[string]any{
|
||||
"RecipientName": recipient.Name,
|
||||
@@ -221,3 +299,20 @@ var followAcceptedEmailTpl = template.Must(template.New("followAccepted").Parse(
|
||||
<p style="margin-top:1.5em">Your servants,<br>
|
||||
<strong>The Prabhupāda Says team</strong></p>
|
||||
</div>`))
|
||||
|
||||
// Daily sādhana reminder. The Message is the recency-aware nudge; the CTA
|
||||
// opens the streak page.
|
||||
var reminderEmailTpl = template.Must(template.New("reminder").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>{{.Message}}</p>
|
||||
|
||||
<p style="margin:1.25em 0">
|
||||
<a href="{{.URL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open your sādhana</a>
|
||||
</p>
|
||||
|
||||
<p style="margin-top:1.5em;font-size:14px;color:#7a6a55">You can turn these reminders off any time in Settings → Notifications.</p>
|
||||
|
||||
<p style="margin-top:1em">Your servants,<br>
|
||||
<strong>The Prabhupāda Says team</strong></p>
|
||||
</div>`))
|
||||
|
||||
Reference in New Issue
Block a user