Files
spsays-bff/internal/service/admin_service.go
T
2026-06-16 14:47:45 +05:30

152 lines
6.3 KiB
Go

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(`<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>Thank you for writing to us. Here's our reply to your feedback:</p>
<blockquote style="white-space:pre-line;border-left:3px solid #9a6b2f;padding-left:1em;margin:1em 0;color:#3a3024">{{.Reply}}</blockquote>
<p style="margin:1.25em 0">
<a href="{{.URL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open Prabhupāda Says</a>
</p>
<p style="margin-top:1em">Your servants,<br>
<strong>The Prabhupāda Says team</strong></p>
</div>`))