Files
spsays-bff/internal/service/social_graph_service.go
T

487 lines
17 KiB
Go

// 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"
"time"
"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
}
// IsBlockedBetween reports whether a block exists in EITHER direction
// between two users — viewer→target or target→viewer. Content visibility
// is gated on this (a block either way hides the target's profile feed,
// tagged posts, and follower lists from the viewer), mirroring the
// "viewerBlocked || targetBlocked" rule RequestFollow enforces. Returns
// false for anonymous viewers (empty id) and for the self case.
func (s *SocialGraphService) IsBlockedBetween(ctx context.Context, a, b string) (bool, error) {
if a == "" || b == "" || a == b {
return false, nil
}
aBlockedB, bBlockedA, err := s.blocks.BlockState(ctx, a, b)
if err != nil {
return false, err
}
return aBlockedB || bBlockedA, 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
}
// Followers / Following resolve the handle and return one page of the
// profile's accepted followers (or followings), optionally filtered by a
// name/handle search `q`, plus the total matching count for pagination.
func (s *SocialGraphService) Followers(ctx context.Context, handle, q string, limit, offset int) ([]model.UserSummary, int, error) {
u, err := s.users.FindByHandle(ctx, handle)
if err != nil {
return nil, 0, err
}
return s.follows.ListFollowers(ctx, u.ID, q, limit, offset)
}
// NewUsers returns up to `limit` accounts that joined in the last 24h for the
// feed's "new on the platform" discovery strip — excluding the viewer, anyone
// they already have a follow edge with, and blocked pairs.
func (s *SocialGraphService) NewUsers(ctx context.Context, viewerID string, limit int) ([]model.UserSummary, error) {
return s.users.RecentlyJoined(ctx, viewerID, time.Now().Add(-24*time.Hour), limit)
}
func (s *SocialGraphService) Following(ctx context.Context, handle, q string, limit, offset int) ([]model.UserSummary, int, error) {
u, err := s.users.FindByHandle(ctx, handle)
if err != nil {
return nil, 0, err
}
return s.follows.ListFollowing(ctx, u.ID, q, limit, offset)
}
// IncomingRequests returns the pending follow requests addressed to userID
// (the caller) — the "Friend Requests" tab. Caller-scoped, so it takes the id
// directly rather than a handle.
func (s *SocialGraphService) IncomingRequests(ctx context.Context, userID, q string, limit, offset int) ([]model.UserSummary, int, error) {
return s.follows.ListIncomingRequests(ctx, userID, q, limit, offset)
}
// 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>`))