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>
|
||||
|
||||
|
||||
@@ -39,6 +39,12 @@ const (
|
||||
NotifCommentReply = "comment_reply"
|
||||
NotifCommentMention = "comment_mention"
|
||||
|
||||
// NotifDiscipleJoined is broadcast to the whole community when an admin
|
||||
// recognises an account as one of Śrīla Prabhupāda's own initiated
|
||||
// disciples. The actor is the disciple, so it renders and links exactly
|
||||
// like the other actor-driven kinds.
|
||||
NotifDiscipleJoined = "disciple_joined"
|
||||
|
||||
// Reminder kinds are delivered live (email + push + a websocket toast)
|
||||
// but NOT persisted — they're ephemeral nudges, not history.
|
||||
NotifChantReminder = "chant_reminder"
|
||||
@@ -53,6 +59,7 @@ var PrefCategories = map[string][]string{
|
||||
"follows": {NotifFollowRequest, NotifFollowAccepted},
|
||||
"post_interactions": {NotifPostLike, NotifPostMention, NotifPostComment, NotifCommentReply},
|
||||
"sadhna_reminders": {NotifChantReminder, NotifQuoteReminder, NotifChantIncomplete},
|
||||
"community": {NotifDiscipleJoined},
|
||||
}
|
||||
|
||||
// PrefChannels is every delivery channel a matrix cell can address.
|
||||
@@ -672,6 +679,90 @@ func (s *NotificationService) NotifyAdminMessage(ctx context.Context, recipientI
|
||||
return nil
|
||||
}
|
||||
|
||||
// pushBroadcastDelay paces the per-user push fan-out. Web Push is one HTTP
|
||||
// request per subscription to an external service (FCM/Mozilla/Apple), so a
|
||||
// tight loop over hundreds of devotees would hammer those endpoints and spike
|
||||
// outbound connections. A small gap keeps the broadcast a background trickle.
|
||||
const pushBroadcastDelay = 50 * time.Millisecond
|
||||
|
||||
// BroadcastDiscipleJoined tells the community that `actor` — an account an
|
||||
// admin has just recognised as one of Śrīla Prabhupāda's own initiated
|
||||
// disciples — has joined.
|
||||
//
|
||||
// Two channels, deliberately decoupled by cost:
|
||||
//
|
||||
// - In-app: a single bulk INSERT for every user. Cheap and instant, so it
|
||||
// happens inline and the row is durable the moment this returns.
|
||||
// - Push + realtime: one call per recipient against external services, so it
|
||||
// runs paced. The caller is expected to invoke this on a detached,
|
||||
// time-limited context (not the admin's request context, which dies with
|
||||
// the response).
|
||||
//
|
||||
// No email — announcements are in-app/push only by design; see the
|
||||
// deliverability reasoning in the disciple-link notes.
|
||||
//
|
||||
// Errors from the fan-out are logged, not returned: the announcement is
|
||||
// already durably recorded in-app, and a failed push to one devotee must not
|
||||
// abort the rest.
|
||||
func (s *NotificationService) BroadcastDiscipleJoined(ctx context.Context, actor *model.User) error {
|
||||
if actor == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
written, err := s.notifs.BroadcastToAllUsers(ctx, NotifDiscipleJoined, actor.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.Info("disciple-joined broadcast: in-app rows written",
|
||||
zap.String("actor", actor.Handle), zap.Int64("recipients", written))
|
||||
|
||||
// Everything below is best-effort delivery on top of the durable row.
|
||||
recipients, err := s.notifs.PushRecipientsForKind(ctx, NotifDiscipleJoined, actor.ID)
|
||||
if err != nil {
|
||||
s.logger.Warn("disciple-joined broadcast: push recipient lookup failed", zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
if len(recipients) == 0 || s.push == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
name := actorDisplay(actor)
|
||||
payload := PushPayload{
|
||||
Title: "A direct disciple has joined",
|
||||
Body: name + " — an initiated disciple of Śrīla Prabhupāda — is now on Prabhupāda Says.",
|
||||
URL: s.frontendURL + "/u/" + actor.Handle,
|
||||
Icon: s.iconURL(),
|
||||
Tag: NotifDiscipleJoined,
|
||||
}
|
||||
|
||||
sent := 0
|
||||
for _, uid := range recipients {
|
||||
// Honour cancellation so a shutdown (or the caller's timeout) stops
|
||||
// the fan-out cleanly rather than running to completion detached.
|
||||
if ctx.Err() != nil {
|
||||
s.logger.Warn("disciple-joined broadcast: push fan-out cut short",
|
||||
zap.Int("sent", sent), zap.Int("total", len(recipients)), zap.Error(ctx.Err()))
|
||||
return nil
|
||||
}
|
||||
if s.rt != nil {
|
||||
if err := s.rt.Trigger(ctx, "notifications."+uid, "notification", map[string]any{
|
||||
"kind": NotifDiscipleJoined,
|
||||
"actor_name": name,
|
||||
"actor_handle": actor.Handle,
|
||||
}); err != nil {
|
||||
s.logger.Debug("disciple-joined realtime publish failed",
|
||||
zap.String("recipient", uid), zap.Error(err))
|
||||
}
|
||||
}
|
||||
s.push.SendToUser(ctx, uid, payload)
|
||||
sent++
|
||||
time.Sleep(pushBroadcastDelay)
|
||||
}
|
||||
s.logger.Info("disciple-joined broadcast: push fan-out complete",
|
||||
zap.String("actor", actor.Handle), zap.Int("recipients", sent))
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user