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 }