474 lines
18 KiB
Go
474 lines
18 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
|
)
|
|
|
|
// AdminRepo holds the read-mostly queries that back the admin panel. Every
|
|
// list method returns its page plus the unfiltered total (via COUNT(*) OVER())
|
|
// so the UI can paginate without a second round-trip. All queries are new —
|
|
// they read existing tables but don't alter any existing repo behaviour.
|
|
type AdminRepo struct{ pg *Postgres }
|
|
|
|
func NewAdminRepo(pg *Postgres) *AdminRepo { return &AdminRepo{pg: pg} }
|
|
|
|
// clampLimit normalises pagination so a bad client can't ask for everything.
|
|
func clampLimit(limit int) int {
|
|
if limit <= 0 {
|
|
return 25
|
|
}
|
|
if limit > 100 {
|
|
return 100
|
|
}
|
|
return limit
|
|
}
|
|
|
|
// ListUsers returns a page of users filtered by an optional query (matched
|
|
// against name, handle, or email), newest first, with the total count.
|
|
func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int) ([]model.AdminUser, int, error) {
|
|
limit = clampLimit(limit)
|
|
rows, err := r.pg.Pool.Query(ctx, `
|
|
SELECT u.id, u.email, u.handle, u.name, u.avatar_url, u.role, u.plan,
|
|
u.email_verified_at, u.banned_at,
|
|
(SELECT COUNT(*) FROM posts p WHERE p.user_id = u.id AND p.deleted_at IS NULL) AS post_count,
|
|
u.created_at,
|
|
(u.password_hash IS NOT NULL) AS has_password,
|
|
(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||'%'
|
|
OR COALESCE(u.email, '') ILIKE '%'||$1||'%')
|
|
ORDER BY u.created_at DESC
|
|
LIMIT $2 OFFSET $3`, q, limit, offset)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("admin list users: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []model.AdminUser{}
|
|
total := 0
|
|
for rows.Next() {
|
|
var u model.AdminUser
|
|
var bookmarkLimit, publicCollLimit, privateCollLimit *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,
|
|
&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.
|
|
lim := model.ResolveLimits(u.Plan, bookmarkLimit, publicCollLimit, privateCollLimit)
|
|
u.BookmarkLimit = lim.Bookmarks
|
|
u.PublicCollectionLimit = lim.PublicCollections
|
|
u.PrivateCollectionLimit = lim.PrivateCollections
|
|
out = append(out, u)
|
|
}
|
|
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) {
|
|
tag, err := r.pg.Pool.Exec(ctx, `
|
|
UPDATE users
|
|
SET bookmark_limit = $2,
|
|
public_collection_limit = $3,
|
|
private_collection_limit = $4,
|
|
updated_at = now()
|
|
WHERE id = $1`, userID, bookmark, publicCollection, privateCollection)
|
|
if err != nil {
|
|
return false, fmt.Errorf("admin set user limits: %w", err)
|
|
}
|
|
return tag.RowsAffected() == 1, nil
|
|
}
|
|
|
|
// UserExists reports whether a user id is real (used before sending a
|
|
// notification to it).
|
|
func (r *AdminRepo) UserExists(ctx context.Context, id string) (bool, error) {
|
|
var exists bool
|
|
err := r.pg.Pool.QueryRow(ctx,
|
|
`SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)`, id).Scan(&exists)
|
|
if err != nil {
|
|
return false, fmt.Errorf("admin user exists: %w", err)
|
|
}
|
|
return exists, nil
|
|
}
|
|
|
|
// ListPosts returns a page of posts filtered by an optional query (caption,
|
|
// source title, or author handle), newest first, with the total count.
|
|
func (r *AdminRepo) ListPosts(ctx context.Context, q string, limit, offset int) ([]model.AdminPost, int, error) {
|
|
limit = clampLimit(limit)
|
|
rows, err := r.pg.Pool.Query(ctx, `
|
|
SELECT p.id, p.user_id, p.caption, p.source_title, p.source_url, p.is_hidden, p.deleted_at, p.created_at,
|
|
u.name, u.handle, p.likes_count,
|
|
(SELECT COUNT(*) FROM post_comments c WHERE c.post_id = p.id AND c.deleted_at IS NULL) AS comments_count,
|
|
COUNT(*) OVER() AS total
|
|
FROM posts p
|
|
JOIN users u ON u.id = p.user_id
|
|
WHERE ($1 = '' OR p.caption ILIKE '%'||$1||'%' OR p.source_title ILIKE '%'||$1||'%'
|
|
OR u.handle ILIKE '%'||$1||'%')
|
|
ORDER BY p.created_at DESC
|
|
LIMIT $2 OFFSET $3`, q, limit, offset)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("admin list posts: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []model.AdminPost{}
|
|
total := 0
|
|
for rows.Next() {
|
|
var p model.AdminPost
|
|
if err := rows.Scan(&p.ID, &p.UserID, &p.Caption, &p.SourceTitle, &p.SourceURL,
|
|
&p.IsHidden, &p.DeletedAt, &p.CreatedAt, &p.AuthorName, &p.AuthorHandle,
|
|
&p.LikesCount, &p.CommentsCount, &total); err != nil {
|
|
return nil, 0, fmt.Errorf("scan admin post: %w", err)
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
return out, total, rows.Err()
|
|
}
|
|
|
|
// ListCollections returns a page of collections filtered by an optional query
|
|
// (title, description, or owner handle), newest first, with the total count.
|
|
func (r *AdminRepo) ListCollections(ctx context.Context, q string, limit, offset int) ([]model.AdminCollection, int, error) {
|
|
limit = clampLimit(limit)
|
|
rows, err := r.pg.Pool.Query(ctx, `
|
|
SELECT c.id, c.user_id, c.title, c.description, c.is_public, c.created_at,
|
|
(SELECT COUNT(*) FROM collection_items ci WHERE ci.collection_id = c.id) AS item_count,
|
|
u.name, u.handle,
|
|
COUNT(*) OVER() AS total
|
|
FROM collections c
|
|
JOIN users u ON u.id = c.user_id
|
|
WHERE ($1 = '' OR c.title ILIKE '%'||$1||'%' OR COALESCE(c.description, '') ILIKE '%'||$1||'%'
|
|
OR u.handle ILIKE '%'||$1||'%')
|
|
ORDER BY c.created_at DESC
|
|
LIMIT $2 OFFSET $3`, q, limit, offset)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("admin list collections: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []model.AdminCollection{}
|
|
total := 0
|
|
for rows.Next() {
|
|
var c model.AdminCollection
|
|
if err := rows.Scan(&c.ID, &c.UserID, &c.Title, &c.Description, &c.IsPublic,
|
|
&c.CreatedAt, &c.ItemCount, &c.OwnerName, &c.OwnerHandle, &total); err != nil {
|
|
return nil, 0, fmt.Errorf("scan admin collection: %w", err)
|
|
}
|
|
out = append(out, c)
|
|
}
|
|
return out, total, rows.Err()
|
|
}
|
|
|
|
// ListFeedback returns a page of feedback. status is "all" | "open" |
|
|
// "resolved". Newest first, with the total count for the chosen filter.
|
|
func (r *AdminRepo) ListFeedback(ctx context.Context, status string, limit, offset int) ([]model.AdminFeedback, int, error) {
|
|
limit = clampLimit(limit)
|
|
if status != "open" && status != "resolved" {
|
|
status = "all"
|
|
}
|
|
rows, err := r.pg.Pool.Query(ctx, `
|
|
SELECT f.id, f.category, f.message, f.contact_email, f.created_at,
|
|
f.acknowledged_at, f.resolved_at, f.replied_at, f.reply_message,
|
|
u.handle, u.name, u.email,
|
|
COUNT(*) OVER() AS total
|
|
FROM feedback f
|
|
LEFT JOIN users u ON u.id = f.user_id
|
|
WHERE ($1 = 'all'
|
|
OR ($1 = 'open' AND f.resolved_at IS NULL)
|
|
OR ($1 = 'resolved' AND f.resolved_at IS NOT NULL))
|
|
ORDER BY f.created_at DESC
|
|
LIMIT $2 OFFSET $3`, status, limit, offset)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("admin list feedback: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []model.AdminFeedback{}
|
|
total := 0
|
|
for rows.Next() {
|
|
var f model.AdminFeedback
|
|
if err := rows.Scan(&f.ID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt,
|
|
&f.AcknowledgedAt, &f.ResolvedAt, &f.RepliedAt, &f.ReplyMessage,
|
|
&f.AuthorHandle, &f.AuthorName, &f.AuthorEmail, &total); err != nil {
|
|
return nil, 0, fmt.Errorf("scan admin feedback: %w", err)
|
|
}
|
|
out = append(out, f)
|
|
}
|
|
return out, total, rows.Err()
|
|
}
|
|
|
|
// SaveReply stores an admin reply on a feedback row and stamps replied_at. It
|
|
// does NOT change resolved_at — replying and resolving are independent actions
|
|
// (resolve via ResolveFeedback). It returns the recipient's email (the
|
|
// submitter's account email, else the anonymous contact_email — may be nil if
|
|
// neither is known) and name so the caller can send the reply email. ok is
|
|
// false when the id doesn't exist.
|
|
func (r *AdminRepo) SaveReply(ctx context.Context, id, message string) (email *string, name string, ok bool, err error) {
|
|
err = r.pg.Pool.QueryRow(ctx, `
|
|
UPDATE feedback f
|
|
SET reply_message = $2,
|
|
replied_at = now()
|
|
WHERE f.id = $1
|
|
RETURNING COALESCE((SELECT email FROM users WHERE id = f.user_id), f.contact_email),
|
|
COALESCE((SELECT name FROM users WHERE id = f.user_id), '')`,
|
|
id, message,
|
|
).Scan(&email, &name)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, "", false, nil
|
|
}
|
|
if err != nil {
|
|
return nil, "", false, fmt.Errorf("admin save feedback reply: %w", err)
|
|
}
|
|
return email, name, true, nil
|
|
}
|
|
|
|
// ListReports returns a page of reports of one type ("post" | "comment" |
|
|
// "user"), unresolved first then newest, filtered by an optional query, with
|
|
// the total count.
|
|
func (r *AdminRepo) ListReports(ctx context.Context, reportType, q string, limit, offset int) ([]model.AdminReport, int, error) {
|
|
limit = clampLimit(limit)
|
|
var sql string
|
|
switch reportType {
|
|
case "post":
|
|
sql = `
|
|
SELECT r.id, r.category, r.reason, r.created_at, r.resolved_at,
|
|
ru.handle, ru.name,
|
|
r.post_id, p.caption, pu.handle,
|
|
r.resolution_note, COALESCE(p.deleted_at IS NOT NULL, false),
|
|
COUNT(*) OVER() AS total
|
|
FROM post_reports r
|
|
LEFT JOIN users ru ON ru.id = r.reporter_id
|
|
LEFT JOIN posts p ON p.id = r.post_id
|
|
LEFT JOIN users pu ON pu.id = p.user_id
|
|
WHERE ($1 = '' OR COALESCE(p.caption, '') ILIKE '%'||$1||'%'
|
|
OR COALESCE(ru.handle, '') ILIKE '%'||$1||'%'
|
|
OR COALESCE(pu.handle, '') ILIKE '%'||$1||'%')
|
|
ORDER BY (r.resolved_at IS NOT NULL), r.created_at DESC
|
|
LIMIT $2 OFFSET $3`
|
|
case "comment":
|
|
sql = `
|
|
SELECT r.id, r.category, r.reason, r.created_at, r.resolved_at,
|
|
ru.handle, ru.name,
|
|
r.comment_id, c.body, cu.handle,
|
|
r.resolution_note, COALESCE(c.deleted_at IS NOT NULL, false),
|
|
COUNT(*) OVER() AS total
|
|
FROM comment_reports r
|
|
LEFT JOIN users ru ON ru.id = r.reporter_id
|
|
LEFT JOIN post_comments c ON c.id = r.comment_id
|
|
LEFT JOIN users cu ON cu.id = c.user_id
|
|
WHERE ($1 = '' OR COALESCE(c.body, '') ILIKE '%'||$1||'%'
|
|
OR COALESCE(ru.handle, '') ILIKE '%'||$1||'%'
|
|
OR COALESCE(cu.handle, '') ILIKE '%'||$1||'%')
|
|
ORDER BY (r.resolved_at IS NOT NULL), r.created_at DESC
|
|
LIMIT $2 OFFSET $3`
|
|
case "user":
|
|
sql = `
|
|
SELECT r.id, r.category, r.reason, r.created_at, r.resolved_at,
|
|
ru.handle, ru.name,
|
|
r.reported_id, tu.handle, tu.handle,
|
|
r.resolution_note, COALESCE(tu.banned_at IS NOT NULL, false),
|
|
COUNT(*) OVER() AS total
|
|
FROM user_reports r
|
|
LEFT JOIN users ru ON ru.id = r.reporter_id
|
|
LEFT JOIN users tu ON tu.id = r.reported_id
|
|
WHERE ($1 = '' OR COALESCE(tu.handle, '') ILIKE '%'||$1||'%'
|
|
OR COALESCE(tu.name, '') ILIKE '%'||$1||'%'
|
|
OR COALESCE(ru.handle, '') ILIKE '%'||$1||'%')
|
|
ORDER BY (r.resolved_at IS NOT NULL), r.created_at DESC
|
|
LIMIT $2 OFFSET $3`
|
|
default:
|
|
return nil, 0, fmt.Errorf("unknown report type %q", reportType)
|
|
}
|
|
|
|
rows, err := r.pg.Pool.Query(ctx, sql, q, limit, offset)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("admin list %s reports: %w", reportType, err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []model.AdminReport{}
|
|
total := 0
|
|
for rows.Next() {
|
|
rep := model.AdminReport{Type: reportType}
|
|
if err := rows.Scan(&rep.ID, &rep.Category, &rep.Reason, &rep.CreatedAt, &rep.ResolvedAt,
|
|
&rep.ReporterHandle, &rep.ReporterName,
|
|
&rep.TargetID, &rep.TargetText, &rep.TargetAuthorHandle,
|
|
&rep.ResolutionNote, &rep.TargetRemoved, &total); err != nil {
|
|
return nil, 0, fmt.Errorf("scan admin report: %w", err)
|
|
}
|
|
out = append(out, rep)
|
|
}
|
|
return out, total, rows.Err()
|
|
}
|
|
|
|
// ResolveReport stamps resolved_at=now() and stores the admin's note on a
|
|
// still-open report of the given type. Idempotent and one-way (the WHERE
|
|
// guard). Returns true when this call is the one that resolved it.
|
|
func (r *AdminRepo) ResolveReport(ctx context.Context, reportType, id, note string) (bool, error) {
|
|
var table string
|
|
switch reportType {
|
|
case "post":
|
|
table = "post_reports"
|
|
case "comment":
|
|
table = "comment_reports"
|
|
case "user":
|
|
table = "user_reports"
|
|
default:
|
|
return false, fmt.Errorf("unknown report type %q", reportType)
|
|
}
|
|
// table is from a fixed whitelist above — safe to interpolate.
|
|
tag, err := r.pg.Pool.Exec(ctx,
|
|
fmt.Sprintf(`UPDATE %s SET resolved_at = now(), resolution_note = $2 WHERE id = $1 AND resolved_at IS NULL`, table),
|
|
id, note,
|
|
)
|
|
if err != nil {
|
|
return false, fmt.Errorf("admin resolve %s report: %w", reportType, err)
|
|
}
|
|
return tag.RowsAffected() == 1, nil
|
|
}
|
|
|
|
// ─── Moderation actions ──────────────────────────────────────────────────────
|
|
|
|
// SetPostHidden hides/unhides any post (admin override — not owner-scoped).
|
|
// Returns false if the id doesn't exist.
|
|
func (r *AdminRepo) SetPostHidden(ctx context.Context, postID string, hidden bool) (bool, error) {
|
|
tag, err := r.pg.Pool.Exec(ctx,
|
|
`UPDATE posts SET is_hidden = $2, updated_at = now() WHERE id = $1`, postID, hidden)
|
|
if err != nil {
|
|
return false, fmt.Errorf("admin set post hidden: %w", err)
|
|
}
|
|
return tag.RowsAffected() == 1, nil
|
|
}
|
|
|
|
// SetPostDeleted soft-deletes or restores a post by stamping/clearing
|
|
// deleted_at. A soft-deleted post is filtered out of every read path (feeds,
|
|
// profile, hidden tab, collections, detail) but kept in the table so it can be
|
|
// restored. Returns false if the id doesn't exist.
|
|
func (r *AdminRepo) SetPostDeleted(ctx context.Context, postID string, deleted bool) (bool, error) {
|
|
var sql string
|
|
if deleted {
|
|
sql = `UPDATE posts SET deleted_at = now(), updated_at = now() WHERE id = $1`
|
|
} else {
|
|
sql = `UPDATE posts SET deleted_at = NULL, updated_at = now() WHERE id = $1`
|
|
}
|
|
tag, err := r.pg.Pool.Exec(ctx, sql, postID)
|
|
if err != nil {
|
|
return false, fmt.Errorf("admin set post deleted: %w", err)
|
|
}
|
|
return tag.RowsAffected() == 1, nil
|
|
}
|
|
|
|
// SetCommentDeleted soft-deletes or restores a comment (same semantics as
|
|
// SetPostDeleted). Returns false if the id doesn't exist.
|
|
func (r *AdminRepo) SetCommentDeleted(ctx context.Context, commentID string, deleted bool) (bool, error) {
|
|
var sql string
|
|
if deleted {
|
|
sql = `UPDATE post_comments SET deleted_at = now(), updated_at = now() WHERE id = $1`
|
|
} else {
|
|
sql = `UPDATE post_comments SET deleted_at = NULL, updated_at = now() WHERE id = $1`
|
|
}
|
|
tag, err := r.pg.Pool.Exec(ctx, sql, commentID)
|
|
if err != nil {
|
|
return false, fmt.Errorf("admin set comment deleted: %w", err)
|
|
}
|
|
return tag.RowsAffected() == 1, nil
|
|
}
|
|
|
|
// SetUserBanned suspends/un-suspends an account. Banning also bumps
|
|
// token_version, which invalidates the user's existing JWTs so the lock-out is
|
|
// immediate (RequireAuth additionally rejects banned users). Returns false if
|
|
// the id doesn't exist.
|
|
func (r *AdminRepo) SetUserBanned(ctx context.Context, userID string, banned bool) (bool, error) {
|
|
var sql string
|
|
if banned {
|
|
sql = `UPDATE users SET banned_at = now(), token_version = token_version + 1, updated_at = now() WHERE id = $1`
|
|
} else {
|
|
sql = `UPDATE users SET banned_at = NULL, updated_at = now() WHERE id = $1`
|
|
}
|
|
tag, err := r.pg.Pool.Exec(ctx, sql, userID)
|
|
if err != nil {
|
|
return false, fmt.Errorf("admin set user banned: %w", err)
|
|
}
|
|
return tag.RowsAffected() == 1, nil
|
|
}
|