# app/models/user.rb class User < ApplicationRecord devise :database_authenticatable, :recoverable, :rememberable, :validatable # Perfil de acesso: nulo é válido (motorista não usa; e quem ainda não foi # migrado cai no padrão do papel — ver #permissoes_efetivas). belongs_to :perfil_acesso, optional: true, inverse_of: :users # ── Roles ────────────────────────────────────────────────── # externo: acesso somente-leitura ao dashboard (ex.: gerências de outras áreas # que só precisam visualizar os indicadores). Login normal por e-mail/senha. enum role: { admin: 0, gerente: 1, operador: 2, motorista: 3, externo: 4 } ROLES_LABEL = { 'admin' => 'Administrador', 'gerente' => 'Gerente', 'operador' => 'Operador', 'motorista' => 'Motorista', 'externo' => 'Externo (só dashboard)' }.freeze # ── Validações ────────────────────────────────────────────── validates :nome, presence: true, length: { minimum: 3 } validates :role, presence: true validates :pin_code, length: { is: 4 }, numericality: { only_integer: true }, allow_nil: true, if: :motorista? validate :pin_unico_para_motoristas, if: :motorista? # E-mail em branco vira NULL: evita colisão no índice único quando vários # motoristas são criados sem e-mail (no Postgres NULLs são distintos entre si). before_validation :normalizar_email_em_branco # Motoristas podem não ter e-mail (login via PIN) def email_required? !motorista? end def password_required? !motorista? ? super : false end # ── Escopos ───────────────────────────────────────────────── scope :ativos, -> { where(ativo: true) } scope :por_nome, -> { order(:nome) } scope :nao_motoristas, -> { where.not(role: :motorista) } # ── Helpers ───────────────────────────────────────────────── def nome_display nome.presence || email end def role_label ROLES_LABEL[role] || role.humanize end # Conta desativada não entra — nem por e-mail/senha. # # Só o login por PIN checava `ativo?` (Users::SessionsController), então # desativar um gerente não impedia nada, apesar do formulário prometer # "usuários inativos não conseguem fazer login". Devise chama este método em # todo login e em toda request de sessão existente, então desativar alguém já # derruba a sessão aberta dele. def active_for_authentication? super && ativo? end def inactive_message ativo? ? super : :conta_desativada end # ── Permissões ────────────────────────────────────────────── # O que o usuário pode fazer sai do PERFIL DE ACESSO (PerfilAcesso), que o ADM # monta na tela. Enquanto ele não tem perfil, vale o padrão do papel legado # (Permissao::PADRAO_POR_ROLE) — é o que mantém o sistema funcionando durante a # migração e o que faz um usuário recém-criado não ficar sem nada. def pode?(chave) permissoes_efetivas.include?(chave.to_s) end # Set (não Array): `pode?` é chamado dezenas de vezes por render de tela — uma # vez por item de menu e por botão. # # ⚠️ A primeira linha existe para NÃO tocar o banco quando não há perfil: os # specs de policy usam `build(:admin)` (objeto sem id), e `perfil_acesso` # dispararia consulta com id nulo. def permissoes_efetivas @permissoes_efetivas ||= if perfil_acesso_id.nil? Set.new(Permissao.do_role(role)) else Set.new(perfil_acesso&.permissoes_validas || Permissao.do_role(role)) end end # A coluna nasce numa migration própria. Se o código subir antes de `db:migrate` # rodar, `perfil_acesso_id` não existe e TODA tela quebraria por NoMethodError — # aqui o sistema simplesmente continua no padrão do papel até a coluna chegar. # Pode sair depois que a migration estiver aplicada em produção. def perfil_acesso_id return nil unless self.class.column_names.include?('perfil_acesso_id') super end # Trocar o perfil no meio da request (tela de usuários) tem que refletir. def recarregar_permissoes! @permissoes_efetivas = nil self end # Primeira tela que este usuário pode abrir, na ordem em que o menu as mostra. # # Existe para não haver LOOP: a raiz do app é o dashboard e o tratamento de # "acesso negado" redireciona para a raiz — um perfil sem `dashboard.ver` # entraria em nega → raiz → nega para sempre. `nil` significa "não pode abrir # nada", e quem chama mostra a tela de sem-acesso. HOME_POR_PERMISSAO = [ ['dashboard.ver', :dashboard_path], ['dashboard.operacoes', :operacoes_dashboard_path], ['consolidacao.ver', :consolidacoes_path], ['admin.usuarios', :admin_usuarios_path], ['admin.perfis', :admin_perfis_path], ['admin.configuracoes', :admin_configuracoes_path], ['admin.romaneio', :admin_romaneios_path], ['notificacao.contatos', :admin_contatos_path], ['notificacao.eventos', :admin_eventos_path], ['notificacao.envios', :admin_envios_path] ].freeze def home_rota return :motorista_dashboard_path if motorista? HOME_POR_PERMISSAO.find { |chave, _| pode?(chave) }&.last end # Mantidos porque a navbar e vários controllers já os chamam — agora são só # apelidos das chaves correspondentes. def pode_ver_config? pode?('admin.configuracoes') end def pode_consolidar? pode?('consolidacao.ver') end # ── Trava do último administrador ─────────────────────────── # Quem, ATIVO, ainda consegue reabrir a porta do sistema (mexer em perfis e em # usuários). Sem isto, o ADM consegue se trancar para fora: a tela que controla # acesso é justamente a que ele perderia, e a volta só existiria por console. # # `ignorando` simula "e se esta pessoa for desativada, excluída ou trocada de # perfil?" — passe o usuário que está sendo alterado. def self.administradores_de_acesso(ignorando: nil) ativos.includes(:perfil_acesso) .reject { |u| ignorando && u.id == ignorando.id } .select { |u| u.pode?('admin.perfis') && u.pode?('admin.usuarios_gerenciar') } end def self.ultimo_administrador?(usuario) return false unless usuario.pode?('admin.perfis') && usuario.pode?('admin.usuarios_gerenciar') administradores_de_acesso(ignorando: usuario).empty? end # Token de login rápido via QR Code (extrato) — Fase 7/8 def regenerar_login_token! update!(login_token: SecureRandom.urlsafe_base64(24)) end private def normalizar_email_em_branco self.email = nil if email.blank? end def pin_unico_para_motoristas return if pin_code.blank? conflito = User.motorista.where(pin_code: pin_code).where.not(id: id) errors.add(:pin_code, 'já está em uso por outro motorista') if conflito.exists? end end