Pārlūkot izejas kodu

ajustes usuarios controller e algumas melhorias

leonardo 3 mēneši atpakaļ
vecāks
revīzija
1c583553c7

+ 33 - 44
chat/chatChain.js

@@ -1,7 +1,6 @@
 import { config } from "../src/config/index.js";
-import { chatCompletion } from "../src/services/ollamaClient.js";
+import { chatCompletion, chatCompletionStream } from "../src/services/ollamaClient.js";
 import { searchDocs } from "../chat/searchChat.js";
-import fs from "node:fs";
 
 function buildContextBlock(hits) {
   const lines = [];
@@ -14,40 +13,7 @@ function buildContextBlock(hits) {
   return lines.join("\n").trim();
 }
 
-export async function answerWithContext({ message, sessionId }) {
-  const hits = await searchDocs({ query: message, topK: config.rag.topK });
-  const context = buildContextBlock(hits);
-  // #region debug-point D:image-rag-context
-  (() => {
-    let u = "http://127.0.0.1:7777/event";
-    let s = "image-rag-miss";
-    try {
-      const e = fs.readFileSync(".dbg/image-rag-miss.env", "utf8");
-      u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
-      s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
-    } catch {}
-    fetch(u, {
-      method: "POST",
-      headers: { "content-type": "application/json" },
-      body: JSON.stringify({
-        sessionId: s,
-        runId: "post",
-        hypothesisId: "D",
-        location: "api/chat/chatChain.js",
-        msg: "[DEBUG] answerWithContext context built",
-        data: {
-          questionHead: String(message ?? "").slice(0, 140),
-          hits: Array.isArray(hits) ? hits.length : null,
-          contextChars: context.length,
-          topScore: hits?.[0]?.score ?? null,
-          topSource: hits?.[0]?.source ?? null
-        },
-        ts: Date.now()
-      })
-    }).catch(() => {});
-  })();
-  // #endregion
-
+function buildMessages(message, context, sessionId) {
   const system = [
     "Você é um assistente de atendimento interno de uma empresa.",
     "Responda apenas com base no CONTEXTO quando ele estiver disponível.",
@@ -55,23 +21,46 @@ export async function answerWithContext({ message, sessionId }) {
     "Seja direto e em português."
   ].join("\n");
 
-  const messages = [
+  return [
     { role: "system", content: system },
     ...(context ? [{ role: "system", content: `CONTEXTO:\n${context}` }] : []),
     ...(sessionId ? [{ role: "system", content: `session_id:${sessionId}` }] : []),
     { role: "user", content: message }
   ];
+}
 
+function hitsToSources(hits) {
+  return hits.map((h) => ({
+    id: h.id,
+    source: h.source,
+    score: h.score,
+    chunkIndex: h.chunkIndex,
+    metadata: h.metadata
+  }));
+}
+
+export async function answerWithContext({ message, sessionId }) {
+  const hits = await searchDocs({ query: message, topK: config.rag.topK });
+  const context = buildContextBlock(hits);
+  const messages = buildMessages(message, context, sessionId);
   const completion = await chatCompletion({ messages });
 
   return {
     answer: completion.content,
-    sources: hits.map((h) => ({
-      id: h.id,
-      source: h.source,
-      score: h.score,
-      chunkIndex: h.chunkIndex,
-      metadata: h.metadata
-    }))
+    sources: hitsToSources(hits)
+  };
+}
+
+export async function answerWithContextStream({ message, sessionId, onChunk, signal }) {
+  const hits = await searchDocs({ query: message, topK: config.rag.topK });
+  const context = buildContextBlock(hits);
+  const messages = buildMessages(message, context, sessionId);
+  const sources = hitsToSources(hits);
+
+  const completion = await chatCompletionStream({ messages, onChunk, signal });
+
+  return {
+    answer: completion.content,
+    sources
   };
 }

+ 0 - 211
chat/ingest.js

@@ -5,7 +5,6 @@ import { ensureCollection } from "../src/services/collectionService.js";
 import { qdrant } from "../src/services/qdrantClient.js";
 import { createHash } from "node:crypto";
 import { createRequire } from "node:module";
-import fs from "node:fs";
 import mammoth from "mammoth";
 
 const require = createRequire(import.meta.url);
@@ -76,37 +75,6 @@ export async function ingestDocuments(documents) {
 
   if (allChunks.length === 0) return { upserted: 0 };
 
-  // #region debug-point B:ingest-chunks
-  (() => {
-    let u = "http://127.0.0.1:7777/event";
-    let s = "image-rag-miss";
-    try {
-      const e = fs.readFileSync(".dbg/image-rag-miss.env", "utf8");
-      u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
-      s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
-    } catch {}
-    fetch(u, {
-      method: "POST",
-      headers: { "content-type": "application/json" },
-      body: JSON.stringify({
-        sessionId: s,
-        runId: "post",
-        hypothesisId: "B",
-        location: "api/chat/ingest.js",
-        msg: "[DEBUG] ingestDocuments chunks built",
-        data: {
-          collection: collectionName,
-          documents: Array.isArray(documents) ? documents.length : null,
-          chunks: allChunks.length,
-          avgChunkLen: allChunks.length ? Math.round(allChunks.reduce((a, c) => a + (c?.text?.length ?? 0), 0) / allChunks.length) : 0,
-          sample0: allChunks[0]?.text ? String(allChunks[0].text).slice(0, 160) : ""
-        },
-        ts: Date.now()
-      })
-    }).catch(() => {});
-  })();
-  // #endregion
-
   const vectors = await embedTexts(allChunks.map((c) => c.text));
   const vectorSize = vectors[0]?.length ?? 0;
   if (!vectorSize) {
@@ -128,61 +96,11 @@ export async function ingestDocuments(documents) {
     }
   }));
 
-  // #region debug-point B:ingest-upsert
-  (() => {
-    let u = "http://127.0.0.1:7777/event";
-    let s = "image-rag-miss";
-    try {
-      const e = fs.readFileSync(".dbg/image-rag-miss.env", "utf8");
-      u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
-      s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
-    } catch {}
-    fetch(u, {
-      method: "POST",
-      headers: { "content-type": "application/json" },
-      body: JSON.stringify({
-        sessionId: s,
-        runId: "post",
-        hypothesisId: "B",
-        location: "api/chat/ingest.js",
-        msg: "[DEBUG] ingestDocuments upsert start",
-        data: { collection: collectionName, points: points.length, vectorSize },
-        ts: Date.now()
-      })
-    }).catch(() => {});
-  })();
-  // #endregion
-
   await qdrant.upsert(collectionName, {
     wait: true,
     points
   });
 
-  // #region debug-point B:ingest-upsert-done
-  (() => {
-    let u = "http://127.0.0.1:7777/event";
-    let s = "image-rag-miss";
-    try {
-      const e = fs.readFileSync(".dbg/image-rag-miss.env", "utf8");
-      u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
-      s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
-    } catch {}
-    fetch(u, {
-      method: "POST",
-      headers: { "content-type": "application/json" },
-      body: JSON.stringify({
-        sessionId: s,
-        runId: "post",
-        hypothesisId: "B",
-        location: "api/chat/ingest.js",
-        msg: "[DEBUG] ingestDocuments upsert done",
-        data: { collection: collectionName, points: points.length },
-        ts: Date.now()
-      })
-    }).catch(() => {});
-  })();
-  // #endregion
-
   return { upserted: points.length };
 }
 
@@ -206,57 +124,8 @@ export async function extractDocumentsFromUpload({ buffer, filename, mimeType, s
   const src = source ?? filename ?? "upload";
   const metadata = { filename: filename ?? null, mimeType: mimeType ?? null, kind };
 
-  // #region debug-point A:extract-start
-  (() => {
-    let u = "http://127.0.0.1:7777/event";
-    let s = "image-rag-miss";
-    try {
-      const e = fs.readFileSync(".dbg/image-rag-miss.env", "utf8");
-      u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
-      s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
-    } catch {}
-    fetch(u, {
-      method: "POST",
-      headers: { "content-type": "application/json" },
-      body: JSON.stringify({
-        sessionId: s,
-        runId: "post",
-        hypothesisId: "A",
-        location: "api/chat/ingest.js",
-        msg: "[DEBUG] extractDocumentsFromUpload start",
-        data: { kind, filename: filename ?? null, mimeType: mimeType ?? null, bytes: buffer?.length ?? null, source: src },
-        ts: Date.now()
-      })
-    }).catch(() => {});
-  })();
-  // #endregion
-
   if (kind === "txt") {
     const text = buffer.toString("utf8").trim();
-    // #region debug-point A:extract-txt
-    (() => {
-      let u = "http://127.0.0.1:7777/event";
-      let s = "image-rag-miss";
-      try {
-        const e = fs.readFileSync(".dbg/image-rag-miss.env", "utf8");
-        u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
-        s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
-      } catch {}
-      fetch(u, {
-        method: "POST",
-        headers: { "content-type": "application/json" },
-        body: JSON.stringify({
-          sessionId: s,
-          runId: "post",
-          hypothesisId: "A",
-          location: "api/chat/ingest.js",
-          msg: "[DEBUG] extract txt done",
-          data: { chars: text.length, head: text.slice(0, 160) },
-          ts: Date.now()
-        })
-      }).catch(() => {});
-    })();
-    // #endregion
     return [{ text, source: src, metadata }];
   }
 
@@ -282,30 +151,6 @@ export async function extractDocumentsFromUpload({ buffer, filename, mimeType, s
 
     const parsed = await pdfParse(buffer);
     const text = String(parsed?.text ?? "").trim();
-    // #region debug-point A:extract-pdf
-    (() => {
-      let u = "http://127.0.0.1:7777/event";
-      let s = "image-rag-miss";
-      try {
-        const e = fs.readFileSync(".dbg/image-rag-miss.env", "utf8");
-        u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
-        s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
-      } catch {}
-      fetch(u, {
-        method: "POST",
-        headers: { "content-type": "application/json" },
-        body: JSON.stringify({
-          sessionId: s,
-          runId: "post",
-          hypothesisId: "A",
-          location: "api/chat/ingest.js",
-          msg: "[DEBUG] extract pdf done",
-          data: { chars: text.length, head: text.slice(0, 160) },
-          ts: Date.now()
-        })
-      }).catch(() => {});
-    })();
-    // #endregion
     return [{ text, source: src, metadata }];
   }
 
@@ -373,38 +218,6 @@ export async function extractDocumentsFromUpload({ buffer, filename, mimeType, s
     if (ocrTexts.length) textParts.push(ocrTexts.join("\n\n"));
 
     const text = textParts.join("\n\n").trim();
-    // #region debug-point A:extract-docx
-    (() => {
-      let u = "http://127.0.0.1:7777/event";
-      let s = "image-rag-miss";
-      try {
-        const e = fs.readFileSync(".dbg/image-rag-miss.env", "utf8");
-        u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
-        s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
-      } catch {}
-      fetch(u, {
-        method: "POST",
-        headers: { "content-type": "application/json" },
-        body: JSON.stringify({
-          sessionId: s,
-          runId: "post",
-          hypothesisId: "A",
-          location: "api/chat/ingest.js",
-          msg: "[DEBUG] extract docx done",
-          data: {
-            baseChars: baseText.length,
-            totalChars: text.length,
-            imagesTotal: images.length,
-            imagesProcessed: images.length - visionSkipped,
-            imagesSkipped: visionSkipped,
-            visionError: visionError || null,
-            head: text.slice(0, 160)
-          },
-          ts: Date.now()
-        })
-      }).catch(() => {});
-    })();
-    // #endregion
     return [
       {
         text,
@@ -437,30 +250,6 @@ export async function extractDocumentsFromUpload({ buffer, filename, mimeType, s
         });
         text = String(r?.content ?? "").trim();
       }
-      // #region debug-point A:extract-image
-      (() => {
-        let u = "http://127.0.0.1:7777/event";
-        let s = "image-rag-miss";
-        try {
-          const e = fs.readFileSync(".dbg/image-rag-miss.env", "utf8");
-          u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
-          s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
-        } catch {}
-        fetch(u, {
-          method: "POST",
-          headers: { "content-type": "application/json" },
-          body: JSON.stringify({
-            sessionId: s,
-            runId: "pre",
-            hypothesisId: "A",
-            location: "api/chat/ingest.js",
-            msg: "[DEBUG] extract image done",
-            data: { chars: text.length, head: text.slice(0, 160) },
-            ts: Date.now()
-          })
-        }).catch(() => {});
-      })();
-      // #endregion
       if (isVisionRefusal(text)) {
         const err = new Error("vision_refused");
         err.statusCode = 400;

+ 0 - 110
chat/searchChat.js

@@ -1,7 +1,6 @@
 import { config } from "../src/config/index.js";
 import { embedTexts } from "../src/services/ollamaClient.js";
 import { qdrant } from "../src/services/qdrantClient.js";
-import fs from "node:fs";
 
 function isRefusalText(text) {
   const t = String(text ?? "").toLowerCase();
@@ -13,60 +12,6 @@ function isRefusalText(text) {
 
 export async function searchDocs({ query, topK }) {
   const collectionName = config.qdrant.collection;
-  // #region debug-point C:image-rag-search-start
-  (() => {
-    let u = "http://127.0.0.1:7777/event";
-    let s = "image-rag-miss";
-    try {
-      const e = fs.readFileSync(".dbg/image-rag-miss.env", "utf8");
-      u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
-      s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
-    } catch {}
-    fetch(u, {
-      method: "POST",
-      headers: { "content-type": "application/json" },
-      body: JSON.stringify({
-        sessionId: s,
-        runId: "post",
-        hypothesisId: "C",
-        location: "api/chat/searchChat.js",
-        msg: "[DEBUG] searchDocs start",
-        data: { collection: collectionName, topK: topK ?? config.rag.topK, queryHead: String(query ?? "").slice(0, 140) },
-        ts: Date.now()
-      })
-    }).catch(() => {});
-  })();
-  // #endregion
-  // #region debug-point B:search-start
-  (() => {
-    let u = "http://127.0.0.1:7777/event";
-    let s = "chat-502-gateway";
-    try {
-      const e = fs.readFileSync(".dbg/chat-502-gateway.env", "utf8");
-      u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
-      s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
-    } catch {}
-    fetch(u, {
-      method: "POST",
-      headers: { "content-type": "application/json" },
-      body: JSON.stringify({
-        sessionId: s,
-        runId: "post-fix",
-        hypothesisId: "B",
-        location: "api/chat/searchChat.js",
-        msg: "[DEBUG] searchDocs start",
-        data: {
-          collection: collectionName,
-          topK: topK ?? config.rag.topK,
-          queryLen: typeof query === "string" ? query.length : null,
-          qdrantUrl: config.qdrant.url,
-          ollamaUrl: config.ollama.url
-        },
-        ts: Date.now()
-      })
-    }).catch(() => {});
-  })();
-  // #endregion
   const [vector] = await embedTexts([query]);
 
   const result = await qdrant.search(collectionName, {
@@ -76,61 +21,6 @@ export async function searchDocs({ query, topK }) {
     with_vector: false
   });
 
-  // #region debug-point C:image-rag-search-result
-  (() => {
-    let u = "http://127.0.0.1:7777/event";
-    let s = "image-rag-miss";
-    try {
-      const e = fs.readFileSync(".dbg/image-rag-miss.env", "utf8");
-      u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
-      s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
-    } catch {}
-    const top = Array.isArray(result) && result.length ? result[0] : null;
-    fetch(u, {
-      method: "POST",
-      headers: { "content-type": "application/json" },
-      body: JSON.stringify({
-        sessionId: s,
-        runId: "post",
-        hypothesisId: "C",
-        location: "api/chat/searchChat.js",
-        msg: "[DEBUG] qdrant.search result",
-        data: {
-          count: Array.isArray(result) ? result.length : null,
-          topScore: top?.score ?? null,
-          topSource: top?.payload?.source ?? null,
-          topTextHead: typeof top?.payload?.text === "string" ? top.payload.text.slice(0, 160) : ""
-        },
-        ts: Date.now()
-      })
-    }).catch(() => {});
-  })();
-  // #endregion
-
-  // #region debug-point B:search-result
-  (() => {
-    let u = "http://127.0.0.1:7777/event";
-    let s = "chat-502-gateway";
-    try {
-      const e = fs.readFileSync(".dbg/chat-502-gateway.env", "utf8");
-      u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
-      s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
-    } catch {}
-    fetch(u, {
-      method: "POST",
-      headers: { "content-type": "application/json" },
-      body: JSON.stringify({
-        sessionId: s,
-        runId: "post-fix",
-        hypothesisId: "B",
-        location: "api/chat/searchChat.js",
-        msg: "[DEBUG] qdrant.search ok",
-        data: { count: Array.isArray(result) ? result.length : null },
-        ts: Date.now()
-      })
-    }).catch(() => {});
-  })();
-  // #endregion
   return (result ?? [])
     .map((r) => ({
       score: r.score,

+ 29 - 0
db/migrations/20260615000001_create_conversations_table.cjs

@@ -0,0 +1,29 @@
+exports.up = function (knex) {
+  return knex.schema
+    .createTable("conversations", (t) => {
+      t.increments("Id");
+      t.integer("UsuarioId").unsigned().notNullable();
+      t.string("Title", 255).notNullable().defaultTo("Nova conversa");
+      t.timestamp("CreatedAt").notNullable().defaultTo(knex.fn.now());
+      t.timestamp("UpdatedAt").notNullable().defaultTo(knex.fn.now());
+
+      t.foreign("UsuarioId").references("usuarios.Id").onDelete("CASCADE");
+      t.index(["UsuarioId"]);
+      t.index(["UpdatedAt"]);
+    })
+    .createTable("messages", (t) => {
+      t.increments("Id");
+      t.integer("ConversationId").unsigned().notNullable();
+      t.string("Role", 10).notNullable();
+      t.text("Content").notNullable();
+      t.json("Sources").nullable();
+      t.timestamp("SentAt").notNullable().defaultTo(knex.fn.now());
+
+      t.foreign("ConversationId").references("conversations.Id").onDelete("CASCADE");
+      t.index(["ConversationId"]);
+    });
+};
+
+exports.down = function (knex) {
+  return knex.schema.dropTable("messages").dropTable("conversations");
+};

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

@@ -1,5 +1,7 @@
 import bcrypt from "bcryptjs";
 import { db } from "../db/knex.js";
+import { signAccessToken, generateRefreshToken, hashRefreshToken } from "../services/authTokens.js";
+import { config } from "../config/index.js";
 
 async function recordFailedAttempt(_login) {}
 
@@ -33,10 +35,7 @@ export const UsuarioController = {
 
     Login: async function (req, res, next) {
         try {
-            const { Login, login, Senha, senha, RemenberMe = false } = req.body ?? {};
-            const userAgent = req.headers["user-agent"] || "unknown";
-            void RemenberMe;
-            void userAgent;
+            const { Login, login, Senha, senha } = req.body ?? {};
 
             const loginBody = Login ?? login;
             const senhaBody = Senha ?? senha;
@@ -64,28 +63,187 @@ export const UsuarioController = {
             const passwordIsValid = bcrypt.compareSync(senhaInformada, String(usuario.Senha ?? ""));
             if (!passwordIsValid) {
                 await recordFailedAttempt(loginInformado);
-                return res.status(401).send({ status: false, msg: "Combinacao de usuario e senho invalida!" });
+                return res.status(401).send({ status: false, msg: "Combinacao de usuario e senha invalida!" });
+            }
+
+            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 expiresAt = new Date(Date.now() + config.jwt.refreshTtlSeconds * 1000);
+                await db("refresh_tokens").insert({
+                    UsuarioId: usuario.Id,
+                    TokenHash: tokenHash,
+                    ExpiresAt: expiresAt
+                });
             }
 
             return res.status(200).send({
                 status: true,
                 msg: "Login realizado com sucesso!",
-                usuario: formatarUsuario(usuario)
+                usuario: formatarUsuario(usuario),
+                ...(accessToken ? { accessToken, refreshToken } : {})
             });
         } catch (error) {
             return next(error);
         }
     },
 
-    Logout: async function (_req, res, next) {
+    Refresh: async function (req, res, next) {
+        try {
+            const { refreshToken } = req.body ?? {};
+            if (!refreshToken) {
+                return res.status(401).send({ status: false, msg: "Refresh token ausente!" });
+            }
+
+            const tokenHash = hashRefreshToken(refreshToken);
+            const stored = await db("refresh_tokens")
+                .where({ TokenHash: tokenHash })
+                .whereNull("RevokedAt")
+                .where("ExpiresAt", ">", new Date())
+                .first();
+
+            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();
+            if (!usuario || String(usuario.Status) === "0") {
+                return res.status(401).send({ status: false, msg: "Usuario inativo!" });
+            }
+
+            const accessToken = signAccessToken({ sub: usuario.Id, login: usuario.Login, nivel: usuario.Nivel });
+
+            return res.status(200).send({ status: true, accessToken });
+        } catch (error) {
+            return next(error);
+        }
+    },
+
+    Logout: async function (req, res, next) {
         try {
+            const { refreshToken } = req.body ?? {};
+            if (refreshToken) {
+                const tokenHash = hashRefreshToken(refreshToken);
+                await db("refresh_tokens")
+                    .where({ TokenHash: tokenHash })
+                    .whereNull("RevokedAt")
+                    .update({ RevokedAt: new Date() });
+            }
             return res.status(200).send({ status: true, msg: "Logout realizado com sucesso!" });
         } catch (error) {
             return next(error);
         }
+    },
+
+    Criar: async function (req, res, next) {
+        try {
+            const { Nome, Login, Email, Senha, Nivel, Setor } = req.body ?? {};
+
+            if (!Nome || !Login || !Email || !Senha || !Nivel || !Setor) {
+                return res.status(400).send({ status: false, msg: "Todos os campos são obrigatórios!" });
+            }
+
+            const loginTrimado = String(Login).trim();
+            const existente = await db("usuarios").where({ Login: loginTrimado }).first();
+            if (existente) {
+                return res.status(409).send({ status: false, msg: "Login já cadastrado!" });
+            }
+
+            const senhaHash = bcrypt.hashSync(String(Senha), 10);
+            const [id] = await db("usuarios").insert({
+                Nome: String(Nome).trim(),
+                Login: loginTrimado,
+                Email: String(Email).trim(),
+                Senha: senhaHash,
+                Status: "1",
+                Nivel: String(Nivel),
+                Setor: String(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);
+        }
+    },
+
+    Atualizar: async function (req, res, next) {
+        try {
+            const id = Number(req.params.id);
+            if (!id) return res.status(400).send({ status: false, msg: "ID inválido!" });
+
+            const { Nome, Email, Nivel, Setor } = req.body ?? {};
+            const updates = {};
+            if (Nome) updates.Nome = String(Nome).trim();
+            if (Email) updates.Email = String(Email).trim();
+            if (Nivel !== undefined) updates.Nivel = String(Nivel);
+            if (Setor) updates.Setor = String(Setor).trim();
+
+            if (Object.keys(updates).length === 0) {
+                return res.status(400).send({ status: false, msg: "Nenhum campo para atualizar!" });
+            }
+
+            await db("usuarios").where({ Id: id }).update(updates);
+            const usuario = await db("usuarios").where({ Id: id }).first();
+            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) });
+        } catch (error) {
+            return next(error);
+        }
+    },
+
+    ToggleStatus: async function (req, res, next) {
+        try {
+            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();
+            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 });
+
+            return res.status(200).send({ status: true, novoStatus });
+        } catch (error) {
+            return next(error);
+        }
+    },
+
+    AlterarSenha: async function (req, res, next) {
+        try {
+            const id = Number(req.params.id);
+            if (!id) return res.status(400).send({ status: false, msg: "ID inválido!" });
+
+            const { senhaAtual, senhaNova } = req.body ?? {};
+            if (!senhaAtual || !senhaNova) {
+                return res.status(400).send({ status: false, msg: "Senhas obrigatórias!" });
+            }
+
+            const usuario = await db("usuarios").where({ Id: id }).first();
+            if (!usuario) return res.status(404).send({ status: false, msg: "Usuário não encontrado!" });
+
+            const senhaValida = bcrypt.compareSync(String(senhaAtual), String(usuario.Senha ?? ""));
+            if (!senhaValida) {
+                return res.status(401).send({ status: false, msg: "Senha atual incorreta!" });
+            }
+
+            if (String(senhaNova).length < 6) {
+                return res.status(400).send({ status: false, msg: "Nova senha deve ter pelo menos 6 caracteres!" });
+            }
+
+            const novaHash = bcrypt.hashSync(String(senhaNova), 10);
+            await db("usuarios").where({ Id: id }).update({ Senha: novaHash });
+
+            return res.status(200).send({ status: true, msg: "Senha alterada com sucesso!" });
+        } catch (error) {
+            return next(error);
+        }
     }
- 
-     
 };
 
 export default UsuarioController;

+ 1 - 1
src/middleware/auth.js

@@ -10,7 +10,7 @@ export function authMiddleware(req, res, next) {
   const header = req.header("authorization") ?? "";
   const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length).trim() : "";
 
-  const isPublic = req.path === "/auth/login" || req.path === "/auth/logout";
+  const isPublic = req.path === "/auth/login" || req.path === "/auth/logout" || req.path === "/auth/refresh";
 
   const hasAuthConfigured = Boolean(config.apiKey || config.jwt.secret);
   if (!hasAuthConfigured) return next();

+ 1 - 0
src/routes/auth.js

@@ -5,3 +5,4 @@ export const authRouter = Router();
 
 authRouter.post("/login", UsuarioController.Login);
 authRouter.post("/logout", UsuarioController.Logout);
+authRouter.post("/refresh", UsuarioController.Refresh);

+ 57 - 64
src/routes/chat.js

@@ -1,12 +1,13 @@
 import { Router } from "express";
 import { z } from "zod";
-import { answerWithContext } from "../../chat/chatChain.js";
-import fs from "node:fs";
+import { answerWithContext, answerWithContextStream } from "../../chat/chatChain.js";
+import { addMessage } from "../services/conversationsService.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
@@ -19,74 +20,66 @@ const chatBodySchema = z.object({
 
 chatRouter.post("/", async (req, res, next) => {
   try {
-    // #region debug-point A:chat-start
-    (() => {
-      let u = "http://127.0.0.1:7777/event";
-      let s = "chat-502-gateway";
-      try {
-        const e = fs.readFileSync(".dbg/chat-502-gateway.env", "utf8");
-        u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
-        s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
-      } catch {}
-      fetch(u, {
-        method: "POST",
-        headers: { "content-type": "application/json" },
-        body: JSON.stringify({
-          sessionId: s,
-          runId: "post-fix",
-          hypothesisId: "A",
-          location: "api/routes/chat.js",
-          msg: "[DEBUG] /api/chat received",
-          data: {
-            method: req.method,
-            path: req.originalUrl,
-            contentType: req.header("content-type") ?? "",
-            bodyType: typeof req.body,
-            hasBody: Boolean(req.body),
-            messageLen: typeof req.body?.message === "string" ? req.body.message.length : null
-          },
-          ts: Date.now()
-        })
-      }).catch(() => {});
-    })();
-    // #endregion
     const body = chatBodySchema.parse(req.body);
     const result = await answerWithContext({
       message: body.message,
-      sessionId: body.sessionId,
-      model: body.model,
-      options: body.options
+      sessionId: body.sessionId ?? (body.conversationId ? String(body.conversationId) : undefined)
     });
+
+    const userId = req.user?.sub;
+    if (userId && body.conversationId) {
+      Promise.all([
+        addMessage(body.conversationId, { role: "user", content: body.message }),
+        addMessage(body.conversationId, { role: "assistant", content: result.answer, sources: result.sources })
+      ]).catch(() => {});
+    }
+
     res.json(result);
   } catch (err) {
-    // #region debug-point D:chat-error
-    (() => {
-      let u = "http://127.0.0.1:7777/event";
-      let s = "chat-502-gateway";
-      try {
-        const e = fs.readFileSync(".dbg/chat-502-gateway.env", "utf8");
-        u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
-        s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
-      } catch {}
-      fetch(u, {
-        method: "POST",
-        headers: { "content-type": "application/json" },
-        body: JSON.stringify({
-          sessionId: s,
-          runId: "post-fix",
-          hypothesisId: "D",
-          location: "api/routes/chat.js",
-          msg: "[DEBUG] /api/chat error",
-          data: {
-            name: err?.name ?? null,
-            message: typeof err?.message === "string" ? err.message : null,
-            statusCode: err?.statusCode ?? err?.status ?? null
-          },
-          ts: Date.now()
-        })
-      }).catch(() => {});
-    })();
-    // #endregion
     next(err);
   }
 });
+
+chatRouter.post("/stream", 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,
+      sessionId: body.sessionId ?? (body.conversationId ? String(body.conversationId) : undefined),
+      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();
+
+    const userId = req.user?.sub;
+    if (userId && body.conversationId) {
+      Promise.all([
+        addMessage(body.conversationId, { role: "user", content: body.message }),
+        addMessage(body.conversationId, { role: "assistant", content: result.answer, sources: result.sources })
+      ]).catch(() => {});
+    }
+  } 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();
+    }
+  }
+});

+ 75 - 0
src/routes/conversations.js

@@ -0,0 +1,75 @@
+import { Router } from "express";
+import { z } from "zod";
+import {
+  listConversations,
+  createConversation,
+  getConversationMessages,
+  updateConversationTitle,
+  deleteConversation
+} from "../services/conversationsService.js";
+
+export const conversationsRouter = Router();
+
+conversationsRouter.get("/", async (req, res, next) => {
+  try {
+    const userId = req.user?.sub;
+    if (!userId) return res.status(401).json({ error: "unauthorized" });
+    const items = await listConversations(userId);
+    res.json({ items });
+  } catch (err) {
+    next(err);
+  }
+});
+
+conversationsRouter.post("/", async (req, res, next) => {
+  try {
+    const userId = req.user?.sub;
+    if (!userId) return res.status(401).json({ error: "unauthorized" });
+    const title = z.string().max(200).catch("Nova conversa").parse(req.body?.title);
+    const conv = await createConversation(userId, title);
+    res.json(conv);
+  } catch (err) {
+    next(err);
+  }
+});
+
+conversationsRouter.get("/:id/messages", async (req, res, next) => {
+  try {
+    const userId = req.user?.sub;
+    if (!userId) return res.status(401).json({ error: "unauthorized" });
+    const conversationId = Number(req.params.id);
+    if (!conversationId) return res.status(400).json({ error: "invalid_id" });
+    const msgs = await getConversationMessages(conversationId, userId);
+    if (!msgs) return res.status(404).json({ error: "not_found" });
+    res.json({ items: msgs });
+  } catch (err) {
+    next(err);
+  }
+});
+
+conversationsRouter.patch("/:id", async (req, res, next) => {
+  try {
+    const userId = req.user?.sub;
+    if (!userId) return res.status(401).json({ error: "unauthorized" });
+    const conversationId = Number(req.params.id);
+    if (!conversationId) return res.status(400).json({ error: "invalid_id" });
+    const title = z.string().min(1).max(200).parse(req.body?.title);
+    await updateConversationTitle(conversationId, userId, title);
+    res.json({ ok: true });
+  } catch (err) {
+    next(err);
+  }
+});
+
+conversationsRouter.delete("/:id", async (req, res, next) => {
+  try {
+    const userId = req.user?.sub;
+    if (!userId) return res.status(401).json({ error: "unauthorized" });
+    const conversationId = Number(req.params.id);
+    if (!conversationId) return res.status(400).json({ error: "invalid_id" });
+    await deleteConversation(conversationId, userId);
+    res.json({ ok: true });
+  } catch (err) {
+    next(err);
+  }
+});

+ 14 - 1
src/routes/documents.js

@@ -1,6 +1,6 @@
 import { Router } from "express";
 import { z } from "zod";
-import { listDocuments } from "../services/documentsService.js";
+import { listDocuments, deleteDocumentsBySource } from "../services/documentsService.js";
 
 export const documentsRouter = Router();
 
@@ -15,3 +15,16 @@ documentsRouter.get("/", async (req, res, next) => {
     next(err);
   }
 });
+
+documentsRouter.delete("/source/:source", async (req, res, next) => {
+  try {
+    const source = req.params.source;
+    if (!source) {
+      return res.status(400).json({ error: "source_required" });
+    }
+    await deleteDocumentsBySource(source);
+    res.json({ ok: true });
+  } catch (err) {
+    next(err);
+  }
+});

+ 2 - 0
src/routes/index.js

@@ -5,6 +5,7 @@ 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";
 
 export const apiRouter = Router();
 
@@ -14,3 +15,4 @@ apiRouter.use("/chat", chatRouter);
 apiRouter.use("/search", searchRouter);
 apiRouter.use("/ingest", ingestRouter);
 apiRouter.use("/documents", documentsRouter);
+apiRouter.use("/conversations", conversationsRouter);

+ 0 - 49
src/routes/ingest.js

@@ -2,7 +2,6 @@ import { Router } from "express";
 import { z } from "zod";
 import multer from "multer";
 import { extractDocumentsFromUpload, ingestDocuments } from "../../chat/ingest.js";
-import fs from "node:fs";
 
 export const ingestRouter = Router();
 
@@ -41,30 +40,6 @@ ingestRouter.post("/file", upload.single("file"), async (req, res, next) => {
     }
 
     const source = typeof req.body?.source === "string" && req.body.source.trim() ? req.body.source.trim() : undefined;
-    // #region debug-point A:ingest-file-start
-    (() => {
-      let u = "http://127.0.0.1:7777/event";
-      let s = "image-rag-miss";
-      try {
-        const e = fs.readFileSync(".dbg/image-rag-miss.env", "utf8");
-        u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
-        s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
-      } catch {}
-      fetch(u, {
-        method: "POST",
-        headers: { "content-type": "application/json" },
-        body: JSON.stringify({
-          sessionId: s,
-          runId: "post",
-          hypothesisId: "A",
-          location: "api/routes/ingest.js",
-          msg: "[DEBUG] /api/ingest/file received",
-          data: { filename: f.originalname, mimeType: f.mimetype, bytes: f.size ?? null, source: source ?? null },
-          ts: Date.now()
-        })
-      }).catch(() => {});
-    })();
-    // #endregion
     const docs = await extractDocumentsFromUpload({
       buffer: f.buffer,
       filename: f.originalname,
@@ -73,30 +48,6 @@ ingestRouter.post("/file", upload.single("file"), async (req, res, next) => {
     });
 
     const extractedChars = docs.reduce((acc, d) => acc + (d?.text?.length ?? 0), 0);
-    // #region debug-point A:ingest-file-extracted
-    (() => {
-      let u = "http://127.0.0.1:7777/event";
-      let s = "image-rag-miss";
-      try {
-        const e = fs.readFileSync(".dbg/image-rag-miss.env", "utf8");
-        u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
-        s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
-      } catch {}
-      fetch(u, {
-        method: "POST",
-        headers: { "content-type": "application/json" },
-        body: JSON.stringify({
-          sessionId: s,
-          runId: "post",
-          hypothesisId: "A",
-          location: "api/routes/ingest.js",
-          msg: "[DEBUG] /api/ingest/file extracted",
-          data: { documents: docs.length, extractedChars, head: String(docs?.[0]?.text ?? "").slice(0, 160) },
-          ts: Date.now()
-        })
-      }).catch(() => {});
-    })();
-    // #endregion
     if (!extractedChars) {
       res.status(400).json({ error: "empty_extracted_text" });
       return;

+ 4 - 0
src/routes/users.js

@@ -4,3 +4,7 @@ import { UsuarioController } from "../controllers/Usuario.Controller.js";
 export const usersRouter = Router();
 
 usersRouter.get("/", UsuarioController.Listar);
+usersRouter.post("/", UsuarioController.Criar);
+usersRouter.put("/:id", UsuarioController.Atualizar);
+usersRouter.patch("/:id/status", UsuarioController.ToggleStatus);
+usersRouter.patch("/:id/senha", UsuarioController.AlterarSenha);

+ 50 - 0
src/services/conversationsService.js

@@ -0,0 +1,50 @@
+import { db } from "../db/knex.js";
+
+export async function listConversations(userId) {
+  return db("conversations")
+    .where({ UsuarioId: userId })
+    .orderBy("UpdatedAt", "desc")
+    .select("Id", "Title", "CreatedAt", "UpdatedAt");
+}
+
+export async function createConversation(userId, title = "Nova conversa") {
+  const [id] = await db("conversations").insert({
+    UsuarioId: userId,
+    Title: String(title).slice(0, 255)
+  });
+  return { id, title };
+}
+
+export async function getConversationMessages(conversationId, userId) {
+  const conv = await db("conversations").where({ Id: conversationId, UsuarioId: userId }).first();
+  if (!conv) return null;
+
+  const msgs = await db("messages")
+    .where({ ConversationId: conversationId })
+    .orderBy("SentAt", "asc")
+    .select("Id", "Role", "Content", "Sources", "SentAt");
+
+  return msgs;
+}
+
+export async function addMessage(conversationId, { role, content, sources = null }) {
+  await db("messages").insert({
+    ConversationId: conversationId,
+    Role: role,
+    Content: content,
+    Sources: sources ? JSON.stringify(sources) : null
+  });
+  await db("conversations")
+    .where({ Id: conversationId })
+    .update({ UpdatedAt: db.fn.now() });
+}
+
+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() });
+}
+
+export async function deleteConversation(conversationId, userId) {
+  await db("conversations").where({ Id: conversationId, UsuarioId: userId }).delete();
+}

+ 10 - 0
src/services/documentsService.js

@@ -30,3 +30,13 @@ export async function listDocuments({ limit, offset }) {
   const nextOffset = points?.next_page_offset ?? points?.nextPageOffset ?? null;
   return { items, nextOffset };
 }
+
+export async function deleteDocumentsBySource(source) {
+  const collectionName = config.qdrant.collection;
+  await qdrant.delete(collectionName, {
+    wait: true,
+    filter: {
+      must: [{ key: "source", match: { value: source } }]
+    }
+  });
+}

+ 44 - 76
src/services/ollamaClient.js

@@ -1,85 +1,14 @@
 import { config } from "../config/index.js";
-import fs from "node:fs";
 
 async function ollamaFetch(path, body) {
-  let res;
-  try {
-    res = await fetch(`${config.ollama.url}${path}`, {
-      method: "POST",
-      headers: { "content-type": "application/json" },
-      body: JSON.stringify(body)
-    });
-  } catch (err) {
-    // #region debug-point C:ollama-fetch-throw
-    (() => {
-      let u = "http://127.0.0.1:7777/event";
-      let s = "chat-502-gateway";
-      try {
-        const e = fs.readFileSync(".dbg/chat-502-gateway.env", "utf8");
-        u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
-        s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
-      } catch {}
-      fetch(u, {
-        method: "POST",
-        headers: { "content-type": "application/json" },
-        body: JSON.stringify({
-          sessionId: s,
-          runId: "post-fix",
-          hypothesisId: "C",
-          location: "api/services/ollamaClient.js",
-          msg: "[DEBUG] ollama fetch threw",
-          data: {
-            url: config.ollama.url,
-            path,
-            model: body?.model ?? null,
-            promptLen: typeof body?.prompt === "string" ? body.prompt.length : null,
-            messagesCount: Array.isArray(body?.messages) ? body.messages.length : null,
-            name: err?.name ?? null,
-            message: typeof err?.message === "string" ? err.message : null
-          },
-          ts: Date.now()
-        })
-      }).catch(() => {});
-    })();
-    // #endregion
-    throw err;
-  }
+  const res = await fetch(`${config.ollama.url}${path}`, {
+    method: "POST",
+    headers: { "content-type": "application/json" },
+    body: JSON.stringify(body)
+  });
 
   if (!res.ok) {
     const text = await res.text().catch(() => "");
-    // #region debug-point C:ollama-non-200
-    (() => {
-      let u = "http://127.0.0.1:7777/event";
-      let s = "chat-502-gateway";
-      try {
-        const e = fs.readFileSync(".dbg/chat-502-gateway.env", "utf8");
-        u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
-        s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
-      } catch {}
-      fetch(u, {
-        method: "POST",
-        headers: { "content-type": "application/json" },
-        body: JSON.stringify({
-          sessionId: s,
-          runId: "post-fix",
-          hypothesisId: "C",
-          location: "api/services/ollamaClient.js",
-          msg: "[DEBUG] ollama non-200",
-          data: {
-            url: config.ollama.url,
-            path,
-            status: res.status,
-            statusText: res.statusText,
-            model: body?.model ?? null,
-            promptLen: typeof body?.prompt === "string" ? body.prompt.length : null,
-            messagesCount: Array.isArray(body?.messages) ? body.messages.length : null,
-            responseSnippet: typeof text === "string" ? text.slice(0, 300) : ""
-          },
-          ts: Date.now()
-        })
-      }).catch(() => {});
-    })();
-    // #endregion
     let msg = `ollama_error:${res.status}:${text || res.statusText}`;
     if (
       res.status === 404 &&
@@ -131,6 +60,45 @@ export async function chatCompletion({ messages }) {
   };
 }
 
+export async function chatCompletionStream({ messages, onChunk, signal }) {
+  const res = await fetch(`${config.ollama.url}/api/chat`, {
+    method: "POST",
+    headers: { "content-type": "application/json" },
+    body: JSON.stringify({ model: config.ollama.chatModel, messages, stream: true }),
+    signal
+  });
+
+  if (!res.ok) {
+    const text = await res.text().catch(() => "");
+    const err = new Error(`ollama_error:${res.status}:${text || res.statusText}`);
+    err.statusCode = 502;
+    throw err;
+  }
+
+  const reader = res.body.getReader();
+  const decoder = new TextDecoder();
+  let fullContent = "";
+
+  while (true) {
+    const { done, value } = await reader.read();
+    if (done) break;
+    const text = decoder.decode(value, { stream: true });
+    for (const line of text.split("\n")) {
+      if (!line.trim()) continue;
+      try {
+        const parsed = JSON.parse(line);
+        const delta = parsed?.message?.content ?? "";
+        if (delta) {
+          fullContent += delta;
+          onChunk(delta);
+        }
+      } catch {}
+    }
+  }
+
+  return { content: fullContent };
+}
+
 export async function visionExtractFromImage({ imageBase64, prompt }) {
   const data = await ollamaFetch("/api/chat", {
     model: config.ollama.visionModel,