449 lines
18 KiB
Go
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>`))
|