feat: social graph features

This commit is contained in:
Krispire Admin
2026-06-04 19:29:48 +05:30
parent d9678ebb3b
commit efa1eaf23b
28 changed files with 1882 additions and 78 deletions
+13
View File
@@ -75,6 +75,19 @@ func NewAuthService(
}
}
// CaptureCountry records the account's country only if it isn't already
// set. Best-effort: a blank code is a no-op, and DB errors are logged but
// swallowed so the caller (a post-signup goroutine) never fails on it.
func (s *AuthService) CaptureCountry(ctx context.Context, userID, country string) {
if strings.TrimSpace(country) == "" {
return
}
if err := s.users.SetCountryIfEmpty(ctx, userID, country); err != nil {
s.logger.Warn("capture signup country failed",
zap.String("user_id", userID), zap.Error(err))
}
}
// AuthResult is the value handlers return on signup/login. Token is the raw
// JWT the handler will plant in the response cookie; the User is the public
// profile to ship in the response body.
+2 -2
View File
@@ -281,7 +281,7 @@ func derefOr(p *string, fallback string) string {
// quotes back the exact feedback the devotee submitted so they know
// precisely what was addressed. Base font is 17px for comfortable
// reading on phones.
var feedbackResolvedEmailTpl = template.Must(template.New("feedbackResolved").Parse(`<div style="font-family:Georgia,'Times New Roman',serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
var feedbackResolvedEmailTpl = template.Must(template.New("feedbackResolved").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 .Name}}, {{.Name}}{{end}} 🙏</p>
<p>The feedback you shared with us has been addressed and the necessary
@@ -302,7 +302,7 @@ var feedbackResolvedEmailTpl = template.Must(template.New("feedbackResolved").Pa
// note sent when an admin presses "Acknowledge". Same Vaishnava styling
// and 17px base as the resolved email, quotes the submission back, and
// promises a follow-up email on completion.
var feedbackAcknowledgedEmailTpl = template.Must(template.New("feedbackAcknowledged").Parse(`<div style="font-family:Georgia,'Times New Roman',serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
var feedbackAcknowledgedEmailTpl = template.Must(template.New("feedbackAcknowledged").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 .Name}}, {{.Name}}{{end}} 🙏</p>
<p>Thank you for the feedback you shared with us:</p>
+189
View File
@@ -0,0 +1,189 @@
// Package service — NotificationService is the single seam every
// user-facing event flows through ("the notification plugin"). Today it
// does two things per event:
//
// 1. Persists a row in `notifications` (so an in-app notification center
// and, later, websocket + push delivery can read the same history).
// 2. Sends an email to the recipient via the shared mailer.
//
// Everything here is fire-and-forget from the caller's perspective: a
// follow request must succeed even if the email bounces or the insert
// fails, so Notify* never returns an error — failures are logged. When
// websocket/push land they hook in right here alongside the email send.
package service
import (
"bytes"
"context"
"html/template"
"strings"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
"go.uber.org/zap"
)
// Notification kinds — short machine strings stored in notifications.kind.
const (
NotifFollowRequest = "follow_request"
NotifFollowAccepted = "follow_accepted"
)
type NotificationService struct {
notifs *repository.NotificationRepo
users *repository.UserRepo
mail mailer.Mailer
logger *zap.Logger
frontendURL string
}
func NewNotificationService(notifs *repository.NotificationRepo, users *repository.UserRepo, mail mailer.Mailer, logger *zap.Logger, frontendURL string) *NotificationService {
return &NotificationService{
notifs: notifs,
users: users,
mail: mail,
logger: logger,
frontendURL: strings.TrimRight(frontendURL, "/"),
}
}
// List returns the caller's notifications, newest first.
func (s *NotificationService) List(ctx context.Context, userID string, limit, offset int) ([]model.Notification, error) {
if limit <= 0 || limit > 100 {
limit = 30
}
if offset < 0 {
offset = 0
}
return s.notifs.ListByUser(ctx, userID, limit, offset)
}
// UnreadCount returns the caller's unread notification count (for the bell
// badge).
func (s *NotificationService) UnreadCount(ctx context.Context, userID string) (int, error) {
return s.notifs.CountUnread(ctx, userID)
}
// MarkAllRead marks all of the caller's notifications read.
func (s *NotificationService) MarkAllRead(ctx context.Context, userID string) error {
return s.notifs.MarkAllRead(ctx, userID)
}
// NotifyFollowRequest tells `recipientID` that `actorID` asked to follow
// them. Persists the row and emails the recipient (if they have an email
// on file).
func (s *NotificationService) NotifyFollowRequest(ctx context.Context, recipientID, actorID string) {
s.dispatch(ctx, recipientID, actorID, NotifFollowRequest,
"%s requested to follow you — Prabhupāda Says")
}
// NotifyFollowAccepted tells `recipientID` that `actorID` accepted their
// follow request (i.e. the recipient can now see actor's activity).
func (s *NotificationService) NotifyFollowAccepted(ctx context.Context, recipientID, actorID string) {
s.dispatch(ctx, recipientID, actorID, NotifFollowAccepted,
"%s accepted your follow request — Prabhupāda Says")
}
// dispatch persists the notification and sends the matching email. All
// failures are logged, never propagated.
func (s *NotificationService) dispatch(ctx context.Context, recipientID, actorID, kind, subjectFmt string) {
actor := actorID
if _, err := s.notifs.Create(ctx, recipientID, &actor, kind); err != nil {
s.logger.Warn("persist notification failed",
zap.String("recipient", recipientID), zap.String("kind", kind), zap.Error(err))
// Persistence failed, but still try to email — the email is the
// only delivery channel that exists today.
}
recipient, err := s.users.FindByID(ctx, recipientID)
if err != nil {
s.logger.Warn("notification recipient lookup failed", zap.String("recipient", recipientID), zap.Error(err))
return
}
if recipient.Email == nil || strings.TrimSpace(*recipient.Email) == "" {
// No email on file (OAuth-only account that never added one) —
// the persisted row is the notification; nothing to send.
return
}
actorUser, err := s.users.FindByID(ctx, actorID)
if err != nil {
s.logger.Warn("notification actor lookup failed", zap.String("actor", actorID), zap.Error(err))
return
}
body, err := s.render(kind, recipient, actorUser)
if err != nil {
s.logger.Warn("render notification email failed", zap.String("kind", kind), zap.Error(err))
return
}
subject := strings.Replace(subjectFmt, "%s", actorDisplay(actorUser), 1)
if err := s.mail.Send(strings.TrimSpace(*recipient.Email), subject, body); err != nil {
s.logger.Warn("notification email send failed",
zap.String("to", *recipient.Email), zap.String("kind", kind), zap.Error(err))
}
}
func (s *NotificationService) render(kind string, recipient, actor *model.User) (string, error) {
data := map[string]any{
"RecipientName": recipient.Name,
"ActorName": actorDisplay(actor),
"ActorHandle": actor.Handle,
"ProfileURL": s.frontendURL + "/u/" + actor.Handle,
}
var tpl *template.Template
switch kind {
case NotifFollowRequest:
tpl = followRequestEmailTpl
case NotifFollowAccepted:
tpl = followAcceptedEmailTpl
default:
tpl = followRequestEmailTpl
}
var buf bytes.Buffer
if err := tpl.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
// actorDisplay prefers the display name, falling back to @handle.
func actorDisplay(u *model.User) string {
if n := strings.TrimSpace(u.Name); n != "" {
return n
}
return "@" + u.Handle
}
// Vaishnava-styled, 17px base to match the feedback emails. Both quote
// the actor and link to their profile (where the recipient can accept a
// pending request).
var followRequestEmailTpl = template.Must(template.New("followRequest").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>{{.ActorName}}</strong> (<a href="{{.ProfileURL}}" style="color:#9a6b2f;font-weight:600">@{{.ActorHandle}}</a>)
has requested to follow you on Prabhupāda Says.</p>
<p>You can review and accept or decline the request from their profile:</p>
<p style="margin:1.25em 0">
<a href="{{.ProfileURL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">View request</a>
</p>
<p style="margin-top:1.5em">Your servants,<br>
<strong>The Prabhupāda Says team</strong></p>
</div>`))
var followAcceptedEmailTpl = template.Must(template.New("followAccepted").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>{{.ActorName}}</strong> (<a href="{{.ProfileURL}}" style="color:#9a6b2f;font-weight:600">@{{.ActorHandle}}</a>)
has accepted your follow request. You're now following them on Prabhupāda Says.</p>
<p style="margin:1.25em 0">
<a href="{{.ProfileURL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Visit profile</a>
</p>
<p style="margin-top:1.5em">Your servants,<br>
<strong>The Prabhupāda Says team</strong></p>
</div>`))
+435
View File
@@ -0,0 +1,435 @@
// Package service — SocialGraphService is the follow / block / report
// brain. It sits over FollowRepo + BlockRepo + ReportRepo, resolves the
// target by handle, enforces the rules (no self-follow, no following
// across a block), keeps the request-based follow lifecycle honest, and
// routes every notable event through the NotificationService.
//
// Follows are request-based end to end: RequestFollow only ever creates a
// PENDING edge; the target turns it into a real follow via AcceptFollow.
// Every mutating method returns the freshly-recomputed RelationshipResult
// (the viewer's state toward the target + the target's follower/following
// counts) so the frontend can update its buttons and stat line from one
// response.
package service
import (
"bytes"
"context"
"errors"
"fmt"
"html/template"
"strings"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
"go.uber.org/zap"
)
// Sentinels specific to the social graph (ErrForbidden / ErrNotFound are
// shared from social_service.go in this package).
var (
// ErrBlocked — the action can't proceed because one of the two users
// has blocked the other.
ErrBlocked = errors.New("blocked")
// ErrSelfRelation — you can't follow / block / report yourself.
ErrSelfRelation = errors.New("cannot perform this action on yourself")
)
// reportCategories is the allow-list the Report flow validates against —
// mirrored by the frontend's report dialog dropdown.
var reportCategories = map[string]bool{
"spam": true,
"harassment": true,
"impersonation": true,
"inappropriate": true,
"other": true,
}
// postReportCategories is the allow-list for reporting a post — mirrored
// by the frontend's post report dialog.
var postReportCategories = map[string]bool{
"spam": true,
"harassment": true,
"misinformation": true,
"inappropriate": true,
"other": true,
}
// RelationshipResult is the payload every relationship mutation returns:
// the viewer's relationship to the target plus the target's live counts.
type RelationshipResult struct {
Relationship *model.RelationshipState `json:"relationship"`
FollowerCount int `json:"follower_count"`
FollowingCount int `json:"following_count"`
}
type SocialGraphService struct {
follows *repository.FollowRepo
blocks *repository.BlockRepo
reports *repository.ReportRepo
posts *repository.PostRepo
users *repository.UserRepo
notif *NotificationService
mail mailer.Mailer
admins []string
logger *zap.Logger
}
func NewSocialGraphService(
follows *repository.FollowRepo,
blocks *repository.BlockRepo,
reports *repository.ReportRepo,
posts *repository.PostRepo,
users *repository.UserRepo,
notif *NotificationService,
mail mailer.Mailer,
admins []string,
logger *zap.Logger,
) *SocialGraphService {
return &SocialGraphService{
follows: follows, blocks: blocks, reports: reports, posts: posts,
users: users, notif: notif, mail: mail, admins: admins, logger: logger,
}
}
// resolve looks up the target by handle and rejects self-targeting.
func (s *SocialGraphService) resolve(ctx context.Context, viewerID, handle string) (*model.User, error) {
t, err := s.users.FindByHandle(ctx, handle)
if err != nil {
return nil, err // ErrUserNotFound → 404 via mapSocialErr
}
if t.ID == viewerID {
return nil, ErrSelfRelation
}
return t, nil
}
// RequestFollow inserts a pending follow edge viewer→target and notifies
// the target. Refuses if either party has blocked the other. Idempotent:
// re-requesting an existing edge doesn't re-notify.
func (s *SocialGraphService) RequestFollow(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
t, err := s.resolve(ctx, viewerID, handle)
if err != nil {
return nil, err
}
viewerBlocked, targetBlocked, err := s.blocks.BlockState(ctx, viewerID, t.ID)
if err != nil {
return nil, err
}
if viewerBlocked || targetBlocked {
return nil, ErrBlocked
}
created, err := s.follows.Request(ctx, viewerID, t.ID)
if err != nil {
return nil, err
}
if created {
s.notif.NotifyFollowRequest(ctx, t.ID, viewerID)
}
return s.result(ctx, viewerID, t)
}
// Unfollow removes the viewer→target edge — covers both "stop following"
// (accepted) and "cancel my pending request". Idempotent.
func (s *SocialGraphService) Unfollow(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
t, err := s.resolve(ctx, viewerID, handle)
if err != nil {
return nil, err
}
if err := s.follows.Delete(ctx, viewerID, t.ID); err != nil {
return nil, err
}
return s.result(ctx, viewerID, t)
}
// AcceptFollow accepts the target's pending request to follow the viewer
// (edge target→viewer). On the pending→accepted transition the target is
// notified that their request went through. `handle` is the requester.
func (s *SocialGraphService) AcceptFollow(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
t, err := s.resolve(ctx, viewerID, handle)
if err != nil {
return nil, err
}
accepted, err := s.follows.Accept(ctx, t.ID, viewerID)
if err != nil {
return nil, err
}
if accepted {
s.notif.NotifyFollowAccepted(ctx, t.ID, viewerID)
}
return s.result(ctx, viewerID, t)
}
// RejectFollow declines the target's pending request to follow the viewer
// by deleting the target→viewer edge. Idempotent; no notification (a
// silent decline is kinder than an explicit rejection email).
func (s *SocialGraphService) RejectFollow(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
t, err := s.resolve(ctx, viewerID, handle)
if err != nil {
return nil, err
}
if err := s.follows.Delete(ctx, t.ID, viewerID); err != nil {
return nil, err
}
return s.result(ctx, viewerID, t)
}
// RemoveFollower drops the target from the viewer's followers by deleting
// the target→viewer edge. Mechanically the same as RejectFollow but
// distinct in intent (an accepted follower is being removed). Idempotent.
func (s *SocialGraphService) RemoveFollower(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
t, err := s.resolve(ctx, viewerID, handle)
if err != nil {
return nil, err
}
if err := s.follows.Delete(ctx, t.ID, viewerID); err != nil {
return nil, err
}
return s.result(ctx, viewerID, t)
}
// Block records viewer→target and severs any follow edges both ways.
func (s *SocialGraphService) Block(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
t, err := s.resolve(ctx, viewerID, handle)
if err != nil {
return nil, err
}
if err := s.blocks.Block(ctx, viewerID, t.ID); err != nil {
return nil, err
}
return s.result(ctx, viewerID, t)
}
// Unblock removes the viewer→target block.
func (s *SocialGraphService) Unblock(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
t, err := s.resolve(ctx, viewerID, handle)
if err != nil {
return nil, err
}
if err := s.blocks.Unblock(ctx, viewerID, t.ID); err != nil {
return nil, err
}
return s.result(ctx, viewerID, t)
}
// Report files a report against the target and emails the admins. The
// category must be in the allow-list and the reason non-empty.
func (s *SocialGraphService) Report(ctx context.Context, viewerID, handle, category, reason string) error {
t, err := s.resolve(ctx, viewerID, handle)
if err != nil {
return err
}
category = strings.ToLower(strings.TrimSpace(category))
if !reportCategories[category] {
return errors.New("invalid report category")
}
reason = strings.TrimSpace(reason)
if reason == "" {
return errors.New("reason is required")
}
if len(reason) > 2000 {
return errors.New("reason too long (max 2000)")
}
if _, err := s.reports.Create(ctx, viewerID, t.ID, category, reason); err != nil {
return err
}
// Email the admins — fire-and-forget; the row is already persisted.
reporter, rerr := s.users.FindByID(ctx, viewerID)
if rerr != nil {
s.logger.Warn("report saved but reporter lookup failed; emailing without reporter info",
zap.String("reporter", viewerID), zap.Error(rerr))
}
s.notifyAdminsOfReport(reporter, t, category, reason)
return nil
}
// ReportPost files a report against a specific post and emails the admins.
// The category must be in the post allow-list; the reason is optional.
func (s *SocialGraphService) ReportPost(ctx context.Context, reporterID, postID, category, reason string) error {
category = strings.ToLower(strings.TrimSpace(category))
if !postReportCategories[category] {
return errors.New("invalid report category")
}
reason = strings.TrimSpace(reason)
if len(reason) > 2000 {
return errors.New("reason too long (max 2000)")
}
post, err := s.posts.FindByID(ctx, postID)
if err != nil {
return err // ErrPostNotFound → 404 via mapSocialErr
}
if _, err := s.reports.CreatePostReport(ctx, reporterID, post.ID, category, reason); err != nil {
return err
}
reporter, rerr := s.users.FindByID(ctx, reporterID)
if rerr != nil {
s.logger.Warn("post report saved but reporter lookup failed; emailing without reporter info",
zap.String("reporter", reporterID), zap.Error(rerr))
}
s.notifyAdminsOfPostReport(reporter, post, category, reason)
return nil
}
// Relationship computes the viewer's relationship toward targetID.
func (s *SocialGraphService) Relationship(ctx context.Context, viewerID, targetID string) (*model.RelationshipState, error) {
out := &model.RelationshipState{}
outStatus, err := s.follows.Status(ctx, viewerID, targetID) // viewer → target
if err != nil {
return nil, err
}
inStatus, err := s.follows.Status(ctx, targetID, viewerID) // target → viewer
if err != nil {
return nil, err
}
out.Following = outStatus == "accepted"
out.FollowRequested = outStatus == "pending"
out.FollowsYou = inStatus == "accepted"
out.RequestsYou = inStatus == "pending"
viewerBlocked, blockedByTarget, err := s.blocks.BlockState(ctx, viewerID, targetID)
if err != nil {
return nil, err
}
out.Blocked = viewerBlocked
out.BlockedBy = blockedByTarget
return out, nil
}
// Counts returns the accepted-follower and accepted-following totals for
// a user.
func (s *SocialGraphService) Counts(ctx context.Context, userID string) (followers, following int, err error) {
followers, err = s.follows.CountFollowers(ctx, userID)
if err != nil {
return 0, 0, err
}
following, err = s.follows.CountFollowing(ctx, userID)
if err != nil {
return 0, 0, err
}
return followers, following, nil
}
// ProfileExtras is the read used by the public-profile endpoint: the
// target's follower/following counts plus, when a non-owner viewer is
// signed in, their relationship to the target. rel is nil for anonymous
// viewers and for the owner viewing their own profile.
func (s *SocialGraphService) ProfileExtras(ctx context.Context, viewerID string, target *model.User) (followers, following int, rel *model.RelationshipState, err error) {
followers, following, err = s.Counts(ctx, target.ID)
if err != nil {
return 0, 0, nil, err
}
if viewerID == "" || viewerID == target.ID {
return followers, following, nil, nil
}
rel, err = s.Relationship(ctx, viewerID, target.ID)
if err != nil {
return 0, 0, nil, err
}
return followers, following, rel, nil
}
// result recomputes the viewer→target relationship and the target's
// counts after a mutation.
func (s *SocialGraphService) result(ctx context.Context, viewerID string, target *model.User) (*RelationshipResult, error) {
rel, err := s.Relationship(ctx, viewerID, target.ID)
if err != nil {
return nil, err
}
followers, following, err := s.Counts(ctx, target.ID)
if err != nil {
return nil, err
}
return &RelationshipResult{
Relationship: rel,
FollowerCount: followers,
FollowingCount: following,
}, nil
}
func (s *SocialGraphService) notifyAdminsOfReport(reporter, reported *model.User, category, reason string) {
if len(s.admins) == 0 {
s.logger.Info("user report received but no admin recipients configured",
zap.String("reported", reported.ID))
return
}
from := "anonymous"
if reporter != nil {
from = fmt.Sprintf("%s (@%s)", reporter.Name, reporter.Handle)
}
var buf bytes.Buffer
if err := userReportEmailTpl.Execute(&buf, map[string]any{
"ReporterFrom": from,
"ReportedName": reported.Name,
"ReportedHandle": reported.Handle,
"Category": category,
"Reason": reason,
}); err != nil {
s.logger.Warn("render user-report email failed", zap.Error(err))
return
}
subject := fmt.Sprintf("[Report · %s] @%s", category, reported.Handle)
body := buf.String()
for _, to := range s.admins {
if err := s.mail.Send(to, subject, body); err != nil {
s.logger.Warn("user-report email send failed", zap.String("to", to), zap.Error(err))
}
}
}
var userReportEmailTpl = template.Must(template.New("userReport").Parse(`<p><strong>A user has been reported</strong></p>
<p><strong>Reported:</strong> {{.ReportedName}} (@{{.ReportedHandle}})<br>
<strong>Reported by:</strong> {{.ReporterFrom}}<br>
<strong>Category:</strong> {{.Category}}</p>
<p><strong>Reason:</strong></p>
<pre style="white-space:pre-wrap;font-family:inherit;background:#fafafa;padding:0.75em 1em;border-radius:6px;border:1px solid #eee">{{.Reason}}</pre>`))
func (s *SocialGraphService) notifyAdminsOfPostReport(reporter *model.User, post *model.Post, category, reason string) {
if len(s.admins) == 0 {
s.logger.Info("post report received but no admin recipients configured",
zap.String("post", post.ID))
return
}
from := "anonymous"
if reporter != nil {
from = fmt.Sprintf("%s (@%s)", reporter.Name, reporter.Handle)
}
author := post.AuthorName
if post.AuthorHandle != "" {
author = fmt.Sprintf("%s (@%s)", post.AuthorName, post.AuthorHandle)
}
if author == "" {
author = "unknown"
}
var buf bytes.Buffer
if err := postReportEmailTpl.Execute(&buf, map[string]any{
"PostID": post.ID,
"Author": author,
"ReporterFrom": from,
"Category": category,
"Reason": reason,
"Caption": post.Caption,
"SourceURL": post.SourceURL,
}); err != nil {
s.logger.Warn("render post-report email failed", zap.Error(err))
return
}
subject := fmt.Sprintf("[Post report · %s] %s", category, summarize(post.Caption, 50))
body := buf.String()
for _, to := range s.admins {
if err := s.mail.Send(to, subject, body); err != nil {
s.logger.Warn("post-report email send failed", zap.String("to", to), zap.Error(err))
}
}
}
var postReportEmailTpl = template.Must(template.New("postReport").Parse(`<p><strong>A post has been reported</strong></p>
<p><strong>Post ID:</strong> {{.PostID}}<br>
<strong>Author:</strong> {{.Author}}<br>
<strong>Reported by:</strong> {{.ReporterFrom}}<br>
<strong>Category:</strong> {{.Category}}</p>
{{if .Reason}}<p><strong>Reason:</strong></p>
<pre style="white-space:pre-wrap;font-family:inherit;background:#fafafa;padding:0.75em 1em;border-radius:6px;border:1px solid #eee">{{.Reason}}</pre>{{end}}
<p><strong>Caption:</strong></p>
<pre style="white-space:pre-wrap;font-family:inherit;background:#fafafa;padding:0.75em 1em;border-radius:6px;border:1px solid #eee">{{.Caption}}</pre>
<p style="color:#999;font-size:12px"><a href="{{.SourceURL}}">{{.SourceURL}}</a></p>`))
+107 -8
View File
@@ -35,28 +35,127 @@ func NewUserService(users *repository.UserRepo, r2 *repository.R2) *UserService
// `handle` is normalized to the project's slug format before being passed
// to the DB so users can't sneak in spaces or punctuation. Empty after
// normalization → 400.
func (s *UserService) UpdateProfile(ctx context.Context, id string, name, avatarURL, handle *string) (*model.User, error) {
if name != nil {
trimmed := strings.TrimSpace(*name)
// UpdateProfileInput is the partial profile patch a handler hands the
// service. Every field is a pointer so nil means "leave unchanged". The
// service validates/normalizes each present field, then maps to a
// repository.ProfileUpdate.
type UpdateProfileInput struct {
Name *string
AvatarURL *string
Handle *string
Country *string
Sampradaya *string
Gender *string
Initiated *bool
InitiatedAt *string
DailyChantingRounds *int
PreferredChantingTime *string
PeakActiveTime *string
}
// validGenders is the accepted set; "" clears the field.
var validGenders = map[string]bool{"male": true, "female": true, "other": true}
// validSampradayas is the accepted lineage set. Currently just Gaudiya
// (the default); add codes here as more lineages are supported.
var validSampradayas = map[string]bool{"gaudiya": true}
func (s *UserService) UpdateProfile(ctx context.Context, id string, in UpdateProfileInput) (*model.User, error) {
p := repository.ProfileUpdate{
AvatarURL: in.AvatarURL, // passthrough: nil=leave, ""=clear, x=set
Initiated: in.Initiated,
DailyChantingRounds: in.DailyChantingRounds,
}
if in.Name != nil {
trimmed := strings.TrimSpace(*in.Name)
if trimmed == "" {
return nil, errors.New("name cannot be empty")
}
if len(trimmed) > 120 {
return nil, errors.New("name too long (max 120)")
}
name = &trimmed
p.Name = &trimmed
}
if handle != nil {
clean := normalizeHandle(*handle)
if in.Handle != nil {
clean := normalizeHandle(*in.Handle)
if len(clean) < 3 {
return nil, errors.New("handle must be at least 3 characters (letters, digits, dashes)")
}
if len(clean) > 32 {
return nil, errors.New("handle too long (max 32)")
}
handle = &clean
p.Handle = &clean
}
return s.users.UpdateProfile(ctx, id, name, avatarURL, handle)
if in.Country != nil {
// "" clears; otherwise a 2-letter ISO-3166-1 alpha-2 code (the
// frontend dropdown only ever sends valid codes).
cc := strings.ToUpper(strings.TrimSpace(*in.Country))
if cc != "" && len(cc) != 2 {
return nil, errors.New("country must be a 2-letter code")
}
p.Country = &cc
}
if in.Sampradaya != nil {
// "" clears (optional field); otherwise must be a known lineage.
s := strings.ToLower(strings.TrimSpace(*in.Sampradaya))
if s != "" && !validSampradayas[s] {
return nil, errors.New("unknown sampradaya")
}
p.Sampradaya = &s
}
if in.Gender != nil {
g := strings.ToLower(strings.TrimSpace(*in.Gender))
if g != "" && !validGenders[g] {
return nil, errors.New("gender must be male, female, or other")
}
p.Gender = &g
}
if in.InitiatedAt != nil {
d := strings.TrimSpace(*in.InitiatedAt)
if d != "" {
if _, err := time.Parse("2006-01-02", d); err != nil {
return nil, errors.New("initiated_at must be a date (YYYY-MM-DD)")
}
}
p.InitiatedAt = &d
}
if in.DailyChantingRounds != nil {
// 0 is the clear sentinel; real values are 1–1008 (DB enforces too).
n := *in.DailyChantingRounds
if n != 0 && (n < 1 || n > 1008) {
return nil, errors.New("daily_chanting_rounds must be between 1 and 1008")
}
}
if in.PreferredChantingTime != nil {
t, err := normalizeClock(*in.PreferredChantingTime)
if err != nil {
return nil, errors.New("preferred_chanting_time must be HH:MM")
}
p.PreferredChantingTime = &t
}
if in.PeakActiveTime != nil {
t, err := normalizeClock(*in.PeakActiveTime)
if err != nil {
return nil, errors.New("peak_active_time must be HH:MM")
}
p.PeakActiveTime = &t
}
return s.users.UpdateProfile(ctx, id, p)
}
// normalizeClock validates an "HH:MM" 24-hour clock time, returning "" for
// a blank input (clear). The system-managed notification fields use this.
func normalizeClock(raw string) (string, error) {
t := strings.TrimSpace(raw)
if t == "" {
return "", nil
}
if _, err := time.Parse("15:04", t); err != nil {
return "", err
}
return t, nil
}
// Search returns users matching the query (handle or name substring).