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

257 lines
7.5 KiB
Go

package service
import (
"context"
"errors"
"regexp"
"strings"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/rbac"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
"go.uber.org/zap"
)
var (
ErrStaffSelf = errors.New("you can't change your own staff access")
ErrLastSuperadmin = errors.New("there must always be at least one active superadmin")
ErrRoleSystem = errors.New("system roles can't be edited or deleted")
ErrRoleBadPermission = errors.New("unknown permission")
ErrRoleName = errors.New("a role needs a name")
ErrRoleNoPermissions = errors.New("a role needs at least one permission")
)
// StaffService manages who can use the admin panel and with what powers.
// Every method here is reachable only with rbac.ManageStaff — superadmins.
type StaffService struct {
staff *repository.StaffRepo
// mail tells newly added staff about their access; nil sends nothing.
mail mailer.Mailer
logger *zap.Logger
adminURL string
}
// NewStaffService wires staff management. adminURL is the panel's address for
// the welcome email; empty falls back to the production panel.
func NewStaffService(staff *repository.StaffRepo, mail mailer.Mailer, logger *zap.Logger, adminURL string) *StaffService {
if adminURL == "" {
adminURL = defaultAdminURL
}
if logger == nil {
logger = zap.NewNop()
}
return &StaffService{staff: staff, mail: mail, logger: logger, adminURL: adminURL}
}
// Me describes the caller, for the panel to decide what to show.
type Me struct {
RoleKey string `json:"role_key"`
RoleName string `json:"role_name"`
Permissions []string `json:"permissions"`
}
func (s *StaffService) List(ctx context.Context) ([]model.StaffMember, error) {
return s.staff.ListStaff(ctx)
}
// isSuperadminRole reports whether a role id is the seeded superadmin.
func (s *StaffService) isSuperadminRole(ctx context.Context, roleID string) (bool, error) {
ro, err := s.staff.Role(ctx, roleID)
if err != nil {
return false, err
}
return ro.Key == repository.SuperadminKey, nil
}
// guardSuperadminLoss refuses a change that would take an ACTIVE superadmin
// out of that state if they're the last one — demoting, revoking or blocking
// them would leave no one able to manage staff, with no way back from the UI.
func (s *StaffService) guardSuperadminLoss(ctx context.Context, target *model.StaffMember) error {
if target.RoleKey != repository.SuperadminKey || target.BlockedAt != nil {
return nil
}
n, err := s.staff.ActiveSuperadmins(ctx)
if err != nil {
return err
}
if n <= 1 {
return ErrLastSuperadmin
}
return nil
}
// Grant gives an account a role, by handle.
func (s *StaffService) Grant(ctx context.Context, actorID, handle, roleID string) (*model.StaffMember, error) {
userID, err := s.staff.UserIDByHandle(ctx, strings.TrimPrefix(strings.TrimSpace(handle), "@"))
if err != nil {
return nil, err
}
if userID == actorID {
return nil, ErrStaffSelf
}
if _, err := s.staff.Role(ctx, roleID); err != nil {
return nil, err
}
// Granting to someone who is already staff is a role change, and must
// pass the same last-superadmin check.
if existing, err := s.staff.StaffMember(ctx, userID); err == nil {
return s.ChangeRole(ctx, actorID, existing.UserID, roleID)
}
if err := s.staff.SetRole(ctx, userID, &roleID); err != nil {
return nil, err
}
m, err := s.staff.StaffMember(ctx, userID)
if err != nil {
return nil, err
}
// Only a NEW staff member is welcomed; role changes above stay quiet.
s.sendStaffWelcome(m)
return m, nil
}
// ChangeRole moves a staff member to another role.
func (s *StaffService) ChangeRole(ctx context.Context, actorID, targetID, roleID string) (*model.StaffMember, error) {
if targetID == actorID {
return nil, ErrStaffSelf
}
target, err := s.staff.StaffMember(ctx, targetID)
if err != nil {
return nil, err
}
toSuper, err := s.isSuperadminRole(ctx, roleID)
if err != nil {
return nil, err
}
if !toSuper {
if err := s.guardSuperadminLoss(ctx, target); err != nil {
return nil, err
}
}
if err := s.staff.SetRole(ctx, targetID, &roleID); err != nil {
return nil, err
}
return s.staff.StaffMember(ctx, targetID)
}
// Revoke removes a staff member's access entirely.
func (s *StaffService) Revoke(ctx context.Context, actorID, targetID string) error {
if targetID == actorID {
return ErrStaffSelf
}
target, err := s.staff.StaffMember(ctx, targetID)
if err != nil {
return err
}
if err := s.guardSuperadminLoss(ctx, target); err != nil {
return err
}
return s.staff.SetRole(ctx, targetID, nil)
}
// SetBlocked suspends or restores a staff member's panel access, keeping their
// role so restoring them is one action rather than a re-grant.
func (s *StaffService) SetBlocked(ctx context.Context, actorID, targetID string, blocked bool) (*model.StaffMember, error) {
if targetID == actorID {
return nil, ErrStaffSelf
}
target, err := s.staff.StaffMember(ctx, targetID)
if err != nil {
return nil, err
}
if blocked {
if err := s.guardSuperadminLoss(ctx, target); err != nil {
return nil, err
}
}
if err := s.staff.SetBlocked(ctx, targetID, blocked); err != nil {
return nil, err
}
return s.staff.StaffMember(ctx, targetID)
}
// ── Roles ─────────────────────────────────────────────────────────────────
func (s *StaffService) Roles(ctx context.Context) ([]model.AdminRole, error) {
return s.staff.ListRoles(ctx)
}
// cleanPermissions validates a custom role's permissions against the catalog.
// The wildcard and admins:manage aren't in the catalog, so they are refused
// here — a custom role can never become a second superadmin.
func cleanPermissions(perms []string) ([]string, error) {
seen := map[string]bool{}
out := []string{}
for _, p := range perms {
p = strings.TrimSpace(p)
if p == "" || seen[p] {
continue
}
if !rbac.Valid(p) {
return nil, ErrRoleBadPermission
}
seen[p] = true
out = append(out, p)
}
if len(out) == 0 {
return nil, ErrRoleNoPermissions
}
return out, nil
}
var nonKey = regexp.MustCompile(`[^a-z0-9]+`)
func roleKeyFor(name string) string {
k := strings.Trim(nonKey.ReplaceAllString(strings.ToLower(name), "_"), "_")
if len(k) > 40 {
k = k[:40]
}
return k
}
func (s *StaffService) CreateRole(ctx context.Context, name, description string, perms []string) (*model.AdminRole, error) {
name = strings.TrimSpace(name)
key := roleKeyFor(name)
if name == "" || key == "" {
return nil, ErrRoleName
}
clean, err := cleanPermissions(perms)
if err != nil {
return nil, err
}
return s.staff.CreateRole(ctx, key, name, strings.TrimSpace(description), clean)
}
func (s *StaffService) UpdateRole(ctx context.Context, id, name, description string, perms []string) (*model.AdminRole, error) {
ro, err := s.staff.Role(ctx, id)
if err != nil {
return nil, err
}
if ro.IsSystem {
return nil, ErrRoleSystem
}
name = strings.TrimSpace(name)
if name == "" {
return nil, ErrRoleName
}
clean, err := cleanPermissions(perms)
if err != nil {
return nil, err
}
if err := s.staff.UpdateRole(ctx, id, name, strings.TrimSpace(description), clean); err != nil {
return nil, err
}
return s.staff.Role(ctx, id)
}
func (s *StaffService) DeleteRole(ctx context.Context, id string) error {
ro, err := s.staff.Role(ctx, id)
if err != nil {
return err
}
if ro.IsSystem {
return ErrRoleSystem
}
return s.staff.DeleteRole(ctx, id)
}