feat: add email notification for post like
This commit is contained in:
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>`))
|
||||
|
||||
Reference in New Issue
Block a user