feat: disciple link

This commit is contained in:
Krispire Admin
2026-08-07 22:46:08 +05:30
parent 4553725d4e
commit afcdd7ef19
21 changed files with 863 additions and 23 deletions
+63
View File
@@ -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 {
+25 -2
View File
@@ -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) {
+5
View File
@@ -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 != "",
})
}
+8 -2
View File
@@ -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
<div class="author">
{{if .AuthorHandle}}
@{{.AuthorHandle}}{{if .AuthorName}} · {{.AuthorName}}{{end}}
{{else if .ContactEmail}}
anonymous · <code>{{.ContactEmail}}</code>
{{else if .HasContactEmail}}
anonymous · reply address on file
{{else}}
anonymous
{{end}}
+46
View File
@@ -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)
}
}
}
+21
View File
@@ -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.
+8
View File
@@ -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
+3
View File
@@ -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,
+20 -6
View File
@@ -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(),
})
}
+72 -1
View File
@@ -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) {
+55
View File
@@ -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{})
}
+10 -2
View File
@@ -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)
+56
View File
@@ -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
+38 -1
View File
@@ -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
+26 -2
View File
@@ -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)
+272 -7
View File
@@ -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>
+91
View File
@@ -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 {
@@ -0,0 +1,4 @@
DROP INDEX IF EXISTS users_disciple_id_key;
ALTER TABLE users
DROP COLUMN IF EXISTS disciple_id;
@@ -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;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS disciple_announcements;
@@ -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()
);