package service import ( "bytes" "context" "errors" "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" ) // ErrAdminUserNotFound is returned by NotifyUser when the target id is unknown. var ErrAdminUserNotFound = errors.New("user not found") // AdminService backs the admin panel: read-mostly listing plus two actions // (notify a user, reply to feedback) and report resolution. It reuses the // existing NotificationService for delivery and the shared mailer for the // feedback-reply email — no new infrastructure. type AdminService struct { repo *repository.AdminRepo notif *NotificationService mail mailer.Mailer logger *zap.Logger frontendURL string } func NewAdminService(repo *repository.AdminRepo, notif *NotificationService, mail mailer.Mailer, logger *zap.Logger, frontendURL string) *AdminService { return &AdminService{repo: repo, notif: notif, mail: mail, logger: logger, frontendURL: frontendURL} } func (s *AdminService) ListUsers(ctx context.Context, q string, limit, offset int) ([]model.AdminUser, int, error) { return s.repo.ListUsers(ctx, q, limit, offset) } func (s *AdminService) ListPosts(ctx context.Context, q string, limit, offset int) ([]model.AdminPost, int, error) { return s.repo.ListPosts(ctx, q, limit, offset) } func (s *AdminService) ListCollections(ctx context.Context, q string, limit, offset int) ([]model.AdminCollection, int, error) { return s.repo.ListCollections(ctx, q, limit, offset) } func (s *AdminService) ListFeedback(ctx context.Context, status string, limit, offset int) ([]model.AdminFeedback, int, error) { return s.repo.ListFeedback(ctx, status, limit, offset) } func (s *AdminService) ListReports(ctx context.Context, reportType, q string, limit, offset int) ([]model.AdminReport, int, error) { return s.repo.ListReports(ctx, reportType, q, limit, offset) } // NotifyUser sends a free-text admin message to one user (in-app + push + // email). Returns ErrAdminUserNotFound if the id doesn't exist. func (s *AdminService) NotifyUser(ctx context.Context, userID, title, body string) error { exists, err := s.repo.UserExists(ctx, userID) if err != nil { return err } if !exists { return ErrAdminUserNotFound } return s.notif.NotifyAdminMessage(ctx, userID, title, body) } // ReplyFeedback stores the reply (resolving the row) and emails it to the // submitter when an address is known. Returns found=false if the id is // unknown, and emailed=true when a reply email was actually sent. func (s *AdminService) ReplyFeedback(ctx context.Context, id, message string) (found, emailed bool, err error) { email, name, ok, err := s.repo.SaveReply(ctx, id, message) if err != nil || !ok { return ok, false, err } if email == nil || strings.TrimSpace(*email) == "" { // Reply stored, but the submitter was anonymous with no contact email. return true, false, nil } var buf bytes.Buffer if err := feedbackReplyEmailTpl.Execute(&buf, map[string]any{ "RecipientName": name, "Reply": message, "URL": s.frontendURL, }); err != nil { s.logger.Warn("render feedback reply email failed", zap.Error(err)) return true, false, nil } if err := s.mail.Send(strings.TrimSpace(*email), "Re: your feedback — Prabhupāda Says", buf.String()); err != nil { s.logger.Warn("feedback reply email send failed", zap.Error(err)) return true, false, nil } return true, true, nil } // ResolveReport stamps a report resolved. found=true when this call resolved a // previously-open report (idempotent: a no-op on an already-resolved/unknown id // returns false). func (s *AdminService) ResolveReport(ctx context.Context, reportType, id string) (bool, error) { return s.repo.ResolveReport(ctx, reportType, id) } // ─── Moderation actions ────────────────────────────────────────────────────── // SetPostHidden hides or unhides any post (found=false if unknown id). func (s *AdminService) SetPostHidden(ctx context.Context, postID string, hidden bool) (bool, error) { return s.repo.SetPostHidden(ctx, postID, hidden) } // DeletePost soft-deletes a post (hidden from all reads, restorable). func (s *AdminService) DeletePost(ctx context.Context, postID string) (bool, error) { return s.repo.SetPostDeleted(ctx, postID, true) } // RestorePost reverses a soft-delete. func (s *AdminService) RestorePost(ctx context.Context, postID string) (bool, error) { return s.repo.SetPostDeleted(ctx, postID, false) } // DeleteComment soft-deletes a comment (restorable). func (s *AdminService) DeleteComment(ctx context.Context, commentID string) (bool, error) { return s.repo.SetCommentDeleted(ctx, commentID, true) } // RestoreComment reverses a soft-delete. func (s *AdminService) RestoreComment(ctx context.Context, commentID string) (bool, error) { return s.repo.SetCommentDeleted(ctx, commentID, false) } // SetUserBanned suspends or restores an account. func (s *AdminService) SetUserBanned(ctx context.Context, userID string, banned bool) (bool, error) { return s.repo.SetUserBanned(ctx, userID, banned) } // feedbackReplyEmailTpl renders the reply email. Reply text is auto-escaped by // html/template, so admin-entered text can't inject markup. var feedbackReplyEmailTpl = template.Must(template.New("feedbackReply").Parse(`

Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏

Thank you for writing to us. Here's our reply to your feedback:

{{.Reply}}

Open Prabhupāda Says

Your servants,
The Prabhupāda Says team

`))