feat: disciple link
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"html/template"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
@@ -21,17 +22,23 @@ var ErrAdminUserNotFound = errors.New("user not found")
|
||||
// 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
|
||||
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, mail mailer.Mailer, logger *zap.Logger, frontendURL string) *AdminService {
|
||||
return &AdminService{repo: repo, stats: stats, notif: notif, feedback: feedback, mail: mail, logger: logger, frontendURL: frontendURL}
|
||||
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
|
||||
@@ -56,7 +63,230 @@ func (s *AdminService) ResolveFeedback(ctx context.Context, id string) error {
|
||||
}
|
||||
|
||||
func (s *AdminService) ListUsers(ctx context.Context, q string, limit, offset int) ([]model.AdminUser, int, error) {
|
||||
return s.repo.ListUsers(ctx, q, limit, offset)
|
||||
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) {
|
||||
@@ -165,6 +395,41 @@ func (s *AdminService) SetUserLimits(ctx context.Context, userID string, bookmar
|
||||
|
||||
// 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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user