feat: add email notification for post like

This commit is contained in:
Krispire Admin
2026-06-09 16:57:12 +05:30
parent e9299051f5
commit d86d876ac1
6 changed files with 165 additions and 30 deletions
+34
View File
@@ -1,6 +1,7 @@
package handler
import (
"encoding/json"
"net/http"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
@@ -49,3 +50,36 @@ func (h *NotificationHandler) MarkAllRead(w http.ResponseWriter, r *http.Request
}
response.NoContent(w)
}
type emailPrefsResp struct {
LikeEmails bool `json:"like_emails"`
}
// GetEmailPrefs — GET /notifications/email-prefs. The caller's email-notification
// preferences (currently just aggregated like emails).
func (h *NotificationHandler) GetEmailPrefs(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
enabled, err := h.svc.LikeEmailPref(r.Context(), uid)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load email preferences")
return
}
response.JSON(w, http.StatusOK, emailPrefsResp{LikeEmails: enabled})
}
// UpdateEmailPrefs — PUT /notifications/email-prefs { like_emails }.
func (h *NotificationHandler) UpdateEmailPrefs(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req struct {
LikeEmails bool `json:"like_emails"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := h.svc.SetLikeEmailPref(r.Context(), uid, req.LikeEmails); err != nil {
response.Error(w, http.StatusInternalServerError, "could not update email preferences")
return
}
response.JSON(w, http.StatusOK, emailPrefsResp{LikeEmails: req.LikeEmails})
}
+46 -13
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
@@ -347,25 +348,57 @@ func (r *NotificationRepo) ListByUser(ctx context.Context, userID string, limit,
// UpsertLike records (or aggregates) a like notification: one row per
// (recipient, post). On a repeat like it refreshes the most-recent actor,
// bumps actor_count, and re-marks the row unread + most-recent. Returns the
// new actor_count.
func (r *NotificationRepo) UpsertLike(ctx context.Context, userID, actorID, postID string) (int, error) {
var count int
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO notifications (user_id, actor_id, kind, post_id, actor_count)
VALUES ($1, $2, 'post_like', $3, 1)
// bumps actor_count, and re-marks the row unread + most-recent.
//
// It also throttles the aggregated like *email*: like_email_sent_at is stamped
// to now() on the first like and again only once `throttle` has elapsed since
// the last email. Returns the new actor_count and whether an email is due this
// call (i.e. the stamp was (re)set now) — decided atomically in one statement
// to avoid double-emailing under concurrent likes.
func (r *NotificationRepo) UpsertLike(ctx context.Context, userID, actorID, postID string, throttle time.Duration) (count int, emailDue bool, err error) {
secs := int(throttle.Seconds())
err = r.pg.Pool.QueryRow(ctx, `
INSERT INTO notifications (user_id, actor_id, kind, post_id, actor_count, like_email_sent_at)
VALUES ($1, $2, 'post_like', $3, 1, now())
ON CONFLICT (user_id, post_id) WHERE kind = 'post_like'
DO UPDATE SET actor_id = EXCLUDED.actor_id,
actor_count = notifications.actor_count + 1,
read_at = NULL,
created_at = now()
RETURNING actor_count`,
userID, actorID, postID,
).Scan(&count)
created_at = now(),
like_email_sent_at = CASE
WHEN notifications.like_email_sent_at IS NULL
OR notifications.like_email_sent_at < now() - make_interval(secs => $4)
THEN now()
ELSE notifications.like_email_sent_at
END
RETURNING actor_count, (like_email_sent_at = now())`,
userID, actorID, postID, secs,
).Scan(&count, &emailDue)
if err != nil {
return 0, fmt.Errorf("upsert like notification: %w", err)
return 0, false, fmt.Errorf("upsert like notification: %w", err)
}
return count, nil
return count, emailDue, nil
}
// LikeEmailsEnabled reports whether the user wants aggregated like emails.
func (r *NotificationRepo) LikeEmailsEnabled(ctx context.Context, userID string) (bool, error) {
var enabled bool
if err := r.pg.Pool.QueryRow(ctx,
`SELECT like_emails_enabled FROM users WHERE id = $1`, userID,
).Scan(&enabled); err != nil {
return false, fmt.Errorf("get like-email pref: %w", err)
}
return enabled, nil
}
// SetLikeEmailsEnabled flips the user's like-email opt-in.
func (r *NotificationRepo) SetLikeEmailsEnabled(ctx context.Context, userID string, enabled bool) error {
if _, err := r.pg.Pool.Exec(ctx,
`UPDATE users SET like_emails_enabled = $1 WHERE id = $2`, enabled, userID,
); err != nil {
return fmt.Errorf("set like-email pref: %w", err)
}
return nil
}
// CountUnread returns how many of a recipient's notifications are unread.
+2
View File
@@ -791,6 +791,8 @@ func (s *Server) setupRouter() {
r.Get("/", s.notificationH.List)
r.Get("/unread-count", s.notificationH.UnreadCount)
r.Post("/read", s.notificationH.MarkAllRead)
r.Get("/email-prefs", s.notificationH.GetEmailPrefs)
r.Put("/email-prefs", s.notificationH.UpdateEmailPrefs)
})
// --- Web Push --- VAPID key + per-user subscription management.
+72 -17
View File
@@ -18,6 +18,7 @@ import (
"fmt"
"html/template"
"strings"
"time"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
@@ -83,6 +84,16 @@ func (s *NotificationService) MarkAllRead(ctx context.Context, userID string) er
return s.notifs.MarkAllRead(ctx, userID)
}
// LikeEmailPref / SetLikeEmailPref back the Settings → Notifications toggle
// for aggregated like emails.
func (s *NotificationService) LikeEmailPref(ctx context.Context, userID string) (bool, error) {
return s.notifs.LikeEmailsEnabled(ctx, userID)
}
func (s *NotificationService) SetLikeEmailPref(ctx context.Context, userID string, enabled bool) error {
return s.notifs.SetLikeEmailsEnabled(ctx, userID, enabled)
}
// NotifyFollowRequest tells `recipientID` that `actorID` asked to follow
// them. Persists the row and emails the recipient (if they have an email
// on file).
@@ -107,13 +118,13 @@ func (s *NotificationService) NotifyPostLike(ctx context.Context, postID, author
if authorID == "" || likerID == "" || authorID == likerID {
return
}
// Aggregate into the single (author, post) like-notification row, getting
// back how many likers it now represents.
count, err := s.notifs.UpsertLike(ctx, authorID, likerID, postID)
// Aggregate into the single (author, post) like-notification row. Returns
// the new liker count and whether an aggregated email is due (throttled).
count, emailDue, err := s.notifs.UpsertLike(ctx, authorID, likerID, postID, likeEmailThrottle)
if err != nil {
s.logger.Warn("aggregate like notification failed",
zap.String("recipient", authorID), zap.Error(err))
count = 1
count, emailDue = 1, false
}
actorUser, _ := s.users.FindByID(ctx, likerID)
@@ -136,24 +147,51 @@ func (s *NotificationService) NotifyPostLike(ctx context.Context, postID, author
}
}
// Web Push — tapping opens the author's own feed anchored to the post.
// Tagged per-post so repeated likes collapse into one notification.
if s.push == nil {
return
}
// Tapping a notification opens the author's own feed anchored to the post.
author, _ := s.users.FindByID(ctx, authorID)
url := s.frontendURL + "/notifications"
if author, err := s.users.FindByID(ctx, authorID); err == nil && author != nil {
if author != nil {
url = s.frontendURL + "/u/" + author.Handle + "/posts#post-" + postID
}
s.push.SendToUser(ctx, authorID, PushPayload{
Title: "New Like",
Body: body,
URL: url,
Icon: s.iconURL(),
Tag: NotifPostLike + ":" + postID,
})
// Web Push — tagged per-post so repeated likes collapse into one.
if s.push != nil {
s.push.SendToUser(ctx, authorID, PushPayload{
Title: "New Like",
Body: body,
URL: url,
Icon: s.iconURL(),
Tag: NotifPostLike + ":" + postID,
})
}
// Aggregated email — only when the throttle says one is due (so a burst of
// likes yields a single email), the author has an address, and they
// haven't opted out. `body` already aggregates ("X and N others …").
if !emailDue || author == nil || author.Email == nil || strings.TrimSpace(*author.Email) == "" {
return
}
if enabled, perr := s.notifs.LikeEmailsEnabled(ctx, authorID); perr != nil || !enabled {
return
}
var buf bytes.Buffer
if err := likeEmailTpl.Execute(&buf, map[string]any{
"RecipientName": author.Name,
"Message": body,
"URL": url,
}); err != nil {
s.logger.Warn("render like email failed", zap.Error(err))
return
}
if err := s.mail.Send(strings.TrimSpace(*author.Email), "New likes on your post — Prabhupāda Says", buf.String()); err != nil {
s.logger.Warn("like email send failed", zap.String("to", *author.Email), zap.Error(err))
}
}
// likeEmailThrottle is the minimum gap between aggregated like emails for a
// single post — a burst of likes within the window produces one email.
const likeEmailThrottle = 6 * time.Hour
// likeMessage renders the aggregated like text: "X liked your post" or
// "X and N others liked your post".
func likeMessage(who string, count int) string {
@@ -402,3 +440,20 @@ var reminderEmailTpl = template.Must(template.New("reminder").Parse(`<div style=
<p style="margin-top:1em">Your servants,<br>
<strong>The Prabhupāda Says team</strong></p>
</div>`))
// Aggregated like email. Message is "X and N others liked your post"; the CTA
// opens the author's post.
var likeEmailTpl = template.Must(template.New("postLike").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">View your post</a>
</p>
<p style="margin-top:1.5em;font-size:14px;color:#7a6a55">You can turn off like emails any time in Settings → Notifications.</p>
<p style="margin-top:1em">Your servants,<br>
<strong>The Prabhupāda Says team</strong></p>
</div>`))
@@ -0,0 +1,2 @@
ALTER TABLE users DROP COLUMN IF EXISTS like_emails_enabled;
ALTER TABLE notifications DROP COLUMN IF EXISTS like_email_sent_at;
+9
View File
@@ -0,0 +1,9 @@
-- Aggregated like emails.
--
-- like_email_sent_at throttles the per-post like email: once an aggregated
-- "X and N others liked your post" email goes out, further likes within the
-- throttle window only update the in-app/push aggregate, not a fresh email.
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS like_email_sent_at TIMESTAMPTZ;
-- Per-user opt-out for like emails (push + in-app are unaffected).
ALTER TABLE users ADD COLUMN IF NOT EXISTS like_emails_enabled BOOLEAN NOT NULL DEFAULT TRUE;