Files
spsays-bff/internal/repository/staff_repo.go
T
2026-10-15 19:43:17 +05:30

242 lines
7.8 KiB
Go

package repository
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
var (
ErrNotStaff = errors.New("not a staff member")
ErrRoleNotFound = errors.New("role not found")
ErrRoleKeyTaken = errors.New("a role with that name already exists")
ErrRoleInUse = errors.New("role is still assigned to staff")
)
// SuperadminKey is the seeded role that holds the wildcard.
const SuperadminKey = "superadmin"
type StaffRepo struct{ pg *Postgres }
func NewStaffRepo(pg *Postgres) *StaffRepo { return &StaffRepo{pg: pg} }
// Access loads the caller's role and permissions. ErrNotStaff when the account
// holds no admin role at all.
func (r *StaffRepo) Access(ctx context.Context, userID string) (*model.StaffAccess, error) {
var a model.StaffAccess
err := r.pg.Pool.QueryRow(ctx, `
SELECT ro.key, ro.name, ro.permissions, u.admin_blocked_at IS NOT NULL
FROM users u
JOIN admin_roles ro ON ro.id = u.admin_role_id
WHERE u.id = $1 AND u.banned_at IS NULL`, userID,
).Scan(&a.RoleKey, &a.RoleName, &a.Permissions, &a.Blocked)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotStaff
}
if err != nil {
return nil, fmt.Errorf("staff access: %w", err)
}
return &a, nil
}
const staffSelect = `
SELECT u.id, u.handle, u.name, u.email, u.avatar_url,
ro.id, ro.key, ro.name, u.admin_blocked_at
FROM users u
JOIN admin_roles ro ON ro.id = u.admin_role_id`
func scanStaff(row pgx.Row, m *model.StaffMember) error {
return row.Scan(&m.UserID, &m.Handle, &m.Name, &m.Email, &m.AvatarURL,
&m.RoleID, &m.RoleKey, &m.RoleName, &m.BlockedAt)
}
// ListStaff returns every account holding a role, superadmins first.
func (r *StaffRepo) ListStaff(ctx context.Context) ([]model.StaffMember, error) {
rows, err := r.pg.Pool.Query(ctx, staffSelect+`
ORDER BY (ro.key = 'superadmin') DESC, ro.name, u.name`)
if err != nil {
return nil, fmt.Errorf("list staff: %w", err)
}
defer rows.Close()
out := []model.StaffMember{}
for rows.Next() {
var m model.StaffMember
if err := scanStaff(rows, &m); err != nil {
return nil, fmt.Errorf("scan staff: %w", err)
}
out = append(out, m)
}
return out, rows.Err()
}
// StaffMember loads one staff member. ErrNotStaff if they hold no role.
func (r *StaffRepo) StaffMember(ctx context.Context, userID string) (*model.StaffMember, error) {
var m model.StaffMember
err := scanStaff(r.pg.Pool.QueryRow(ctx, staffSelect+` WHERE u.id = $1`, userID), &m)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotStaff
}
if err != nil {
return nil, fmt.Errorf("staff member: %w", err)
}
return &m, nil
}
// SetRole grants or changes a role. A nil roleID revokes staff access and
// clears any block, so a later re-grant starts clean.
func (r *StaffRepo) SetRole(ctx context.Context, userID string, roleID *string) error {
tag, err := r.pg.Pool.Exec(ctx, `
UPDATE users
SET admin_role_id = $2,
admin_blocked_at = CASE WHEN $2::uuid IS NULL THEN NULL ELSE admin_blocked_at END,
updated_at = now()
WHERE id = $1`, userID, roleID)
if err != nil {
return fmt.Errorf("set staff role: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrUserGone
}
return nil
}
// SetBlocked suspends or restores a staff member's panel access.
func (r *StaffRepo) SetBlocked(ctx context.Context, userID string, blocked bool) error {
_, err := r.pg.Pool.Exec(ctx, `
UPDATE users
SET admin_blocked_at = CASE WHEN $2 THEN now() ELSE NULL END
WHERE id = $1 AND admin_role_id IS NOT NULL`, userID, blocked)
if err != nil {
return fmt.Errorf("set staff blocked: %w", err)
}
return nil
}
// ActiveSuperadmins counts superadmins who aren't blocked — the floor that
// stops the last one from being demoted, revoked or blocked.
func (r *StaffRepo) ActiveSuperadmins(ctx context.Context) (int, error) {
var n int
err := r.pg.Pool.QueryRow(ctx, `
SELECT count(*) FROM users u
JOIN admin_roles ro ON ro.id = u.admin_role_id
WHERE ro.key = $1 AND u.admin_blocked_at IS NULL AND u.banned_at IS NULL`,
SuperadminKey).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count superadmins: %w", err)
}
return n, nil
}
// UserIDByHandle resolves a handle for granting a role.
func (r *StaffRepo) UserIDByHandle(ctx context.Context, handle string) (string, error) {
var id string
err := r.pg.Pool.QueryRow(ctx,
`SELECT id FROM users WHERE handle = $1 AND banned_at IS NULL`, handle).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrUserGone
}
if err != nil {
return "", fmt.Errorf("resolve handle: %w", err)
}
return id, nil
}
// ── Roles ─────────────────────────────────────────────────────────────────
func (r *StaffRepo) ListRoles(ctx context.Context) ([]model.AdminRole, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT ro.id, ro.key, ro.name, ro.description, ro.is_system,
ro.permissions, ro.created_at,
(SELECT count(*) FROM users u WHERE u.admin_role_id = ro.id)
FROM admin_roles ro
ORDER BY ro.is_system DESC,
array_position(ARRAY['superadmin','admin','moderator'], ro.key::text),
ro.name`)
if err != nil {
return nil, fmt.Errorf("list roles: %w", err)
}
defer rows.Close()
out := []model.AdminRole{}
for rows.Next() {
var ro model.AdminRole
if err := rows.Scan(&ro.ID, &ro.Key, &ro.Name, &ro.Description,
&ro.IsSystem, &ro.Permissions, &ro.CreatedAt, &ro.MemberCount); err != nil {
return nil, fmt.Errorf("scan role: %w", err)
}
out = append(out, ro)
}
return out, rows.Err()
}
func (r *StaffRepo) Role(ctx context.Context, id string) (*model.AdminRole, error) {
var ro model.AdminRole
err := r.pg.Pool.QueryRow(ctx, `
SELECT id, key, name, description, is_system, permissions, created_at
FROM admin_roles WHERE id = $1`, id,
).Scan(&ro.ID, &ro.Key, &ro.Name, &ro.Description, &ro.IsSystem, &ro.Permissions, &ro.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrRoleNotFound
}
if err != nil {
return nil, fmt.Errorf("load role: %w", err)
}
return &ro, nil
}
func (r *StaffRepo) CreateRole(ctx context.Context, key, name, description string, perms []string) (*model.AdminRole, error) {
var ro model.AdminRole
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO admin_roles (key, name, description, permissions)
VALUES ($1, $2, $3, $4)
RETURNING id, key, name, description, is_system, permissions, created_at`,
key, name, description, perms,
).Scan(&ro.ID, &ro.Key, &ro.Name, &ro.Description, &ro.IsSystem, &ro.Permissions, &ro.CreatedAt)
if err != nil {
if isUniqueViolation(err, "admin_roles_key_key") {
return nil, ErrRoleKeyTaken
}
return nil, fmt.Errorf("create role: %w", err)
}
return &ro, nil
}
// UpdateRole edits a CUSTOM role; system roles are filtered out in SQL so a
// bug higher up still can't rewrite superadmin.
func (r *StaffRepo) UpdateRole(ctx context.Context, id, name, description string, perms []string) error {
tag, err := r.pg.Pool.Exec(ctx, `
UPDATE admin_roles
SET name = $2, description = $3, permissions = $4, updated_at = now()
WHERE id = $1 AND NOT is_system`, id, name, description, perms)
if err != nil {
return fmt.Errorf("update role: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrRoleNotFound
}
return nil
}
// DeleteRole removes an unused CUSTOM role.
func (r *StaffRepo) DeleteRole(ctx context.Context, id string) error {
var members int
if err := r.pg.Pool.QueryRow(ctx,
`SELECT count(*) FROM users WHERE admin_role_id = $1`, id).Scan(&members); err != nil {
return fmt.Errorf("count role members: %w", err)
}
if members > 0 {
return ErrRoleInUse
}
tag, err := r.pg.Pool.Exec(ctx,
`DELETE FROM admin_roles WHERE id = $1 AND NOT is_system`, id)
if err != nil {
return fmt.Errorf("delete role: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrRoleNotFound
}
return nil
}