feat: add admin apis

This commit is contained in:
Krispire Admin
2026-06-16 14:47:45 +05:30
parent aa61f4f0b1
commit a49bf7591e
24 changed files with 1164 additions and 22 deletions
+151
View File
@@ -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>`))
+9
View File
@@ -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)
+77
View File
@@ -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>`))