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

A user has been reported

Reported: {{.ReportedName}} (@{{.ReportedHandle}})
Reported by: {{.ReporterFrom}}
Category: {{.Category}}

Reason:

{{.Reason}}
`)) 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(`

A post has been reported

Post ID: {{.PostID}}
Author: {{.Author}}
Reported by: {{.ReporterFrom}}
Category: {{.Category}}

{{if .Reason}}

Reason:

{{.Reason}}
{{end}}

Caption:

{{.Caption}}

{{.SourceURL}}

`))