leonardo 1 月之前
父节点
当前提交
ef2c5f3ba0

+ 1 - 1
db/seeds/usuario.js

@@ -7,7 +7,7 @@ export async function seed(knex) {
       Email: "leonardo@star.psi.br",
       Senha: "$2b$08$OckwNAnmdnIyjiDtCRpXN./1h2pphmTFdKpQz.U3ZLHt63Rq7NmHC",
       Status: "1",
-      Nivel: "1",
+      Nivel: "3",
       Setor: "TI"
     }
   ];

+ 42 - 20
scripts/exportarDatasetTreinamento.js

@@ -37,46 +37,60 @@ function systemPrompt(setor) {
   ].join(" ");
 }
 
-// remove telefones do texto (privacidade); preserva números de protocolo/valores curtos
 function scrubTelefones(text) {
   return text
     .replace(/\+?55\s?\(?\d{2}\)?\s?9?\d{4}[-\s]?\d{4}/g, "[telefone]")
     .replace(/\(?\d{2}\)?\s9\d{4}[-\s]?\d{4}/g, "[telefone]");
 }
 
+// um atendente real dominava tanto os exemplos de encaminhamento ("vou transferir pro colega
+// Jonas") que o modelo aprendia esse nome como resposta genérica de "pra quem eu transfiro"
+// (145 ocorrências em 4,3% das amostras — achado no teste comparativo do ciclo 2, 2026-09-19).
+// Remove o nome específico, mantendo a estrutura da frase.
+function scrubNomeColega(text) {
+  return text
+    .replace(/(\bcolegas?\s+)[A-ZÀ-Ú][a-zà-úãõç]+,?\s*/gi, "$1")
+    .replace(/[ \t]{2,}/g, " ");
+}
+
+function ordenarMensagens(mensagens) {
+  return [...mensagens].sort((a, b) => {
+    const ta = a.Timestamp ? new Date(a.Timestamp).getTime() : 0;
+    const tb = b.Timestamp ? new Date(b.Timestamp).getTime() : 0;
+    return ta - tb || (Number(a.Id) || 0) - (Number(b.Id) || 0);
+  });
+}
+
 function mensagemTexto(m) {
   const transcricaoRaw = typeof m.Transcricao === "string" ? m.Transcricao.trim() : "";
   const transcricaoWhisperRaw = typeof m.TranscricaoWhisper === "string" ? m.TranscricaoWhisper.trim() : "";
-  // ifbot cobre só ~22% dos áudios de atendente; o fallback Whisper local cobre a maior
-  // parte do resto (~95% combinado) — sem checar os dois, a maioria dos áudios cai no
-  // placeholder `[mídia: ...]` mesmo já tendo transcrição disponível no banco.
+ 
   const transcricao = TRANSCRICAO_SENTINELS.has(transcricaoRaw)
     ? ""
     : transcricaoRaw || (TRANSCRICAO_SENTINELS.has(transcricaoWhisperRaw) ? "" : transcricaoWhisperRaw);
   let texto;
   if (transcricao) {
-    // áudio transcrito (ifbot ou fallback Whisper local): o Body é só o nome do arquivo
-    texto = `[áudio] ${transcricao}`;
+  
+    texto = m.Resposta === 1 ? transcricao : `[áudio] ${transcricao}`;
   } else if (m.Tipodemidia && m.Tipodemidia !== "text") {
-    // Body de mídia sem transcrição é só o caminho do arquivo (ex.: /data/aud/x.mp3), não texto
-    // real — sempre usa o placeholder aqui, em vez de só quando o stripHtml sair vazio.
+
     texto = `[mídia: ${m.Tipodemidia}]`;
   } else if (m.Resposta === 1) {
     texto = parseAtendenteBody(m.Body).texto;
   } else {
     texto = stripHtml(m.Body);
   }
-  return texto ? scrubTelefones(texto) : null;
+  if (!texto) return null;
+  texto = scrubTelefones(texto);
+  if (m.Resposta === 1) texto = scrubNomeColega(texto);
+  return texto;
 }
 
