feat: social graph features
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>`))
|
||||
@@ -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>`))
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user