Selaa lähdekoodia

ajustes: refatorando backend

leonardo 2 kuukautta sitten
vanhempi
commit
06070b2ae9
47 muutettua tiedostoa jossa 723 lisäystä ja 464 poistoa
  1. 0 54
      app.js
  2. 6 0
      index.js
  3. 9 4
      package.json
  4. 0 10
      server.js
  5. 20 0
      src/config/db.config.js
  6. 8 0
      src/config/index.js
  7. 6 0
      src/config/pass.js
  8. 3 0
      src/config/server.js
  9. 13 13
      src/controllers/Auth.Controller.js
  10. 86 0
      src/controllers/Chat.Controller.js
  11. 73 0
      src/controllers/Conversations.Controller.js
  12. 27 0
      src/controllers/Documents.Controller.js
  13. 58 0
      src/controllers/Ingest.Controller.js
  14. 18 0
      src/controllers/Search.Controller.js
  15. 9 11
      src/controllers/Usuario.Controller.js
  16. 0 16
      src/db/knex.js
  17. 14 0
      src/factories/Banco.factory.js
  18. 11 0
      src/factories/Core.factory.js
  19. 63 0
      src/factories/Server.factory.js
  20. 4 4
      src/jobs/cleanupTokens.js
  21. 0 0
      src/middleware/Validajwt.js
  22. 5 0
      src/middleware/errorHandler.js
  23. 14 0
      src/middleware/schemas/Chat.Schema.js
  24. 17 0
      src/middleware/schemas/Ingest.Schema.js
  25. 6 0
      src/middleware/schemas/Search.Schema.js
  26. 0 0
      src/middleware/schemas/Usuario.Schema.js
  27. 3 1
      src/middleware/validate.js
  28. 25 0
      src/models/Conversation.model.js
  29. 31 0
      src/models/Message.model.js
  30. 25 0
      src/models/RefreshToken.model.js
  31. 17 0
      src/models/Usuario.model.js
  32. 0 0
      src/routes/Auth.Rotas.js
  33. 10 0
      src/routes/Chat.Rotas.js
  34. 13 0
      src/routes/Conversations.Rotas.js
  35. 9 0
      src/routes/Documents.Rotas.js
  36. 18 0
      src/routes/Ingest.Rotas.js
  37. 9 0
      src/routes/Search.Rotas.js
  38. 9 9
      src/routes/Usuario.Rotas.js
  39. 0 100
      src/routes/chat.js
  40. 0 74
      src/routes/conversations.js
  41. 0 27
      src/routes/documents.js
  42. 25 15
      src/routes/index.js
  43. 0 82
      src/routes/ingest.js
  44. 0 24
      src/routes/search.js
  45. 22 20
      src/services/conversationsService.js
  46. 30 0
      src/shared/errors/AppError.js
  47. 7 0
      src/shared/errors/index.js

+ 0 - 54
app.js

@@ -1,54 +0,0 @@
-import express from "express";
-import cors from "cors";
-import helmet from "helmet";
-import morgan from "morgan";
-import { config } from "./src/config/index.js";
-import { db } from "./src/db/knex.js";
-import { qdrant } from "./src/services/qdrantClient.js";
-import { authMiddleware } from "./src/middleware/auth.js";
-import { rateLimitMiddleware } from "./src/middleware/rateLimit.js";
-import { errorHandler } from "./src/middleware/errorHandler.js";
-import { apiRouter } from "./src/routes/index.js";
-
-export function createApp() {
-  const app = express();
-
-  app.disable("x-powered-by");
-  app.use(helmet());
-  app.use(
-    cors({
-      origin: config.corsOrigin,
-      credentials: true
-    })
-  );
-  app.use(express.json({ limit: "10mb" }));
-  app.use(morgan("dev"));
-
-  app.get("/health", async (_req, res) => {
-    const checks = { api: true, db: false, qdrant: false, ollama: false };
-
-    try {
-      await db.raw("SELECT 1");
-      checks.db = true;
-    } catch {}
-
-    try {
-      await qdrant.getCollections();
-      checks.qdrant = true;
-    } catch {}
-
-    try {
-      const r = await fetch(`${config.ollama.url}/api/tags`, { signal: AbortSignal.timeout(3000) });
-      checks.ollama = r.ok;
-    } catch {}
-
-    const ok = Object.values(checks).every(Boolean);
-    res.status(ok ? 200 : 503).json({ ok, checks });
-  });
-
-  app.use("/api", rateLimitMiddleware, authMiddleware, apiRouter);
-
-  app.use(errorHandler);
-
-  return app;
-}

+ 6 - 0
index.js

@@ -0,0 +1,6 @@
+import { CoreFactory } from "./src/factories/Core.factory.js";
+
+CoreFactory.Iniciar().catch((error) => {
+  console.error("Falha ao iniciar a aplicação:", error);
+  process.exit(1);
+});

+ 9 - 4
package.json

@@ -2,14 +2,17 @@
   "name": "ia-empresa-api",
   "private": true,
   "type": "module",
+  "imports": {
+    "#*": "./src/*"
+  },
   "scripts": {
-    "dev": "node --watch server.js",
-    "start": "node server.js",
+    "dev": "node --watch index.js",
+    "start": "node index.js",
     "migrate:latest": "knex migrate:latest",
     "migrate:rollback": "knex migrate:rollback",
     "seed:run": "knex seed:run",
-    "lint": "node -c server.js && node -c app.js",
-    "build": "node -c server.js"
+    "lint": "node -c index.js",
+    "build": "node -c index.js"
   },
   "dependencies": {
     "@qdrant/js-client-rest": "^1.11.0",
@@ -24,6 +27,8 @@
     "morgan": "^1.10.0",
     "multer": "^2.0.0",
     "mysql2": "^3.22.5",
+    "objection": "^3.1.5",
+    "objection-unique": "^1.2.2",
     "pdf-parse": "^1.1.1",
     "zod": "^3.23.8"
   },

+ 0 - 10
server.js

@@ -1,10 +0,0 @@
-import { createApp } from "./app.js";
-import { config } from "./src/config/index.js";
-import { scheduleCleanup } from "./src/jobs/cleanupTokens.js";
-
-const app = createApp();
-
-app.listen(config.port, () => {
-  process.stdout.write(`API listening on http://localhost:${config.port}\n`);
-  scheduleCleanup();
-});

+ 20 - 0
src/config/db.config.js

@@ -0,0 +1,20 @@
+import knexLib from "knex";
+import { Model } from "objection";
+import { config } from "./index.js";
+
+export const db = knexLib({
+  client: "mysql2",
+  connection: {
+    host: config.db.host,
+    user: config.db.user,
+    port: config.db.port,
+    password: config.db.password,
+    database: config.db.schema,
+    charset: config.db.charset
+  }
+});
+
+
+Model.knex(db);
+
+export { db as knex };

+ 8 - 0
src/config/index.js

