feat: add admin apis
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
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>`))
|
||||
@@ -35,6 +35,7 @@ var (
|
||||
ErrEmailAlreadyVerified = errors.New("email already verified")
|
||||
ErrWeakPassword = errors.New("password must be at least 8 characters")
|
||||
ErrHandleTaken = errors.New("handle already in use")
|
||||
ErrAccountSuspended = errors.New("this account has been suspended")
|
||||
)
|
||||
|
||||
// AuthService bundles every flow that mutates auth state.
|
||||
@@ -193,6 +194,11 @@ func (s *AuthService) LogIn(ctx context.Context, email, password string) (*Login
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(password)); err != nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
// Suspended accounts can't sign in (checked after the password so we don't
|
||||
// reveal ban status to someone who doesn't know the password).
|
||||
if u.BannedAt != nil {
|
||||
return nil, ErrAccountSuspended
|
||||
}
|
||||
|
||||
// OTP gate — unverified email AND account is older than the grace
|
||||
// window. Mint an OTP and signal the caller to collect it. We do NOT
|
||||
@@ -233,6 +239,9 @@ func (s *AuthService) VerifyLoginOTP(ctx context.Context, email, password, otp s
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(password)); err != nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
if u.BannedAt != nil {
|
||||
return nil, ErrAccountSuspended
|
||||
}
|
||||
|
||||
otpHash := hashToken(otp)
|
||||
tok, err := s.tokens.FindValidForUser(ctx, u.ID, otpHash, model.PurposeLoginOTP)
|
||||
|
||||
@@ -612,6 +612,66 @@ func (s *NotificationService) publishPush(ctx context.Context, recipientID, kind
|
||||
})
|
||||
}
|
||||
|
||||
// NotifyAdminMessage delivers a free-text message from an admin to one user:
|
||||
// a persisted in-app notification (kind 'admin_message') plus best-effort
|
||||
// realtime, Web Push, and email. Admin messages bypass the per-kind opt-out
|
||||
// matrix (there's no category for them) — they're deliberate, low-volume, and
|
||||
// operationally important. Returns an error only if the row couldn't be
|
||||
// persisted; the three delivery channels are best-effort.
|
||||
func (s *NotificationService) NotifyAdminMessage(ctx context.Context, recipientID, title, body string) error {
|
||||
if err := s.notifs.CreateAdminMessage(ctx, recipientID, body); err != nil {
|
||||
return err
|
||||
}
|
||||
url := s.frontendURL + "/notifications"
|
||||
|
||||
if s.rt != nil {
|
||||
if err := s.rt.Trigger(ctx, "notifications."+recipientID, "notification", map[string]any{
|
||||
"kind": "admin_message",
|
||||
"body": body,
|
||||
}); err != nil {
|
||||
s.logger.Warn("realtime admin message publish failed",
|
||||
zap.String("recipient", recipientID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
if s.push != nil {
|
||||
pushTitle := strings.TrimSpace(title)
|
||||
if pushTitle == "" {
|
||||
pushTitle = "Prabhupāda Says"
|
||||
}
|
||||
s.push.SendToUser(ctx, recipientID, PushPayload{
|
||||
Title: pushTitle,
|
||||
Body: body,
|
||||
URL: url,
|
||||
Icon: s.iconURL(),
|
||||
Tag: "admin_message",
|
||||
})
|
||||
}
|
||||
|
||||
// Email — best-effort, only when the recipient has a verified-or-not address.
|
||||
recipient, _ := s.users.FindByID(ctx, recipientID)
|
||||
if recipient != nil && recipient.Email != nil && strings.TrimSpace(*recipient.Email) != "" {
|
||||
var buf bytes.Buffer
|
||||
if err := adminMessageEmailTpl.Execute(&buf, map[string]any{
|
||||
"RecipientName": recipient.Name,
|
||||
"Title": strings.TrimSpace(title),
|
||||
"Body": body,
|
||||
"URL": url,
|
||||
}); err != nil {
|
||||
s.logger.Warn("render admin message email failed", zap.Error(err))
|
||||
} else {
|
||||
subject := strings.TrimSpace(title)
|
||||
if subject == "" {
|
||||
subject = "A message from Prabhupāda Says"
|
||||
}
|
||||
if err := s.mail.Send(strings.TrimSpace(*recipient.Email), subject, buf.String()); err != nil {
|
||||
s.logger.Warn("admin message email send failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// iconURL is the absolute URL of the notification icon (the app's square
|
||||
// icon), so it renders regardless of the client's base path.
|
||||
func (s *NotificationService) iconURL() string {
|
||||
@@ -715,3 +775,20 @@ var likeEmailTpl = template.Must(template.New("postLike").Parse(`<div style="fon
|
||||
<p style="margin-top:1em">Your servants,<br>
|
||||
<strong>The Prabhupāda Says team</strong></p>
|
||||
</div>`))
|
||||
|
||||
// adminMessageEmailTpl renders an admin → user direct message. Body is
|
||||
// auto-escaped by html/template, so admin-entered text can't inject markup.
|
||||
var adminMessageEmailTpl = template.Must(template.New("adminMessage").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>
|
||||
|
||||
{{if .Title}}<p style="font-weight:600;font-size:19px;margin-bottom:0.5em">{{.Title}}</p>{{end}}
|
||||
|
||||
<p style="white-space:pre-line">{{.Body}}</p>
|
||||
|
||||
<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>`))
|
||||
|
||||
Reference in New Issue
Block a user