-// converte a conversa em turnos user/assistant: 0 = cliente (user), 1 = atendente (assistant);
-// mensagens de sistema (9) ficam de fora; consecutivas do mesmo papel são mescladas
+
 export function conversaParaMessages(atendimento, mensagens) {
-  const ordenadas = [...mensagens].sort((a, b) => {
-    const ta = a.Timestamp ? new Date(a.Timestamp).getTime() : 0;
-    const tb = b.Timestamp ? new Date(b.Timestamp).getTime() : 0;
-    return ta - tb || (Number(a.Id) || 0) - (Number(b.Id) || 0);
-  });
+  const ordenadas = ordenarMensagens(mensagens);
+  const primeiraBruta = ordenadas.find((m) => m.Resposta === 0 || m.Resposta === 1);
+  const outbound = primeiraBruta?.Resposta === 1;
 
   const turnos = [];
   for (const m of ordenadas) {
@@ -89,16 +103,23 @@ export function conversaParaMessages(atendimento, mensagens) {
     else turnos.push({ role, content: texto });
   }
 
-  // a amostra deve começar no cliente e terminar na resposta do atendente
   while (turnos.length && turnos[0].role !== "user") turnos.shift();
   while (turnos.length && turnos[turnos.length - 1].role !== "assistant") turnos.pop();
 
-  if (turnos.length < 4) return null; // exige pelo menos 2 trocas reais
+  if (outbound) {
+    // atendimento iniciado pelo atendente (contato proativo): a mensagem de abertura que dá
+    // o contexto real já foi descartada no trim acima, deixando o primeiro par cliente/atendente
+    // órfão — ex.: cliente só responde "bom dia" e o atendente já confirma algo sem relação
+    // visível com a saudação (achado no teste comparativo do ciclo 2, 2026-09-19). Descarta só
+    // esse primeiro par (não o atendimento inteiro), mantendo a conversa real que vier depois.
+    turnos.splice(0, 2);
+  }
+
+  if (turnos.length < 4) return null;
 
   return [{ role: "system", content: systemPrompt(atendimento.Setor) }, ...turnos];
 }
 
-// embaralhamento determinístico para split train/valid reproduzível
 function shuffleDeterministico(arr) {
   let seed = 42;
   const rand = () => {
@@ -136,7 +157,8 @@ async function main() {
       .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
     if (!atendimento) continue;
 
-    const messages = conversaParaMessages(atendimento, atendimento.mensagens ?? []);
+    const mensagens = atendimento.mensagens ?? [];
+    const messages = conversaParaMessages(atendimento, mensagens);
     if (!messages) {
       descartadosCurtos += 1;
       continue;

+ 12 - 207
src/controllers/Auth.Controller.js

@@ -1,133 +1,16 @@
-import bcrypt from "bcryptjs";
-import { AppError } from "../shared/errors/index.js";
-import { Usuario } from "../models/Usuario.model.js";
-import { RefreshToken } from "../models/RefreshToken.model.js";
-import { SolicitacaoResetSenha } from "../models/SolicitacaoResetSenha.model.js";
-import { signAccessToken, generateRefreshToken, hashRefreshToken } from "../services/authTokens.js";
-import { config } from "../config/index.js";
+import * as authService from "../services/authService.js";
 import { formatarUsuario } from "../utils/formatarUsuario.js";
 
-
-const _failedAttempts = new Map();
-const MAX_ATTEMPTS = 5;
-const BLOCK_MS = 15 * 60 * 1000;
-const MAX_TRACKED_KEYS = 10_000;
-const REUSE_GRACE_MS = 60 * 1000;
-
-const _resetAttempts = new Map();
-const RESET_MAX = 5;
-const RESET_WINDOW_MS = 15 * 60 * 1000;
-
-
-const DUMMY_HASH = bcrypt.hashSync("oraculo-dummy-password", 10);
-
-setInterval(() => {
-  const now = Date.now();
-  for (const [key, entry] of _failedAttempts) {
-    const expired = entry.blockedUntil > 0 ? entry.blockedUntil <= now : entry.lastAttempt + BLOCK_MS <= now;
-    if (expired) _failedAttempts.delete(key);
-  }
-  for (const [ip, entry] of _resetAttempts) {
-    if (entry.windowStart + RESET_WINDOW_MS <= now) _resetAttempts.delete(ip);
-  }
-}, 60_000).unref();
-
-
-function attemptKey(req, login) {
-  return `${req.ip ?? "unknown"}|${login}`;
-}
-
-function recordFailedAttempt(key) {
-  const now = Date.now();
-  const entry = _failedAttempts.get(key) ?? { count: 0, blockedUntil: 0, lastAttempt: 0 };
-  entry.count += 1;
-  entry.lastAttempt = now;
-  if (entry.count >= MAX_ATTEMPTS) {
-    entry.blockedUntil = now + BLOCK_MS;
-    entry.count = 0;
-  }
-  
-  _failedAttempts.delete(key);
-  if (_failedAttempts.size >= MAX_TRACKED_KEYS) {
-    _failedAttempts.delete(_failedAttempts.keys().next().value);
-  }
-  _failedAttempts.set(key, entry);
-}
-
-function resetRetryAfterSeconds(ip) {
-  const now = Date.now();
-  const entry = _resetAttempts.get(ip);
-  if (!entry || now - entry.windowStart > RESET_WINDOW_MS) {
-    if (_resetAttempts.size >= MAX_TRACKED_KEYS) {
-      _resetAttempts.delete(_resetAttempts.keys().next().value);
-    }
-    _resetAttempts.set(ip, { count: 1, windowStart: now });
-    return 0;
-  }
-  entry.count += 1;
-  if (entry.count > RESET_MAX) {
-    return Math.ceil((entry.windowStart + RESET_WINDOW_MS - now) / 1000);
-  }
-  return 0;
-}
-
-function blockedSecondsRemaining(key) {
-  const entry = _failedAttempts.get(key);
-  if (!entry) return 0;
-  const remainingMs = entry.blockedUntil - Date.now();
-  if (remainingMs > 0) return Math.ceil(remainingMs / 1000);
-  if (entry.blockedUntil > 0) _failedAttempts.delete(key);
-  return 0;
-}
-
 export const AuthController = {
   Login: async function (req, res, next) {
     try {
       const { login, senha, rememberMe } = req.body;
-
-      const loginInformado = login.trim();
-      const senhaInformada = senha;
-      const chaveTentativa = attemptKey(req, loginInformado);
-
-      const retryAfterSeconds = blockedSecondsRemaining(chaveTentativa);
-      if (retryAfterSeconds > 0) {
-        const err = new AppError("Muitas tentativas incorretas. Tente novamente mais tarde.", 429);
-        err.details = { retryAfterSeconds };
-        throw err;
-      }
-
-      const usuario = await Usuario.query().findOne(
-        loginInformado.includes("@") ? { Email: loginInformado } : { Login: loginInformado }
-      );
-
-      
-      const hashSenha = usuario ? String(usuario.Senha ?? "") : DUMMY_HASH;
-      const passwordIsValid = await bcrypt.compare(senhaInformada, hashSenha);
-      const contaInativa = usuario ? String(usuario.Status) === "0" : false;
-
-      if (!usuario || !passwordIsValid || contaInativa) {
-        recordFailedAttempt(chaveTentativa);
-        throw new AppError("Combinação de usuário e senha inválida!", 401);
-      }
-
-      _failedAttempts.delete(chaveTentativa);
-
-      let accessToken = null;
-      let refreshToken = null;
-
-      if (config.jwt.secret) {
-        accessToken = signAccessToken({ sub: usuario.Id, login: usuario.Login, nivel: usuario.Nivel });
-        refreshToken = generateRefreshToken();
-        const tokenHash = hashRefreshToken(refreshToken);
-        const ttlSeconds = rememberMe ? config.jwt.refreshTtlSeconds : config.jwt.refreshTtlShortSeconds;
-        const expiresAt = new Date(Date.now() + ttlSeconds * 1000);
-        await RefreshToken.query().insert({
-          UsuarioId: usuario.Id,
-          TokenHash: tokenHash,
-          ExpiresAt: expiresAt,
-          RememberMe: Boolean(rememberMe)
-        });
-      }
+      const { usuario, accessToken, refreshToken } = await authService.login({
+        login,
+        senha,
+        rememberMe,
+        ip: req.ip
+      });
 
       return res.status(200).send({
         status: true,
@@ -142,57 +25,10 @@ export const AuthController = {
 
   Refresh: async function (req, res, next) {
     try {
-      const { refreshToken } = req.body;
-      const tokenHash = hashRefreshToken(refreshToken);
-      const stored = await RefreshToken.query().findOne({ TokenHash: tokenHash });
-
-      if (!stored || new Date(stored.ExpiresAt) <= new Date()) {
-        throw new AppError("Refresh token inválido ou expirado!", 401);
-      }
-
-      if (stored.RevokedAt) {
-        
-        const revokedThereMs = Date.now() - new Date(stored.RevokedAt).getTime();
-        if (revokedThereMs > REUSE_GRACE_MS) {
-          await RefreshToken.query()
-            .patch({ RevokedAt: new Date() })
-            .where({ UsuarioId: stored.UsuarioId })
-            .whereNull("RevokedAt");
-        }
-        throw new AppError("Refresh token inválido ou expirado!", 401);
-      }
-
-      const usuario = await Usuario.query().findById(stored.UsuarioId);
-      if (!usuario || String(usuario.Status) === "0") {
-        throw new AppError("Usuario inativo!", 401);
-      }
-
-      const accessToken = signAccessToken({ sub: usuario.Id, login: usuario.Login, nivel: usuario.Nivel });
-
-      const novoRefreshToken = generateRefreshToken();
-      const ttlSeconds = stored.RememberMe ? config.jwt.refreshTtlSeconds : config.jwt.refreshTtlShortSeconds;
-
-      const revogadoNestaRequisicao = await RefreshToken.transaction(async (trx) => {
-        const numUpdated = await RefreshToken.query(trx)
-          .patch({ RevokedAt: new Date() })
-          .where({ Id: stored.Id })
-          .whereNull("RevokedAt");
-        if (numUpdated === 0) return false;
-
-        await RefreshToken.query(trx).insert({
-          UsuarioId: usuario.Id,
-          TokenHash: hashRefreshToken(novoRefreshToken),
-          ExpiresAt: new Date(Date.now() + ttlSeconds * 1000),
-          RememberMe: stored.RememberMe
-        });
-        return true;
+      const { accessToken, refreshToken } = await authService.refresh({
+        refreshToken: req.body.refreshToken
       });
-
-      if (!revogadoNestaRequisicao) {
-        throw new AppError("Refresh token inválido ou expirado!", 401);
-      }
-
-      return res.status(200).send({ status: true, accessToken, refreshToken: novoRefreshToken });
+      return res.status(200).send({ status: true, accessToken, refreshToken });
     } catch (error) {
       return next(error);
     }
@@ -200,31 +36,7 @@ export const AuthController = {
 
   SolicitarResetSenha: async function (req, res, next) {
     try {
-      const loginInformado = req.body.login.trim();
-
-      const retryAfterSeconds = resetRetryAfterSeconds(req.ip ?? "unknown");
-      if (retryAfterSeconds > 0) {
-        const err = new AppError("Muitas solicitações. Tente novamente mais tarde.", 429);
-        err.details = { retryAfterSeconds };
-        throw err;
-      }
-
-      
-      const usuario = await Usuario.query()
-        .where(loginInformado.includes("@") ? { Email: loginInformado } : { Login: loginInformado })
-        .orderBy("Id")
-        .first();
-
-      
-      if (usuario && String(usuario.Status) !== "0") {
-        const pendente = await SolicitacaoResetSenha.query().findOne({
-          UsuarioId: usuario.Id,
-          Status: "pendente"
-        });
-        if (!pendente) {
-          await SolicitacaoResetSenha.query().insert({ UsuarioId: usuario.Id, Status: "pendente" });
-        }
-      }
+      await authService.solicitarResetSenha({ login: req.body.login, ip: req.ip });
 
       return res.status(200).send({
         status: true,
@@ -237,14 +49,7 @@ export const AuthController = {
 
   Logout: async function (req, res, next) {
     try {
-      const { refreshToken } = req.body ?? {};
-      if (refreshToken) {
-        const tokenHash = hashRefreshToken(refreshToken);
-        await RefreshToken.query()
-          .patch({ RevokedAt: new Date() })
-          .where({ TokenHash: tokenHash })
-          .whereNull("RevokedAt");
-      }
+      await authService.logout({ refreshToken: req.body?.refreshToken });
       return res.status(200).send({ status: true, msg: "Logout realizado com sucesso!" });
     } catch (error) {
       return next(error);

+ 11 - 1
src/jobs/cleanupTokens.js

@@ -1,9 +1,19 @@
 import { RefreshToken } from "../models/RefreshToken.model.js";
+import { REUSE_GRACE_MS } from "../services/authService.js";
+
+const REVOKED_CLEANUP_BUFFER_MS = 5 * 60 * 1000;
 
 export async function cleanupExpiredTokens() {
+  const now = new Date();
+  const revokedCutoff = new Date(now.getTime() - REUSE_GRACE_MS - REVOKED_CLEANUP_BUFFER_MS);
+
   await RefreshToken.query()
     .delete()
-    .where("ExpiresAt", "<", new Date());
+    .where((builder) => {
+      builder
+        .where("ExpiresAt", "<", now)
+        .orWhere((b) => b.whereNotNull("RevokedAt").andWhere("RevokedAt", "<", revokedCutoff));
+    });
 }
 
 export function scheduleCleanup(intervalMs = 6 * 60 * 60 * 1000) {

+ 2 - 0
src/middleware/requireAdmin.js

@@ -1,6 +1,8 @@
 import { isAdmin } from "../utils/isAdmin.js";
+import { UnauthorizedError } from "../shared/errors/index.js";
 
 export function requireAdmin(req, res, next) {
+  if (!req.user?.sub) return next(new UnauthorizedError());
   if (!isAdmin(req.user)) {
     return res.status(403).json({
       status: false,

+ 217 - 0
src/services/authService.js

@@ -0,0 +1,217 @@
+import bcrypt from "bcryptjs";
+import { AppError } from "../shared/errors/index.js";
+import { Usuario } from "../models/Usuario.model.js";
+import { RefreshToken } from "../models/RefreshToken.model.js";
+import { SolicitacaoResetSenha } from "../models/SolicitacaoResetSenha.model.js";
+import { signAccessToken, generateRefreshToken, hashRefreshToken } from "./authTokens.js";
+import { config } from "../config/index.js";
+
+const _failedAttempts = new Map();
+const MAX_ATTEMPTS = 5;
+const BLOCK_MS = 15 * 60 * 1000;
+const MAX_TRACKED_KEYS = 10_000;
+export const REUSE_GRACE_MS = 60 * 1000;
+
+const _resetAttempts = new Map();
+const RESET_MAX = 5;
+const RESET_WINDOW_MS = 15 * 60 * 1000;
+
+
+const DUMMY_HASH = bcrypt.hashSync("oraculo-dummy-password", 10);
+
+setInterval(() => {
+  const now = Date.now();
+  for (const [key, entry] of _failedAttempts) {
+    const expired = entry.blockedUntil > 0 ? entry.blockedUntil <= now : entry.lastAttempt + BLOCK_MS <= now;
+    if (expired) _failedAttempts.delete(key);
+  }
+  for (const [ip, entry] of _resetAttempts) {
+    if (entry.windowStart + RESET_WINDOW_MS <= now) _resetAttempts.delete(ip);
+  }
+}, 60_000).unref();
+
+
+function attemptKey(ip, login) {
+  return `${ip ?? "unknown"}|${login}`;
+}
+
+function recordFailedAttempt(key) {
+  const now = Date.now();
+  const entry = _failedAttempts.get(key) ?? { count: 0, blockedUntil: 0, lastAttempt: 0 };
+  entry.count += 1;
+  entry.lastAttempt = now;
+  if (entry.count >= MAX_ATTEMPTS) {
+    entry.blockedUntil = now + BLOCK_MS;
+    entry.count = 0;
+  }
+
+  _failedAttempts.delete(key);
+  if (_failedAttempts.size >= MAX_TRACKED_KEYS) {
+    _failedAttempts.delete(_failedAttempts.keys().next().value);
+  }
+  _failedAttempts.set(key, entry);
+}
+
+function resetRetryAfterSeconds(ip) {
+  const now = Date.now();
+  const entry = _resetAttempts.get(ip);
+  if (!entry || now - entry.windowStart > RESET_WINDOW_MS) {
+    if (_resetAttempts.size >= MAX_TRACKED_KEYS) {
+      _resetAttempts.delete(_resetAttempts.keys().next().value);
+    }
+    _resetAttempts.set(ip, { count: 1, windowStart: now });
+    return 0;
+  }
+  entry.count += 1;
+  if (entry.count > RESET_MAX) {
+    return Math.ceil((entry.windowStart + RESET_WINDOW_MS - now) / 1000);
+  }
+  return 0;
+}
+
+function blockedSecondsRemaining(key) {
+  const entry = _failedAttempts.get(key);
+  if (!entry) return 0;
+  const remainingMs = entry.blockedUntil - Date.now();
+  if (remainingMs > 0) return Math.ceil(remainingMs / 1000);
+  if (entry.blockedUntil > 0) _failedAttempts.delete(key);
+  return 0;
+}
+
+export async function login({ login, senha, rememberMe, ip }) {
+  const loginInformado = login.trim();
+  const chaveTentativa = attemptKey(ip, loginInformado);
+
+  const retryAfterSeconds = blockedSecondsRemaining(chaveTentativa);
+  if (retryAfterSeconds > 0) {
+    const err = new AppError("Muitas tentativas incorretas. Tente novamente mais tarde.", 429);
+    err.details = { retryAfterSeconds };
+    throw err;
+  }
+
+  const usuario = await Usuario.query().findOne(
+    loginInformado.includes("@") ? { Email: loginInformado } : { Login: loginInformado }
+  );
+
+
+  const hashSenha = usuario ? String(usuario.Senha ?? "") : DUMMY_HASH;
+  const passwordIsValid = await bcrypt.compare(senha, hashSenha);
+  const contaInativa = usuario ? String(usuario.Status) === "0" : false;
+
+  if (!usuario || !passwordIsValid || contaInativa) {
+    recordFailedAttempt(chaveTentativa);
+    throw new AppError("Combinação de usuário e senha inválida!", 401);
+  }
+
+  _failedAttempts.delete(chaveTentativa);
+
+  let accessToken = null;
+  let refreshToken = null;
+
+  if (config.jwt.secret) {
+    accessToken = signAccessToken({ sub: usuario.Id, login: usuario.Login, nivel: usuario.Nivel });
+    refreshToken = generateRefreshToken();
+    const tokenHash = hashRefreshToken(refreshToken);
+    const ttlSeconds = rememberMe ? config.jwt.refreshTtlSeconds : config.jwt.refreshTtlShortSeconds;
+    const expiresAt = new Date(Date.now() + ttlSeconds * 1000);
+    await RefreshToken.query().insert({
+      UsuarioId: usuario.Id,
+      TokenHash: tokenHash,
+      ExpiresAt: expiresAt,
+      RememberMe: Boolean(rememberMe)
+    });
+  }
+
+  return { usuario, accessToken, refreshToken };
+}
+
+export async function refresh({ refreshToken }) {
+  const tokenHash = hashRefreshToken(refreshToken);
+  const stored = await RefreshToken.query().findOne({ TokenHash: tokenHash });
+
+  if (!stored || new Date(stored.ExpiresAt) <= new Date()) {
+    throw new AppError("Refresh token inválido ou expirado!", 401);
+  }
+
+  if (stored.RevokedAt) {
+
+    const revokedThereMs = Date.now() - new Date(stored.RevokedAt).getTime();
+    if (revokedThereMs > REUSE_GRACE_MS) {
+      await RefreshToken.query()
+        .patch({ RevokedAt: new Date() })
+        .where({ UsuarioId: stored.UsuarioId })
+        .whereNull("RevokedAt");
+    }
+    throw new AppError("Refresh token inválido ou expirado!", 401);
+  }
+
+  const usuario = await Usuario.query().findById(stored.UsuarioId);
+  if (!usuario || String(usuario.Status) === "0") {
+    throw new AppError("Usuario inativo!", 401);
+  }
+
+  const accessToken = signAccessToken({ sub: usuario.Id, login: usuario.Login, nivel: usuario.Nivel });
+
+  const novoRefreshToken = generateRefreshToken();
+  const ttlSeconds = stored.RememberMe ? config.jwt.refreshTtlSeconds : config.jwt.refreshTtlShortSeconds;
+
+  const revogadoNestaRequisicao = await RefreshToken.transaction(async (trx) => {
+    const numUpdated = await RefreshToken.query(trx)
+      .patch({ RevokedAt: new Date() })
+      .where({ Id: stored.Id })
+      .whereNull("RevokedAt");
+    if (numUpdated === 0) return false;
+
+    await RefreshToken.query(trx).insert({
+      UsuarioId: usuario.Id,
+      TokenHash: hashRefreshToken(novoRefreshToken),
+      ExpiresAt: new Date(Date.now() + ttlSeconds * 1000),
+      RememberMe: stored.RememberMe
+    });
+    return true;
+  });
+
+  if (!revogadoNestaRequisicao) {
+    throw new AppError("Refresh token inválido ou expirado!", 401);
+  }
+
+  return { accessToken, refreshToken: novoRefreshToken };
+}
+
+export async function solicitarResetSenha({ login, ip }) {
+  const loginInformado = login.trim();
+
+  const retryAfterSeconds = resetRetryAfterSeconds(ip ?? "unknown");
+  if (retryAfterSeconds > 0) {
+    const err = new AppError("Muitas solicitações. Tente novamente mais tarde.", 429);
+    err.details = { retryAfterSeconds };
+    throw err;
+  }
+
+
+  const usuario = await Usuario.query()
+    .where(loginInformado.includes("@") ? { Email: loginInformado } : { Login: loginInformado })
+    .orderBy("Id")
+    .first();
+
+
+  if (usuario && String(usuario.Status) !== "0") {
+    const pendente = await SolicitacaoResetSenha.query().findOne({
+      UsuarioId: usuario.Id,
+      Status: "pendente"
+    });
+    if (!pendente) {
+      await SolicitacaoResetSenha.query().insert({ UsuarioId: usuario.Id, Status: "pendente" });
+    }
+  }
+}
+
+export async function logout({ refreshToken }) {
+  if (refreshToken) {
+    const tokenHash = hashRefreshToken(refreshToken);
+    await RefreshToken.query()
+      .patch({ RevokedAt: new Date() })
+      .where({ TokenHash: tokenHash })
+      .whereNull("RevokedAt");
+  }
+}