package admin import ( "context" "encoding/json" "errors" "unicode/utf8" "github.com/google/uuid" "github.com/yuxingu/digital-psychology/apps/api/internal/repository" ) const ( RoleSuper = "super" RoleOps = "ops" pushTitleMaxRunes = 128 ) var ( ErrForbidden = errString("forbidden") ErrInvalidRole = errString("invalid role") ErrPushNotFound = errString("push job not found") ErrInvalidPush = errString("invalid push job") ErrAdminNotFound = errString("admin not found") ErrLastSuper = errString("cannot demote last super") ) // RequireSuper returns ErrForbidden unless admin role is super. func (s *Service) RequireSuper(ctx context.Context, adminID uuid.UUID) error { acc, err := s.Repo.FindAccountByID(ctx, adminID) if err != nil || acc == nil { return ErrAdminNotFound } if acc.Role != RoleSuper { return ErrForbidden } return nil } // BanUser transitions user status to banned via the lifecycle state machine. func (s *Service) BanUser(ctx context.Context, adminID, userID uuid.UUID) error { return s.TransitionUserStatus(ctx, adminID, userID, "banned", "admin ban") } // UnbanUser transitions user status to active via the lifecycle state machine. func (s *Service) UnbanUser(ctx context.Context, adminID, userID uuid.UUID) error { return s.TransitionUserStatus(ctx, adminID, userID, "active", "admin unban") } // ListAdmins returns admin accounts (super only caller). func (s *Service) ListAdmins(ctx context.Context, actorID uuid.UUID) ([]repository.AdminListItem, error) { if err := s.RequireSuper(ctx, actorID); err != nil { return nil, err } return s.Repo.ListAdmins(ctx) } // UpdateAdminRole changes an admin role (super only). func (s *Service) UpdateAdminRole(ctx context.Context, actorID, targetID uuid.UUID, role string) error { if err := s.RequireSuper(ctx, actorID); err != nil { return err } if role != RoleSuper && role != RoleOps { return ErrInvalidRole } target, err := s.Repo.FindAccountByID(ctx, targetID) if err != nil || target == nil { return ErrAdminNotFound } if target.Role == RoleSuper && role != RoleSuper { n, err := s.Repo.CountAdminsByRole(ctx, RoleSuper) if err != nil { return err } if n <= 1 { return ErrLastSuper } } meta, _ := json.Marshal(map[string]any{"role": role}) err = s.Repo.UpdateAdminRoleWithAudit(ctx, actorID, targetID, role, meta) if errors.Is(err, repository.ErrAdminNotFound) { return ErrAdminNotFound } return err } // ListPushJobs lists push stubs. func (s *Service) ListPushJobs(ctx context.Context, limit, offset int) ([]repository.PushJob, error) { return s.Repo.ListPushJobs(ctx, limit, offset) } // CreatePushJob creates a draft push stub. func (s *Service) CreatePushJob(ctx context.Context, adminID uuid.UUID, title, body, audience string) (*repository.PushJob, error) { if err := validatePushTitle(title); err != nil { return nil, err } meta, _ := json.Marshal(map[string]any{"title": title}) return s.Repo.CreatePushJobWithAudit(ctx, adminID, title, body, audience, meta) } // UpdatePushJob updates a push stub. func (s *Service) UpdatePushJob( ctx context.Context, adminID, jobID uuid.UUID, title, body, status *string, ) (*repository.PushJob, error) { if status != nil && *status != "draft" && *status != "cancelled" { return nil, ErrInvalidPush } if title != nil { if err := validatePushTitle(*title); err != nil { return nil, err } } meta, _ := json.Marshal(map[string]any{}) job, err := s.Repo.UpdatePushJobWithAudit(ctx, adminID, jobID, title, body, status, meta) if errors.Is(err, repository.ErrPushJobNotFound) { return nil, ErrPushNotFound } return job, err } func validatePushTitle(title string) error { if title == "" || utf8.RuneCountInString(title) > pushTitleMaxRunes { return ErrInvalidPush } return nil }