@@ -9,6 +9,14 @@ export const config = {
   auth: {
     mode: process.env.AUTH_MODE ?? "none"
   },
+  db: {
+    host: process.env.DB_HOST,
+    user: process.env.DB_USER,
+    port: process.env.DB_PORT ? Number(process.env.DB_PORT) : undefined,
+    password: process.env.DB_PASS,
+    schema: process.env.DB_SCHEMA,
+    charset: "utf8mb4"
+  },
   jwt: {
     secret: process.env.JWT_SECRET ?? "",
     issuer: process.env.JWT_ISSUER ?? "oraculo-api",

+ 6 - 0
src/config/pass.js

@@ -0,0 +1,6 @@
+import { config } from "./index.js";
+
+export const secret = config.jwt.secret;
+export const issuer = config.jwt.issuer;
+export const accessTtlSeconds = config.jwt.accessTtlSeconds;
+export const refreshTtlSeconds = config.jwt.refreshTtlSeconds;

+ 3 - 0
src/config/server.js

@@ -0,0 +1,3 @@
+import { config } from "./index.js";
+
+export const port = config.port;

+ 13 - 13
src/controllers/Auth.Controller.js

@@ -1,5 +1,6 @@
 import bcrypt from "bcryptjs";
-import { db } from "../db/knex.js";
+import { Usuario } from "../models/Usuario.model.js";
+import { RefreshToken } from "../models/RefreshToken.model.js";
 import { signAccessToken, generateRefreshToken, hashRefreshToken } from "../services/authTokens.js";
 import { config } from "../config/index.js";
 import { formatarUsuario } from "../utils/formatarUsuario.js";
@@ -46,9 +47,9 @@ export const AuthController = {
         return res.status(429).send({ status: false, msg: "Muitas tentativas incorretas. Tente novamente em 15 minutos." });
       }
 
-      const usuario = await db("usuarios")
-        .where(loginInformado.includes("@") ? { Email: loginInformado } : { Login: loginInformado })
-        .first();
+      const usuario = await Usuario.query().findOne(
+        loginInformado.includes("@") ? { Email: loginInformado } : { Login: loginInformado }
+      );
 
       if (!usuario) {
         recordFailedAttempt(loginInformado);
@@ -75,7 +76,7 @@ export const AuthController = {
         refreshToken = generateRefreshToken();
         const tokenHash = hashRefreshToken(refreshToken);
         const expiresAt = new Date(Date.now() + config.jwt.refreshTtlSeconds * 1000);
-        await db("refresh_tokens").insert({
+        await RefreshToken.query().insert({
           UsuarioId: usuario.Id,
           TokenHash: tokenHash,
           ExpiresAt: expiresAt
@@ -101,17 +102,16 @@ export const AuthController = {
       }
 
       const tokenHash = hashRefreshToken(refreshToken);
-      const stored = await db("refresh_tokens")
-        .where({ TokenHash: tokenHash })
+      const stored = await RefreshToken.query()
+        .findOne({ TokenHash: tokenHash })
         .whereNull("RevokedAt")
-        .where("ExpiresAt", ">", new Date())
-        .first();
+        .where("ExpiresAt", ">", new Date());
 
       if (!stored) {
         return res.status(401).send({ status: false, msg: "Refresh token inválido ou expirado!" });
       }
 
-      const usuario = await db("usuarios").where({ Id: stored.UsuarioId }).first();
+      const usuario = await Usuario.query().findById(stored.UsuarioId);
       if (!usuario || String(usuario.Status) === "0") {
         return res.status(401).send({ status: false, msg: "Usuario inativo!" });
       }
@@ -129,10 +129,10 @@ export const AuthController = {
       const { refreshToken } = req.body ?? {};
       if (refreshToken) {
         const tokenHash = hashRefreshToken(refreshToken);
-        await db("refresh_tokens")
+        await RefreshToken.query()
+          .patch({ RevokedAt: new Date() })
           .where({ TokenHash: tokenHash })
-          .whereNull("RevokedAt")
-          .update({ RevokedAt: new Date() });
+          .whereNull("RevokedAt");
       }
       return res.status(200).send({ status: true, msg: "Logout realizado com sucesso!" });
     } catch (error) {

+ 86 - 0
src/controllers/Chat.Controller.js

@@ -0,0 +1,86 @@
+import { answerWithContext, answerWithContextStream } from "../chat/chatChain.js";
+import { addMessage, verifyConversationOwner } from "../services/conversationsService.js";
+
+export const ChatController = {
+  Responder: async function (req, res, next) {
+    try {
+      const body = req.body;
+      const result = await answerWithContext({
+        message: body.message,
+        conversationId: body.conversationId,
+        userId: req.userId,
+        options: body.options
+      });
+
+      if (req.userId && body.conversationId) {
+        try {
+          const owns = await verifyConversationOwner(body.conversationId, req.userId);
+          if (owns) {
+            await Promise.all([
+              addMessage(body.conversationId, { role: "user", content: body.message }),
+              addMessage(body.conversationId, { role: "assistant", content: result.answer, sources: result.sources })
+            ]);
+          }
+        } catch (err) {
+          console.error("[chat] falha ao salvar mensagem:", err);
+        }
+      }
+
+      res.json(result);
+    } catch (err) {
+      next(err);
+    }
+  },
+
+  ResponderStream: async function (req, res, next) {
+    let abortController;
+    try {
+      const body = req.body;
+
+      res.setHeader("Content-Type", "text/event-stream");
+      res.setHeader("Cache-Control", "no-cache");
+      res.setHeader("Connection", "keep-alive");
+      res.setHeader("X-Accel-Buffering", "no");
+      res.flushHeaders();
+
+      abortController = new AbortController();
+      res.on("close", () => abortController.abort());
+
+      const result = await answerWithContextStream({
+        message: body.message,
+        conversationId: body.conversationId,
+        userId: req.userId,
+        options: body.options,
+        signal: abortController.signal,
+        onChunk: (delta) => {
+          res.write(`data: ${JSON.stringify({ type: "delta", delta })}\n\n`);
+        }
+      });
+
+      res.write(`data: ${JSON.stringify({ type: "sources", sources: result.sources })}\n\n`);
+      res.write("data: [DONE]\n\n");
+      res.end();
+
+      if (req.userId && body.conversationId) {
+        verifyConversationOwner(body.conversationId, req.userId)
+          .then((owns) => {
+            if (!owns) return;
+            return Promise.all([
+              addMessage(body.conversationId, { role: "user", content: body.message }),
+              addMessage(body.conversationId, { role: "assistant", content: result.answer, sources: result.sources })
+            ]);
+          })
+          .catch((err) => console.error("[chat] falha ao salvar mensagem:", err));
+      }
+    } catch (err) {
+      if (!res.headersSent) {
+        next(err);
+      } else if (err.name !== "AbortError") {
+        res.write(`data: ${JSON.stringify({ type: "error", error: err.message })}\n\n`);
+        res.end();
+      }
+    }
+  }
+};
+
+export default ChatController;

+ 73 - 0
src/controllers/Conversations.Controller.js

@@ -0,0 +1,73 @@
+import { z } from "zod";
+import {
+  listConversations,
+  createConversation,
+  getConversationMessages,
+  updateConversationTitle,
+  deleteConversation,
+  exportConversationAsMarkdown
+} from "../services/conversationsService.js";
+
+export const ConversationsController = {
+  Listar: async function (req, res, next) {
+    try {
+      const limit  = z.coerce.number().int().positive().max(100).catch(50).parse(req.query.limit);
+      const offset = z.coerce.number().int().nonnegative().catch(0).parse(req.query.offset);
+      const items  = await listConversations(req.userId, { limit, offset });
+      res.json({ items });
+    } catch (err) {
+      next(err);
+    }
+  },
+
+  Criar: async function (req, res, next) {
+    try {
+      const title = z.string().max(200).catch("Nova conversa").parse(req.body?.title);
+      const conv = await createConversation(req.userId, title);
+      res.json(conv);
+    } catch (err) {
+      next(err);
+    }
+  },
+
+  Mensagens: async function (req, res, next) {
+    try {
+      const msgs = await getConversationMessages(req.params.id, req.userId);
+      if (!msgs) return res.status(404).json({ error: "not_found" });
+      res.json({ items: msgs });
+    } catch (err) {
+      next(err);
+    }
+  },
+
+  Renomear: async function (req, res, next) {
+    try {
+      const title = z.string().min(1).max(200).parse(req.body?.title);
+      await updateConversationTitle(req.params.id, req.userId, title);
+      res.json({ ok: true });
+    } catch (err) {
+      next(err);
+    }
+  },
+
+  Remover: async function (req, res, next) {
+    try {
+      await deleteConversation(req.params.id, req.userId);
+      res.json({ ok: true });
+    } catch (err) {
+      next(err);
+    }
+  },
+
+  Exportar: async function (req, res, next) {
+    try {
+      const markdown = await exportConversationAsMarkdown(req.params.id, req.userId);
+      if (markdown == null) return res.status(404).json({ error: "not_found" });
+      res.json({ markdown });
+    } catch (err) {
+      next(err);
+    }
+  }
+};
+
+export default ConversationsController;

+ 27 - 0
src/controllers/Documents.Controller.js

@@ -0,0 +1,27 @@
+import { z } from "zod";
+import { listDocuments, deleteDocumentsBySource } from "../services/documentsService.js";
+
+export const DocumentsController = {
+  Listar: async function (req, res, next) {
+    try {
+      const limit = z.coerce.number().int().positive().max(200).catch(50).parse(req.query.limit);
+      const offset = z.coerce.number().int().nonnegative().optional().parse(req.query.offset);
+      const result = await listDocuments({ limit, offset });
+      res.json(result);
+    } catch (err) {
+      next(err);
+    }
+  },
+
+  RemoverPorFonte: async function (req, res, next) {
+    try {
+      const source = z.string().min(1).max(255).parse(decodeURIComponent(req.params.source));
+      await deleteDocumentsBySource(source);
+      res.json({ ok: true });
+    } catch (err) {
+      next(err);
+    }
+  }
+};
+
+export default DocumentsController;

+ 58 - 0
src/controllers/Ingest.Controller.js

@@ -0,0 +1,58 @@
+import { extractDocumentsFromUpload, ingestDocuments, fetchUrlText } from "../services/ingestService.js";
+
+export const IngestController = {
+  Ingerir: async function (req, res, next) {
+    try {
+      const result = await ingestDocuments(req.body.documents);
+      res.json(result);
+    } catch (err) {
+      next(err);
+    }
+  },
+
+  IngerirArquivo: async function (req, res, next) {
+    try {
+      const f = req.file;
+      if (!f?.buffer) {
+        res.status(400).json({ error: "file_required" });
+        return;
+      }
+
+      const source = typeof req.body?.source === "string" && req.body.source.trim() ? req.body.source.trim() : undefined;
+      const docs = await extractDocumentsFromUpload({
+        buffer: f.buffer,
+        filename: f.originalname,
+        mimeType: f.mimetype,
+        source
+      });
+
+      const extractedChars = docs.reduce((acc, d) => acc + (d?.text?.length ?? 0), 0);
+      if (!extractedChars) {
+        res.status(400).json({ error: "empty_extracted_text" });
+        return;
+      }
+
+      const result = await ingestDocuments(docs);
+      res.json({ ...result, documents: docs.length, extractedChars });
+    } catch (err) {
+      next(err);
+    }
+  },
+
+  IngerirUrl: async function (req, res, next) {
+    try {
+      const body = req.body;
+      const text = await fetchUrlText(body.url);
+      if (!text) {
+        return res.status(400).json({ error: "empty_extracted_text" });
+      }
+      const source = body.source ?? body.url;
+      const result = await ingestDocuments([{ text, source, title: source }]);
+      res.json({ ...result, source, extractedChars: text.length });
+    } catch (err) {
+      next(err);
+    }
+  }
+};
+
+export default IngestController;

+ 18 - 0
src/controllers/Search.Controller.js

@@ -0,0 +1,18 @@
+import { searchDocs } from "../services/searchService.js";
+
+export const SearchController = {
+  Buscar: async function (req, res, next) {
+    try {
+      const body = req.body;
+      const results = await searchDocs({
+        query: body.query,
+        topK: body.topK
+      });
+      res.json({ results });
+    } catch (err) {
+      next(err);
+    }
+  }
+};
+
+export default SearchController;

+ 9 - 11
src/controllers/Usuario.Controller.js

@@ -1,11 +1,11 @@
 import bcrypt from "bcryptjs";
-import { db } from "../db/knex.js";
+import { Usuario } from "../models/Usuario.model.js";
 import { formatarUsuario } from "../utils/formatarUsuario.js";
 
 export const UsuarioController = {
   Listar: async function (req, res, next) {
     try {
-      const usuarios = await db("usuarios")
+      const usuarios = await Usuario.query()
         .select("Id", "Nome", "Login", "Email", "Status", "Nivel", "Setor")
         .orderBy("Nome", "asc");
 
@@ -23,13 +23,13 @@ export const UsuarioController = {
       const { Nome, Login, Email, Senha, Nivel, Setor } = req.body;
 
       const loginTrimado = Login.trim();
-      const existente = await db("usuarios").where({ Login: loginTrimado }).first();
+      const existente = await Usuario.query().findOne({ Login: loginTrimado });
       if (existente) {
         return res.status(409).send({ status: false, msg: "Login já cadastrado!" });
       }
 
       const senhaHash = await bcrypt.hash(Senha, 10);
-      const [id] = await db("usuarios").insert({
+      const usuario = await Usuario.query().insert({
         Nome: Nome.trim(),
         Login: loginTrimado,
         Email: Email.trim(),
@@ -39,7 +39,6 @@ export const UsuarioController = {
         Setor: Setor.trim()
       });
 
-      const usuario = await db("usuarios").where({ Id: id }).first();
       return res.status(201).send({ status: true, usuario: formatarUsuario(usuario) });
     } catch (error) {
       return next(error);
@@ -58,8 +57,7 @@ export const UsuarioController = {
       if (Nivel !== undefined) updates.Nivel = Nivel;
       if (Setor !== undefined) updates.Setor = Setor.trim();
 
-      await db("usuarios").where({ Id: id }).update(updates);
-      const usuario = await db("usuarios").where({ Id: id }).first();
+      const usuario = await Usuario.query().patchAndFetchById(id, updates);
       if (!usuario) return res.status(404).send({ status: false, msg: "Usuário não encontrado!" });
 
       return res.status(200).send({ status: true, usuario: formatarUsuario(usuario) });
@@ -73,11 +71,11 @@ export const UsuarioController = {
       const id = Number(req.params.id);
       if (!id) return res.status(400).send({ status: false, msg: "ID inválido!" });
 
-      const usuario = await db("usuarios").where({ Id: id }).first();
+      const usuario = await Usuario.query().findById(id);
       if (!usuario) return res.status(404).send({ status: false, msg: "Usuário não encontrado!" });
 
       const novoStatus = String(usuario.Status) === "1" ? "0" : "1";
-      await db("usuarios").where({ Id: id }).update({ Status: novoStatus });
+      await Usuario.query().findById(id).patch({ Status: novoStatus });
 
       return res.status(200).send({ status: true, novoStatus });
     } catch (error) {
@@ -98,7 +96,7 @@ export const UsuarioController = {
 
       const { senhaAtual, senhaNova } = req.body;
 
-      const usuario = await db("usuarios").where({ Id: id }).first();
+      const usuario = await Usuario.query().findById(id);
       if (!usuario) return res.status(404).send({ status: false, msg: "Usuário não encontrado!" });
 
       const senhaValida = bcrypt.compareSync(senhaAtual, String(usuario.Senha ?? ""));
@@ -107,7 +105,7 @@ export const UsuarioController = {
       }
 
       const novaHash = bcrypt.hashSync(senhaNova, 10);
-      await db("usuarios").where({ Id: id }).update({ Senha: novaHash });
+      await Usuario.query().findById(id).patch({ Senha: novaHash });
 
       return res.status(200).send({ status: true, msg: "Senha alterada com sucesso!" });
     } catch (error) {

+ 0 - 16
src/db/knex.js

@@ -1,16 +0,0 @@
-import "dotenv/config";
-import knex from "knex";
-
-export const db = knex({
-  client: "mysql2",
-  connection: {
-    host: process.env.DB_HOST,
-    user: process.env.DB_USER,
-    port: process.env.DB_PORT ? Number(process.env.DB_PORT) : undefined,
-    password: process.env.DB_PASS,
-    database: process.env.DB_SCHEMA,
-    charset: "utf8mb4",
-    collation: "utf8mb4_bin"
-  }
-});
-

+ 14 - 0
src/factories/Banco.factory.js

@@ -0,0 +1,14 @@
+import { db } from "../config/db.config.js";
+
+export class BancoFactory {
+  static async Iniciar() {
+    try {
+      await db.raw("SELECT 1");
+      process.stdout.write("Banco de dados conectado.\n");
+    } catch (error) {
+      process.stderr.write(`Falha ao conectar no banco: ${error.message}\n`);
+    }
+  }
+}
+
+export default BancoFactory;

+ 11 - 0
src/factories/Core.factory.js

@@ -0,0 +1,11 @@
+import { BancoFactory } from "./Banco.factory.js";
+import { ServerFactory } from "./Server.factory.js";
+
+export class CoreFactory {
+  static async Iniciar() {
+    await BancoFactory.Iniciar();
+    ServerFactory.Iniciar();
+  }
+}
+
+export default CoreFactory;

+ 63 - 0
src/factories/Server.factory.js

@@ -0,0 +1,63 @@
+import express from "express";
+import cors from "cors";
+import helmet from "helmet";
+import morgan from "morgan";
+import { config } from "../config/index.js";
+import { port } from "../config/server.js";
+import { db } from "../config/db.config.js";
+import { qdrant } from "../services/qdrantClient.js";
+import { errorHandler } from "../middleware/ErrorHandler.js";
+import { Roteamento } from "../routes/index.js";
+import { scheduleCleanup } from "../jobs/cleanupTokens.js";
+
+export class ServerFactory {
+  static Iniciar() {
+    const app = express();
+
+    app.disable("x-powered-by");
+    app.use(helmet());
+    app.use(
+      cors({
+        origin: config.corsOrigin,
+        credentials: true
+      })
+    );
+    app.use(express.json({ limit: "10mb" }));
+    app.use(morgan("dev"));
+
+    app.get("/health", async (_req, res) => {
+      const checks = { api: true, db: false, qdrant: false, ollama: false };
+
+      try {
+        await db.raw("SELECT 1");
+        checks.db = true;
+      } catch {}
+
+      try {
+        await qdrant.getCollections();
+        checks.qdrant = true;
+      } catch {}
+
+      try {
+        const r = await fetch(`${config.ollama.url}/api/tags`, { signal: AbortSignal.timeout(3000) });
+        checks.ollama = r.ok;
+      } catch {}
+
+      const ok = Object.values(checks).every(Boolean);
+      res.status(ok ? 200 : 503).json({ ok, checks });
+    });
+
+    Roteamento.IniciarRoteamento(app);
+    app.use(errorHandler);
+
+    const server = app.listen(port, () => {
+      process.stdout.write(`API listening on http://localhost:${port}\n`);
+      scheduleCleanup();
+    });
+
+    this.app = app;
+    return server;
+  }
+}
+
+export default ServerFactory;

+ 4 - 4
src/jobs/cleanupTokens.js

@@ -1,9 +1,9 @@
-import { db } from "../db/knex.js";
+import { RefreshToken } from "../models/RefreshToken.model.js";
 
 export async function cleanupExpiredTokens() {
-  await db("refresh_tokens")
-    .where("ExpiresAt", "<", new Date())
-    .delete();
+  await RefreshToken.query()
+    .delete()
+    .where("ExpiresAt", "<", new Date());
 }
 
 export function scheduleCleanup(intervalMs = 6 * 60 * 60 * 1000) {

+ 0 - 0
src/middleware/auth.js → src/middleware/Validajwt.js


+ 5 - 0
src/middleware/errorHandler.js

@@ -1,10 +1,15 @@
 import { ZodError } from "zod";
+import { AppError } from "../shared/errors/index.js";
 
 export function errorHandler(err, _req, res, _next) {
   if (err instanceof ZodError) {
     return res.status(400).json({ error: "validation_error", details: err.errors });
   }
 
+  if (err instanceof AppError) {
+    return res.status(err.statusCode).json({ error: err.message });
+  }
+
   const status = Number(err?.statusCode ?? err?.status ?? 500);
   const message = typeof err?.message === "string" ? err.message : "internal_error";
 

+ 14 - 0
src/middleware/schemas/Chat.Schema.js

@@ -0,0 +1,14 @@
+import { z } from "zod";
+
+export const chatBodySchema = z.object({
+  message: z.string().min(1),
+  conversationId: z.coerce.number().int().positive().optional(),
+  sessionId: z.string().min(1).optional(),
+  model: z.string().min(1).optional(),
+  options: z
+    .object({
+      temperature: z.number().min(0).max(2).optional(),
+      top_p: z.number().min(0).max(1).optional()
+    })
+    .optional()
+});

+ 17 - 0
src/middleware/schemas/Ingest.Schema.js

@@ -0,0 +1,17 @@
+import { z } from "zod";
+
+const docSchema = z.object({
+  id: z.string().min(1).optional(),
+  text: z.string().min(1),
+  source: z.string().min(1).optional(),
+  metadata: z.record(z.any()).optional()
+});
+
+export const ingestBodySchema = z.object({
+  documents: z.array(docSchema).min(1)
+});
+
+export const ingestUrlSchema = z.object({
+  url: z.string().url().startsWith("https://"),
+  source: z.string().min(1).optional()
+});

+ 6 - 0
src/middleware/schemas/Search.Schema.js

@@ -0,0 +1,6 @@
+import { z } from "zod";
+
+export const searchBodySchema = z.object({
+  query: z.string().min(1),
+  topK: z.number().int().positive().optional()
+});

+ 0 - 0
src/middleware/schemas/usuario.js → src/middleware/schemas/Usuario.Schema.js


+ 3 - 1
src/middleware/validate.js

@@ -1,7 +1,9 @@
 export function validate(schema) {
   return (req, _res, next) => {
     try {
-      req.body = schema.parse(req.body ?? {});
+      if (schema.body)   req.body = schema.body.parse(req.body ?? {});
+      if (schema.params) schema.params.parse(req.params);
+      if (schema.query)  schema.query.parse(req.query);
       next();
     } catch (err) {
       next(err);

+ 25 - 0
src/models/Conversation.model.js

@@ -0,0 +1,25 @@
+import { Model } from "objection";
+import "../config/db.config.js"; 
+import { Message } from "./Message.model.js";
+
+export class Conversation extends Model {
+  static get tableName() {
+    return "conversations";
+  }
+
+  static get idColumn() {
+    return "Id";
+  }
+
+  static get relationMappings() {
+    return {
+      messages: {
+        relation: Model.HasManyRelation,
+        modelClass: Message,
+        join: { from: "conversations.Id", to: "messages.ConversationId" }
+      }
+    };
+  }
+}
+
+export default Conversation;

+ 31 - 0
src/models/Message.model.js

@@ -0,0 +1,31 @@
+import { Model } from "objection";
+import "../config/db.config.js"; 
+import { Conversation } from "./Conversation.model.js";
+
+export class Message extends Model {
+  static get tableName() {
+    return "messages";
+  }
+
+  static get idColumn() {
+    return "Id";
+  }
+
+  
+  static get jsonAttributes() {
+    return [];
+  }
+
+  static get relationMappings() {
+  
+    return {
+      conversation: {
+        relation: Model.BelongsToOneRelation,
+        modelClass: Conversation,
+        join: { from: "messages.ConversationId", to: "conversations.Id" }
+      }
+    };
+  }
+}
+
+export default Message;

+ 25 - 0
src/models/RefreshToken.model.js

@@ -0,0 +1,25 @@
+import { Model } from "objection";
+import "../config/db.config.js"; 
+import { Usuario } from "./Usuario.model.js";
+
+export class RefreshToken extends Model {
+  static get tableName() {
+    return "refresh_tokens";
+  }
+
+  static get idColumn() {
+    return "Id";
+  }
+
+  static get relationMappings() {
+    return {
+      usuario: {
+        relation: Model.BelongsToOneRelation,
+        modelClass: Usuario,
+        join: { from: "refresh_tokens.UsuarioId", to: "usuarios.Id" }
+      }
+    };
+  }
+}
+
+export default RefreshToken;

+ 17 - 0
src/models/Usuario.model.js

@@ -0,0 +1,17 @@
+import { Model } from "objection";
+import Unique from "objection-unique";
+import "../config/db.config.js";
+
+const unique = Unique({ fields: ["Login"], identifiers: ["Id"] });
+
+export class Usuario extends unique(Model) {
+  static get tableName() {
+    return "usuarios";
+  }
+
+  static get idColumn() {
+    return "Id";
+  }
+}
+
+export default Usuario;

+ 0 - 0
src/routes/auth.js → src/routes/Auth.Rotas.js


+ 10 - 0
src/routes/Chat.Rotas.js

@@ -0,0 +1,10 @@
+import { Router } from "express";
+import { ChatController } from "../controllers/Chat.Controller.js";
+import { requireUser } from "../middleware/RequireUser.js";
+import { validate } from "../middleware/Validate.js";
+import { chatBodySchema } from "../middleware/schemas/Chat.Schema.js";
+
+export const chatRouter = Router();
+
+chatRouter.post("/", requireUser, validate({ body: chatBodySchema }), ChatController.Responder);
+chatRouter.post("/stream", requireUser, validate({ body: chatBodySchema }), ChatController.ResponderStream);

+ 13 - 0
src/routes/Conversations.Rotas.js

@@ -0,0 +1,13 @@
+import { Router } from "express";
+import { ConversationsController } from "../controllers/Conversations.Controller.js";
+import { requireUser } from "../middleware/RequireUser.js";
+import { parseId } from "../middleware/ParseId.js";
+
+export const conversationsRouter = Router();
+
+conversationsRouter.get("/", requireUser, ConversationsController.Listar);
+conversationsRouter.post("/", requireUser, ConversationsController.Criar);
+conversationsRouter.get("/:id/messages", requireUser, parseId, ConversationsController.Mensagens);
+conversationsRouter.patch("/:id", requireUser, parseId, ConversationsController.Renomear);
+conversationsRouter.delete("/:id", requireUser, parseId, ConversationsController.Remover);
+conversationsRouter.get("/:id/export", requireUser, parseId, ConversationsController.Exportar);

+ 9 - 0
src/routes/Documents.Rotas.js

@@ -0,0 +1,9 @@
+import { Router } from "express";
+import { DocumentsController } from "../controllers/Documents.Controller.js";
+import { requireUser } from "../middleware/RequireUser.js";
+import { requireAdmin } from "../middleware/RequireAdmin.js";
+
+export const documentsRouter = Router();
+
+documentsRouter.get("/", requireUser, DocumentsController.Listar);
+documentsRouter.delete("/source/:source", requireUser, requireAdmin, DocumentsController.RemoverPorFonte);

+ 18 - 0
src/routes/Ingest.Rotas.js

@@ -0,0 +1,18 @@
+import { Router } from "express";
+import multer from "multer";
+import { IngestController } from "../controllers/Ingest.Controller.js";
+import { requireUser } from "../middleware/RequireUser.js";
+import { requireAdmin } from "../middleware/RequireAdmin.js";
+import { validate } from "../middleware/Validate.js";
+import { ingestBodySchema, ingestUrlSchema } from "../middleware/schemas/Ingest.Schema.js";
+
+export const ingestRouter = Router();
+
+const upload = multer({
+  storage: multer.memoryStorage(),
+  limits: { fileSize: 25 * 1024 * 1024 }
+});
+
+ingestRouter.post("/", requireUser, requireAdmin, validate({ body: ingestBodySchema }), IngestController.Ingerir);
+ingestRouter.post("/file", requireUser, requireAdmin, upload.single("file"), IngestController.IngerirArquivo);
+ingestRouter.post("/url", requireUser, requireAdmin, validate({ body: ingestUrlSchema }), IngestController.IngerirUrl);

+ 9 - 0
src/routes/Search.Rotas.js

@@ -0,0 +1,9 @@
+import { Router } from "express";
+import { SearchController } from "../controllers/Search.Controller.js";
+import { requireUser } from "../middleware/RequireUser.js";
+import { validate } from "../middleware/Validate.js";
+import { searchBodySchema } from "../middleware/schemas/Search.Schema.js";
+
+export const searchRouter = Router();
+
+searchRouter.post("/", requireUser, validate({ body: searchBodySchema }), SearchController.Buscar);

+ 9 - 9
src/routes/users.js → src/routes/Usuario.Rotas.js

@@ -1,14 +1,14 @@
 import { Router } from "express";
 import { UsuarioController } from "../controllers/Usuario.Controller.js";
-import { requireUser } from "../middleware/requireUser.js";
-import { requireAdmin } from "../middleware/requireAdmin.js";
-import { validate } from "../middleware/validate.js";
-import { criarSchema, atualizarSchema, senhaSchema } from "../middleware/schemas/usuario.js";
+import { requireUser } from "../middleware/RequireUser.js";
+import { requireAdmin } from "../middleware/RequireAdmin.js";
+import { validate } from "../middleware/Validate.js";
+import { criarSchema, atualizarSchema, senhaSchema } from "../middleware/schemas/Usuario.Schema.js";
 
 export const usersRouter = Router();
 
-usersRouter.get("/",             requireUser, requireAdmin,                        UsuarioController.Listar);
-usersRouter.post("/",            requireUser, requireAdmin, validate(criarSchema), UsuarioController.Criar);
-usersRouter.put("/:id",          requireUser, requireAdmin, validate(atualizarSchema), UsuarioController.Atualizar);
-usersRouter.patch("/:id/status", requireUser, requireAdmin,                        UsuarioController.ToggleStatus);
-usersRouter.patch("/:id/senha",  requireUser,               validate(senhaSchema), UsuarioController.AlterarSenha);
+usersRouter.get("/",             requireUser, requireAdmin,                                  UsuarioController.Listar);
+usersRouter.post("/",            requireUser, requireAdmin, validate({ body: criarSchema }),     UsuarioController.Criar);
+usersRouter.put("/:id",          requireUser, requireAdmin, validate({ body: atualizarSchema }), UsuarioController.Atualizar);
+usersRouter.patch("/:id/status", requireUser, requireAdmin,                                  UsuarioController.ToggleStatus);
+usersRouter.patch("/:id/senha",  requireUser,               validate({ body: senhaSchema }),     UsuarioController.AlterarSenha);

+ 0 - 100
src/routes/chat.js

@@ -1,100 +0,0 @@
-import { Router } from "express";
-import { z } from "zod";
-import { answerWithContext, answerWithContextStream } from "../chat/chatChain.js";
-import { addMessage, verifyConversationOwner } from "../services/conversationsService.js";
-import { requireUser } from "../middleware/requireUser.js";
-
-export const chatRouter = Router();
-
-const chatBodySchema = z.object({
-  message: z.string().min(1),
-  conversationId: z.coerce.number().int().positive().optional(),
-  sessionId: z.string().min(1).optional(),
-  model: z.string().min(1).optional(),
-  options: z
-    .object({
-      temperature: z.number().min(0).max(2).optional(),
-      top_p: z.number().min(0).max(1).optional()
-    })
-    .optional()
-});
-
-chatRouter.post("/", requireUser, async (req, res, next) => {
-  try {
-    const body = chatBodySchema.parse(req.body);
-    const result = await answerWithContext({
-      message: body.message,
-      conversationId: body.conversationId,
-      userId: req.userId,
-      options: body.options
-    });
-
-    if (req.userId && body.conversationId) {
-      try {
-        const owns = await verifyConversationOwner(body.conversationId, req.userId);
-        if (owns) {
-          await Promise.all([
-            addMessage(body.conversationId, { role: "user", content: body.message }),
-            addMessage(body.conversationId, { role: "assistant", content: result.answer, sources: result.sources })
-          ]);
-        }
-      } catch (err) {
-        console.error("[chat] falha ao salvar mensagem:", err);
-      }
-    }
-
-    res.json(result);
-  } catch (err) {
-    next(err);
-  }
-});
-
-chatRouter.post("/stream", requireUser, async (req, res, next) => {
-  let abortController;
-  try {
-    const body = chatBodySchema.parse(req.body);
-
-    res.setHeader("Content-Type", "text/event-stream");
-    res.setHeader("Cache-Control", "no-cache");
-    res.setHeader("Connection", "keep-alive");
-    res.setHeader("X-Accel-Buffering", "no");
-    res.flushHeaders();
-
-    abortController = new AbortController();
-    res.on("close", () => abortController.abort());
-
-    const result = await answerWithContextStream({
-      message: body.message,
-      conversationId: body.conversationId,
-      userId: req.userId,
-      options: body.options,
-      signal: abortController.signal,
-      onChunk: (delta) => {
-        res.write(`data: ${JSON.stringify({ type: "delta", delta })}\n\n`);
-      }
-    });
-
-    res.write(`data: ${JSON.stringify({ type: "sources", sources: result.sources })}\n\n`);
-    res.write("data: [DONE]\n\n");
-    res.end();
-
-    if (req.userId && body.conversationId) {
-      verifyConversationOwner(body.conversationId, req.userId)
-        .then((owns) => {
-          if (!owns) return;
-          return Promise.all([
-            addMessage(body.conversationId, { role: "user", content: body.message }),
-            addMessage(body.conversationId, { role: "assistant", content: result.answer, sources: result.sources })
-          ]);
-        })
-        .catch((err) => console.error("[chat] falha ao salvar mensagem:", err));
-    }
-  } catch (err) {
-    if (!res.headersSent) {
-      next(err);
-    } else if (err.name !== "AbortError") {
-      res.write(`data: ${JSON.stringify({ type: "error", error: err.message })}\n\n`);
-      res.end();
-    }
-  }
-});

+ 0 - 74
src/routes/conversations.js

@@ -1,74 +0,0 @@
-import { Router } from "express";
-import { z } from "zod";
-import {
-  listConversations,
-  createConversation,
-  getConversationMessages,
-  updateConversationTitle,
-  deleteConversation,
-  exportConversationAsMarkdown
-} from "../services/conversationsService.js";
-import { requireUser } from "../middleware/requireUser.js";
-import { parseId } from "../middleware/parseId.js";
-
-export const conversationsRouter = Router();
-
-conversationsRouter.get("/", requireUser, async (req, res, next) => {
-  try {
-    const limit  = z.coerce.number().int().positive().max(100).catch(50).parse(req.query.limit);
-    const offset = z.coerce.number().int().nonnegative().catch(0).parse(req.query.offset);
-    const items  = await listConversations(req.userId, { limit, offset });
-    res.json({ items });
-  } catch (err) {
-    next(err);
-  }
-});
-
-conversationsRouter.post("/", requireUser, async (req, res, next) => {
-  try {
-    const title = z.string().max(200).catch("Nova conversa").parse(req.body?.title);
-    const conv = await createConversation(req.userId, title);
-    res.json(conv);
-  } catch (err) {
-    next(err);
-  }
-});
-
-conversationsRouter.get("/:id/messages", requireUser, parseId, async (req, res, next) => {
-  try {
-    const msgs = await getConversationMessages(req.params.id, req.userId);
-    if (!msgs) return res.status(404).json({ error: "not_found" });
-    res.json({ items: msgs });
-  } catch (err) {
-    next(err);
-  }
-});
-
-conversationsRouter.patch("/:id", requireUser, parseId, async (req, res, next) => {
-  try {
-    const title = z.string().min(1).max(200).parse(req.body?.title);
-    await updateConversationTitle(req.params.id, req.userId, title);
-    res.json({ ok: true });
-  } catch (err) {
-    next(err);
-  }
-});
-
-conversationsRouter.delete("/:id", requireUser, parseId, async (req, res, next) => {
-  try {
-    await deleteConversation(req.params.id, req.userId);
-    res.json({ ok: true });
-  } catch (err) {
-    next(err);
-  }
-});
-
-conversationsRouter.get("/:id/export", requireUser, parseId, async (req, res, next) => {
-  try {
-    const markdown = await exportConversationAsMarkdown(req.params.id, req.userId);
-    if (markdown == null) return res.status(404).json({ error: "not_found" });
-    res.json({ markdown });
-  } catch (err) {
-    next(err);
-  }
-});

+ 0 - 27
src/routes/documents.js

@@ -1,27 +0,0 @@
-import { Router } from "express";
-import { z } from "zod";
-import { listDocuments, deleteDocumentsBySource } from "../services/documentsService.js";
-import { requireUser } from "../middleware/requireUser.js";
-
-export const documentsRouter = Router();
-
-documentsRouter.get("/", requireUser, async (req, res, next) => {
-  try {
-    const limit = z.coerce.number().int().positive().max(200).catch(50).parse(req.query.limit);
-    const offset = z.coerce.number().int().nonnegative().optional().parse(req.query.offset);
-    const result = await listDocuments({ limit, offset });
-    res.json(result);
-  } catch (err) {
-    next(err);
-  }
-});
-
-documentsRouter.delete("/source/:source", requireUser, async (req, res, next) => {
-  try {
-    const source = z.string().min(1).max(255).parse(decodeURIComponent(req.params.source));
-    await deleteDocumentsBySource(source);
-    res.json({ ok: true });
-  } catch (err) {
-    next(err);
-  }
-});

+ 25 - 15
src/routes/index.js

@@ -1,18 +1,28 @@
 import { Router } from "express";
-import { chatRouter } from "./chat.js";
-import { searchRouter } from "./search.js";
-import { ingestRouter } from "./ingest.js";
-import { documentsRouter } from "./documents.js";
-import { authRouter } from "./auth.js";
-import { usersRouter } from "./users.js";
-import { conversationsRouter } from "./conversations.js";
+import { rateLimitMiddleware } from "../middleware/RateLimit.js";
+import { authMiddleware } from "../middleware/Validajwt.js";
+import { authRouter } from "./Auth.Rotas.js";
+import { usersRouter } from "./Usuario.Rotas.js";
+import { chatRouter } from "./Chat.Rotas.js";
+import { searchRouter } from "./Search.Rotas.js";
+import { ingestRouter } from "./Ingest.Rotas.js";
+import { documentsRouter } from "./Documents.Rotas.js";
+import { conversationsRouter } from "./Conversations.Rotas.js";
 
-export const apiRouter = Router();
+export class Roteamento {
+  static IniciarRoteamento(app) {
+    const api = Router();
 
-apiRouter.use("/auth", authRouter);
-apiRouter.use("/users", usersRouter);
-apiRouter.use("/chat", chatRouter);
-apiRouter.use("/search", searchRouter);
-apiRouter.use("/ingest", ingestRouter);
-apiRouter.use("/documents", documentsRouter);
-apiRouter.use("/conversations", conversationsRouter);
+    api.use("/auth", authRouter);
+    api.use("/users", usersRouter);
+    api.use("/chat", chatRouter);
+    api.use("/search", searchRouter);
+    api.use("/ingest", ingestRouter);
+    api.use("/documents", documentsRouter);
+    api.use("/conversations", conversationsRouter);
+
+    app.use("/api", rateLimitMiddleware, authMiddleware, api);
+  }
+}
+
+export default Roteamento;

+ 0 - 82
src/routes/ingest.js

@@ -1,82 +0,0 @@
-import { Router } from "express";
-import { z } from "zod";
-import multer from "multer";
-import { extractDocumentsFromUpload, ingestDocuments, fetchUrlText } from "../services/ingestService.js";
-import { requireUser } from "../middleware/requireUser.js";
-
-export const ingestRouter = Router();
-
-const upload = multer({
-  storage: multer.memoryStorage(),
-  limits: { fileSize: 25 * 1024 * 1024 }
-});
-
-const docSchema = z.object({
-  id: z.string().min(1).optional(),
-  text: z.string().min(1),
-  source: z.string().min(1).optional(),
-  metadata: z.record(z.any()).optional()
-});
-
-const ingestBodySchema = z.object({
-  documents: z.array(docSchema).min(1)
-});
-
-const ingestUrlSchema = z.object({
-  url: z.string().url().startsWith("https://"),
-  source: z.string().min(1).optional()
-});
-
-ingestRouter.post("/", requireUser, async (req, res, next) => {
-  try {
-    const body = ingestBodySchema.parse(req.body);
-    const result = await ingestDocuments(body.documents);
-    res.json(result);
-  } catch (err) {
-    next(err);
-  }
-});
-
-ingestRouter.post("/file", requireUser, upload.single("file"), async (req, res, next) => {
-  try {
-    const f = req.file;
-    if (!f?.buffer) {
-      res.status(400).json({ error: "file_required" });
-      return;
-    }
-
-    const source = typeof req.body?.source === "string" && req.body.source.trim() ? req.body.source.trim() : undefined;
-    const docs = await extractDocumentsFromUpload({
-      buffer: f.buffer,
-      filename: f.originalname,
-      mimeType: f.mimetype,
-      source
-    });
-
-    const extractedChars = docs.reduce((acc, d) => acc + (d?.text?.length ?? 0), 0);
-    if (!extractedChars) {
-      res.status(400).json({ error: "empty_extracted_text" });
-      return;
-    }
-
-    const result = await ingestDocuments(docs);
-    res.json({ ...result, documents: docs.length, extractedChars });
-  } catch (err) {
-    next(err);
-  }
-});
-
-ingestRouter.post("/url", requireUser, async (req, res, next) => {
-  try {
-    const body = ingestUrlSchema.parse(req.body);
-    const text = await fetchUrlText(body.url);
-    if (!text) {
-      return res.status(400).json({ error: "empty_extracted_text" });
-    }
-    const source = body.source ?? body.url;
-    const result = await ingestDocuments([{ text, source, title: source }]);
-    res.json({ ...result, source, extractedChars: text.length });
-  } catch (err) {
-    next(err);
-  }
-});

+ 0 - 24
src/routes/search.js

@@ -1,24 +0,0 @@
-import { Router } from "express";
-import { z } from "zod";
-import { searchDocs } from "../services/searchService.js";
-import { requireUser } from "../middleware/requireUser.js";
-
-export const searchRouter = Router();
-
-const searchBodySchema = z.object({
-  query: z.string().min(1),
-  topK: z.number().int().positive().optional()
-});
-
-searchRouter.post("/", requireUser, async (req, res, next) => {
-  try {
-    const body = searchBodySchema.parse(req.body);
-    const results = await searchDocs({
-      query: body.query,
-      topK: body.topK
-    });
-    res.json({ results });
-  } catch (err) {
-    next(err);
-  }
-});

+ 22 - 20
src/services/conversationsService.js

@@ -1,29 +1,31 @@
-import { db } from "../db/knex.js";
+import { Conversation } from "../models/Conversation.model.js";
+import { Message } from "../models/Message.model.js";
 
 export async function listConversations(userId, { limit = 50, offset = 0 } = {}) {
   const safeLimit = Math.min(Number(limit) || 50, 100);
   const safeOffset = Math.max(Number(offset) || 0, 0);
-  return db("conversations")
+  return Conversation.query()
     .where({ UsuarioId: userId })
     .orderBy("UpdatedAt", "desc")
     .limit(safeLimit)
     .offset(safeOffset)
-    .select("Id", "Title", "CreatedAt", "UpdatedAt");
+    .select("Id", "Title", "CreatedAt", "UpdatedAt")
+    .select(Conversation.relatedQuery("messages").count().as("MessageCount"));
 }
 
 export async function createConversation(userId, title = "Nova conversa") {
-  const [id] = await db("conversations").insert({
+  const conv = await Conversation.query().insert({
     UsuarioId: userId,
     Title: String(title).slice(0, 255)
   });
-  return { id, title };
+  return { id: conv.Id, title };
 }
 
 export async function getConversationMessages(conversationId, userId) {
-  const conv = await db("conversations").where({ Id: conversationId, UsuarioId: userId }).first();
+  const conv = await Conversation.query().findOne({ Id: conversationId, UsuarioId: userId });
   if (!conv) return null;
 
-  const msgs = await db("messages")
+  const msgs = await Message.query()
     .where({ ConversationId: conversationId })
     .orderBy("SentAt", "asc")
     .select("Id", "Role", "Content", "Sources", "SentAt");
@@ -32,7 +34,7 @@ export async function getConversationMessages(conversationId, userId) {
 }
 
 export async function verifyConversationOwner(conversationId, userId) {
-  const conv = await db("conversations").where({ Id: conversationId, UsuarioId: userId }).first();
+  const conv = await Conversation.query().findOne({ Id: conversationId, UsuarioId: userId });
   return Boolean(conv);
 }
 
@@ -41,7 +43,7 @@ export async function getRecentMessages(conversationId, userId, limit = 12) {
     const owns = await verifyConversationOwner(conversationId, userId);
     if (!owns) return [];
   }
-  const msgs = await db("messages")
+  const msgs = await Message.query()
     .where({ ConversationId: conversationId })
     .orderBy("SentAt", "desc")
     .limit(limit)
@@ -50,34 +52,34 @@ export async function getRecentMessages(conversationId, userId, limit = 12) {
 }
 
 export async function addMessage(conversationId, { role, content, sources = null }) {
-  await db.transaction(async (trx) => {
-    await trx("messages").insert({
+  await Conversation.transaction(async (trx) => {
+    await Message.query(trx).insert({
       ConversationId: conversationId,
       Role: role,
       Content: content,
       Sources: sources ? JSON.stringify(sources) : null
     });
-    await trx("conversations")
-      .where({ Id: conversationId })
-      .update({ UpdatedAt: trx.fn.now() });
+    await Conversation.query(trx)
+      .patch({ UpdatedAt: trx.fn.now() })
+      .where({ Id: conversationId });
   });
 }
 
 export async function updateConversationTitle(conversationId, userId, title) {
-  await db("conversations")
-    .where({ Id: conversationId, UsuarioId: userId })
-    .update({ Title: String(title).slice(0, 255), UpdatedAt: db.fn.now() });
+  await Conversation.query()
+    .patch({ Title: String(title).slice(0, 255), UpdatedAt: Conversation.knex().fn.now() })
+    .where({ Id: conversationId, UsuarioId: userId });
 }
 
 export async function deleteConversation(conversationId, userId) {
-  await db("conversations").where({ Id: conversationId, UsuarioId: userId }).delete();
+  await Conversation.query().delete().where({ Id: conversationId, UsuarioId: userId });
 }
 
 export async function getConversationForExport(conversationId, userId) {
-  const conv = await db("conversations").where({ Id: conversationId, UsuarioId: userId }).first();
+  const conv = await Conversation.query().findOne({ Id: conversationId, UsuarioId: userId });
   if (!conv) return null;
 
-  const msgs = await db("messages")
+  const msgs = await Message.query()
     .where({ ConversationId: conversationId })
     .orderBy("SentAt", "asc")
     .select("Role", "Content", "SentAt");

+ 30 - 0
src/shared/errors/AppError.js

@@ -0,0 +1,30 @@
+export class AppError extends Error {
+  constructor(message, statusCode) {
+    super(message);
+    this.statusCode = statusCode;
+  }
+}
+
+export class NotFoundError extends AppError {
+  constructor(message = "Not Found") {
+    super(message, 404);
+  }
+}
+
+export class UnauthorizedError extends AppError {
+  constructor(message = "Unauthorized") {
+    super(message, 401);
+  }
+}
+
+export class BadRequestError extends AppError {
+  constructor(message = "Bad Request") {
+    super(message, 400);
+  }
+}
+
+export class InternalServerError extends AppError {
+  constructor(message = "Internal Server Error") {
+    super(message, 500);
+  }
+}

+ 7 - 0
src/shared/errors/index.js

@@ -0,0 +1,7 @@
+export {
+  AppError,
+  NotFoundError,
+  UnauthorizedError,
+  BadRequestError,
+  InternalServerError
+} from "./AppError.js";