diff --git a/internal/handler/notification.go b/internal/handler/notification.go index 2c0a6a7..bebd144 100644 --- a/internal/handler/notification.go +++ b/internal/handler/notification.go @@ -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}) +} diff --git a/internal/repository/social_graph_repo.go b/internal/repository/social_graph_repo.go index c66aa8a..f5eb076 100644 --- a/internal/repository/social_graph_repo.go +++ b/internal/repository/social_graph_repo.go @@ -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. diff --git a/internal/server/server.go b/internal/server/server.go index f40ec5d..a3eb4d4 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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. diff --git a/internal/service/notification_service.go b/internal/service/notification_service.go index b64d70d..efbcd81 100644 --- a/internal/service/notification_service.go +++ b/internal/service/notification_service.go @@ -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(`
Your servants,
The Prabhupāda Says team

`)) + +// 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(`
+

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

+ +

{{.Message}}.

+ +

+ View your post +

+ +

You can turn off like emails any time in Settings → Notifications.

+ +

Your servants,
+ The Prabhupāda Says team

+
`)) diff --git a/migrations/0032_like_email_prefs.down.sql b/migrations/0032_like_email_prefs.down.sql new file mode 100644 index 0000000..ed8de60 --- /dev/null +++ b/migrations/0032_like_email_prefs.down.sql @@ -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; diff --git a/migrations/0032_like_email_prefs.up.sql b/migrations/0032_like_email_prefs.up.sql new file mode 100644 index 0000000..06f261f --- /dev/null +++ b/migrations/0032_like_email_prefs.up.sql @@ -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;