242 lines
7.8 KiB
Go
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
|
|
}
|