feat: disciple link
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 != "",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"html/template"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
@@ -21,17 +22,23 @@ var ErrAdminUserNotFound = errors.New("user not found")
|
||||
// existing NotificationService for delivery and the shared mailer for the
|
||||
// feedback-reply email — no new infrastructure.
|
||||
type AdminService struct {
|
||||
repo *repository.AdminRepo
|
||||
stats *repository.StatsRepo
|
||||
notif *NotificationService
|
||||
feedback *FeedbackService
|
||||
repo *repository.AdminRepo
|
||||
stats *repository.StatsRepo
|
||||
notif *NotificationService
|
||||
feedback *FeedbackService
|
||||
// users / disciples back the direct-disciple linking feature: the link
|
||||
// column lives in Postgres (users) while the disciple record lives in
|
||||
// Mongo (disciples), so the service is where the two stores meet. Both
|
||||
// may be nil in degraded boots (no Mongo) — the link methods guard.
|
||||
users *repository.UserRepo
|
||||
disciples *repository.DiscipleRepo
|
||||
mail mailer.Mailer
|
||||
logger *zap.Logger
|
||||
frontendURL string
|
||||
}
|
||||
|
||||
func NewAdminService(repo *repository.AdminRepo, stats *repository.StatsRepo, notif *NotificationService, feedback *FeedbackService, mail mailer.Mailer, logger *zap.Logger, frontendURL string) *AdminService {
|
||||
return &AdminService{repo: repo, stats: stats, notif: notif, feedback: feedback, mail: mail, logger: logger, frontendURL: frontendURL}
|
||||
func NewAdminService(repo *repository.AdminRepo, stats *repository.StatsRepo, notif *NotificationService, feedback *FeedbackService, users *repository.UserRepo, disciples *repository.DiscipleRepo, mail mailer.Mailer, logger *zap.Logger, frontendURL string) *AdminService {
|
||||
return &AdminService{repo: repo, stats: stats, notif: notif, feedback: feedback, users: users, disciples: disciples, mail: mail, logger: logger, frontendURL: frontendURL}
|
||||
}
|
||||
|
||||
// Stats returns the system snapshot (counts + 7-day deltas + feedback
|
||||
@@ -56,7 +63,230 @@ func (s *AdminService) ResolveFeedback(ctx context.Context, id string) error {
|
||||
}
|
||||
|
||||
func (s *AdminService) ListUsers(ctx context.Context, q string, limit, offset int) ([]model.AdminUser, int, error) {
|
||||
return s.repo.ListUsers(ctx, q, limit, offset)
|
||||
users, total, err := s.repo.ListUsers(ctx, q, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
s.hydrateDiscipleNames(ctx, users)
|
||||
return users, total, nil
|
||||
}
|
||||
|
||||
// hydrateDiscipleNames fills DiscipleName on any linked rows in the page with a
|
||||
// single Mongo lookup. Best-effort by design: the panel is far more useful with
|
||||
// an un-named link than with a 500, so a Mongo failure logs and leaves the
|
||||
// names nil (the UI then shows the bare "linked" state).
|
||||
func (s *AdminService) hydrateDiscipleNames(ctx context.Context, users []model.AdminUser) {
|
||||
if s.disciples == nil {
|
||||
return
|
||||
}
|
||||
ids := make([]string, 0, len(users))
|
||||
for _, u := range users {
|
||||
if u.DiscipleID != nil && *u.DiscipleID != "" {
|
||||
ids = append(ids, *u.DiscipleID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
docs, err := s.disciples.FindByIDs(ctx, ids)
|
||||
if err != nil {
|
||||
s.logger.Warn("admin: hydrate disciple names failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
byID := make(map[string]string, len(docs))
|
||||
for _, d := range docs {
|
||||
byID[d.ID.Hex()] = strings.TrimSpace(d.InitiatedName + " " + d.Suffix)
|
||||
}
|
||||
for i := range users {
|
||||
if users[i].DiscipleID == nil {
|
||||
continue
|
||||
}
|
||||
if name, ok := byID[*users[i].DiscipleID]; ok {
|
||||
users[i].DiscipleName = &name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ErrDiscipleNotFound is returned by LinkDisciple when the requested disciple
|
||||
// id doesn't resolve to a document in Mongo.
|
||||
var ErrDiscipleNotFound = errors.New("disciple not found")
|
||||
|
||||
// ErrDisciplesUnavailable is returned when the Mongo-backed disciple
|
||||
// collection isn't wired (degraded boot) — linking can't be validated, so we
|
||||
// refuse rather than write an unverified id.
|
||||
var ErrDisciplesUnavailable = errors.New("disciple directory unavailable")
|
||||
|
||||
// SearchDisciples backs the admin panel's disciple picker. An empty query
|
||||
// returns the first page alphabetically so the picker opens with content.
|
||||
// Each option carries the handle of the account already linked to it, letting
|
||||
// the UI disable those rows instead of surfacing a conflict on submit.
|
||||
func (s *AdminService) SearchDisciples(ctx context.Context, q string, limit int) ([]model.AdminDisciple, error) {
|
||||
if s.disciples == nil {
|
||||
return nil, ErrDisciplesUnavailable
|
||||
}
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 25
|
||||
}
|
||||
docs, err := s.disciples.Search(ctx, strings.TrimSpace(q), limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Which disciples are already claimed, and by whom.
|
||||
claimed := map[string]string{}
|
||||
if s.users != nil {
|
||||
links, err := s.users.ListDiscipleLinks(ctx)
|
||||
if err != nil {
|
||||
s.logger.Warn("admin: load disciple links failed", zap.Error(err))
|
||||
} else {
|
||||
for _, l := range links {
|
||||
claimed[l.DiscipleID] = l.Handle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]model.AdminDisciple, 0, len(docs))
|
||||
for _, d := range docs {
|
||||
id := d.ID.Hex()
|
||||
opt := model.AdminDisciple{
|
||||
ID: id,
|
||||
InitiatedName: d.InitiatedName,
|
||||
Suffix: d.Suffix,
|
||||
InitiationDate: d.InitiationDate,
|
||||
InitiationPlace: d.InitiationPlace,
|
||||
}
|
||||
if h, ok := claimed[id]; ok {
|
||||
handle := h
|
||||
opt.LinkedHandle = &handle
|
||||
}
|
||||
out = append(out, opt)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LinkDisciple marks a user as one of Śrīla Prabhupāda's direct disciples by
|
||||
// pointing them at a disciple record. The id is validated against Mongo first
|
||||
// so a typo can't create a link that renders as an unnamed badge forever.
|
||||
// Returns ErrAdminUserNotFound, ErrDiscipleNotFound, or
|
||||
// repository.ErrDiscipleClaimed.
|
||||
func (s *AdminService) LinkDisciple(ctx context.Context, userID, discipleID string) error {
|
||||
if s.disciples == nil {
|
||||
return ErrDisciplesUnavailable
|
||||
}
|
||||
doc, err := s.disciples.FindByID(ctx, strings.TrimSpace(discipleID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if doc == nil {
|
||||
return ErrDiscipleNotFound
|
||||
}
|
||||
found, previous, err := s.repo.LinkDisciple(ctx, userID, doc.ID.Hex())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return ErrAdminUserNotFound
|
||||
}
|
||||
|
||||
// Everything below is first-link-only. Correcting a mis-link (previous
|
||||
// != nil) must not greet the devotee twice or re-announce them.
|
||||
if previous != nil && *previous != "" {
|
||||
return nil
|
||||
}
|
||||
if s.users == nil {
|
||||
return nil
|
||||
}
|
||||
u, err := s.users.FindByID(ctx, userID)
|
||||
if err != nil || u == nil {
|
||||
s.logger.Warn("disciple linked but user lookup failed; skipping welcome + announcement",
|
||||
zap.String("user_id", userID), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
|
||||
s.sendDiscipleWelcome(u, strings.TrimSpace(doc.InitiatedName+" "+doc.Suffix))
|
||||
s.announceDiscipleJoined(ctx, u, doc.ID.Hex())
|
||||
return nil
|
||||
}
|
||||
|
||||
// announceDiscipleJoined tells the rest of the community that a direct
|
||||
// disciple has joined — in-app for everyone, plus web push for those who've
|
||||
// enabled it. No email: see the deliverability reasoning behind keeping
|
||||
// announcements off the transactional mail domain.
|
||||
//
|
||||
// The DB claim is atomic and synchronous (so a double-click can't double
|
||||
// announce), but the fan-out itself runs detached: pushing to hundreds of
|
||||
// devotees is one external HTTP call each, far too slow to hold the admin's
|
||||
// request open. `context.WithoutCancel` keeps it alive past the response, with
|
||||
// a hard ceiling so a wedged run can't leak forever.
|
||||
func (s *AdminService) announceDiscipleJoined(ctx context.Context, u *model.User, discipleID string) {
|
||||
if s.notif == nil {
|
||||
return
|
||||
}
|
||||
if u.BannedAt != nil {
|
||||
// Don't announce a suspended account to the community.
|
||||
s.logger.Info("disciple linked on a suspended account; skipping announcement",
|
||||
zap.String("user_id", u.ID))
|
||||
return
|
||||
}
|
||||
claimed, err := s.repo.ClaimDiscipleAnnouncement(ctx, u.ID, discipleID)
|
||||
if err != nil {
|
||||
s.logger.Warn("disciple announcement claim failed; skipping broadcast",
|
||||
zap.String("user_id", u.ID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
if !claimed {
|
||||
// Already announced previously — nothing to do.
|
||||
return
|
||||
}
|
||||
|
||||
bg, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Minute)
|
||||
go func() {
|
||||
defer cancel()
|
||||
if err := s.notif.BroadcastDiscipleJoined(bg, u); err != nil {
|
||||
s.logger.Error("disciple-joined broadcast failed",
|
||||
zap.String("user_id", u.ID), zap.Error(err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// sendDiscipleWelcome emails the newly-recognised disciple. Fire-and-forget,
|
||||
// exactly like the feedback emails: the link is already committed, so a mail
|
||||
// failure must not fail the admin's action — it logs and moves on.
|
||||
func (s *AdminService) sendDiscipleWelcome(u *model.User, discipleName string) {
|
||||
if u.Email == nil || strings.TrimSpace(*u.Email) == "" {
|
||||
// OAuth-only account with no address on file — nothing to send.
|
||||
s.logger.Info("disciple linked; no email on file, skipping welcome",
|
||||
zap.String("user_id", u.ID))
|
||||
return
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := discipleWelcomeEmailTpl.Execute(&buf, map[string]any{
|
||||
"RecipientName": strings.TrimSpace(u.Name),
|
||||
"DiscipleName": discipleName,
|
||||
"ProfileURL": s.frontendURL + "/u/" + u.Handle,
|
||||
}); err != nil {
|
||||
s.logger.Warn("render disciple welcome email failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
if err := s.mail.Send(strings.TrimSpace(*u.Email),
|
||||
"Welcome, and thank you — Prabhupāda Says", buf.String()); err != nil {
|
||||
s.logger.Warn("disciple welcome email send failed",
|
||||
zap.String("user_id", u.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// UnlinkDisciple clears a user's direct-disciple link. Idempotent — clearing an
|
||||
// already-clear link succeeds.
|
||||
func (s *AdminService) UnlinkDisciple(ctx context.Context, userID string) error {
|
||||
ok, err := s.repo.UnlinkDisciple(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return ErrAdminUserNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AdminService) ListPosts(ctx context.Context, q string, limit, offset int) ([]model.AdminPost, int, error) {
|
||||
@@ -165,6 +395,41 @@ func (s *AdminService) SetUserLimits(ctx context.Context, userID string, bookmar
|
||||
|
||||
// feedbackReplyEmailTpl renders the reply email. Reply text is auto-escaped by
|
||||
// html/template, so admin-entered text can't inject markup.
|
||||
// discipleWelcomeEmailTpl greets a devotee an admin has just recognised as one
|
||||
// of Śrīla Prabhupāda's own initiated disciples. Same Vaishnava styling and
|
||||
// 17px base as the other author-facing templates.
|
||||
var discipleWelcomeEmailTpl = template.Must(template.New("discipleWelcome").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
|
||||
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
|
||||
|
||||
<p>Please accept our humble obeisances.</p>
|
||||
|
||||
<p>It is our great fortune to welcome you to
|
||||
<a href="{{.ProfileURL}}" style="color:#9a6b2f;font-weight:600">prabhupadasays.org</a>.
|
||||
We have recognised your account as belonging to
|
||||
{{if .DiscipleName}}<strong>{{.DiscipleName}}</strong>, one of{{else}}one of{{end}}
|
||||
Śrīla Prabhupāda's own initiated disciples.</p>
|
||||
|
||||
<p>Your profile now carries His Divine Grace's picture beside your name, and
|
||||
your name is highlighted in our directory of his disciples — so every devotee
|
||||
who visits knows they are hearing from someone who served Śrīla Prabhupāda
|
||||
personally.</p>
|
||||
|
||||
<p>This whole library exists to preserve and spread Śrīla Prabhupāda's
|
||||
<em>vāṇī</em>. Your presence here — your memories, your realisations, your
|
||||
corrections — is a gift to the devotees who came after. We would be honoured
|
||||
by whatever you care to share.</p>
|
||||
|
||||
<p style="margin:1.25em 0">
|
||||
<a href="{{.ProfileURL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">View your profile</a>
|
||||
</p>
|
||||
|
||||
<p>If anything about your account needs correcting, simply reply to this
|
||||
email and we will attend to it personally.</p>
|
||||
|
||||
<p style="margin-top:1.5em">Your servants,<br>
|
||||
<strong>The Prabhupāda Says team</strong></p>
|
||||
</div>`))
|
||||
|
||||
var feedbackReplyEmailTpl = template.Must(template.New("feedbackReply").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
|
||||
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
|
||||
|
||||
|
||||
@@ -39,6 +39,12 @@ const (
|
||||
NotifCommentReply = "comment_reply"
|
||||
NotifCommentMention = "comment_mention"
|
||||
|
||||
// NotifDiscipleJoined is broadcast to the whole community when an admin
|
||||
// recognises an account as one of Śrīla Prabhupāda's own initiated
|
||||
// disciples. The actor is the disciple, so it renders and links exactly
|
||||
// like the other actor-driven kinds.
|
||||
NotifDiscipleJoined = "disciple_joined"
|
||||
|
||||
// Reminder kinds are delivered live (email + push + a websocket toast)
|
||||
// but NOT persisted — they're ephemeral nudges, not history.
|
||||
NotifChantReminder = "chant_reminder"
|
||||
@@ -53,6 +59,7 @@ var PrefCategories = map[string][]string{
|
||||
"follows": {NotifFollowRequest, NotifFollowAccepted},
|
||||
"post_interactions": {NotifPostLike, NotifPostMention, NotifPostComment, NotifCommentReply},
|
||||
"sadhna_reminders": {NotifChantReminder, NotifQuoteReminder, NotifChantIncomplete},
|
||||
"community": {NotifDiscipleJoined},
|
||||
}
|
||||
|
||||
// PrefChannels is every delivery channel a matrix cell can address.
|
||||
@@ -672,6 +679,90 @@ func (s *NotificationService) NotifyAdminMessage(ctx context.Context, recipientI
|
||||
return nil
|
||||
}
|
||||
|
||||
// pushBroadcastDelay paces the per-user push fan-out. Web Push is one HTTP
|
||||
// request per subscription to an external service (FCM/Mozilla/Apple), so a
|
||||
// tight loop over hundreds of devotees would hammer those endpoints and spike
|
||||
// outbound connections. A small gap keeps the broadcast a background trickle.
|
||||
const pushBroadcastDelay = 50 * time.Millisecond
|
||||
|
||||
// BroadcastDiscipleJoined tells the community that `actor` — an account an
|
||||
// admin has just recognised as one of Śrīla Prabhupāda's own initiated
|
||||
// disciples — has joined.
|
||||
//
|
||||
// Two channels, deliberately decoupled by cost:
|
||||
//
|
||||
// - In-app: a single bulk INSERT for every user. Cheap and instant, so it
|
||||
// happens inline and the row is durable the moment this returns.
|
||||
// - Push + realtime: one call per recipient against external services, so it
|
||||
// runs paced. The caller is expected to invoke this on a detached,
|
||||
// time-limited context (not the admin's request context, which dies with
|
||||
// the response).
|
||||
//
|
||||
// No email — announcements are in-app/push only by design; see the
|
||||
// deliverability reasoning in the disciple-link notes.
|
||||
//
|
||||
// Errors from the fan-out are logged, not returned: the announcement is
|
||||
// already durably recorded in-app, and a failed push to one devotee must not
|
||||
// abort the rest.
|
||||
func (s *NotificationService) BroadcastDiscipleJoined(ctx context.Context, actor *model.User) error {
|
||||
if actor == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
written, err := s.notifs.BroadcastToAllUsers(ctx, NotifDiscipleJoined, actor.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.Info("disciple-joined broadcast: in-app rows written",
|
||||
zap.String("actor", actor.Handle), zap.Int64("recipients", written))
|
||||
|
||||
// Everything below is best-effort delivery on top of the durable row.
|
||||
recipients, err := s.notifs.PushRecipientsForKind(ctx, NotifDiscipleJoined, actor.ID)
|
||||
if err != nil {
|
||||
s.logger.Warn("disciple-joined broadcast: push recipient lookup failed", zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
if len(recipients) == 0 || s.push == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
name := actorDisplay(actor)
|
||||
payload := PushPayload{
|
||||
Title: "A direct disciple has joined",
|
||||
Body: name + " — an initiated disciple of Śrīla Prabhupāda — is now on Prabhupāda Says.",
|
||||
URL: s.frontendURL + "/u/" + actor.Handle,
|
||||
Icon: s.iconURL(),
|
||||
Tag: NotifDiscipleJoined,
|
||||
}
|
||||
|
||||
sent := 0
|
||||
for _, uid := range recipients {
|
||||
// Honour cancellation so a shutdown (or the caller's timeout) stops
|
||||
// the fan-out cleanly rather than running to completion detached.
|
||||
if ctx.Err() != nil {
|
||||
s.logger.Warn("disciple-joined broadcast: push fan-out cut short",
|
||||
zap.Int("sent", sent), zap.Int("total", len(recipients)), zap.Error(ctx.Err()))
|
||||
return nil
|
||||
}
|
||||
if s.rt != nil {
|
||||
if err := s.rt.Trigger(ctx, "notifications."+uid, "notification", map[string]any{
|
||||
"kind": NotifDiscipleJoined,
|
||||
"actor_name": name,
|
||||
"actor_handle": actor.Handle,
|
||||
}); err != nil {
|
||||
s.logger.Debug("disciple-joined realtime publish failed",
|
||||
zap.String("recipient", uid), zap.Error(err))
|
||||
}
|
||||
}
|
||||
s.push.SendToUser(ctx, uid, payload)
|
||||
sent++
|
||||
time.Sleep(pushBroadcastDelay)
|
||||
}
|
||||
s.logger.Info("disciple-joined broadcast: push fan-out complete",
|
||||
zap.String("actor", actor.Handle), zap.Int("recipients", sent))
|
||||
return nil
|
||||
}
|
||||
|
||||
// iconURL is the absolute URL of the notification icon (the app's square
|
||||
// icon), so it renders regardless of the client's base path.
|
||||
func (s *NotificationService) iconURL() string {
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
Reference in New Issue
Block a user