diff --git a/internal/handler/admin.go b/internal/handler/admin.go index 416f2d5..a94fd5e 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/go-chi/chi/v5" + "github.com/prabhupadasays/spsays-bff/internal/repository" "github.com/prabhupadasays/spsays-bff/internal/service" "github.com/prabhupadasays/spsays-bff/pkg/response" ) @@ -331,6 +332,68 @@ func (h *AdminHandler) setUserBanned(w http.ResponseWriter, r *http.Request, ban // { "bookmark_limit": 10|null, "public_collection_limit": …, "private_collection_limit": … }. // Each field is optional: a null (or omitted) value resets that limit to the // plan default; a number caps that resource for this user regardless of plan. +// ListDisciples — GET /admin/disciples?q=&limit= — options for the +// direct-disciple picker. Read-only; an empty q returns the first page +// alphabetically so the picker opens with content. +func (h *AdminHandler) ListDisciples(w http.ResponseWriter, r *http.Request) { + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + items, err := h.svc.SearchDisciples(r.Context(), r.URL.Query().Get("q"), limit) + if err != nil { + if errors.Is(err, service.ErrDisciplesUnavailable) { + response.Error(w, http.StatusServiceUnavailable, "disciple directory unavailable") + return + } + response.Error(w, http.StatusInternalServerError, "could not search disciples") + return + } + response.JSON(w, http.StatusOK, items) +} + +// LinkDisciple — POST /admin/users/{id}/disciple {"disciple_id":"…"} — marks the +// account as one of Śrīla Prabhupāda's direct disciples. Re-posting with a +// different id moves the link. +func (h *AdminHandler) LinkDisciple(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + var in struct { + DiscipleID string `json:"disciple_id"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + response.Error(w, http.StatusBadRequest, "invalid request body") + return + } + if strings.TrimSpace(in.DiscipleID) == "" { + response.Error(w, http.StatusBadRequest, "disciple_id is required") + return + } + switch err := h.svc.LinkDisciple(r.Context(), id, in.DiscipleID); { + case err == nil: + response.JSON(w, http.StatusOK, map[string]any{"linked": true}) + case errors.Is(err, service.ErrAdminUserNotFound): + response.Error(w, http.StatusNotFound, "user not found") + case errors.Is(err, service.ErrDiscipleNotFound): + response.Error(w, http.StatusNotFound, "disciple not found") + case errors.Is(err, repository.ErrDiscipleClaimed): + response.Error(w, http.StatusConflict, "that disciple is already linked to another user") + case errors.Is(err, service.ErrDisciplesUnavailable): + response.Error(w, http.StatusServiceUnavailable, "disciple directory unavailable") + default: + response.Error(w, http.StatusInternalServerError, "could not link disciple") + } +} + +// UnlinkDisciple — DELETE /admin/users/{id}/disciple — removes the link. +func (h *AdminHandler) UnlinkDisciple(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + switch err := h.svc.UnlinkDisciple(r.Context(), id); { + case err == nil: + response.JSON(w, http.StatusOK, map[string]any{"linked": false}) + case errors.Is(err, service.ErrAdminUserNotFound): + response.Error(w, http.StatusNotFound, "user not found") + default: + response.Error(w, http.StatusInternalServerError, "could not unlink disciple") + } +} + func (h *AdminHandler) SetUserLimits(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") var in struct { diff --git a/internal/handler/disciple.go b/internal/handler/disciple.go index b0ca2ce..f2e58db 100644 --- a/internal/handler/disciple.go +++ b/internal/handler/disciple.go @@ -9,10 +9,33 @@ import ( type DiscipleHandler struct { repo *repository.DiscipleRepo + // users (Postgres) resolves which disciples have a platform account + // linked by an admin. Separate store from the disciple records + // themselves, hence the second dependency. + users *repository.UserRepo } -func NewDiscipleHandler(repo *repository.DiscipleRepo) *DiscipleHandler { - return &DiscipleHandler{repo: repo} +func NewDiscipleHandler(repo *repository.DiscipleRepo, users *repository.UserRepo) *DiscipleHandler { + return &DiscipleHandler{repo: repo, users: users} +} + +// Linked — GET /disciples/linked — every disciple that has a platform account, +// as {disciple_id, handle, name, avatar_url}. Public and unpaged: only Śrīla +// Prabhupāda's own disciples are ever linked, so the set is tiny and the +// /disciples browser fetches it once to highlight the matching cards. +func (h *DiscipleHandler) Linked(w http.ResponseWriter, r *http.Request) { + if h.users == nil { + // Degraded boot without Postgres — an empty set just means "no + // highlights", which is a fine reading experience. + response.JSON(w, http.StatusOK, []repository.DiscipleLink{}) + return + } + links, err := h.users.ListDiscipleLinks(r.Context()) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch linked disciples") + return + } + response.JSON(w, http.StatusOK, links) } func (h *DiscipleHandler) List(w http.ResponseWriter, r *http.Request) { diff --git a/internal/handler/social.go b/internal/handler/social.go index b37a8c6..181042b 100644 --- a/internal/handler/social.go +++ b/internal/handler/social.go @@ -686,6 +686,11 @@ func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) { "following_count": following, "relationship": rel, "public_collections": cols, + // True when an admin has linked this account to a record in the + // disciples directory — i.e. one of Śrīla Prabhupāda's own initiated + // disciples. Drives the badge on the profile header. Only the boolean + // is exposed; the underlying Mongo id stays internal. + "is_direct_disciple": u.DiscipleID != nil && *u.DiscipleID != "", }) } diff --git a/internal/handler/stats.go b/internal/handler/stats.go index 6e3fd80..5e7753a 100644 --- a/internal/handler/stats.go +++ b/internal/handler/stats.go @@ -16,6 +16,12 @@ import ( // // It is READ-ONLY: feedback acknowledge/resolve actions live in the // authenticated admin panel, never on this public page. +// +// PRIVACY: because the page is public, it must never render personal contact +// details. Anonymous submitters' email addresses are shown as "reply address +// on file" only — and FeedbackRepo.Recent doesn't even SELECT the column, so +// the address isn't in memory here to leak by accident. Anything added to this +// template needs the same scrutiny; the admin panel is the place for PII. type StatsHandler struct { stats *repository.StatsRepo feedback *repository.FeedbackRepo @@ -127,8 +133,8 @@ code { background: #f4f4f4; padding: 0.05em 0.3em; border-radius: 3px; font-size
{{.ContactEmail}}
+ {{else if .HasContactEmail}}
+ anonymous · reply address on file
{{else}}
anonymous
{{end}}
diff --git a/internal/handler/stats_test.go b/internal/handler/stats_test.go
new file mode 100644
index 0000000..5662dab
--- /dev/null
+++ b/internal/handler/stats_test.go
@@ -0,0 +1,46 @@
+package handler
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/prabhupadasays/spsays-bff/internal/model"
+ "github.com/prabhupadasays/spsays-bff/internal/repository"
+)
+
+// Executes the public /stats template against representative rows and asserts
+// no submitter email can appear in the output.
+func TestStatsTemplateHidesEmails(t *testing.T) {
+ now := time.Now()
+ secret := "leak-me@example.com"
+ handle, name := "gopal", "Gopal Das"
+ rows := []model.Feedback{
+ // anonymous WITH a reply address on file
+ {ID: "1", Category: "bug", Message: "anon with email", CreatedAt: now,
+ HasContactEmail: true, ContactEmail: &secret},
+ // anonymous with no address at all
+ {ID: "2", Category: "general", Message: "anon no email", CreatedAt: now},
+ // authed submitter
+ {ID: "3", Category: "feature", Message: "authed", CreatedAt: now,
+ AuthorHandle: &handle, AuthorName: &name, AcknowledgedAt: &now, ResolvedAt: &now},
+ }
+ var sb strings.Builder
+ err := statsTpl.Execute(&sb, map[string]any{
+ "Stats": &repository.StatsSnapshot{Users: 1},
+ "Feedback": rows,
+ "GeneratedAt": "now",
+ })
+ if err != nil {
+ t.Fatalf("template execute failed: %v", err)
+ }
+ out := sb.String()
+ if strings.Contains(out, secret) || strings.Contains(out, "@example.com") {
+ t.Errorf("submitter email leaked into public /stats output")
+ }
+ for _, want := range []string{"reply address on file", "@gopal", "anon no email"} {
+ if !strings.Contains(out, want) {
+ t.Errorf("expected %q in output", want)
+ }
+ }
+}
diff --git a/internal/model/admin.go b/internal/model/admin.go
index 356d544..d444e0d 100644
--- a/internal/model/admin.go
+++ b/internal/model/admin.go
@@ -31,6 +31,27 @@ type AdminUser struct {
BookmarkLimit int `json:"bookmark_limit"`
PublicCollectionLimit int `json:"public_collection_limit"`
PrivateCollectionLimit int `json:"private_collection_limit"`
+
+ // Direct-disciple link. DiscipleID is the raw Mongo ObjectID stored on the
+ // users row; DiscipleName is hydrated from Mongo by the admin service so the
+ // panel can show who the account is linked to (nil when unlinked, or when
+ // the id no longer resolves to a disciple document).
+ DiscipleID *string `json:"disciple_id"`
+ DiscipleName *string `json:"disciple_name"`
+}
+
+// AdminDisciple is one option in the admin panel's disciple picker — enough to
+// disambiguate similarly-named disciples, plus the handle of the account that
+// already claims it (nil when free to link).
+type AdminDisciple struct {
+ ID string `json:"id"`
+ InitiatedName string `json:"initiated_name"`
+ Suffix string `json:"suffix"`
+ InitiationDate *time.Time `json:"initiation_date"`
+ InitiationPlace *string `json:"initiation_place"`
+ // LinkedHandle is the @handle of the user already linked to this disciple,
+ // so the picker can grey the row out instead of failing on submit.
+ LinkedHandle *string `json:"linked_handle"`
}
// AdminPost is a row in the admin posts table.
diff --git a/internal/model/feedback.go b/internal/model/feedback.go
index 64f03d1..5ff7093 100644
--- a/internal/model/feedback.go
+++ b/internal/model/feedback.go
@@ -38,6 +38,14 @@ type Feedback struct {
ReplyMessage *string `json:"reply_message,omitempty"`
RepliedAt *time.Time `json:"replied_at,omitempty"`
+ // HasContactEmail reports *whether* an anonymous submitter left a reply-to
+ // address, without carrying the address itself. Populated only by
+ // Recent(), whose sole consumer is the PUBLIC /stats page — that page has
+ // to distinguish "anonymous, but we can reply" from "anonymous dead end"
+ // while never handling the email. Admins see the real address in the
+ // admin panel, which is auth-gated.
+ HasContactEmail bool `json:"-"`
+
AuthorHandle *string `json:"author_handle,omitempty"`
AuthorName *string `json:"author_name,omitempty"`
AuthorEmail *string `json:"-"` // server-side only; never serialised
diff --git a/internal/model/social.go b/internal/model/social.go
index aed867d..e2a43d4 100644
--- a/internal/model/social.go
+++ b/internal/model/social.go
@@ -152,6 +152,9 @@ type PublicProfile struct {
JoinedAt time.Time `json:"joined_at"`
PostCount int `json:"post_count"`
BookmarkCount int `json:"-"` // never serialized — private
+ // IsDirectDisciple marks an account an admin has linked to the disciples
+ // directory — one of Śrīla Prabhupāda's own initiated disciples.
+ IsDirectDisciple bool `json:"is_direct_disciple"`
}
// RelationshipState is the viewer's relationship to a target profile,
diff --git a/internal/model/user.go b/internal/model/user.go
index ffc3af7..b3ddafc 100644
--- a/internal/model/user.go
+++ b/internal/model/user.go
@@ -42,6 +42,16 @@ type User struct {
// nil until known.
Country *string `json:"country"`
+ // DiscipleID links this account to a row in the Mongo `disciples`
+ // collection (ObjectID hex) when the account belongs to one of Śrīla
+ // Prabhupāda's own initiated disciples. Admin-set only — never
+ // self-serve — and unique across users, so one disciple maps to at most
+ // one account. nil for everybody else.
+ //
+ // Tagged `json:"-"`: the raw cross-store id is an internal detail. The
+ // public profile exposes only the derived `is_direct_disciple` flag.
+ DiscipleID *string `json:"-"`
+
// --- Devotional profile (user-editable) ---
// Gender is a free-but-constrained string ("male"|"female"|"other"),
// nil when unspecified.
@@ -86,17 +96,21 @@ type User struct {
// MarshalJSON adds a derived `has_password` flag so the frontend can pick
// "Change password" vs "Set a password" for OAuth-only accounts, without
-// the password hash itself ever leaving the server.
+// the password hash itself ever leaving the server. `is_direct_disciple` is
+// likewise derived — the raw cross-store DiscipleID stays server-side while
+// the frontend gets the one bit it needs to render the badge.
func (u User) MarshalJSON() ([]byte, error) {
type alias User
return json.Marshal(&struct {
alias
- HasPassword bool `json:"has_password"`
- Limits UserLimits `json:"limits"`
+ HasPassword bool `json:"has_password"`
+ IsDirectDisciple bool `json:"is_direct_disciple"`
+ Limits UserLimits `json:"limits"`
}{
- alias: alias(u),
- HasPassword: u.PasswordHash != nil,
- Limits: u.EffectiveLimits(),
+ alias: alias(u),
+ HasPassword: u.PasswordHash != nil,
+ IsDirectDisciple: u.DiscipleID != nil && *u.DiscipleID != "",
+ Limits: u.EffectiveLimits(),
})
}
diff --git a/internal/repository/admin_repo.go b/internal/repository/admin_repo.go
index 127b36e..937f1af 100644
--- a/internal/repository/admin_repo.go
+++ b/internal/repository/admin_repo.go
@@ -41,6 +41,7 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int)
(SELECT COALESCE(array_agg(oa.provider ORDER BY oa.provider), ARRAY[]::text[])
FROM oauth_accounts oa WHERE oa.user_id = u.id) AS providers,
u.bookmark_limit, u.public_collection_limit, u.private_collection_limit,
+ u.disciple_id,
COUNT(*) OVER() AS total
FROM users u
WHERE ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.handle ILIKE '%'||$1||'%'
@@ -60,7 +61,8 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int)
if err := rows.Scan(&u.ID, &u.Email, &u.Handle, &u.Name, &u.AvatarURL,
&u.Role, &u.Plan, &u.EmailVerifiedAt, &u.BannedAt, &u.PostCount, &u.CreatedAt,
&u.HasPassword, &u.Providers,
- &bookmarkLimit, &publicCollLimit, &privateCollLimit, &total); err != nil {
+ &bookmarkLimit, &publicCollLimit, &privateCollLimit,
+ &u.DiscipleID, &total); err != nil {
return nil, 0, fmt.Errorf("scan admin user: %w", err)
}
// Resolve plan default + overrides so the panel shows what's in force.
@@ -73,6 +75,75 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int)
return out, total, rows.Err()
}
+// ErrDiscipleClaimed is returned by LinkDisciple when another account already
+// holds the requested disciple — the partial unique index on disciple_id makes
+// the one-to-one rule a database guarantee rather than a check-then-write race.
+var ErrDiscipleClaimed = errors.New("disciple already linked to another user")
+
+// LinkDisciple points a user at a disciple record in Mongo (ObjectID hex).
+// Re-linking simply overwrites any previous value, so "change the link" needs
+// no separate unlink step. Returns ErrDiscipleClaimed when a different account
+// already claims that disciple.
+//
+// `previous` is the link the row held *before* this call (nil when it had
+// none), which is what lets the caller send the welcome email only on the
+// first link — correcting a mis-link shouldn't welcome the devotee twice. The
+// FROM subquery snapshots the old value in the same statement, so there's no
+// read-then-write race.
+func (r *AdminRepo) LinkDisciple(ctx context.Context, userID, discipleID string) (found bool, previous *string, err error) {
+ err = r.pg.Pool.QueryRow(ctx, `
+ UPDATE users u
+ SET disciple_id = $2,
+ updated_at = now()
+ FROM (SELECT id, disciple_id AS old FROM users WHERE id = $1) prev
+ WHERE u.id = prev.id
+ RETURNING prev.old`, userID, discipleID).Scan(&previous)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return false, nil, nil
+ }
+ if err != nil {
+ if IsUniqueViolation(err) {
+ return false, nil, ErrDiscipleClaimed
+ }
+ return false, nil, fmt.Errorf("admin link disciple: %w", err)
+ }
+ return true, previous, nil
+}
+
+// ClaimDiscipleAnnouncement records that this account's arrival has been
+// announced to the community, returning true only for the caller that actually
+// inserted the row.
+//
+// This is the whole idempotency guard for the broadcast: announcing writes a
+// notification to every user, so it must fire at most once per devotee. Doing
+// it as INSERT … ON CONFLICT DO NOTHING makes the check atomic — a
+// double-clicked button, a retried request, or two admins acting at once still
+// yields exactly one announcement.
+func (r *AdminRepo) ClaimDiscipleAnnouncement(ctx context.Context, userID, discipleID string) (bool, error) {
+ tag, err := r.pg.Pool.Exec(ctx, `
+ INSERT INTO disciple_announcements (user_id, disciple_id)
+ VALUES ($1, $2)
+ ON CONFLICT (user_id) DO NOTHING`, userID, discipleID)
+ if err != nil {
+ return false, fmt.Errorf("claim disciple announcement: %w", err)
+ }
+ return tag.RowsAffected() == 1, nil
+}
+
+// UnlinkDisciple clears a user's disciple link. Idempotent: returns true as
+// long as the user exists, whether or not a link was present.
+func (r *AdminRepo) UnlinkDisciple(ctx context.Context, userID string) (bool, error) {
+ tag, err := r.pg.Pool.Exec(ctx, `
+ UPDATE users
+ SET disciple_id = NULL,
+ updated_at = now()
+ WHERE id = $1`, userID)
+ if err != nil {
+ return false, fmt.Errorf("admin unlink disciple: %w", err)
+ }
+ return tag.RowsAffected() == 1, nil
+}
+
// SetUserLimits writes the per-user save-limit overrides (nil clears one back
// to the plan default). Returns false if the id doesn't exist.
func (r *AdminRepo) SetUserLimits(ctx context.Context, userID string, bookmark, publicCollection, privateCollection *int) (bool, error) {
diff --git a/internal/repository/disciple_repo.go b/internal/repository/disciple_repo.go
index 7b516d5..5dca819 100644
--- a/internal/repository/disciple_repo.go
+++ b/internal/repository/disciple_repo.go
@@ -2,6 +2,7 @@ package repository
import (
"context"
+ "errors"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
@@ -88,6 +89,60 @@ func (r *DiscipleRepo) GetByLetterAndSuffix(ctx context.Context, letter *string,
return results, nil
}
+// FindByIDs returns summary docs for the given ObjectID hex strings (order
+// unspecified). Ids that aren't valid ObjectIDs are skipped rather than
+// failing the batch — the ids come from a Postgres column with no FK, so a
+// malformed or stale value must degrade gracefully. An empty/all-invalid
+// input short-circuits without hitting Mongo.
+//
+// Used to hydrate disciple names onto admin user rows: the user→disciple link
+// lives in Postgres while the disciple record lives here.
+func (r *DiscipleRepo) FindByIDs(ctx context.Context, ids []string) ([]DiscipleSummaryDoc, error) {
+ oids := make([]bson.ObjectID, 0, len(ids))
+ for _, id := range ids {
+ if oid, err := bson.ObjectIDFromHex(id); err == nil {
+ oids = append(oids, oid)
+ }
+ }
+ if len(oids) == 0 {
+ return []DiscipleSummaryDoc{}, nil
+ }
+ cursor, err := r.col.Find(ctx,
+ bson.M{"_id": bson.M{"$in": oids}},
+ options.Find().SetProjection(discipleSummaryProjection),
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer cursor.Close(ctx)
+ var results []DiscipleSummaryDoc
+ if err := cursor.All(ctx, &results); err != nil {
+ return nil, err
+ }
+ return results, nil
+}
+
+// FindByID returns one disciple summary, or nil when the id is malformed or
+// no such document exists. Callers use it to validate a link target before
+// writing it to Postgres.
+func (r *DiscipleRepo) FindByID(ctx context.Context, id string) (*DiscipleSummaryDoc, error) {
+ oid, err := bson.ObjectIDFromHex(id)
+ if err != nil {
+ return nil, nil // malformed id — "not found", not an error
+ }
+ var doc DiscipleSummaryDoc
+ err = r.col.FindOne(ctx, bson.M{"_id": oid},
+ options.FindOne().SetProjection(discipleSummaryProjection),
+ ).Decode(&doc)
+ if errors.Is(err, mongo.ErrNoDocuments) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &doc, nil
+}
+
func (r *DiscipleRepo) Count(ctx context.Context) (int64, error) {
return r.col.CountDocuments(ctx, bson.M{})
}
diff --git a/internal/repository/feedback_repo.go b/internal/repository/feedback_repo.go
index 4d58102..48cb81c 100644
--- a/internal/repository/feedback_repo.go
+++ b/internal/repository/feedback_repo.go
@@ -121,12 +121,19 @@ func (r *FeedbackRepo) ListByUser(ctx context.Context, userID string, limit int)
// Recent returns the most-recent N feedback rows joined with users
// for display on /stats. NULL author columns mean anonymous (or the
// original submitter's account was since deleted).
+//
+// PRIVACY: /stats is a public page, so this query deliberately does NOT select
+// contact_email — only a boolean saying whether one exists. Submitters' email
+// addresses must never reach that template, and not fetching them makes that
+// impossible rather than merely unlikely. Callers that legitimately need the
+// address (the reply/thank-you emails) use FindByID instead.
func (r *FeedbackRepo) Recent(ctx context.Context, limit int) ([]model.Feedback, error) {
if limit <= 0 || limit > 200 {
limit = 25
}
rows, err := r.pg.Pool.Query(ctx, `
- SELECT f.id, f.user_id, f.category, f.message, f.contact_email, f.created_at,
+ SELECT f.id, f.user_id, f.category, f.message, f.created_at,
+ (f.contact_email IS NOT NULL AND f.contact_email <> '') AS has_contact_email,
f.acknowledged_at, f.resolved_at,
u.handle, u.name
FROM feedback f
@@ -141,7 +148,8 @@ func (r *FeedbackRepo) Recent(ctx context.Context, limit int) ([]model.Feedback,
out := []model.Feedback{}
for rows.Next() {
var f model.Feedback
- if err := rows.Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt,
+ if err := rows.Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.CreatedAt,
+ &f.HasContactEmail,
&f.AcknowledgedAt, &f.ResolvedAt,
&f.AuthorHandle, &f.AuthorName); err != nil {
return nil, fmt.Errorf("scan feedback: %w", err)
diff --git a/internal/repository/social_graph_repo.go b/internal/repository/social_graph_repo.go
index 6faafe7..9942383 100644
--- a/internal/repository/social_graph_repo.go
+++ b/internal/repository/social_graph_repo.go
@@ -334,6 +334,62 @@ func (r *NotificationRepo) CreateAdminMessage(ctx context.Context, userID, body
return nil
}
+// BroadcastToAllUsers writes one notification of `kind` to every active user
+// except `actorID`, in a single INSERT…SELECT. One round trip regardless of
+// audience size — the alternative (a row per user from Go) would be hundreds
+// of round trips on the request path.
+//
+// Deliberately does NOT filter on notification_prefs: ListByUser already
+// excludes kinds the reader has disabled for the in_app channel at read time,
+// so writing the rows unconditionally keeps a re-enabled preference able to
+// surface history — exactly how the existing kinds behave.
+//
+// Banned accounts are skipped; they can't sign in to read it anyway.
+func (r *NotificationRepo) BroadcastToAllUsers(ctx context.Context, kind, actorID string) (int64, error) {
+ tag, err := r.pg.Pool.Exec(ctx, `
+ INSERT INTO notifications (user_id, actor_id, kind)
+ SELECT u.id, $2, $1
+ FROM users u
+ WHERE u.id <> $2 AND u.banned_at IS NULL`, kind, actorID)
+ if err != nil {
+ return 0, fmt.Errorf("broadcast notification: %w", err)
+ }
+ return tag.RowsAffected(), nil
+}
+
+// PushRecipientsForKind lists the ids of users who should receive a *push* for
+// `kind` — everyone except the actor, minus banned accounts, minus anyone who
+// turned this kind off for the push channel, and minus anyone with no push
+// subscription registered at all.
+//
+// That last filter is what keeps a broadcast cheap: only devotees who actually
+// enabled web push are worth iterating, which is typically a small fraction of
+// the user base.
+func (r *NotificationRepo) PushRecipientsForKind(ctx context.Context, kind, actorID string) ([]string, error) {
+ rows, err := r.pg.Pool.Query(ctx, `
+ SELECT DISTINCT u.id
+ FROM users u
+ JOIN push_subscriptions ps ON ps.user_id = u.id
+ WHERE u.id <> $2 AND u.banned_at IS NULL
+ AND NOT EXISTS (
+ SELECT 1 FROM notification_prefs np
+ WHERE np.user_id = u.id AND np.kind = $1
+ AND np.channel = 'push' AND np.enabled = FALSE)`, kind, actorID)
+ if err != nil {
+ return nil, fmt.Errorf("list push recipients: %w", err)
+ }
+ defer rows.Close()
+ out := []string{}
+ for rows.Next() {
+ var id string
+ if err := rows.Scan(&id); err != nil {
+ return nil, fmt.Errorf("scan push recipient: %w", err)
+ }
+ out = append(out, id)
+ }
+ return out, rows.Err()
+}
+
// ListByUser returns a recipient's notifications, newest first, with the
// actor's display fields joined in for rendering (nil for system
// notifications or since-deleted actors). Kinds the user has turned off for
diff --git a/internal/repository/user_repo.go b/internal/repository/user_repo.go
index 61cb9d4..bdcf6ed 100644
--- a/internal/repository/user_repo.go
+++ b/internal/repository/user_repo.go
@@ -37,7 +37,8 @@ const userColumns = `id, email, handle, password_hash, name, avatar_url, role, p
email_verified_at, token_version, created_at, updated_at, country,
sampradaya, gender, initiated, initiated_at, daily_chanting_rounds,
preferred_chanting_time, peak_active_time, banned_at,
- bookmark_limit, public_collection_limit, private_collection_limit`
+ bookmark_limit, public_collection_limit, private_collection_limit,
+ disciple_id`
// rowScanner is satisfied by both pgx.Row (QueryRow) and pgx.Rows, so
// scanUser works for single-row and iterated reads alike.
@@ -53,6 +54,7 @@ func scanUser(row rowScanner, u *model.User) error {
&u.Sampradaya, &u.Gender, &u.Initiated, &u.InitiatedAt, &u.DailyChantingRounds,
&u.PreferredChantingTime, &u.PeakActiveTime, &u.BannedAt,
&u.BookmarkLimit, &u.PublicCollectionLimit, &u.PrivateCollectionLimit,
+ &u.DiscipleID,
)
}
@@ -402,6 +404,41 @@ func (r *UserRepo) AllHandles(ctx context.Context) ([]string, error) {
return handles, rows.Err()
}
+// DiscipleLink pairs a Mongo disciple id with the platform account an admin
+// has linked to it. Powers the /disciples browser highlight: the reader page
+// fetches the whole (small) set once and marks the matching cards.
+type DiscipleLink struct {
+ DiscipleID string `json:"disciple_id"`
+ Handle string `json:"handle"`
+ Name string `json:"name"`
+ AvatarURL *string `json:"avatar_url"`
+}
+
+// ListDiscipleLinks returns every user→disciple link. Deliberately unpaged:
+// only Śrīla Prabhupāda's own disciples are ever linked, so this is dozens of
+// rows at most and the partial index on disciple_id keeps it a cheap scan.
+// Suspended accounts are excluded so a banned user can't keep a public badge.
+func (r *UserRepo) ListDiscipleLinks(ctx context.Context) ([]DiscipleLink, error) {
+ rows, err := r.pg.Pool.Query(ctx, `
+ SELECT disciple_id, handle, name, avatar_url
+ FROM users
+ WHERE disciple_id IS NOT NULL AND banned_at IS NULL
+ ORDER BY name`)
+ if err != nil {
+ return nil, fmt.Errorf("list disciple links: %w", err)
+ }
+ defer rows.Close()
+ out := []DiscipleLink{}
+ for rows.Next() {
+ var l DiscipleLink
+ if err := rows.Scan(&l.DiscipleID, &l.Handle, &l.Name, &l.AvatarURL); err != nil {
+ return nil, fmt.Errorf("scan disciple link: %w", err)
+ }
+ out = append(out, l)
+ }
+ return out, rows.Err()
+}
+
// RecentlyJoined returns up to `limit` users who registered on/after `since`,
// newest first, as a discovery list for the feed's "new on the platform"
// strip. Excludes the viewer, anyone the viewer already has a follow edge with
diff --git a/internal/server/server.go b/internal/server/server.go
index af02b03..ecb18b3 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -34,6 +34,11 @@ type Server struct {
pg *repository.Postgres
redis *repository.Redis
+ // discipleRepo is held on the struct (unlike the other Mongo repos, which
+ // stay local to New) because initAuth re-builds discipleH with the
+ // Postgres userRepo once it exists — see the direct-disciple link feature.
+ discipleRepo *repository.DiscipleRepo
+
// Worker lifecycle
workerCancel context.CancelFunc
workerCtx context.Context // derived in initShortener; cancelled on Close
@@ -103,12 +108,15 @@ func New(cfg *config.Config, logger *zap.Logger) *Server {
logger: logger,
mongo: mongoDB,
+ discipleRepo: discipleRepo,
+
// Initialize handlers
bookH: handler.NewBookHandler(bookRepo),
letterH: handler.NewLetterHandler(letterRepo),
essayH: handler.NewEssayHandler(essayRepo),
glossaryH: handler.NewGlossaryHandler(glossaryRepo),
- discipleH: handler.NewDiscipleHandler(discipleRepo),
+ // userRepo half is attached later in initAuth (Postgres isn't up yet).
+ discipleH: handler.NewDiscipleHandler(discipleRepo, nil),
journalH: handler.NewJournalHandler(journalRepo),
magazineH: handler.NewMagazineHandler(magazineRepo),
otherDocH: handler.NewOtherDocumentHandler(otherDocRepo),
@@ -196,6 +204,14 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
s.authH = handler.NewAuthHandler(authSvc, cookieOpts)
s.userH = handler.NewUserHandler(userSvc)
+ // Re-wire the disciple handler now that Postgres-backed users exist: its
+ // /disciples/linked route joins the Mongo disciple records to the platform
+ // accounts an admin has linked to them. Built in New() with a nil userRepo
+ // (Mongo is up before Postgres), so this is where it gets the other half.
+ if s.discipleRepo != nil {
+ s.discipleH = handler.NewDiscipleHandler(s.discipleRepo, userRepo)
+ }
+
// Audio playback resume points — per-user, per-file last position that
// lets the floating player pick up where it left off across devices.
audioProgressRepo := repository.NewAudioProgressRepo(s.pg)
@@ -303,7 +319,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
// Reuses the notification service (delivery) and mailer (feedback reply).
// Gated by RequireAuth + RequireAdmin in the router.
adminRepo := repository.NewAdminRepo(s.pg)
- adminSvc := service.NewAdminService(adminRepo, statsRepo, notifSvc, feedbackSvc, mail, logger, cfg.FrontendURL)
+ adminSvc := service.NewAdminService(adminRepo, statsRepo, notifSvc, feedbackSvc, userRepo, s.discipleRepo, mail, logger, cfg.FrontendURL)
s.adminH = handler.NewAdminHandler(adminSvc)
}
@@ -610,6 +626,9 @@ func (s *Server) setupRouter() {
r.Get("/letters", s.discipleH.LetterCounts)
r.Get("/suffixes", s.discipleH.SuffixCounts)
r.Get("/search", s.discipleH.Search)
+ // Disciples with a platform account (admin-linked) — drives the
+ // highlight in the /disciples browser.
+ r.Get("/linked", s.discipleH.Linked)
})
// Journals
@@ -1037,6 +1056,11 @@ func (s *Server) setupRouter() {
r.Post("/users/{id}/ban", s.adminH.BanUser)
r.Delete("/users/{id}/ban", s.adminH.UnbanUser)
r.Post("/users/{id}/limits", s.adminH.SetUserLimits)
+ // Direct-disciple linking: mark an account as one of Śrīla
+ // Prabhupāda's initiated disciples (or clear the mark).
+ r.Get("/disciples", s.adminH.ListDisciples)
+ r.Post("/users/{id}/disciple", s.adminH.LinkDisciple)
+ r.Delete("/users/{id}/disciple", s.adminH.UnlinkDisciple)
r.Get("/posts", s.adminH.ListPosts)
r.Post("/posts/{id}/hide", s.adminH.HidePost)
r.Delete("/posts/{id}/hide", s.adminH.UnhidePost)
diff --git a/internal/service/admin_service.go b/internal/service/admin_service.go
index a03e157..a34b9d7 100644
--- a/internal/service/admin_service.go
+++ b/internal/service/admin_service.go
@@ -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(`Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏
+ +Please accept our humble obeisances.
+ +It is our great fortune to welcome you to + prabhupadasays.org. + We have recognised your account as belonging to + {{if .DiscipleName}}{{.DiscipleName}}, one of{{else}}one of{{end}} + Śrīla Prabhupāda's own initiated disciples.
+ +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.
+ +This whole library exists to preserve and spread Śrīla Prabhupāda's + vāṇī. 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.
+ + + +If anything about your account needs correcting, simply reply to this + email and we will attend to it personally.
+ +Your servants,
+ The Prabhupāda Says team
Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏
diff --git a/internal/service/notification_service.go b/internal/service/notification_service.go index 5ae480a..d571d1a 100644 --- a/internal/service/notification_service.go +++ b/internal/service/notification_service.go @@ -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 { diff --git a/migrations/0045_add_users_disciple_id.down.sql b/migrations/0045_add_users_disciple_id.down.sql new file mode 100644 index 0000000..608df1d --- /dev/null +++ b/migrations/0045_add_users_disciple_id.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS users_disciple_id_key; + +ALTER TABLE users + DROP COLUMN IF EXISTS disciple_id; diff --git a/migrations/0045_add_users_disciple_id.up.sql b/migrations/0045_add_users_disciple_id.up.sql new file mode 100644 index 0000000..12a9047 --- /dev/null +++ b/migrations/0045_add_users_disciple_id.up.sql @@ -0,0 +1,19 @@ +-- Direct-disciple linkage. Śrīla Prabhupāda's own initiated disciples have +-- started joining the platform; an admin links their account to the matching +-- row in the Mongo `disciples` collection so the site can mark them +-- authentically (a badge on their profile, a highlight in /disciples). +-- +-- disciple_id holds the Mongo ObjectID hex (24 chars). It's a cross-store +-- reference, so no foreign key is possible — the admin endpoint validates the +-- id exists in Mongo before writing, and a dangling id degrades gracefully +-- (the name simply doesn't hydrate). +-- +-- The partial UNIQUE index enforces the one-to-one rule: a given disciple can +-- be claimed by at most one account, so two users can't both present as the +-- same disciple. NULLs are unconstrained, which leaves the overwhelming +-- majority of users unaffected and keeps the index tiny. +ALTER TABLE users + ADD COLUMN IF NOT EXISTS disciple_id TEXT; + +CREATE UNIQUE INDEX IF NOT EXISTS users_disciple_id_key + ON users (disciple_id) WHERE disciple_id IS NOT NULL; diff --git a/migrations/0046_create_disciple_announcements.down.sql b/migrations/0046_create_disciple_announcements.down.sql new file mode 100644 index 0000000..e8d37cc --- /dev/null +++ b/migrations/0046_create_disciple_announcements.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS disciple_announcements; diff --git a/migrations/0046_create_disciple_announcements.up.sql b/migrations/0046_create_disciple_announcements.up.sql new file mode 100644 index 0000000..070ade1 --- /dev/null +++ b/migrations/0046_create_disciple_announcements.up.sql @@ -0,0 +1,20 @@ +-- One row per account whose arrival as a direct disciple of Śrīla Prabhupāda +-- has been announced to the community. +-- +-- This table exists purely for idempotency. Announcing writes a notification +-- to *every* user, so it must happen at most once per devotee — not again when +-- an admin corrects which disciple record they're linked to, not again on a +-- double-clicked button, and not again if the process restarts mid-fan-out. +-- The insert is `ON CONFLICT DO NOTHING` and only a caller that actually +-- inserted the row goes on to broadcast, which makes the guard atomic. +-- +-- Keyed on user_id (not disciple_id) deliberately: we are announcing that a +-- *person* has joined. Re-pointing their link at a different disciple record +-- corrects a mistake; it isn't a second arrival. +CREATE TABLE IF NOT EXISTS disciple_announcements ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + -- The Mongo disciple ObjectID they were linked to at announcement time, + -- kept for auditing (which record prompted the announcement). + disciple_id TEXT NOT NULL, + announced_at TIMESTAMPTZ NOT NULL DEFAULT now() +);