455 lines
16 KiB
Go
455 lines
16 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"
|
|
|
|
"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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// 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>`))
|