feat: disciple link
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user