Files
spsays-bff/internal/service/admin_service.go
T
2026-10-09 06:40:13 +05:30

449 lines
18 KiB
Go

package service
import (
"bytes"
"context"
"errors"
"html/template"
"strings"
"time"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
"github.com/prabhupadasays/spsays-bff/pkg/richtext"
"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
stats *repository.StatsRepo
notif *NotificationService
feedback *FeedbackService
// users / disciples back the direct-disciple linking feature: the link
// column lives in Postgres (users) while the disciple record lives in
// Mongo (disciples), so the service is where the two stores meet. Both
// may be nil in degraded boots (no Mongo) — the link methods guard.
users *repository.UserRepo
disciples *repository.DiscipleRepo
mail mailer.Mailer
logger *zap.Logger
frontendURL string
}
func NewAdminService(repo *repository.AdminRepo, stats *repository.StatsRepo, notif *NotificationService, feedback *FeedbackService, users *repository.UserRepo, disciples *repository.DiscipleRepo, mail mailer.Mailer, logger *zap.Logger, frontendURL string) *AdminService {
return &AdminService{repo: repo, stats: stats, notif: notif, feedback: feedback, users: users, disciples: disciples, mail: mail, logger: logger, frontendURL: frontendURL}
}
// Stats returns the system snapshot (counts + 7-day deltas + feedback
// breakdown) — the same data the public /stats page renders, as JSON for the
// admin dashboard home.
func (s *AdminService) Stats(ctx context.Context) (*repository.StatsSnapshot, error) {
return s.stats.Snapshot(ctx)
}
// AcknowledgeFeedback marks a feedback row acknowledged and emails the author
// that we've seen it. Reuses FeedbackService so the email is identical to the
// (now removed) public /stats action. Returns ErrAlreadyAcknowledged when the
// row was already acknowledged.
func (s *AdminService) AcknowledgeFeedback(ctx context.Context, id string) error {
return s.feedback.Acknowledge(ctx, id)
}
// ResolveFeedback marks a feedback row resolved and emails the author a
// thank-you. Returns ErrAlreadyResolved when the row was already resolved.
func (s *AdminService) ResolveFeedback(ctx context.Context, id string) error {
return s.feedback.Resolve(ctx, id)
}
func (s *AdminService) ListUsers(ctx context.Context, q string, limit, offset int) ([]model.AdminUser, int, error) {
users, total, err := s.repo.ListUsers(ctx, q, limit, offset)
if err != nil {
return nil, 0, err
}
s.hydrateDiscipleNames(ctx, users)
return users, total, nil
}
// hydrateDiscipleNames fills DiscipleName on any linked rows in the page with a
// single Mongo lookup. Best-effort by design: the panel is far more useful with
// an un-named link than with a 500, so a Mongo failure logs and leaves the
// names nil (the UI then shows the bare "linked" state).
func (s *AdminService) hydrateDiscipleNames(ctx context.Context, users []model.AdminUser) {
if s.disciples == nil {
return
}
ids := make([]string, 0, len(users))
for _, u := range users {
if u.DiscipleID != nil && *u.DiscipleID != "" {
ids = append(ids, *u.DiscipleID)
}
}
if len(ids) == 0 {
return
}
docs, err := s.disciples.FindByIDs(ctx, ids)
if err != nil {
s.logger.Warn("admin: hydrate disciple names failed", zap.Error(err))
return
}
byID := make(map[string]string, len(docs))
for _, d := range docs {
byID[d.ID.Hex()] = strings.TrimSpace(d.InitiatedName + " " + d.Suffix)
}
for i := range users {
if users[i].DiscipleID == nil {
continue
}
if name, ok := byID[*users[i].DiscipleID]; ok {
users[i].DiscipleName = &name
}
}
}
// ErrDiscipleNotFound is returned by LinkDisciple when the requested disciple
// id doesn't resolve to a document in Mongo.
var ErrDiscipleNotFound = errors.New("disciple not found")
// ErrDisciplesUnavailable is returned when the Mongo-backed disciple
// collection isn't wired (degraded boot) — linking can't be validated, so we
// refuse rather than write an unverified id.
var ErrDisciplesUnavailable = errors.New("disciple directory unavailable")
// SearchDisciples backs the admin panel's disciple picker. An empty query
// returns the first page alphabetically so the picker opens with content.
// Each option carries the handle of the account already linked to it, letting
// the UI disable those rows instead of surfacing a conflict on submit.
func (s *AdminService) SearchDisciples(ctx context.Context, q string, limit int) ([]model.AdminDisciple, error) {
if s.disciples == nil {
return nil, ErrDisciplesUnavailable
}
if limit <= 0 || limit > 100 {
limit = 25
}
docs, err := s.disciples.Search(ctx, strings.TrimSpace(q), limit)
if err != nil {
return nil, err
}
// Which disciples are already claimed, and by whom.
claimed := map[string]string{}
if s.users != nil {
links, err := s.users.ListDiscipleLinks(ctx)
if err != nil {
s.logger.Warn("admin: load disciple links failed", zap.Error(err))
} else {
for _, l := range links {
claimed[l.DiscipleID] = l.Handle
}
}
}
out := make([]model.AdminDisciple, 0, len(docs))
for _, d := range docs {
id := d.ID.Hex()
opt := model.AdminDisciple{
ID: id,
InitiatedName: d.InitiatedName,
Suffix: d.Suffix,
InitiationDate: d.InitiationDate,
InitiationPlace: d.InitiationPlace,
}
if h, ok := claimed[id]; ok {
handle := h
opt.LinkedHandle = &handle
}
out = append(out, opt)
}
return out, nil
}
// LinkDisciple marks a user as one of Śrīla Prabhupāda's direct disciples by
// pointing them at a disciple record. The id is validated against Mongo first
// so a typo can't create a link that renders as an unnamed badge forever.
// Returns ErrAdminUserNotFound, ErrDiscipleNotFound, or
// repository.ErrDiscipleClaimed.
func (s *AdminService) LinkDisciple(ctx context.Context, userID, discipleID string) error {
if s.disciples == nil {
return ErrDisciplesUnavailable
}
doc, err := s.disciples.FindByID(ctx, strings.TrimSpace(discipleID))
if err != nil {
return err
}
if doc == nil {
return ErrDiscipleNotFound
}
found, previous, err := s.repo.LinkDisciple(ctx, userID, doc.ID.Hex())
if err != nil {
return err
}
if !found {
return ErrAdminUserNotFound
}
// Everything below is first-link-only. Correcting a mis-link (previous
// != nil) must not greet the devotee twice or re-announce them.
if previous != nil && *previous != "" {
return nil
}
if s.users == nil {
return nil
}
u, err := s.users.FindByID(ctx, userID)
if err != nil || u == nil {
s.logger.Warn("disciple linked but user lookup failed; skipping welcome + announcement",
zap.String("user_id", userID), zap.Error(err))
return nil
}
s.sendDiscipleWelcome(u, strings.TrimSpace(doc.InitiatedName+" "+doc.Suffix))
s.announceDiscipleJoined(ctx, u, doc.ID.Hex())
return nil
}
// announceDiscipleJoined tells the rest of the community that a direct
// disciple has joined — in-app for everyone, plus web push for those who've
// enabled it. No email: see the deliverability reasoning behind keeping
// announcements off the transactional mail domain.
//
// The DB claim is atomic and synchronous (so a double-click can't double
// announce), but the fan-out itself runs detached: pushing to hundreds of
// devotees is one external HTTP call each, far too slow to hold the admin's
// request open. `context.WithoutCancel` keeps it alive past the response, with
// a hard ceiling so a wedged run can't leak forever.
func (s *AdminService) announceDiscipleJoined(ctx context.Context, u *model.User, discipleID string) {
if s.notif == nil {
return
}
if u.BannedAt != nil {
// Don't announce a suspended account to the community.
s.logger.Info("disciple linked on a suspended account; skipping announcement",
zap.String("user_id", u.ID))
return
}
claimed, err := s.repo.ClaimDiscipleAnnouncement(ctx, u.ID, discipleID)
if err != nil {
s.logger.Warn("disciple announcement claim failed; skipping broadcast",
zap.String("user_id", u.ID), zap.Error(err))
return
}
if !claimed {
// Already announced previously — nothing to do.
return
}
bg, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Minute)
go func() {
defer cancel()
if err := s.notif.BroadcastDiscipleJoined(bg, u); err != nil {
s.logger.Error("disciple-joined broadcast failed",
zap.String("user_id", u.ID), zap.Error(err))
}
}()
}
// sendDiscipleWelcome emails the newly-recognised disciple. Fire-and-forget,
// exactly like the feedback emails: the link is already committed, so a mail
// failure must not fail the admin's action — it logs and moves on.
func (s *AdminService) sendDiscipleWelcome(u *model.User, discipleName string) {
if u.Email == nil || strings.TrimSpace(*u.Email) == "" {
// OAuth-only account with no address on file — nothing to send.
s.logger.Info("disciple linked; no email on file, skipping welcome",
zap.String("user_id", u.ID))
return
}
var buf bytes.Buffer
if err := discipleWelcomeEmailTpl.Execute(&buf, map[string]any{
"RecipientName": strings.TrimSpace(u.Name),
"DiscipleName": discipleName,
"ProfileURL": s.frontendURL + "/u/" + u.Handle,
}); err != nil {
s.logger.Warn("render disciple welcome email failed", zap.Error(err))
return
}
if err := s.mail.Send(strings.TrimSpace(*u.Email),
"Welcome, and thank you — Prabhupāda Says", buf.String()); err != nil {
s.logger.Warn("disciple welcome email send failed",
zap.String("user_id", u.ID), zap.Error(err))
}
}
// UnlinkDisciple clears a user's direct-disciple link. Idempotent — clearing an
// already-clear link succeeds.
func (s *AdminService) UnlinkDisciple(ctx context.Context, userID string) error {
ok, err := s.repo.UnlinkDisciple(ctx, userID)
if err != nil {
return err
}
if !ok {
return ErrAdminUserNotFound
}
return nil
}
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 and emails it to the submitter when an
// address is known. It does not resolve the row (resolve is a separate
// action). 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,
// Rendered markers; escaped by richtext, newlines become <br>.
"Reply": richtext.ToHTML(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, note string) (bool, error) {
return s.repo.ResolveReport(ctx, reportType, id, note)
}
// ─── 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)
}
// SetUserLimits writes per-user save-limit overrides (a nil field resets that
// limit to the plan default). found=false when the id is unknown.
func (s *AdminService) SetUserLimits(ctx context.Context, userID string, bookmark, publicCollection, privateCollection *int) (bool, error) {
return s.repo.SetUserLimits(ctx, userID, bookmark, publicCollection, privateCollection)
}
// feedbackReplyEmailTpl renders the reply email. Reply text is auto-escaped by
// html/template, so admin-entered text can't inject markup.
// discipleWelcomeEmailTpl greets a devotee an admin has just recognised as one
// of Śrīla Prabhupāda's own initiated disciples. Same Vaishnava styling and
// 17px base as the other author-facing templates.
var discipleWelcomeEmailTpl = template.Must(template.New("discipleWelcome").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>Please accept our humble obeisances.</p>
<p>It is our great fortune to welcome you to
<a href="{{.ProfileURL}}" style="color:#9a6b2f;font-weight:600">prabhupadasays.org</a>.
We have recognised your account as belonging to
{{if .DiscipleName}}<strong>{{.DiscipleName}}</strong>, one of{{else}}one of{{end}}
Śrīla Prabhupāda's own initiated disciples.</p>
<p>Your profile now carries His Divine Grace's picture beside your name, and
your name is highlighted in our directory of his disciples — so every devotee
who visits knows they are hearing from someone who served Śrīla Prabhupāda
personally.</p>
<p>This whole library exists to preserve and spread Śrīla Prabhupāda's
<em>vāṇī</em>. Your presence here — your memories, your realisations, your
corrections — is a gift to the devotees who came after. We would be honoured
by whatever you care to share.</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 your profile</a>
</p>
<p>If anything about your account needs correcting, simply reply to this
email and we will attend to it personally.</p>
<p style="margin-top:1.5em">Your servants,<br>
<strong>The Prabhupāda Says team</strong></p>
</div>`))
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="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>`))