From afcdd7ef193c4be46ade29661fed312f954c302a Mon Sep 17 00:00:00 2001 From: Krispire Admin Date: Fri, 7 Aug 2026 22:46:08 +0530 Subject: [PATCH] feat: disciple link --- internal/handler/admin.go | 63 ++++ internal/handler/disciple.go | 27 +- internal/handler/social.go | 5 + internal/handler/stats.go | 10 +- internal/handler/stats_test.go | 46 +++ internal/model/admin.go | 21 ++ internal/model/feedback.go | 8 + internal/model/social.go | 3 + internal/model/user.go | 26 +- internal/repository/admin_repo.go | 73 ++++- internal/repository/disciple_repo.go | 55 ++++ internal/repository/feedback_repo.go | 12 +- internal/repository/social_graph_repo.go | 56 ++++ internal/repository/user_repo.go | 39 ++- internal/server/server.go | 28 +- internal/service/admin_service.go | 279 +++++++++++++++++- internal/service/notification_service.go | 91 ++++++ .../0045_add_users_disciple_id.down.sql | 4 + migrations/0045_add_users_disciple_id.up.sql | 19 ++ ...046_create_disciple_announcements.down.sql | 1 + .../0046_create_disciple_announcements.up.sql | 20 ++ 21 files changed, 863 insertions(+), 23 deletions(-) create mode 100644 internal/handler/stats_test.go create mode 100644 migrations/0045_add_users_disciple_id.down.sql create mode 100644 migrations/0045_add_users_disciple_id.up.sql create mode 100644 migrations/0046_create_disciple_announcements.down.sql create mode 100644 migrations/0046_create_disciple_announcements.up.sql 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
{{if .AuthorHandle}} @{{.AuthorHandle}}{{if .AuthorName}} · {{.AuthorName}}{{end}} - {{else if .ContactEmail}} - anonymous · {{.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.

+ +

+ View your profile +

+ +

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

+
`)) + var feedbackReplyEmailTpl = template.Must(template.New("feedbackReply").Parse(`

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() +);