diff --git a/.env.example b/.env.example index a3394ef..b06bf70 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,12 @@ OLLAMA_LLM_MODEL=qwen2.5:1.5b OLLAMA_EMBEDDING_MODEL=bge-m3 OLLAMA_KEEP_ALIVE=24h +# --- Documentos enviados (S1-19/S1-22) --- +# Limite de tamanho por arquivo, aplicado no backend e informado ao cliente +DOCUMENT_MAX_SIZE_MB=20 +# Webhook do n8n que recebe o evento document.removed (vazio mantém o evento pendente) +DOCUMENT_EVENTS_WEBHOOK_URL= + # --- Aplicações e Microsserviços --- BACKEND_PORT=3001 FRONTEND_PORT=5173 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07fd9db..1fd4e77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,7 @@ jobs: - run: npm run test:seed working-directory: backend - name: Test archive cascade on PostgreSQL - run: node --import tsx --test src/modules/projects/projects.archive.db.test.ts src/modules/projects/hierarchy-archive.db.test.ts src/modules/projects/projects.backlog-tree.db.test.ts src/database/migration-010.db.test.ts src/database/migration-011.db.test.ts + run: node --import tsx --test src/modules/projects/projects.archive.db.test.ts src/modules/projects/hierarchy-archive.db.test.ts src/modules/projects/projects.backlog-tree.db.test.ts src/database/migration-010.db.test.ts src/database/migration-011.db.test.ts src/database/migration-012.db.test.ts src/modules/documents/documents.repository.db.test.ts src/modules/chat/chat.repository.db.test.ts working-directory: backend env: ARCHIVE_TEST_DATABASE_URL: postgresql://seed_test:seed_test_only@localhost:5432/sinapse_seed_test diff --git a/.gitignore b/.gitignore index cc01692..54ccdce 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,7 @@ n8n/local-files/* .coverage htmlcov/ .pytest_cache/ + +# armazenamento local de documentos enviados +backend/storage/ +storage/ diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 2f86325..d2c7adb 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -25,6 +25,11 @@ const envSchema = z.object({ AUTH_LOCKOUT_MINUTES: z.coerce.number().int().min(1).default(15), AUTH_SESSION_IDLE_MINUTES: z.coerce.number().int().min(1).default(30), AUTH_SESSION_MAX_HOURS: z.coerce.number().int().min(1).default(12), + + // S1-19/S1-22 — Documentos + DOCUMENT_MAX_SIZE_MB: z.coerce.number().positive().max(100).default(20), + DOCUMENT_STORAGE_DIR: z.string().min(1).default("storage/documents"), + DOCUMENT_EVENTS_WEBHOOK_URL: z.string().optional(), }); export const env = envSchema.parse(process.env); \ No newline at end of file diff --git a/backend/src/database/migration-012.db.test.ts b/backend/src/database/migration-012.db.test.ts new file mode 100644 index 0000000..ba7deec --- /dev/null +++ b/backend/src/database/migration-012.db.test.ts @@ -0,0 +1,136 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { readFile, readdir } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { Client } from "pg"; +import { validateTarget } from "./seed-lib.js"; + +const migrationsDir = resolve(process.cwd(), "../database/migrations"); +const DOCUMENT_MIGRATION = "012_document_upload.sql"; + +async function migrationFiles(): Promise { + return (await readdir(migrationsDir)).filter((file) => /^\d+_.*\.sql$/.test(file)).sort(); +} + +function connectionFor(adminUrl: string, database: string): string { + const url = new URL(adminUrl); + url.pathname = `/${database}`; + return url.toString(); +} + +async function loadMigration(file: string, vectorAvailable: boolean): Promise { + let sql = await readFile(join(migrationsDir, file), "utf8"); + sql = sql.replaceAll("\\ir ../init.sql", await readFile(join(migrationsDir, "../init.sql"), "utf8")); + if (vectorAvailable) return sql; + return sql + .replace(/CREATE EXTENSION IF NOT EXISTS vector;/g, "") + .replace(/vector\(1024\)/g, "real[]") + .replace(/^.*USING hnsw.*$/gm, ""); +} + +async function applyUntil(client: Client, vectorAvailable: boolean, stop: (file: string) => boolean): Promise { + await client.query("CREATE TABLE IF NOT EXISTS _schema_migrations (version VARCHAR(255) PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP)"); + for (const file of await migrationFiles()) { + if (stop(file)) return; + const applied = await client.query("SELECT 1 FROM _schema_migrations WHERE version=$1", [file]); + if (applied.rowCount) continue; + await client.query(await loadMigration(file, vectorAvailable)); + await client.query("INSERT INTO _schema_migrations (version) VALUES ($1)", [file]); + } +} + +async function withDatabase(adminUrl: string, run: (client: Client, vectorAvailable: boolean) => Promise): Promise { + const admin = new Client({ connectionString: adminUrl }); + await admin.connect(); + const database = `migration012_${randomUUID().replaceAll("-", "").slice(0, 16)}_test`; + const vector = await admin.query("SELECT 1 FROM pg_available_extensions WHERE name='vector'"); + const vectorAvailable = Boolean(vector.rowCount); + await admin.query(`CREATE DATABASE ${database}`); + const client = new Client({ connectionString: connectionFor(adminUrl, database) }); + try { + await client.connect(); + await run(client, vectorAvailable); + } finally { + await client.end().catch(() => undefined); + await admin.query(`DROP DATABASE IF EXISTS ${database} WITH (FORCE)`); + await admin.end(); + } +} + +test("migrations: a 012 sucede a 011 e o runner usa nomes completos sem colisão", { skip: !process.env.ARCHIVE_TEST_DATABASE_URL }, async () => { + const files = await migrationFiles(); + assert.equal(new Set(files).size, files.length); + const index011 = files.indexOf("011_unique_entity_technology.sql"); + const index012 = files.indexOf(DOCUMENT_MIGRATION); + assert.ok(index011 >= 0 && index012 > index011, "012 deve ser aplicada depois da 011"); + assert.equal(files.filter((file) => file.startsWith("012_")).length, 1); +}); + +test("migration 012 em banco limpo cria estruturas, índices e restrições", { skip: !process.env.ARCHIVE_TEST_DATABASE_URL }, async () => { + await withDatabase(validateTarget(process.env.ARCHIVE_TEST_DATABASE_URL, "test"), async (client, vectorAvailable) => { + await applyUntil(client, vectorAvailable, () => false); + const columns = (await client.query("SELECT column_name FROM information_schema.columns WHERE table_name='documento'")).rows.map((row) => row.column_name); + for (const column of ["extensao", "tamanho_bytes", "usuario_id"]) assert.ok(columns.includes(column), column); + const tables = (await client.query("SELECT table_name FROM information_schema.tables WHERE table_schema='public'")).rows.map((row) => row.table_name); + assert.ok(tables.includes("evento_integracao")); + assert.ok(tables.includes("documento_operacao_armazenamento")); + const indexes = (await client.query("SELECT indexname FROM pg_indexes WHERE schemaname='public'")).rows.map((row) => row.indexname); + for (const name of ["idx_documento_projeto_created", "idx_chunk_documento", "idx_evento_integracao_delivery", "idx_documento_storage_pending"]) { + assert.ok(indexes.includes(name), name); + } + + const project = randomUUID(); + const document = randomUUID(); + await client.query("INSERT INTO projeto (id,nome,cliente,status) VALUES ($1,'P012','Teste','ativo')", [project]); + await assert.rejects( + client.query("INSERT INTO documento (id,projeto_id,nome,caminho,tamanho_bytes) VALUES ($1,$2,'zero.txt','x',0)", [document, project]), + /ck_documento_tamanho/, + ); + await assert.rejects( + client.query("INSERT INTO documento (id,projeto_id,nome,caminho,status_processamento) VALUES ($1,$2,'x.txt','x','inventado')", [document, project]), + /ck_documento_status/, + ); + await client.query("INSERT INTO evento_integracao (tipo,chave_idempotencia,payload) VALUES ('document.removed','k1','{}')"); + await assert.rejects(client.query("INSERT INTO evento_integracao (tipo,chave_idempotencia,payload) VALUES ('document.removed','k1','{}')"), /uq_evento_integracao_chave/); + await assert.rejects(client.query("INSERT INTO evento_integracao (tipo,chave_idempotencia,payload,status) VALUES ('t','k2','{}','x')"), /ck_evento_integracao_status/); + await client.query("INSERT INTO documento_operacao_armazenamento (documento_id,projeto_id,acao,caminho) VALUES ($1,$2,'finalizar_upload','a/b')", [document, project]); + await assert.rejects( + client.query("INSERT INTO documento_operacao_armazenamento (documento_id,projeto_id,acao,caminho) VALUES ($1,$2,'finalizar_upload','a/b')", [document, project]), + /uq_documento_storage_action/, + ); + await assert.rejects( + client.query("INSERT INTO documento_operacao_armazenamento (documento_id,projeto_id,acao,caminho) VALUES ($1,$2,'apagar_tudo','a/b')", [document, project]), + ); + }); +}); + +test("migration 012 em banco existente na 011 preserva dados legados e é idempotente", { skip: !process.env.ARCHIVE_TEST_DATABASE_URL }, async () => { + await withDatabase(validateTarget(process.env.ARCHIVE_TEST_DATABASE_URL, "test"), async (client, vectorAvailable) => { + await applyUntil(client, vectorAvailable, (file) => file === DOCUMENT_MIGRATION); + assert.equal((await client.query("SELECT 1 FROM _schema_migrations WHERE version='011_unique_entity_technology.sql'")).rowCount, 1); + assert.equal((await client.query("SELECT 1 FROM _schema_migrations WHERE version=$1", [DOCUMENT_MIGRATION])).rowCount, 0); + + const project = randomUUID(); + const legacy = randomUUID(); + const chunk = randomUUID(); + await client.query("INSERT INTO projeto (id,nome,cliente,status) VALUES ($1,'Legado','Teste','ativo')", [project]); + await client.query("INSERT INTO documento (id,projeto_id,nome,mime,caminho,status_processamento) VALUES ($1,$2,'legado.pdf','application/pdf','/legado/legado.pdf','processado')", [legacy, project]); + await client.query("INSERT INTO chunk (id,projeto_id,entidade_tipo,entidade_id,texto) VALUES ($1,$2,'documento',$3,'trecho legado')", [chunk, project, legacy]); + + await applyUntil(client, vectorAvailable, () => false); + const row = (await client.query("SELECT nome, mime, caminho, status_processamento, extensao, tamanho_bytes, usuario_id FROM documento WHERE id=$1", [legacy])).rows[0]; + assert.deepEqual(row, { + nome: "legado.pdf", mime: "application/pdf", caminho: "/legado/legado.pdf", status_processamento: "processado", + extensao: null, tamanho_bytes: null, usuario_id: null, + }); + assert.equal((await client.query("SELECT count(*)::int AS n FROM chunk WHERE id=$1", [chunk])).rows[0].n, 1); + + const migration = await loadMigration(DOCUMENT_MIGRATION, vectorAvailable); + await client.query(migration); + await client.query(migration); + assert.equal((await client.query("SELECT count(*)::int AS n FROM documento WHERE id=$1", [legacy])).rows[0].n, 1); + const versions = (await client.query("SELECT version FROM _schema_migrations ORDER BY version")).rows.map((item) => item.version); + assert.deepEqual(versions, await migrationFiles()); + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index 572f62f..b1fb973 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -12,8 +12,9 @@ import { criteriaRouter } from "./modules/criteria/criteria.routes.js"; import qualityRouter from "./modules/quality/quality.routes.js"; import { epicsCompatRouter } from "./modules/epics/epics.compat.routes.js"; import { repoAnalysesRouter } from './modules/repo-analyses/repo-analyses.routes'; -import { authRouter } from "./modules/auth/auth.routes.js"; import { documentsRouter } from "./modules/documents/documents.routes.js"; +import { documentsService, startDocumentsBackgroundWorker } from "./modules/documents/documents.service.js"; +import { authRouter } from "./modules/auth/auth.routes.js"; import { searchRouter } from "./modules/search/search.routes.js"; import { chatRouter } from "./modules/chat/chat.routes.js"; import { developersRouter } from "./modules/developers/developers.routes.js"; @@ -33,6 +34,7 @@ app.use("/api/v1/auth", authRouter); // Health Check Endpoint app.get("/health", async (_req: Request, res: Response) => { const dbHealthy = await checkDatabaseConnection(); + const documents = dbHealthy ? await documentsService.health().catch(() => null) : null; res.status(dbHealthy ? 200 : 503).json({ status: dbHealthy ? "healthy" : "degraded", @@ -43,6 +45,7 @@ app.get("/health", async (_req: Request, res: Response) => { database: dbHealthy ? "connected" : "disconnected", aiService: env.AI_SERVICE_URL, }, + documents, }); }); @@ -61,10 +64,6 @@ app.use("/api/v1/criteria", requireAuth, criteriaRouter); app.use("/api/v1/quality", qualityRouter); app.use("/api/v1/audit", auditRouter); -// Documentos do projeto -app.use("/api/v1/projects/:projectId/documents", documentsRouter); -app.use("/api/v1/documents", documentsRouter); - // Busca híbrida e acervo app.use("/api/v1/search", searchRouter); @@ -81,6 +80,9 @@ app.use("/api/v1/admin", adminRouter); // Repo analyzer app.use('/api/v1/projects/:projectId/repo-analyses', repoAnalysesRouter); +// Documentos do projeto (S1-19/S1-20/S1-22) +app.use("/api/v1/projects/:projectId/documents", requireAuth, documentsRouter); + // Swagger Documentation app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec)); @@ -108,6 +110,10 @@ app.use(errorHandler); const PORT = env.PORT; if (env.NODE_ENV !== "test") { + startDocumentsBackgroundWorker(); + if (!env.DOCUMENT_EVENTS_WEBHOOK_URL?.trim()) { + console.warn("[Documents] DOCUMENT_EVENTS_WEBHOOK_URL is not configured; removal events will retry until a consumer is configured."); + } app.listen(PORT, () => { console.log(`[Sinapse Backend] Servidor iniciado na porta ${PORT}`); console.log(`[Sinapse Backend] Healthcheck em http://localhost:${PORT}/health`); diff --git a/backend/src/middleware/errorHandler.ts b/backend/src/middleware/errorHandler.ts index 1aa3169..d34b29d 100644 --- a/backend/src/middleware/errorHandler.ts +++ b/backend/src/middleware/errorHandler.ts @@ -30,6 +30,16 @@ export function errorHandler( return; } + if ((err as { type?: string }).type === "entity.too.large") { + const limit = (err as { limit?: unknown }).limit; + res.status(413).json({ + error: "O arquivo excede o tamanho máximo permitido.", + code: "PAYLOAD_TOO_LARGE", + details: typeof limit === "number" ? { max_bytes: limit } : undefined, + }); + return; + } + if (err instanceof SyntaxError && (err as { type?: string }).type === "entity.parse.failed") { res.status(400).json({ error: "Corpo JSON inválido.", diff --git a/backend/src/middleware/requireAuth.ts b/backend/src/middleware/requireAuth.ts index dca0c91..ad2c5ad 100644 --- a/backend/src/middleware/requireAuth.ts +++ b/backend/src/middleware/requireAuth.ts @@ -9,6 +9,13 @@ import { sessionService, } from "../modules/auth/session.service.js"; +export function extractSessionToken(req: Request): string | undefined { + const authHeader = req.headers.authorization; + if (authHeader && authHeader.startsWith("Bearer ")) return authHeader.split(" ")[1]; + const match = req.headers.cookie?.match(/(?:^|;\s*)sinapse_session=([^;]+)/); + return match?.[1]; +} + export function createRequireAuth( service: SessionService = sessionService, ) { @@ -18,17 +25,7 @@ export function createRequireAuth( next: NextFunction, ): Promise => { try { - let token: string | undefined; - - const authHeader = req.headers.authorization; - if (authHeader && authHeader.startsWith("Bearer ")) { - token = authHeader.split(" ")[1]; - } else if (req.headers.cookie) { - const match = req.headers.cookie.match(/(?:^|;\s*)sinapse_session=([^;]+)/); - if (match) { - token = match[1]; - } - } + const token = extractSessionToken(req); if (!token) { res.status(401).json({ diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index bd2d0b9..7a4a72d 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -15,6 +15,7 @@ import { SessionService, } from "./session.service.js"; import { loginSchema, registerSchema } from "./auth.types.js"; +import { extractSessionToken } from "../../middleware/requireAuth.js"; export class AuthController { constructor( @@ -39,6 +40,18 @@ export class AuthController { return; } + if (parsed.data.role === "admin") { + const token = extractSessionToken(req); + const session = token ? await this.sessions.validateSession(token) : null; + if (!session?.valid || session.user.role !== "admin") { + res.status(403).json({ + error: "Somente administradores podem criar contas de administrador.", + code: "FORBIDDEN", + }); + return; + } + } + const result = await this.service.register(parsed.data); if (!result.success) { diff --git a/backend/src/modules/auth/auth.register-role.test.ts b/backend/src/modules/auth/auth.register-role.test.ts new file mode 100644 index 0000000..9d51e32 --- /dev/null +++ b/backend/src/modules/auth/auth.register-role.test.ts @@ -0,0 +1,86 @@ +import test, { after, before } from "node:test"; +import assert from "node:assert/strict"; +import express from "express"; +import { AddressInfo } from "node:net"; +import { Server } from "node:http"; +import { AuthController } from "./auth.controller.js"; +import { AuthService } from "./auth.service.js"; +import { SessionService } from "./session.service.js"; +import type { UserRole } from "./auth.types.js"; + +class FakeAuthService extends AuthService { + public registered: Array<{ email: string; role: UserRole }> = []; + + async register(data: { nome: string; email: string; password: string; role: UserRole }) { + this.registered.push({ email: data.email, role: data.role }); + return { + success: true as const, + token: "novo-token", + user: { id: "u-new", nome: data.nome, email: data.email, role: data.role }, + }; + } +} + +class FakeSessions extends SessionService { + private readonly roles = new Map([["token-admin", "admin"], ["token-po", "po"], ["token-dev", "dev"]]); + + async validateSession(token: string) { + const role = this.roles.get(token); + if (!role) return { valid: false as const, reason: "not_found" as const }; + return { valid: true as const, user: { id: `id-${role}`, nome: role, email: `${role}@example.com`, role } } as never; + } +} + +const service = new FakeAuthService(); +let server: Server; +let baseUrl: string; + +before(() => { + const app = express(); + app.use(express.json()); + app.post("/register", new AuthController(service, new FakeSessions()).register); + server = app.listen(0); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +after(() => { + server.close(); +}); + +const register = (role: string | undefined, token?: string) => + fetch(`${baseUrl}/register`, { + method: "POST", + headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + body: JSON.stringify({ nome: "Pessoa Teste", email: `${role ?? "padrao"}@example.com`, password: "SenhaSegura123!", ...(role ? { role } : {}) }), + }); + +test("cadastro público não concede administrador", async () => { + const response = await register("admin"); + assert.equal(response.status, 403); + assert.equal(((await response.json()) as { code: string }).code, "FORBIDDEN"); + assert.equal(service.registered.length, 0); +}); + +test("sessões de PO, dev, token inválido e cookie ausente também não criam administrador", async () => { + for (const token of ["token-po", "token-dev", "token-invalido"]) { + assert.equal((await register("admin", token)).status, 403, token); + } + assert.equal(service.registered.length, 0); +}); + +test("somente uma sessão de administrador cria outra conta de administrador", async () => { + const response = await register("admin", "token-admin"); + assert.equal(response.status, 201); + assert.deepEqual(service.registered.at(-1), { email: "admin@example.com", role: "admin" }); +}); + +test("cadastro público de PO, dev e do perfil padrão continua permitido", async () => { + assert.equal((await register("po")).status, 201); + assert.equal((await register("dev")).status, 201); + assert.equal((await register(undefined)).status, 201); + assert.deepEqual(service.registered.map((item) => item.role).slice(-3), ["po", "dev", "po"]); +}); + +test("perfil inválido é recusado com 400", async () => { + assert.equal((await register("superuser")).status, 400); +}); diff --git a/backend/src/modules/chat/chat.fakes.ts b/backend/src/modules/chat/chat.fakes.ts new file mode 100644 index 0000000..450e014 --- /dev/null +++ b/backend/src/modules/chat/chat.fakes.ts @@ -0,0 +1,73 @@ +import { ChatRepository } from "./chat.repository.js"; +import type { AssistantClient } from "./chat.service.js"; +import type { ChatMessage, ChatSource, ChunkMatch, Conversation } from "./chat.types.js"; + +export const ANA = "b0000000-0000-4000-8000-000000000001"; +export const BRUNO = "b0000000-0000-4000-8000-000000000002"; +export const PROJECT_A = "a0000000-0000-4000-8000-000000000001"; +export const PROJECT_B = "a0000000-0000-4000-8000-000000000002"; + +export class FakeChatRepository extends ChatRepository { + public conversations: Conversation[] = []; + public messages: ChatMessage[] = []; + public chunks: Array = []; + public searches: Array<{ projetoId: string | null; patterns: string[] }> = []; + private sequence = 0; + + constructor() { + super(); + } + + private id(prefix: string): string { + this.sequence += 1; + return `${prefix}0000000-0000-4000-8000-${String(this.sequence).padStart(12, "0")}`; + } + + async listConversations(usuarioId: string): Promise { + return this.conversations.filter((item) => item.usuario_id === usuarioId); + } + + async projectExists(projetoId: string): Promise { + return projetoId === PROJECT_A || projetoId === PROJECT_B; + } + + async createConversation(usuarioId: string, projetoId: string | null, titulo: string): Promise { + const now = new Date().toISOString(); + const created: Conversation = { id: this.id("c"), usuario_id: usuarioId, projeto_id: projetoId, titulo, created_at: now, updated_at: now }; + this.conversations.push(created); + return created; + } + + async findOwnedConversation(conversaId: string, usuarioId: string): Promise { + return this.conversations.find((item) => item.id === conversaId && item.usuario_id === usuarioId) ?? null; + } + + async listMessages(conversaId: string): Promise { + return this.messages.filter((item) => item.conversa_id === conversaId); + } + + async addMessage(conversaId: string, remetente: "user" | "assistant", conteudo: string, fontes: ChatSource[] = []): Promise { + this.messages.push({ id: this.id("d"), conversa_id: conversaId, remetente, conteudo, fontes_json: fontes, created_at: new Date().toISOString() }); + } + + async searchChunks(projetoId: string | null, patterns: string[], limit: number): Promise { + this.searches.push({ projetoId, patterns }); + if (patterns.length === 0) return []; + const needles = patterns.map((pattern) => pattern.replaceAll("%", "").toLowerCase()); + return this.chunks + .filter((chunk) => (projetoId === null || chunk.projeto_id === projetoId) && needles.some((needle) => chunk.texto.toLowerCase().includes(needle))) + .slice(0, limit); + } +} + +export class FakeAssistant implements AssistantClient { + public calls: Array<{ pergunta: string; projetoId?: string }> = []; + public available = true; + public answer = { resposta: "Resposta do assistente", fontes: [{ id: "s1", titulo: "Decisão 1", tipo: "decisao" }] }; + + async ask(pergunta: string, projetoId?: string) { + this.calls.push({ pergunta, projetoId }); + if (!this.available) throw new Error("indisponível"); + return this.answer; + } +} diff --git a/backend/src/modules/chat/chat.repository.db.test.ts b/backend/src/modules/chat/chat.repository.db.test.ts new file mode 100644 index 0000000..e678ce1 --- /dev/null +++ b/backend/src/modules/chat/chat.repository.db.test.ts @@ -0,0 +1,50 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { Pool } from "pg"; +import { validateTarget } from "../../database/seed-lib.js"; +import { ChatRepository } from "./chat.repository.js"; +import { ChatService, searchPatterns, type AssistantClient } from "./chat.service.js"; +import { NotFoundError } from "../../shared/errors.js"; + +class Offline implements AssistantClient { + async ask(): Promise { + throw new Error("offline"); + } +} + +test("chat: posse da conversa e busca textual isolada por projeto no PostgreSQL", { skip: !process.env.ARCHIVE_TEST_DATABASE_URL }, async () => { + const pool = new Pool({ connectionString: validateTarget(process.env.ARCHIVE_TEST_DATABASE_URL, "test") }); + const repository = new ChatRepository(pool); + const service = new ChatService(repository, new Offline()); + const [ana, bruno, projectA, projectB, chunkA, chunkB] = Array.from({ length: 6 }, () => randomUUID()); + try { + await pool.query("INSERT INTO usuario (id,nome,email,senha_hash,role) VALUES ($1,'Ana','ana-chat@chat.test','h','po'),($2,'Bruno','bruno-chat@chat.test','h','po')", [ana, bruno]); + await pool.query("INSERT INTO projeto (id,nome,cliente,status) VALUES ($1::uuid,$1::text,'T','ativo'),($2::uuid,$2::text,'T','ativo')", [projectA, projectB]); + await pool.query("INSERT INTO chunk (id,projeto_id,entidade_tipo,entidade_id,texto) VALUES ($1,$2,'documento',$3,'O arquivamento preserva o histórico do projeto A'),($4,$5,'documento',$6,'Arquivamento confidencial do projeto B')", [chunkA, projectA, randomUUID(), chunkB, projectB, randomUUID()]); + + const found = await repository.searchChunks(projectA, searchPatterns("Como funciona o arquivamento?"), 5); + assert.deepEqual(found.map((item) => item.id), [chunkA]); + const unscoped = await repository.searchChunks(null, searchPatterns("arquivamento"), 5); + assert.deepEqual(unscoped.map((item) => item.id).sort(), [chunkA, chunkB].sort()); + assert.deepEqual(await repository.searchChunks(projectA, searchPatterns("inexistente"), 5), []); + assert.deepEqual(await repository.searchChunks(projectA, [], 5), []); + + const result = await service.query(ana, { pergunta: "Como funciona o arquivamento?", projetoId: projectA }); + assert.equal(result.origem, "busca_textual"); + assert.ok(!result.resposta.includes("confidencial")); + + await assert.rejects(service.listMessages(bruno, result.conversa_id), NotFoundError); + await assert.rejects(service.query(bruno, { pergunta: "invasão", conversaId: result.conversa_id }), NotFoundError); + const messages = await service.listMessages(ana, result.conversa_id); + assert.deepEqual(messages.map((item) => item.remetente), ["user", "assistant"]); + assert.equal((await service.listConversations(bruno)).length, 0); + assert.equal((await service.listConversations(ana))[0].projeto_nome, projectA); + } finally { + await pool.query("DELETE FROM conversa WHERE usuario_id = ANY($1::uuid[])", [[ana, bruno]]); + await pool.query("DELETE FROM chunk WHERE id = ANY($1::uuid[])", [[chunkA, chunkB]]); + await pool.query("DELETE FROM projeto WHERE id = ANY($1::uuid[])", [[projectA, projectB]]); + await pool.query("DELETE FROM usuario WHERE id = ANY($1::uuid[])", [[ana, bruno]]); + await pool.end(); + } +}); diff --git a/backend/src/modules/chat/chat.repository.ts b/backend/src/modules/chat/chat.repository.ts new file mode 100644 index 0000000..51168b0 --- /dev/null +++ b/backend/src/modules/chat/chat.repository.ts @@ -0,0 +1,75 @@ +import { Pool } from "pg"; +import { pool } from "../../database/db.js"; +import type { ChatMessage, ChatSource, ChunkMatch, Conversation } from "./chat.types.js"; + +export class ChatRepository { + private readonly pool: Pool; + + constructor(customPool?: Pool) { + this.pool = customPool ?? pool; + } + + async listConversations(usuarioId: string): Promise { + const result = await this.pool.query( + `SELECT c.id, c.usuario_id, c.projeto_id, c.titulo, c.created_at, c.updated_at, p.nome AS projeto_nome + FROM conversa c + LEFT JOIN projeto p ON p.id = c.projeto_id + WHERE c.usuario_id = $1 + ORDER BY c.updated_at DESC, c.id DESC`, + [usuarioId], + ); + return result.rows; + } + + async projectExists(projetoId: string): Promise { + const result = await this.pool.query("SELECT 1 FROM projeto WHERE id = $1", [projetoId]); + return (result.rowCount ?? 0) > 0; + } + + async createConversation(usuarioId: string, projetoId: string | null, titulo: string): Promise { + const result = await this.pool.query( + `INSERT INTO conversa (usuario_id, projeto_id, titulo) VALUES ($1, $2, $3) + RETURNING id, usuario_id, projeto_id, titulo, created_at, updated_at`, + [usuarioId, projetoId, titulo], + ); + return result.rows[0]; + } + + async findOwnedConversation(conversaId: string, usuarioId: string): Promise { + const result = await this.pool.query( + `SELECT c.id, c.usuario_id, c.projeto_id, c.titulo, c.created_at, c.updated_at, p.nome AS projeto_nome + FROM conversa c LEFT JOIN projeto p ON p.id = c.projeto_id + WHERE c.id = $1 AND c.usuario_id = $2`, + [conversaId, usuarioId], + ); + return result.rows[0] ?? null; + } + + async listMessages(conversaId: string): Promise { + const result = await this.pool.query( + `SELECT id, conversa_id, remetente, conteudo, COALESCE(fontes_json, '[]'::jsonb) AS fontes_json, created_at + FROM mensagem WHERE conversa_id = $1 ORDER BY created_at ASC, id ASC`, + [conversaId], + ); + return result.rows; + } + + async addMessage(conversaId: string, remetente: "user" | "assistant", conteudo: string, fontes: ChatSource[] = []): Promise { + await this.pool.query( + "INSERT INTO mensagem (conversa_id, remetente, conteudo, fontes_json) VALUES ($1, $2, $3, $4::jsonb)", + [conversaId, remetente, conteudo, JSON.stringify(fontes)], + ); + await this.pool.query("UPDATE conversa SET updated_at = CURRENT_TIMESTAMP WHERE id = $1", [conversaId]); + } + + async searchChunks(projetoId: string | null, patterns: string[], limit: number): Promise { + if (patterns.length === 0) return []; + const result = await this.pool.query( + `SELECT id, entidade_tipo, entidade_id, texto FROM chunk + WHERE ($1::uuid IS NULL OR projeto_id = $1) AND texto ILIKE ANY($2::text[]) + ORDER BY created_at DESC, id LIMIT $3`, + [projetoId, patterns, limit], + ); + return result.rows; + } +} diff --git a/backend/src/modules/chat/chat.routes.test.ts b/backend/src/modules/chat/chat.routes.test.ts new file mode 100644 index 0000000..7f55e84 --- /dev/null +++ b/backend/src/modules/chat/chat.routes.test.ts @@ -0,0 +1,75 @@ +import test, { after, before } from "node:test"; +import assert from "node:assert/strict"; +import express from "express"; +import { AddressInfo } from "node:net"; +import { Server } from "node:http"; +import { errorHandler } from "../../middleware/errorHandler.js"; +import { createChatRouter } from "./chat.routes.js"; +import { ChatService } from "./chat.service.js"; +import { ANA, BRUNO, FakeAssistant, FakeChatRepository, PROJECT_A } from "./chat.fakes.js"; + +let server: Server; +let baseUrl: string; +const repository = new FakeChatRepository(); +const assistant = new FakeAssistant(); + +before(() => { + const app = express(); + app.use(express.json()); + app.use("/api/v1/chat", createChatRouter(new ChatService(repository, assistant), (req, res, next) => { + const user = req.headers["x-test-user"]; + if (typeof user !== "string") { + res.status(401).json({ error: "Autenticação necessária.", code: "UNAUTHORIZED" }); + return; + } + req.auth = { id: user, nome: "Teste", email: "t@example.com", role: "po" }; + next(); + })); + app.use(errorHandler); + server = app.listen(0); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/v1/chat`; +}); + +after(() => { + server.close(); +}); + +const call = (path: string, user: string | null, init: RequestInit & { json?: unknown } = {}) => + fetch(`${baseUrl}${path}`, { + ...init, + headers: { "Content-Type": "application/json", ...(user ? { "x-test-user": user } : {}) }, + body: init.json === undefined ? undefined : JSON.stringify(init.json), + }); + +test("exige autenticação em todas as rotas", async () => { + assert.equal((await call("/conversations", null)).status, 401); + assert.equal((await call("/query", null, { method: "POST", json: { pergunta: "oi" } })).status, 401); +}); + +test("consulta cria conversa, devolve origem e lista somente as conversas do usuário", async () => { + const response = await call("/query", ANA, { method: "POST", json: { pergunta: "Como funciona o login?", projeto_id: PROJECT_A } }); + assert.equal(response.status, 200); + const body = await response.json() as { conversa_id: string; origem: string; fontes: unknown[] }; + assert.equal(body.origem, "assistente"); + assert.equal(body.fontes.length, 1); + + const mine = await (await call("/conversations", ANA)).json() as { items: Array<{ id: string }> }; + assert.deepEqual(mine.items.map((item) => item.id), [body.conversa_id]); + const theirs = await (await call("/conversations", BRUNO)).json() as { items: unknown[] }; + assert.equal(theirs.items.length, 0); +}); + +test("mensagens de outra pessoa retornam 404 e injeção em conversa alheia é recusada", async () => { + const created = await (await call("/conversations", ANA, { method: "POST", json: { titulo: "Privada", projeto_id: PROJECT_A } })).json() as { id: string }; + assert.equal((await call(`/conversations/${created.id}/messages`, BRUNO)).status, 404); + assert.equal((await call("/query", BRUNO, { method: "POST", json: { pergunta: "oi oi", conversa_id: created.id } })).status, 404); + assert.equal((await call(`/conversations/${created.id}/messages`, ANA)).status, 200); + assert.equal((await call("/conversations/abc/messages", ANA)).status, 400); +}); + +test("valida o corpo da consulta", async () => { + assert.equal((await call("/query", ANA, { method: "POST", json: {} })).status, 400); + assert.equal((await call("/query", ANA, { method: "POST", json: { pergunta: "x".repeat(2001) } })).status, 400); + assert.equal((await call("/query", ANA, { method: "POST", json: { pergunta: "oi", projeto_id: "nao-uuid" } })).status, 400); + assert.equal((await call("/query", ANA, { method: "POST", json: { pergunta: "oi", projeto_id: "a0000000-0000-4000-8000-0000000000ff" } })).status, 404); +}); diff --git a/backend/src/modules/chat/chat.routes.ts b/backend/src/modules/chat/chat.routes.ts index 75bfe28..2415646 100644 --- a/backend/src/modules/chat/chat.routes.ts +++ b/backend/src/modules/chat/chat.routes.ts @@ -1,160 +1,63 @@ -import { Router, Request, Response, NextFunction } from "express"; -import { pool } from "../../database/db.js"; +import { Router, type NextFunction, type Request, type RequestHandler, type Response } from "express"; import { requireAuth } from "../../middleware/requireAuth.js"; -import { env } from "../../config/env.js"; -import axios from "axios"; +import { ValidationError } from "../../shared/errors.js"; +import { ChatService } from "./chat.service.js"; -export const chatRouter = Router(); +function userId(req: Request): string { + if (!req.auth?.id) throw new ValidationError("Autenticação necessária."); + return req.auth.id; +} -chatRouter.use(requireAuth); +function text(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} -// GET /api/v1/chat/conversations - Lista conversas do usuário logado -chatRouter.get("/conversations", async (req: Request, res: Response, next: NextFunction) => { - try { - const usuarioId = req.auth?.id; - if (!usuarioId) { - res.status(401).json({ error: "Não autenticado" }); - return; - } - - const result = await pool.query( - `SELECT c.id, c.usuario_id, c.projeto_id, c.titulo, c.created_at, c.updated_at, p.nome as projeto_nome - FROM conversa c - LEFT JOIN projeto p ON c.projeto_id = p.id - WHERE c.usuario_id = $1 - ORDER BY c.updated_at DESC`, - [usuarioId] - ); - - res.json({ items: result.rows, total: result.rowCount }); - } catch (error) { - next(error); - } -}); - -// POST /api/v1/chat/conversations - Cria nova conversa -chatRouter.post("/conversations", async (req: Request, res: Response, next: NextFunction) => { - try { - const usuarioId = req.auth?.id; - const { titulo, projeto_id } = req.body; - - if (!usuarioId) { - res.status(401).json({ error: "Não autenticado" }); - return; - } - - const result = await pool.query( - `INSERT INTO conversa (usuario_id, projeto_id, titulo) - VALUES ($1, $2, $3) - RETURNING *`, - [usuarioId, projeto_id || null, titulo || "Nova Conversa Sinapse"] - ); - - res.status(201).json(result.rows[0]); - } catch (error) { - next(error); - } -}); - -// GET /api/v1/chat/conversations/:id/messages - Mensagens da conversa -chatRouter.get("/conversations/:id/messages", async (req: Request, res: Response, next: NextFunction) => { - try { - const { id } = req.params; - const result = await pool.query( - `SELECT id, conversa_id, remetente, conteudo, fontes_json, created_at - FROM mensagem - WHERE conversa_id = $1 - ORDER BY created_at ASC`, - [id] - ); - - res.json({ items: result.rows, total: result.rowCount }); - } catch (error) { - next(error); - } -}); +export function createChatRouter(service: ChatService = new ChatService(), authentication: RequestHandler = requireAuth): Router { + const router = Router(); + router.use(authentication); -// POST /api/v1/chat/query - Envia mensagem do usuário, consulta RAG e grava no banco -chatRouter.post("/query", async (req: Request, res: Response, next: NextFunction) => { - try { - const usuarioId = req.auth?.id; - const { conversa_id, projeto_id, pergunta } = req.body; - - if (!pergunta || typeof pergunta !== "string") { - res.status(400).json({ error: "Pergunta é obrigatória" }); - return; + router.get("/conversations", async (req: Request, res: Response, next: NextFunction) => { + try { + const items = await service.listConversations(userId(req)); + res.json({ items, total: items.length }); + } catch (error) { + next(error); } + }); - let conversaId = conversa_id; - - // Se não informou conversa_id, cria uma conversa nova - if (!conversaId && usuarioId) { - const convRes = await pool.query( - `INSERT INTO conversa (usuario_id, projeto_id, titulo) - VALUES ($1, $2, $3) - RETURNING id`, - [usuarioId, projeto_id || null, pergunta.slice(0, 50)] - ); - conversaId = convRes.rows[0].id; + router.post("/conversations", async (req: Request, res: Response, next: NextFunction) => { + try { + const created = await service.createConversation(userId(req), { titulo: req.body?.titulo, projetoId: text(req.body?.projeto_id) }); + res.status(201).json(created); + } catch (error) { + next(error); } + }); - // Registra a mensagem do usuário - if (conversaId) { - await pool.query( - `INSERT INTO mensagem (conversa_id, remetente, conteudo) - VALUES ($1, 'user', $2)`, - [conversaId, pergunta] - ); + router.get("/conversations/:id/messages", async (req: Request, res: Response, next: NextFunction) => { + try { + const items = await service.listMessages(userId(req), String(req.params.id)); + res.json({ items, total: items.length }); + } catch (error) { + next(error); } + }); - // Tenta consultar o serviço Python RAG ou busca chunks locais no Postgres - let respostaText = ""; - let fontes: Array<{ id: string; titulo: string; tipo: string }> = []; - + router.post("/query", async (req: Request, res: Response, next: NextFunction) => { try { - const aiResponse = await axios.post(`${env.AI_SERVICE_URL}/rag/query`, { + const pergunta = req.body?.pergunta; + if (typeof pergunta !== "string") throw new ValidationError("Pergunta é obrigatória."); + res.json(await service.query(userId(req), { pergunta, - projeto_id: projeto_id || undefined, - }, { timeout: 5000 }); - - respostaText = aiResponse.data.resposta || aiResponse.data.conteudo; - fontes = aiResponse.data.fontes || []; - } catch { - // Fallback para busca vetorial / textual direta no PostgreSQL se o serviço Python estiver em inicialização - let sql = "SELECT id, entidade_tipo, entidade_id, texto FROM chunk WHERE 1=1"; - const params: unknown[] = []; - if (projeto_id) { - sql += " AND projeto_id = $1"; - params.push(projeto_id); - } - sql += " ORDER BY created_at DESC LIMIT 3"; - const chunks = await pool.query(sql, params); - - if (chunks.rows.length > 0) { - respostaText = `Com base no acervo indexado do projeto, encontrei as seguintes evidências:\n\n${chunks.rows.map(c => `• ${c.texto}`).join("\n\n")}`; - fontes = chunks.rows.map(c => ({ id: c.id, titulo: `Trecho ${c.entidade_tipo}`, tipo: c.entidade_tipo })); - } else { - respostaText = "Informação não encontrada no acervo do projeto."; - } + conversaId: text(req.body?.conversa_id), + projetoId: text(req.body?.projeto_id), + })); + } catch (error) { + next(error); } + }); - // Registra a mensagem da assistente no DB - if (conversaId) { - await pool.query( - `INSERT INTO mensagem (conversa_id, remetente, conteudo, fontes_json) - VALUES ($1, 'assistant', $2, $3)`, - [conversaId, respostaText, JSON.stringify(fontes)] - ); - - await pool.query("UPDATE conversa SET updated_at = CURRENT_TIMESTAMP WHERE id = $1", [conversaId]); - } + return router; +} - res.json({ - conversa_id: conversaId, - resposta: respostaText, - fontes, - }); - } catch (error) { - next(error); - } -}); +export const chatRouter = createChatRouter(); diff --git a/backend/src/modules/chat/chat.service.test.ts b/backend/src/modules/chat/chat.service.test.ts new file mode 100644 index 0000000..446f960 --- /dev/null +++ b/backend/src/modules/chat/chat.service.test.ts @@ -0,0 +1,106 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { NotFoundError, ValidationError } from "../../shared/errors.js"; +import { ChatService, searchPatterns, sanitizeSources } from "./chat.service.js"; +import { ANA, BRUNO, FakeAssistant, FakeChatRepository, PROJECT_A, PROJECT_B } from "./chat.fakes.js"; + +function setup() { + const repository = new FakeChatRepository(); + const assistant = new FakeAssistant(); + return { repository, assistant, service: new ChatService(repository, assistant) }; +} + +test("nova pergunta cria a conversa, registra as duas mensagens e devolve as fontes do assistente", async () => { + const { service, repository, assistant } = setup(); + const result = await service.query(ANA, { pergunta: " Quais decisões definem o login? ", projetoId: PROJECT_A }); + + assert.equal(result.origem, "assistente"); + assert.equal(result.resposta, "Resposta do assistente"); + assert.deepEqual(result.fontes, [{ id: "s1", titulo: "Decisão 1", tipo: "decisao" }]); + assert.deepEqual(assistant.calls, [{ pergunta: "Quais decisões definem o login?", projetoId: PROJECT_A }]); + assert.equal(repository.conversations[0].projeto_id, PROJECT_A); + assert.equal(repository.conversations[0].titulo, "Quais decisões definem o login?"); + assert.deepEqual(repository.messages.map((item) => item.remetente), ["user", "assistant"]); +}); + +test("valida pergunta vazia, longa demais, projeto inválido e inexistente", async () => { + const { service } = setup(); + await assert.rejects(service.query(ANA, { pergunta: " " }), ValidationError); + await assert.rejects(service.query(ANA, { pergunta: "a".repeat(2001) }), /no máximo 2000/); + await assert.rejects(service.query(ANA, { pergunta: "ok", projetoId: "nao-uuid" }), ValidationError); + await assert.rejects(service.query(ANA, { pergunta: "ok", projetoId: "a0000000-0000-4000-8000-0000000000ff" }), NotFoundError); +}); + +test("não permite usar nem ler a conversa de outro usuário e não revela que ela existe", async () => { + const { service, repository } = setup(); + const conversa = await repository.createConversation(ANA, PROJECT_A, "Privada"); + await repository.addMessage(conversa.id, "user", "segredo de Ana"); + + await assert.rejects(service.listMessages(BRUNO, conversa.id), NotFoundError); + await assert.rejects(service.query(BRUNO, { pergunta: "injeção", conversaId: conversa.id }), NotFoundError); + await assert.rejects(service.listMessages(BRUNO, "invalido"), ValidationError); + assert.equal(repository.messages.length, 1); + assert.equal((await service.listMessages(ANA, conversa.id)).length, 1); +}); + +test("conversa existente herda o projeto quando a pergunta não informa outro", async () => { + const { service, repository, assistant } = setup(); + const conversa = await repository.createConversation(ANA, PROJECT_B, "Projeto B"); + await service.query(ANA, { pergunta: "Como funciona o arquivamento?", conversaId: conversa.id }); + assert.equal(assistant.calls[0].projetoId, PROJECT_B); +}); + +test("com o assistente indisponível usa busca textual restrita ao projeto e informa a origem", async () => { + const { service, repository, assistant } = setup(); + assistant.available = false; + repository.chunks.push( + { id: "k1", projeto_id: PROJECT_A, entidade_tipo: "documento", entidade_id: "d1", texto: "O arquivamento preserva o histórico." }, + { id: "k2", projeto_id: PROJECT_B, entidade_tipo: "documento", entidade_id: "d2", texto: "Arquivamento de outro projeto confidencial." }, + ); + + const result = await service.query(ANA, { pergunta: "Como funciona o arquivamento?", projetoId: PROJECT_A }); + assert.equal(result.origem, "busca_textual"); + assert.match(result.resposta, /O assistente está indisponível/); + assert.match(result.resposta, /preserva o histórico/); + assert.ok(!result.resposta.includes("confidencial")); + assert.deepEqual(result.fontes.map((source) => source.id), ["k1"]); + assert.equal(repository.searches[0].projetoId, PROJECT_A); +}); + +test("sem termos úteis ou sem correspondência não inventa evidência", async () => { + const { service, repository, assistant } = setup(); + assistant.available = false; + repository.chunks.push({ id: "k1", projeto_id: PROJECT_A, entidade_tipo: "documento", entidade_id: "d1", texto: "Texto qualquer sobre backlog." }); + + const noTerms = await service.query(ANA, { pergunta: "e o q?", projetoId: PROJECT_A }); + assert.equal(noTerms.origem, "sem_resultado"); + assert.equal(noTerms.resposta, "Informação não encontrada no acervo do projeto."); + assert.deepEqual(noTerms.fontes, []); + + const noMatch = await service.query(ANA, { pergunta: "Quem escolheu a tecnologia de mensageria?", projetoId: PROJECT_A }); + assert.equal(noMatch.origem, "sem_resultado"); +}); + +test("padrões de busca usam só termos com 4+ caracteres, sem curingas do usuário, e limitam a quantidade", () => { + assert.deepEqual(searchPatterns("Qual é o 100%_teste?"), ["%qual%", "%teste%"]); + assert.deepEqual(searchPatterns("a b c"), []); + assert.deepEqual(searchPatterns("Promoção promoção PROMOÇÃO"), ["%promoção%"]); + assert.equal(searchPatterns("alfa beta gama delta epsilon zeta eta theta iota").length, 6); + assert.ok(searchPatterns("descontos_totais 50%").every((pattern) => /^%[\p{L}\p{N}]+%$/u.test(pattern))); +}); + +test("fontes do assistente são saneadas e limitadas", () => { + assert.deepEqual(sanitizeSources("x"), []); + assert.deepEqual(sanitizeSources([null, { titulo: "sem id" }, { id: "1" }, { id: "2", titulo: "T", tipo: "pbi" }]), [ + { id: "1", titulo: "1", tipo: "documento" }, + { id: "2", titulo: "T", tipo: "pbi" }, + ]); +}); + +test("cria conversa com título padrão e valida o projeto", async () => { + const { service } = setup(); + assert.equal((await service.createConversation(ANA, {})).titulo, "Nova conversa"); + assert.equal((await service.createConversation(ANA, { titulo: " Reunião ", projetoId: PROJECT_A })).titulo, "Reunião"); + await assert.rejects(service.createConversation(ANA, { projetoId: "x" }), ValidationError); + assert.equal((await service.listConversations(BRUNO)).length, 0); +}); diff --git a/backend/src/modules/chat/chat.service.ts b/backend/src/modules/chat/chat.service.ts new file mode 100644 index 0000000..7f16993 --- /dev/null +++ b/backend/src/modules/chat/chat.service.ts @@ -0,0 +1,128 @@ +import axios from "axios"; +import { env } from "../../config/env.js"; +import { NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; +import { ChatRepository } from "./chat.repository.js"; +import { + MAX_QUESTION_LENGTH, + NOT_FOUND_ANSWER, + type AssistantAnswer, + type ChatMessage, + type ChatSource, + type Conversation, + type QueryInput, + type QueryResult, +} from "./chat.types.js"; + +export interface AssistantClient { + ask(pergunta: string, projetoId?: string): Promise; +} + +export function sanitizeSources(value: unknown): ChatSource[] { + if (!Array.isArray(value)) return []; + return value.flatMap((item) => { + if (typeof item !== "object" || item === null) return []; + const source = item as Record; + if (typeof source.id !== "string") return []; + return [{ + id: source.id.slice(0, 100), + titulo: typeof source.titulo === "string" ? source.titulo.slice(0, 200) : source.id.slice(0, 100), + tipo: typeof source.tipo === "string" ? source.tipo.slice(0, 50) : "documento", + }]; + }); +} + +export class HttpAssistantClient implements AssistantClient { + constructor(private readonly baseUrl: string = env.AI_SERVICE_URL) {} + + async ask(pergunta: string, projetoId?: string): Promise { + const response = await axios.post(`${this.baseUrl}/rag/query`, { pergunta, projeto_id: projetoId }, { timeout: 5000 }); + const text = response.data?.resposta ?? response.data?.conteudo; + if (typeof text !== "string" || !text.trim()) throw new Error("Resposta vazia do assistente"); + return { resposta: text, fontes: sanitizeSources(response.data?.fontes) }; + } +} + +export function searchPatterns(question: string): string[] { + const terms = question + .toLocaleLowerCase("pt-BR") + .split(/[^\p{L}\p{N}]+/u) + .filter((term) => term.length >= 4); + return [...new Set(terms)].slice(0, 6).map((term) => `%${term.replace(/[\\%_]/g, "\\$&")}%`); +} + +function makeTitle(question: string): string { + const clean = question.replace(/\s+/g, " ").trim(); + return clean.length > 60 ? `${clean.slice(0, 57)}...` : clean; +} + +export class ChatService { + constructor( + private readonly repository: ChatRepository = new ChatRepository(), + private readonly assistant: AssistantClient = new HttpAssistantClient(), + ) {} + + listConversations(usuarioId: string): Promise { + return this.repository.listConversations(usuarioId); + } + + private async requireProject(projetoId: string | undefined): Promise { + if (!projetoId) return null; + validateUuid(projetoId, "ID do projeto"); + if (!(await this.repository.projectExists(projetoId))) throw new NotFoundError("Projeto não encontrado."); + return projetoId; + } + + async createConversation(usuarioId: string, input: { titulo?: unknown; projetoId?: string }): Promise { + const projetoId = await this.requireProject(input.projetoId); + const titulo = typeof input.titulo === "string" && input.titulo.trim() ? input.titulo.trim().slice(0, 120) : "Nova conversa"; + return this.repository.createConversation(usuarioId, projetoId, titulo); + } + + async listMessages(usuarioId: string, conversaId: string): Promise { + validateUuid(conversaId, "ID da conversa"); + const conversation = await this.repository.findOwnedConversation(conversaId, usuarioId); + if (!conversation) throw new NotFoundError("Conversa não encontrada."); + return this.repository.listMessages(conversaId); + } + + async query(usuarioId: string, input: QueryInput): Promise { + const pergunta = input.pergunta.trim(); + if (!pergunta) throw new ValidationError("Digite uma pergunta."); + if (pergunta.length > MAX_QUESTION_LENGTH) throw new ValidationError(`A pergunta pode ter no máximo ${MAX_QUESTION_LENGTH} caracteres.`); + + let projetoId = await this.requireProject(input.projetoId); + let conversaId = input.conversaId; + if (conversaId) { + validateUuid(conversaId, "ID da conversa"); + const conversation = await this.repository.findOwnedConversation(conversaId, usuarioId); + if (!conversation) throw new NotFoundError("Conversa não encontrada."); + projetoId = projetoId ?? conversation.projeto_id; + } else { + conversaId = (await this.repository.createConversation(usuarioId, projetoId, makeTitle(pergunta))).id; + } + + await this.repository.addMessage(conversaId, "user", pergunta); + + let answer: AssistantAnswer; + let origem: QueryResult["origem"]; + try { + answer = await this.assistant.ask(pergunta, projetoId ?? undefined); + origem = "assistente"; + } catch { + const chunks = await this.repository.searchChunks(projetoId, searchPatterns(pergunta), 3); + if (chunks.length > 0) { + answer = { + resposta: `O assistente está indisponível. Estes trechos do acervo mencionam termos da sua pergunta:\n\n${chunks.map((chunk) => `- ${chunk.texto}`).join("\n\n")}`, + fontes: chunks.map((chunk) => ({ id: chunk.id, titulo: `Trecho de ${chunk.entidade_tipo}`, tipo: chunk.entidade_tipo })), + }; + origem = "busca_textual"; + } else { + answer = { resposta: NOT_FOUND_ANSWER, fontes: [] }; + origem = "sem_resultado"; + } + } + + await this.repository.addMessage(conversaId, "assistant", answer.resposta, answer.fontes); + return { conversa_id: conversaId, resposta: answer.resposta, fontes: answer.fontes, origem }; + } +} diff --git a/backend/src/modules/chat/chat.types.ts b/backend/src/modules/chat/chat.types.ts new file mode 100644 index 0000000..8720581 --- /dev/null +++ b/backend/src/modules/chat/chat.types.ts @@ -0,0 +1,54 @@ +export interface ChatSource { + id: string; + titulo: string; + tipo: string; +} + +export type ChatOrigin = "assistente" | "busca_textual" | "sem_resultado"; + +export interface Conversation { + id: string; + usuario_id: string; + projeto_id: string | null; + projeto_nome?: string | null; + titulo: string; + created_at: string; + updated_at: string; +} + +export interface ChatMessage { + id: string; + conversa_id: string; + remetente: "user" | "assistant" | "system"; + conteudo: string; + fontes_json: ChatSource[]; + created_at: string; +} + +export interface ChunkMatch { + id: string; + entidade_tipo: string; + entidade_id: string; + texto: string; +} + +export interface QueryInput { + conversaId?: string; + projetoId?: string; + pergunta: string; +} + +export interface QueryResult { + conversa_id: string; + resposta: string; + fontes: ChatSource[]; + origem: ChatOrigin; +} + +export interface AssistantAnswer { + resposta: string; + fontes: ChatSource[]; +} + +export const MAX_QUESTION_LENGTH = 2000; +export const NOT_FOUND_ANSWER = "Informação não encontrada no acervo do projeto."; diff --git a/backend/src/modules/documents/documents.controller.ts b/backend/src/modules/documents/documents.controller.ts new file mode 100644 index 0000000..5a60830 --- /dev/null +++ b/backend/src/modules/documents/documents.controller.ts @@ -0,0 +1,62 @@ +import type { NextFunction, Request, Response } from "express"; +import { ValidationError } from "../../shared/errors.js"; +import { documentsService, type DocumentsService } from "./documents.service.js"; + +function paramOf(value: string | string[] | undefined): string { + if (Array.isArray(value)) return value[0] ?? ""; + return value ?? ""; +} + +function decodeFileName(header: string | string[] | undefined): string { + const raw = Array.isArray(header) ? header[0] : header; + if (!raw) throw new ValidationError("Informe o nome do arquivo no cabeçalho X-File-Name."); + try { + return decodeURIComponent(raw); + } catch { + throw new ValidationError("Nome do arquivo inválido."); + } +} + +export class DocumentsController { + constructor(private readonly service: DocumentsService = documentsService) {} + + list = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const cursor = typeof req.query.cursor === "string" ? req.query.cursor : undefined; + const limit = typeof req.query.limit === "string" ? req.query.limit : undefined; + res.status(200).json(await this.service.list(paramOf(req.params.projectId), cursor, limit)); + } catch (error) { + next(error); + } + }; + + upload = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!Buffer.isBuffer(req.body)) throw new ValidationError("Envie o arquivo como corpo binário da requisição."); + const created = await this.service.upload({ + projetoId: paramOf(req.params.projectId), + usuarioId: req.auth?.id ?? "", + fileName: decodeFileName(req.headers["x-file-name"]), + content: req.body, + }); + res.status(201).json(created); + } catch (error) { + next(error); + } + }; + + remove = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + await this.service.remove({ + projetoId: paramOf(req.params.projectId), + documentoId: paramOf(req.params.documentId), + usuarioId: req.auth?.id ?? "", + }); + res.status(204).end(); + } catch (error) { + next(error); + } + }; +} + +export const documentsController = new DocumentsController(); diff --git a/backend/src/modules/documents/documents.events.ts b/backend/src/modules/documents/documents.events.ts new file mode 100644 index 0000000..d1f65cd --- /dev/null +++ b/backend/src/modules/documents/documents.events.ts @@ -0,0 +1,25 @@ +import axios from "axios"; +import { env } from "../../config/env.js"; +import type { DocumentRemovedEvent } from "./documents.types.js"; + +export interface DocumentEventPublisher { + publish(event: DocumentRemovedEvent): Promise; +} + +export class HttpDocumentEventPublisher implements DocumentEventPublisher { + constructor(private readonly webhookUrl: string | undefined = env.DOCUMENT_EVENTS_WEBHOOK_URL) {} + + async publish(event: DocumentRemovedEvent): Promise { + const url = this.webhookUrl?.trim(); + if (!url) return false; + try { + await axios.post(url, event, { + timeout: 10_000, + headers: { "Idempotency-Key": event.event_id }, + }); + return true; + } catch { + return false; + } + } +} diff --git a/backend/src/modules/documents/documents.fakes.ts b/backend/src/modules/documents/documents.fakes.ts new file mode 100644 index 0000000..340dfe1 --- /dev/null +++ b/backend/src/modules/documents/documents.fakes.ts @@ -0,0 +1,269 @@ +import { DocumentsRepository, removalEventKey, type PendingEvent } from "./documents.repository.js"; +import type { DocumentEventPublisher } from "./documents.events.js"; +import type { DocumentStorage } from "./documents.storage.js"; +import type { + CreateDocumentInput, + DocumentRecord, + DocumentRemovedEvent, + DocumentStatus, + RemovalResult, + RemoveDocumentInput, + StoredDocument, +} from "./documents.types.js"; + +export const PROJECT_ID = "a0000000-0000-4000-8000-000000000001"; +export const OTHER_PROJECT_ID = "a0000000-0000-4000-8000-000000000002"; +export const ARCHIVED_PROJECT_ID = "a0000000-0000-4000-8000-000000000003"; +export const USER_ID = "b0000000-0000-4000-8000-000000000001"; + +export function pdfBuffer(size = 64): Buffer { + return Buffer.concat([Buffer.from("%PDF-1.7\n", "latin1"), Buffer.alloc(size, 0x20)]); +} + +export function docxBuffer(): Buffer { + return Buffer.concat([Buffer.from([0x50, 0x4b, 0x03, 0x04]), Buffer.from("[Content_Types].xml word/document.xml", "latin1")]); +} + +export const projectLookup = { + async findById(id: string): Promise<{ id: string; status: string } | null> { + if (id === PROJECT_ID || id === OTHER_PROJECT_ID) return { id, status: "ativo" }; + if (id === ARCHIVED_PROJECT_ID) return { id, status: "arquivado" }; + return null; + }, +}; + +interface StoredRow extends DocumentRecord { + caminho: string; +} + +export class FakeDocumentsRepository extends DocumentsRepository { + public rows: StoredRow[] = []; + public audit: Array<{ acao: string; documentoId: string; dados: Record }> = []; + public events = new Map(); + public failCreate = false; + public failRemove = false; + public chunks = new Map(); + public storageOperations = new Map(); + + constructor() { + super(); + } + + seed(row: Partial & { id: string; projeto_id: string; caminho: string; status_processamento?: DocumentStatus }): StoredRow { + const full: StoredRow = { + nome: "seed.pdf", + extensao: ".pdf", + mime: "application/pdf", + tamanho_bytes: 10, + autor_id: USER_ID, + autor_nome: "Ana PO", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + status_processamento: "pendente", + armazenamento_pendente: false, + ...row, + }; + this.rows.push(full); + return full; + } + + async listByProject(projetoId: string, cursor: { createdAt: string; id: string } | null, limit: number) { + const matching = this.rows + .filter((row) => row.projeto_id === projetoId) + .filter((row) => !cursor || Date.parse(row.created_at) < Date.parse(cursor.createdAt) + || (Date.parse(row.created_at) === Date.parse(cursor.createdAt) && row.id < cursor.id)) + .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at) || b.id.localeCompare(a.id)); + const page = matching.slice(0, limit + 1); + return { + items: page.slice(0, limit).map(({ caminho: _caminho, ...record }) => record), + hasMore: page.length > limit, + }; + } + + async findById(projetoId: string, id: string): Promise { + const row = this.rows.find((item) => item.id === id && item.projeto_id === projetoId); + if (!row) return null; + return { id: row.id, projeto_id: row.projeto_id, nome: row.nome, caminho: row.caminho, status_processamento: row.status_processamento }; + } + + async create(input: CreateDocumentInput): Promise { + if (this.failCreate) throw new Error("falha simulada"); + const row = this.seed({ + id: input.id, + projeto_id: input.projetoId, + nome: input.nome, + extensao: input.extensao, + mime: input.mime, + tamanho_bytes: input.tamanhoBytes, + caminho: input.caminho, + autor_id: input.usuarioId, + armazenamento_pendente: true, + }); + const operationId = `upload:${input.id}`; + this.storageOperations.set(operationId, { + id: operationId, + documento_id: input.id, + projeto_id: input.projetoId, + acao: "finalizar_upload", + caminho: input.caminho, + status: "pendente", + }); + this.audit.push({ acao: "ENVIAR_DOCUMENTO", documentoId: input.id, dados: { nome: input.nome } }); + const { caminho: _caminho, ...record } = row; + return record; + } + + async remove(input: RemoveDocumentInput): Promise { + if (this.failRemove) throw new Error("falha simulada"); + const index = this.rows.findIndex((row) => row.id === input.id && row.projeto_id === input.projetoId); + if (index < 0) return null; + const [row] = this.rows.splice(index, 1); + const storageOperationId = `remove:${input.id}`; + this.storageOperations.set(storageOperationId, { + id: storageOperationId, + documento_id: input.id, + projeto_id: input.projetoId, + acao: "descartar_remocao", + caminho: row.caminho, + status: "pendente", + }); + const chunksRemovidos = this.chunks.get(row.id) ?? 0; + const indexado = chunksRemovidos > 0 || row.status_processamento === "processado"; + let eventoChave: string | null = null; + if (indexado) { + eventoChave = removalEventKey(row.id); + if (!this.events.has(eventoChave)) { + this.events.set(eventoChave, { + status: "pendente", + payload: { + event_id: eventoChave, + event_type: "document.removed", + schema_version: 1, + occurred_at: new Date().toISOString(), + project_id: row.projeto_id, + document_id: row.id, + chunks_removed: chunksRemovidos, + }, + }); + } + } + this.audit.push({ acao: "REMOVER_DOCUMENTO", documentoId: row.id, dados: { indexado } }); + return { + documento: { id: row.id, projeto_id: row.projeto_id, nome: row.nome, caminho: row.caminho, status_processamento: row.status_processamento }, + indexado, + chunksRemovidos, + eventoChave, + storageOperationId, + }; + } + + async completeUploadOperation(documentId: string): Promise { + const row = this.rows.find((item) => item.id === documentId); + if (row) row.armazenamento_pendente = false; + const op = this.storageOperations.get(`upload:${documentId}`); + if (op) op.status = "concluido"; + } + + async completeStorageOperation(id: string): Promise { + const operation = this.storageOperations.get(id); + if (operation) { + operation.status = "concluido"; + if (operation.acao === "finalizar_upload") { + const row = this.rows.find((item) => item.id === operation.documento_id); + if (row) row.armazenamento_pendente = false; + } + } + } + + async markStorageOperationFailed(): Promise {} + + async markStorageOperationFailedForDocument(): Promise {} + + async listPendingStorageOperations(limit: number) { + return [...this.storageOperations.values()].filter((item) => item.status === "pendente").slice(0, limit); + } + + async documentExists(caminho: string): Promise { + return this.rows.some((row) => row.caminho === caminho); + } + + async listPendingEvents(limit: number): Promise { + return [...this.events.entries()] + .filter(([, value]) => value.status !== "publicado") + .slice(0, limit) + .map(([chave, value]) => ({ chave_idempotencia: chave, payload: value.payload })); + } + + async markEventPublished(chave: string): Promise { + const event = this.events.get(chave); + if (event) event.status = "publicado"; + } + + async markEventFailed(chave: string): Promise { + const event = this.events.get(chave); + if (event) event.status = "falha"; + } + + public stats = { eventos_pendentes: 0, evento_mais_antigo_segundos: 0, operacoes_armazenamento_pendentes: 0 }; + + async maintenanceStats() { + return { ...this.stats }; + } +} + +export class FakeStorage implements DocumentStorage { + public files = new Map(); + public uploads = new Map(); + public staged = new Set(); + public failSave = false; + public failStage = false; + public failFinalize = false; + + async save(key: string, content: Buffer): Promise { + if (this.failSave) throw new Error("disco cheio"); + this.uploads.set(key, content); + } + + async finalizeUpload(key: string): Promise { + if (this.failFinalize) throw new Error("falha simulada"); + const content = this.uploads.get(key); + if (content) { + this.files.set(key, content); + this.uploads.delete(key); + } + } + + async remove(key: string): Promise { + this.files.delete(key); + this.uploads.delete(key); + } + + async stageRemoval(key: string): Promise { + if (this.failStage) throw new Error("sem permissão"); + if (!this.files.has(key)) return false; + this.staged.add(key); + return true; + } + + async restore(key: string): Promise { + this.staged.delete(key); + } + + async discard(key: string): Promise { + this.staged.delete(key); + this.files.delete(key); + } + + async reconcileStaged(): Promise {} +} + +export class FakePublisher implements DocumentEventPublisher { + public published: DocumentRemovedEvent[] = []; + public available = true; + + async publish(event: DocumentRemovedEvent): Promise { + if (!this.available) return false; + this.published.push(event); + return true; + } +} diff --git a/backend/src/modules/documents/documents.repository.db.test.ts b/backend/src/modules/documents/documents.repository.db.test.ts new file mode 100644 index 0000000..f77b660 --- /dev/null +++ b/backend/src/modules/documents/documents.repository.db.test.ts @@ -0,0 +1,296 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { Pool } from "pg"; +import { validateTarget } from "../../database/seed-lib.js"; +import { ArchiveConflict } from "../projects/archive.types.js"; +import { lockHierarchy } from "../projects/hierarchy-archive.js"; +import { DocumentsRepository, removalEventKey } from "./documents.repository.js"; + +test("S1-19/S1-22: documentos, auditoria e outbox no PostgreSQL", { skip: !process.env.ARCHIVE_TEST_DATABASE_URL }, async (t) => { + const pool = new Pool({ connectionString: validateTarget(process.env.ARCHIVE_TEST_DATABASE_URL, "test") }); + const repo = new DocumentsRepository(pool); + const [project, other, user, plain, indexed, chunk, foreign] = Array.from({ length: 7 }, () => randomUUID()); + const email = `${user}@documentos.test`; + try { + await pool.query("INSERT INTO usuario (id,nome,email,senha_hash,role) VALUES ($1,'Ana PO',$2,'hash','po')", [user, email]); + await pool.query("INSERT INTO projeto (id,nome,cliente,status) VALUES ($1::uuid,$1::text,'Teste','ativo'),($2::uuid,$2::text,'Teste','ativo')", [project, other]); + + await t.test("cria documento com metadados, autor e auditoria sem conteúdo", async () => { + const created = await repo.create({ + id: plain, projetoId: project, nome: "Escopo.pdf", extensao: ".pdf", mime: "application/pdf", + tamanhoBytes: 1234, caminho: `${project}/${plain}`, usuarioId: user, + }); + assert.equal(created.tamanho_bytes, 1234); + assert.equal(created.autor_nome, "Ana PO"); + assert.equal(created.status_processamento, "pendente"); + const audit = (await pool.query("SELECT acao, dados_json FROM auditoria WHERE entidade_id=$1", [plain])).rows; + assert.equal(audit.length, 1); + assert.equal(audit[0].acao, "ENVIAR_DOCUMENTO"); + assert.deepEqual(Object.keys(audit[0].dados_json).sort(), ["extensao", "nome", "projeto_id", "tamanho_bytes"]); + }); + + await t.test("listagem e busca respeitam o isolamento por projeto", async () => { + await repo.create({ + id: foreign, projetoId: other, nome: "Outro.txt", extensao: ".txt", mime: "text/plain", + tamanhoBytes: 10, caminho: `${other}/${foreign}`, usuarioId: user, + }); + assert.deepEqual((await repo.listByProject(project, null, 20)).items.map((item) => item.id), [plain]); + assert.equal(await repo.findById(project, foreign), null); + assert.equal((await repo.findById(other, foreign))?.id, foreign); + }); + + await t.test("falha da auditoria desfaz a criação do registro", async () => { + const orphan = randomUUID(); + await assert.rejects(repo.create({ + id: orphan, projetoId: project, nome: "x.txt", extensao: ".txt", mime: "text/plain", + tamanhoBytes: 1, caminho: `${project}/${orphan}`, usuarioId: randomUUID(), + })); + assert.equal((await pool.query("SELECT count(*)::int n FROM documento WHERE id=$1", [orphan])).rows[0].n, 0); + }); + + await t.test("remove documento não indexado sem gerar evento e é idempotente", async () => { + const result = await repo.remove({ id: plain, projetoId: project, usuarioId: user }); + assert.equal(result?.indexado, false); + assert.equal(result?.eventoChave, null); + assert.equal(await repo.remove({ id: plain, projetoId: project, usuarioId: user }), null); + assert.equal((await pool.query("SELECT count(*)::int n FROM auditoria WHERE entidade_id=$1 AND acao='REMOVER_DOCUMENTO'", [plain])).rows[0].n, 1); + }); + + await t.test("remoção não atravessa projetos", async () => { + assert.equal(await repo.remove({ id: foreign, projetoId: project, usuarioId: user }), null); + assert.equal((await repo.findById(other, foreign))?.id, foreign); + }); + + await t.test("remove documento indexado, apaga chunks e grava um único evento com chave estável", async () => { + await repo.create({ + id: indexed, projetoId: project, nome: "Indexado.md", extensao: ".md", mime: "text/markdown", + tamanhoBytes: 50, caminho: `${project}/${indexed}`, usuarioId: user, + }); + await pool.query("UPDATE documento SET status_processamento='processado' WHERE id=$1", [indexed]); + await pool.query("INSERT INTO chunk (id,projeto_id,entidade_tipo,entidade_id,texto) VALUES ($1,$2,'documento',$3,'trecho')", [chunk, project, indexed]); + + const result = await repo.remove({ id: indexed, projetoId: project, usuarioId: user }); + assert.equal(result?.indexado, true); + assert.equal(result?.chunksRemovidos, 1); + assert.equal(result?.eventoChave, removalEventKey(indexed)); + assert.equal((await pool.query("SELECT count(*)::int n FROM chunk WHERE entidade_id=$1", [indexed])).rows[0].n, 0); + + assert.equal(await repo.remove({ id: indexed, projetoId: project, usuarioId: user }), null); + const events = (await pool.query("SELECT status, payload FROM evento_integracao WHERE chave_idempotencia=$1", [removalEventKey(indexed)])).rows; + assert.equal(events.length, 1); + assert.equal(events[0].status, "pendente"); + assert.equal(events[0].payload.document_id, indexed); + assert.equal(events[0].payload.project_id, project); + }); + + await t.test("outbox: lease impede entrega dupla, falha aplica backoff e publicado sai da fila", async () => { + const key = removalEventKey(indexed); + const has = async () => (await repo.listPendingEvents(50)).some((item) => item.chave_idempotencia === key); + const state = async () => (await pool.query("SELECT status, tentativas, last_error, next_attempt_at > CURRENT_TIMESTAMP AS adiado FROM evento_integracao WHERE chave_idempotencia=$1", [key])).rows[0]; + + assert.equal(await has(), true, "evento pendente deve ser reservado"); + assert.equal(await has(), false, "evento reservado não pode ser entregue a outra instância"); + + await repo.markEventFailed(key); + const failed = await state(); + assert.equal(failed.status, "falha"); + assert.equal(failed.tentativas, 1); + assert.equal(failed.adiado, true); + assert.equal(failed.last_error, "Falha ao entregar evento ao consumidor configurado."); + assert.equal(await has(), false, "backoff deve ocultar o evento até a próxima tentativa"); + + await pool.query("UPDATE evento_integracao SET next_attempt_at = CURRENT_TIMESTAMP - INTERVAL '1 second' WHERE chave_idempotencia=$1", [key]); + assert.equal(await has(), true, "evento vencido volta para a fila"); + + await pool.query("UPDATE evento_integracao SET locked_until = CURRENT_TIMESTAMP - INTERVAL '1 second' WHERE chave_idempotencia=$1", [key]); + assert.equal(await has(), true, "lease expirado permite nova reserva"); + + await repo.markEventPublished(key); + await pool.query("UPDATE evento_integracao SET next_attempt_at = CURRENT_TIMESTAMP - INTERVAL '1 second', locked_until = NULL WHERE chave_idempotencia=$1", [key]); + assert.equal(await has(), false, "evento publicado nunca volta para a fila"); + assert.equal((await state()).status, "publicado"); + }); + + await t.test("outbox: duas instâncias reservam eventos disjuntos", async () => { + const keys = Array.from({ length: 6 }, () => `document.removed:${randomUUID()}`); + try { + for (const key of keys) { + await pool.query("INSERT INTO evento_integracao (tipo, chave_idempotencia, payload) VALUES ('document.removed', $1, '{}'::jsonb)", [key]); + } + const otherPool = new Pool({ connectionString: validateTarget(process.env.ARCHIVE_TEST_DATABASE_URL, "test") }); + const other = new DocumentsRepository(otherPool); + const [a, b] = await Promise.all([repo.listPendingEvents(3), other.listPendingEvents(3)]); + const ours = new Set(keys); + const first = a.map((item) => item.chave_idempotencia).filter((key) => ours.has(key)); + const second = b.map((item) => item.chave_idempotencia).filter((key) => ours.has(key)); + assert.equal(first.filter((key) => second.includes(key)).length, 0); + assert.ok(first.length + second.length <= keys.length); + await otherPool.end(); + } finally { + await pool.query("DELETE FROM evento_integracao WHERE chave_idempotencia = ANY($1::text[])", [keys]); + } + }); + + await t.test("armazenamento: operações duráveis são reservadas, reagendadas e concluídas", async () => { + const documentId = randomUUID(); + await repo.create({ + id: documentId, projetoId: project, nome: "fila.txt", extensao: ".txt", mime: "text/plain", + tamanhoBytes: 4, caminho: `${project}/${documentId}`, usuarioId: user, + }); + const mine = async () => (await repo.listPendingStorageOperations(100)).filter((item) => item.documento_id === documentId); + const first = await mine(); + assert.equal(first.length, 1); + assert.equal(first[0].acao, "finalizar_upload"); + assert.equal((await mine()).length, 0, "operação reservada não é entregue de novo"); + + await repo.markStorageOperationFailed(first[0].id); + assert.equal((await mine()).length, 0, "backoff oculta a operação"); + await pool.query("UPDATE documento_operacao_armazenamento SET next_attempt_at = CURRENT_TIMESTAMP - INTERVAL '1 second' WHERE id=$1", [first[0].id]); + const retry = await mine(); + assert.equal(retry.length, 1); + + await repo.completeStorageOperation(retry[0].id); + await pool.query("UPDATE documento_operacao_armazenamento SET next_attempt_at = CURRENT_TIMESTAMP - INTERVAL '1 second', locked_until = NULL WHERE id=$1", [first[0].id]); + assert.equal((await mine()).length, 0); + assert.equal((await repo.listByProject(project, null, 50)).items.find((item) => item.id === documentId)?.armazenamento_pendente, false); + }); + + await t.test("paginação por cursor é estável, sem repetição e isolada por projeto", async () => { + const paged = randomUUID(); + const ids: string[] = []; + await pool.query("INSERT INTO projeto (id,nome,cliente,status) VALUES ($1::uuid,$1::text,'Teste','ativo')", [paged]); + for (let index = 0; index < 5; index += 1) { + const id = randomUUID(); + ids.push(id); + await pool.query( + "INSERT INTO documento (id,projeto_id,nome,extensao,mime,tamanho_bytes,caminho,usuario_id,created_at) VALUES ($1,$2,$3,'.txt','text/plain',1,$4,$5,'2026-01-01T00:00:00Z')", + [id, paged, `doc-${index}.txt`, `${paged}/${id}`, user], + ); + } + const seen: string[] = []; + let cursor: { createdAt: string; id: string } | null = null; + for (let guard = 0; guard < 10; guard += 1) { + const page = await repo.listByProject(paged, cursor, 2); + seen.push(...page.items.map((item) => item.id)); + if (!page.hasMore) break; + const last = page.items[page.items.length - 1]; + cursor = { createdAt: last.created_at, id: last.id }; + } + assert.equal(new Set(seen).size, 5); + assert.deepEqual([...seen].sort(), [...ids].sort()); + assert.ok(seen.every((id) => ids.includes(id)), "nenhum documento de outro projeto"); + const expected = [...ids].sort().reverse(); + assert.deepEqual(seen, expected, "ordem estável por created_at desc e id desc mesmo com datas iguais"); + await pool.query("DELETE FROM documento WHERE projeto_id=$1", [paged]); + await pool.query("DELETE FROM projeto WHERE id=$1", [paged]); + }); + + await t.test("estatísticas de manutenção contam eventos e operações pendentes", async () => { + const key = `document.removed:${randomUUID()}`; + const before = await repo.maintenanceStats(); + await pool.query("INSERT INTO evento_integracao (tipo, chave_idempotencia, payload, created_at) VALUES ('document.removed', $1, '{}'::jsonb, CURRENT_TIMESTAMP - INTERVAL '2 hours')", [key]); + try { + const after = await repo.maintenanceStats(); + assert.equal(after.eventos_pendentes, before.eventos_pendentes + 1); + assert.ok(after.evento_mais_antigo_segundos >= 7200); + assert.equal(typeof after.operacoes_armazenamento_pendentes, "number"); + } finally { + await pool.query("DELETE FROM evento_integracao WHERE chave_idempotencia=$1", [key]); + } + }); + + await t.test("corrida arquivamento/upload: o escritor aguarda o lock e recebe conflito", async () => { + const archivedProject = randomUUID(); + const attemptedDocument = randomUUID(); + await pool.query("INSERT INTO projeto (id,nome,cliente,status) VALUES ($1::uuid,$1::text,'Teste','ativo')", [archivedProject]); + const archiver = await pool.connect(); + try { + await archiver.query("BEGIN"); + await lockHierarchy(archiver); + await archiver.query("UPDATE projeto SET status='arquivado' WHERE id=$1", [archivedProject]); + const write = repo.create({ + id: attemptedDocument, + projetoId: archivedProject, + nome: "corrida.txt", + extensao: ".txt", + mime: "text/plain", + tamanhoBytes: 8, + caminho: `${archivedProject}/${attemptedDocument}`, + usuarioId: user, + }); + const deadline = Date.now() + 2_000; + let waiting = false; + while (!waiting && Date.now() < deadline) { + const result = await pool.query<{ waiting: boolean }>( + "SELECT EXISTS (SELECT 1 FROM pg_stat_activity WHERE wait_event_type='Lock' AND query LIKE 'LOCK TABLE projeto,%') AS waiting", + ); + waiting = result.rows[0].waiting; + if (!waiting) await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.equal(waiting, true, "a gravação deve aguardar o lock do arquivamento"); + await archiver.query("COMMIT"); + await assert.rejects(write, ArchiveConflict); + assert.equal((await pool.query("SELECT count(*)::int AS total FROM documento WHERE id=$1", [attemptedDocument])).rows[0].total, 0); + } catch (error) { + await archiver.query("ROLLBACK").catch(() => undefined); + throw error; + } finally { + archiver.release(); + } + }); + + await t.test("corrida arquivamento/DELETE: conflito preserva documento, chunks e auditoria", async () => { + const archivedProject = randomUUID(); + const archivedDocument = randomUUID(); + const archivedChunk = randomUUID(); + await pool.query("INSERT INTO projeto (id,nome,cliente,status) VALUES ($1::uuid,$1::text,'Teste','ativo')", [archivedProject]); + await repo.create({ + id: archivedDocument, + projetoId: archivedProject, + nome: "protegido.txt", + extensao: ".txt", + mime: "text/plain", + tamanhoBytes: 8, + caminho: `${archivedProject}/${archivedDocument}`, + usuarioId: user, + }); + await pool.query("INSERT INTO chunk (id,projeto_id,entidade_tipo,entidade_id,texto) VALUES ($1,$2,'documento',$3,'protegido')", [archivedChunk, archivedProject, archivedDocument]); + + const archiver = await pool.connect(); + try { + await archiver.query("BEGIN"); + await lockHierarchy(archiver); + await archiver.query("UPDATE projeto SET status='arquivado' WHERE id=$1", [archivedProject]); + const removal = repo.remove({ id: archivedDocument, projetoId: archivedProject, usuarioId: user }); + const deadline = Date.now() + 2_000; + let waiting = false; + while (!waiting && Date.now() < deadline) { + const result = await pool.query<{ waiting: boolean }>( + "SELECT EXISTS (SELECT 1 FROM pg_stat_activity WHERE wait_event_type='Lock' AND query LIKE 'LOCK TABLE projeto,%') AS waiting", + ); + waiting = result.rows[0].waiting; + if (!waiting) await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.equal(waiting, true, "a remoção deve aguardar o lock do arquivamento"); + await archiver.query("COMMIT"); + await assert.rejects(removal, ArchiveConflict); + assert.equal((await repo.findById(archivedProject, archivedDocument))?.id, archivedDocument); + assert.equal((await pool.query("SELECT count(*)::int AS total FROM chunk WHERE id=$1", [archivedChunk])).rows[0].total, 1); + assert.equal((await pool.query("SELECT count(*)::int AS total FROM auditoria WHERE entidade_id=$1 AND acao='REMOVER_DOCUMENTO'", [archivedDocument])).rows[0].total, 0); + } catch (error) { + await archiver.query("ROLLBACK").catch(() => undefined); + throw error; + } finally { + archiver.release(); + } + }); + } finally { + await pool.query("DELETE FROM evento_integracao WHERE chave_idempotencia = ANY($1::text[])", [[removalEventKey(indexed), removalEventKey(plain)]]); + await pool.query("DELETE FROM auditoria WHERE entidade_id = ANY($1::uuid[])", [[plain, indexed, foreign]]); + await pool.query("DELETE FROM chunk WHERE id=$1", [chunk]); + await pool.query("DELETE FROM projeto WHERE id = ANY($1::uuid[])", [[project, other]]); + await pool.query("DELETE FROM usuario WHERE id=$1", [user]); + await pool.end(); + } +}); diff --git a/backend/src/modules/documents/documents.repository.ts b/backend/src/modules/documents/documents.repository.ts new file mode 100644 index 0000000..d13cafe --- /dev/null +++ b/backend/src/modules/documents/documents.repository.ts @@ -0,0 +1,298 @@ +import { Pool } from "pg"; +import { pool } from "../../database/db.js"; +import { auditService } from "../audit/audit.service.js"; +import { assertWritable, lockHierarchy } from "../projects/hierarchy-archive.js"; +import { + DOCUMENT_REMOVED_EVENT_TYPE, + type CreateDocumentInput, + type DocumentMaintenanceStats, + type DocumentRecord, + type DocumentRemovedEvent, + type RemovalResult, + type RemoveDocumentInput, + type StoredDocument, +} from "./documents.types.js"; + +interface DocumentRow extends Omit { + tamanho_bytes: string | null; +} + +export interface PendingEvent { + chave_idempotencia: string; + payload: DocumentRemovedEvent; +} + +export interface PendingStorageOperation { + id: string; + documento_id: string; + projeto_id: string; + acao: "finalizar_upload" | "descartar_remocao"; + caminho: string; +} + +export interface DocumentPage { + items: DocumentRecord[]; + hasMore: boolean; +} + +const SELECT_COLUMNS = ` + d.id, d.projeto_id, d.nome, d.extensao, d.mime, d.tamanho_bytes, d.status_processamento, + d.usuario_id AS autor_id, u.nome AS autor_nome, + EXISTS ( + SELECT 1 FROM documento_operacao_armazenamento op + WHERE op.documento_id = d.id AND op.acao = 'finalizar_upload' AND op.status = 'pendente' + ) AS armazenamento_pendente, + d.created_at::text AS created_at, d.updated_at::text AS updated_at +`; + +function toRecord(row: DocumentRow): DocumentRecord { + return { ...row, tamanho_bytes: row.tamanho_bytes === null ? null : Number(row.tamanho_bytes) }; +} + +export function removalEventKey(documentId: string): string { + return `${DOCUMENT_REMOVED_EVENT_TYPE}:${documentId}`; +} + +export class DocumentsRepository { + private readonly pool: Pool; + + constructor(customPool?: Pool) { + this.pool = customPool ?? pool; + } + + async listByProject(projetoId: string, cursor: { createdAt: string; id: string } | null, limit: number): Promise { + const result = await this.pool.query( + `SELECT ${SELECT_COLUMNS} + FROM documento d + LEFT JOIN usuario u ON u.id = d.usuario_id + WHERE d.projeto_id = $1 + AND ($2::timestamptz IS NULL OR (d.created_at, d.id) < ($2::timestamptz, $3::uuid)) + ORDER BY d.created_at DESC, d.id DESC + LIMIT $4`, + [projetoId, cursor?.createdAt ?? null, cursor?.id ?? null, limit + 1], + ); + const hasMore = result.rows.length > limit; + const rows = hasMore ? result.rows.slice(0, limit) : result.rows; + return { items: rows.map(toRecord), hasMore }; + } + + async findById(projetoId: string, id: string): Promise { + const result = await this.pool.query( + `SELECT id, projeto_id, nome, caminho, status_processamento + FROM documento WHERE id = $1 AND projeto_id = $2`, + [id, projetoId], + ); + return result.rows[0] ?? null; + } + + async create(input: CreateDocumentInput): Promise { + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + await lockHierarchy(client); + await assertWritable(client, "projeto", input.projetoId); + await client.query( + `INSERT INTO documento (id, projeto_id, nome, extensao, mime, tamanho_bytes, caminho, usuario_id, status_processamento) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pendente')`, + [input.id, input.projetoId, input.nome, input.extensao, input.mime, input.tamanhoBytes, input.caminho, input.usuarioId], + ); + await auditService.record({ + usuario_id: input.usuarioId, + entidade_tipo: "documento", + entidade_id: input.id, + acao: "ENVIAR_DOCUMENTO", + dados_json: { projeto_id: input.projetoId, nome: input.nome, extensao: input.extensao, tamanho_bytes: input.tamanhoBytes }, + }, client); + await client.query( + `INSERT INTO documento_operacao_armazenamento (documento_id, projeto_id, acao, caminho) + VALUES ($1, $2, 'finalizar_upload', $3)`, + [input.id, input.projetoId, input.caminho], + ); + const created = await client.query( + `SELECT ${SELECT_COLUMNS} + FROM documento d LEFT JOIN usuario u ON u.id = d.usuario_id + WHERE d.id = $1`, + [input.id], + ); + await client.query("COMMIT"); + return toRecord(created.rows[0]); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + + async remove(input: RemoveDocumentInput): Promise { + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + await lockHierarchy(client); + await assertWritable(client, "projeto", input.projetoId); + const deleted = await client.query( + `DELETE FROM documento WHERE id = $1 AND projeto_id = $2 + RETURNING id, projeto_id, nome, caminho, status_processamento`, + [input.id, input.projetoId], + ); + const documento = deleted.rows[0]; + if (!documento) { + await client.query("ROLLBACK"); + return null; + } + const chunks = await client.query( + `DELETE FROM chunk WHERE entidade_tipo = 'documento' AND entidade_id = $1 AND projeto_id = $2`, + [input.id, input.projetoId], + ); + const chunksRemovidos = chunks.rowCount ?? 0; + const storageOperation = await client.query<{ id: string }>( + `INSERT INTO documento_operacao_armazenamento (documento_id, projeto_id, acao, caminho) + VALUES ($1, $2, 'descartar_remocao', $3) RETURNING id`, + [input.id, input.projetoId, documento.caminho], + ); + const indexado = chunksRemovidos > 0 || documento.status_processamento === "processado"; + let eventoChave: string | null = null; + if (indexado) { + eventoChave = removalEventKey(input.id); + const event: DocumentRemovedEvent = { + event_id: eventoChave, + event_type: DOCUMENT_REMOVED_EVENT_TYPE, + schema_version: 1, + occurred_at: new Date().toISOString(), + project_id: input.projetoId, + document_id: input.id, + chunks_removed: chunksRemovidos, + }; + await client.query( + `INSERT INTO evento_integracao (tipo, chave_idempotencia, payload) + VALUES ($1, $2, $3) ON CONFLICT (chave_idempotencia) DO NOTHING`, + [DOCUMENT_REMOVED_EVENT_TYPE, eventoChave, JSON.stringify(event)], + ); + } + await auditService.record({ + usuario_id: input.usuarioId, + entidade_tipo: "documento", + entidade_id: input.id, + acao: "REMOVER_DOCUMENTO", + dados_json: { projeto_id: input.projetoId, nome: documento.nome, indexado, chunks_removidos: chunksRemovidos }, + }, client); + await client.query("COMMIT"); + return { documento, indexado, chunksRemovidos, eventoChave, storageOperationId: storageOperation.rows[0].id }; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + + async listPendingEvents(limit: number): Promise { + const result = await this.pool.query( + `WITH picked AS ( + SELECT chave_idempotencia FROM evento_integracao + WHERE status <> 'publicado' AND tipo = $1 + AND next_attempt_at <= CURRENT_TIMESTAMP + AND (locked_until IS NULL OR locked_until <= CURRENT_TIMESTAMP) + ORDER BY next_attempt_at, created_at + LIMIT $2 FOR UPDATE SKIP LOCKED + ) + UPDATE evento_integracao event + SET locked_until = CURRENT_TIMESTAMP + INTERVAL '1 minute' + FROM picked + WHERE event.chave_idempotencia = picked.chave_idempotencia + RETURNING event.chave_idempotencia, event.payload`, + [DOCUMENT_REMOVED_EVENT_TYPE, limit], + ); + return result.rows; + } + + async markEventPublished(chave: string): Promise { + await this.pool.query( + `UPDATE evento_integracao SET status = 'publicado', publicado_em = CURRENT_TIMESTAMP, tentativas = tentativas + 1, + locked_until = NULL, last_error = NULL + WHERE chave_idempotencia = $1`, + [chave], + ); + } + + async markEventFailed(chave: string): Promise { + await this.pool.query( + `UPDATE evento_integracao SET status = 'falha', tentativas = tentativas + 1, + next_attempt_at = CURRENT_TIMESTAMP + make_interval(secs => LEAST(3600, (15 * power(2, LEAST(tentativas, 8)))::int)), + locked_until = NULL, last_error = 'Falha ao entregar evento ao consumidor configurado.' + WHERE chave_idempotencia = $1`, + [chave], + ); + } + + async completeStorageOperation(id: string): Promise { + await this.pool.query( + `UPDATE documento_operacao_armazenamento SET status = 'concluido', tentativas = tentativas + 1, + locked_until = NULL, last_error = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = $1`, + [id], + ); + } + + async completeUploadOperation(documentId: string): Promise { + await this.pool.query( + `UPDATE documento_operacao_armazenamento SET status = 'concluido', tentativas = tentativas + 1, + locked_until = NULL, last_error = NULL, updated_at = CURRENT_TIMESTAMP + WHERE documento_id = $1 AND acao = 'finalizar_upload' AND status = 'pendente'`, + [documentId], + ); + } + + async listPendingStorageOperations(limit: number): Promise { + const result = await this.pool.query( + `WITH picked AS ( + SELECT id FROM documento_operacao_armazenamento + WHERE status = 'pendente' AND next_attempt_at <= CURRENT_TIMESTAMP + AND (locked_until IS NULL OR locked_until <= CURRENT_TIMESTAMP) + ORDER BY next_attempt_at, created_at + LIMIT $1 FOR UPDATE SKIP LOCKED + ) + UPDATE documento_operacao_armazenamento operation + SET locked_until = CURRENT_TIMESTAMP + INTERVAL '1 minute' + FROM picked WHERE operation.id = picked.id + RETURNING operation.id, operation.documento_id, operation.projeto_id, operation.acao, operation.caminho`, + [limit], + ); + return result.rows; + } + + async markStorageOperationFailed(id: string): Promise { + await this.pool.query( + `UPDATE documento_operacao_armazenamento SET tentativas = tentativas + 1, + next_attempt_at = CURRENT_TIMESTAMP + make_interval(secs => LEAST(3600, (15 * power(2, LEAST(tentativas, 8)))::int)), + locked_until = NULL, last_error = 'Falha ao reconciliar arquivo local.', updated_at = CURRENT_TIMESTAMP + WHERE id = $1 AND status = 'pendente'`, + [id], + ); + } + + async markStorageOperationFailedForDocument(documentId: string, action: PendingStorageOperation["acao"]): Promise { + await this.pool.query( + `UPDATE documento_operacao_armazenamento SET tentativas = tentativas + 1, + next_attempt_at = CURRENT_TIMESTAMP + make_interval(secs => LEAST(3600, (15 * power(2, LEAST(tentativas, 8)))::int)), + locked_until = NULL, last_error = 'Falha ao finalizar o armazenamento local.', updated_at = CURRENT_TIMESTAMP + WHERE documento_id = $1 AND acao = $2 AND status = 'pendente'`, + [documentId, action], + ); + } + + async maintenanceStats(): Promise { + const result = await this.pool.query( + `SELECT + (SELECT count(*)::int FROM evento_integracao WHERE status <> 'publicado') AS eventos_pendentes, + (SELECT COALESCE(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP - min(created_at)), 0)::int + FROM evento_integracao WHERE status <> 'publicado') AS evento_mais_antigo_segundos, + (SELECT count(*)::int FROM documento_operacao_armazenamento WHERE status = 'pendente') AS operacoes_armazenamento_pendentes`, + ); + return result.rows[0]; + } + + async documentExists(caminho: string): Promise { + const result = await this.pool.query<{ exists: boolean }>("SELECT EXISTS (SELECT 1 FROM documento WHERE caminho = $1) AS exists", [caminho]); + return result.rows[0]?.exists ?? false; + } +} diff --git a/backend/src/modules/documents/documents.routes.test.ts b/backend/src/modules/documents/documents.routes.test.ts new file mode 100644 index 0000000..a650da4 --- /dev/null +++ b/backend/src/modules/documents/documents.routes.test.ts @@ -0,0 +1,129 @@ +import test, { after, before } from "node:test"; +import assert from "node:assert/strict"; +import express from "express"; +import { AddressInfo } from "node:net"; +import { Server } from "node:http"; +import { DocumentsController } from "./documents.controller.js"; +import { DocumentsService } from "./documents.service.js"; +import { createDocumentsRouter } from "./documents.routes.js"; +import { errorHandler } from "../../middleware/errorHandler.js"; +import type { UserRole } from "../auth/auth.types.js"; +import { + FakeDocumentsRepository, + FakePublisher, + FakeStorage, + OTHER_PROJECT_ID, + PROJECT_ID, + USER_ID, + pdfBuffer, + projectLookup, +} from "./documents.fakes.js"; + +const LIMIT = 2048; +const DOCUMENT_ID = "c0000000-0000-4000-8000-000000000001"; + +let server: Server; +let baseUrl: string; +let role: UserRole = "po"; +const repository = new FakeDocumentsRepository(); +const storage = new FakeStorage(); + +before(async () => { + const service = new DocumentsService(repository, storage, new FakePublisher(), projectLookup, LIMIT); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.auth = { id: USER_ID, nome: "Ana PO", email: "ana@example.com", role }; + next(); + }); + app.use("/api/v1/projects/:projectId/documents", createDocumentsRouter(new DocumentsController(service), LIMIT)); + app.use(errorHandler); + server = app.listen(0); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/v1/projects`; +}); + +after(() => { + server.close(); +}); + +function upload(projectId: string, body: Buffer, fileName: string | null = "escopo.pdf", contentType = "application/octet-stream") { + const headers: Record = { "Content-Type": contentType }; + if (fileName !== null) headers["X-File-Name"] = encodeURIComponent(fileName); + return fetch(`${baseUrl}/${projectId}/documents`, { method: "POST", headers, body: new Uint8Array(body) }); +} + +test("perfil de leitura não pode enviar nem remover documentos", async () => { + role = "dev"; + assert.equal((await upload(PROJECT_ID, pdfBuffer())).status, 403); + assert.equal((await fetch(`${baseUrl}/${PROJECT_ID}/documents/${DOCUMENT_ID}`, { method: "DELETE" })).status, 403); + assert.equal(storage.files.size, 0); + role = "po"; +}); + +test("upload válido retorna 201 com metadados e o tamanho vem do conteúdo, não do cliente", async () => { + const content = pdfBuffer(100); + const response = await upload(PROJECT_ID, content, "Escopo Ação.pdf", "text/plain"); + assert.equal(response.status, 201); + const body = await response.json() as { nome: string; mime: string; tamanho_bytes: number; status_processamento: string }; + assert.equal(body.nome, "Escopo Ação.pdf"); + assert.equal(body.mime, "application/pdf"); + assert.equal(body.tamanho_bytes, content.length); + assert.equal(body.status_processamento, "pendente"); +}); + +test("MIME declarado falso é ignorado e o conteúdo real decide", async () => { + const before = storage.files.size; + const response = await upload(PROJECT_ID, Buffer.from("isto é só texto"), "falso.pdf", "application/pdf"); + assert.equal(response.status, 400); + assert.match(((await response.json()) as { error: string }).error, /não corresponde/); + assert.equal(storage.files.size, before); +}); + +test("arquivo acima do limite retorna 413 informando o limite", async () => { + const response = await upload(PROJECT_ID, Buffer.alloc(LIMIT + 10, 0x61), "grande.txt"); + assert.equal(response.status, 413); + const body = await response.json() as { code: string; details?: { max_bytes: number } }; + assert.equal(body.code, "PAYLOAD_TOO_LARGE"); + assert.equal(body.details?.max_bytes, LIMIT); +}); + +test("requisições malformadas são recusadas com 400", async () => { + assert.equal((await upload(PROJECT_ID, pdfBuffer(), null)).status, 400); + assert.equal((await upload(PROJECT_ID, Buffer.alloc(0), "vazio.txt")).status, 400); + const json = await fetch(`${baseUrl}/${PROJECT_ID}/documents`, { + method: "POST", + headers: { "Content-Type": "application/json", "X-File-Name": "a.txt" }, + body: JSON.stringify({ arquivo: "a" }), + }); + assert.equal(json.status, 400); +}); + +test("projeto inexistente retorna 404 e id malformado retorna 400", async () => { + assert.equal((await upload("d0000000-0000-4000-8000-0000000000ff", pdfBuffer())).status, 404); + assert.equal((await upload("nao-e-uuid", pdfBuffer())).status, 400); +}); + +test("listagem isola por projeto e devolve os limites configurados", async () => { + repository.seed({ id: "c0000000-0000-4000-8000-0000000000aa", projeto_id: OTHER_PROJECT_ID, caminho: "x/y", nome: "alheio.pdf" }); + const response = await fetch(`${baseUrl}/${PROJECT_ID}/documents`); + assert.equal(response.status, 200); + const body = await response.json() as { items: Array<{ nome: string; projeto_id: string }>; limites: { max_bytes: number } }; + assert.ok(body.items.length >= 1); + assert.ok(body.items.every((item) => item.projeto_id === PROJECT_ID)); + assert.ok(!body.items.some((item) => item.nome === "alheio.pdf")); + assert.equal(body.limites.max_bytes, LIMIT); +}); + +test("DELETE é idempotente: 204 na primeira e na repetição", async () => { + repository.seed({ id: DOCUMENT_ID, projeto_id: PROJECT_ID, caminho: `${PROJECT_ID}/${DOCUMENT_ID}` }); + storage.files.set(`${PROJECT_ID}/${DOCUMENT_ID}`, pdfBuffer()); + const url = `${baseUrl}/${PROJECT_ID}/documents/${DOCUMENT_ID}`; + assert.equal((await fetch(url, { method: "DELETE" })).status, 204); + assert.equal((await fetch(url, { method: "DELETE" })).status, 204); + assert.ok(!repository.rows.some((row) => row.id === DOCUMENT_ID)); + assert.ok(!storage.files.has(`${PROJECT_ID}/${DOCUMENT_ID}`)); +}); + +test("DELETE com id de documento malformado retorna 400", async () => { + assert.equal((await fetch(`${baseUrl}/${PROJECT_ID}/documents/abc`, { method: "DELETE" })).status, 400); +}); diff --git a/backend/src/modules/documents/documents.routes.ts b/backend/src/modules/documents/documents.routes.ts index 8012d4d..eb258ae 100644 --- a/backend/src/modules/documents/documents.routes.ts +++ b/backend/src/modules/documents/documents.routes.ts @@ -1,74 +1,76 @@ -import { Router, Request, Response, NextFunction } from "express"; -import { pool } from "../../database/db.js"; -import { requireAuth } from "../../middleware/requireAuth.js"; +import express, { Router } from "express"; +import { env } from "../../config/env.js"; +import { requireRole } from "../../middleware/requireRole.js"; +import { DocumentsController, documentsController } from "./documents.controller.js"; -export const documentsRouter = Router({ mergeParams: true }); +const canWrite = requireRole("admin", "po"); -documentsRouter.use(requireAuth); +export function createDocumentsRouter( + controller: DocumentsController = documentsController, + maxBytes: number = Math.floor(env.DOCUMENT_MAX_SIZE_MB * 1024 * 1024), +): Router { + const router = Router({ mergeParams: true }); + const binaryBody = express.raw({ type: () => true, limit: maxBytes }); -// GET /api/v1/projects/:projectId/documents OR /api/v1/documents -documentsRouter.get("/", async (req: Request, res: Response, next: NextFunction) => { - try { - const projectId = req.params.projectId || (req.query.projectId as string); - let query = "SELECT d.id, d.projeto_id, d.nome, d.mime, d.caminho, d.status_processamento, d.created_at, d.updated_at, p.nome as projeto_nome FROM documento d JOIN projeto p ON d.projeto_id = p.id"; - const params: unknown[] = []; + /** + * @swagger + * /api/v1/projects/{projectId}/documents: + * get: + * summary: Listar documentos do projeto + * tags: [Documents] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Documentos do projeto e limites de envio + * 401: + * description: Não autenticado + * 404: + * description: Projeto não encontrado + */ + router.get("/", controller.list); - if (projectId) { - query += " WHERE d.projeto_id = $1"; - params.push(projectId); - } - query += " ORDER BY d.created_at DESC"; + /** + * @swagger + * /api/v1/projects/{projectId}/documents: + * post: + * summary: Enviar documento (PDF, DOCX, MD ou TXT) como corpo binário + * tags: [Documents] + * security: + * - bearerAuth: [] + * responses: + * 201: + * description: Documento armazenado + * 400: + * description: Arquivo inválido ou projeto arquivado + * 403: + * description: Sem permissão de escrita + * 404: + * description: Projeto não encontrado + * 413: + * description: Arquivo acima do limite configurado + */ + router.post("/", canWrite, binaryBody, controller.upload); - const result = await pool.query(query, params); - res.json({ items: result.rows, total: result.rowCount }); - } catch (error) { - next(error); - } -}); + /** + * @swagger + * /api/v1/projects/{projectId}/documents/{documentId}: + * delete: + * summary: Remover documento de forma idempotente + * tags: [Documents] + * security: + * - bearerAuth: [] + * responses: + * 204: + * description: Documento removido ou já inexistente + * 403: + * description: Sem permissão de escrita + * 404: + * description: Projeto não encontrado + */ + router.delete("/:documentId", canWrite, controller.remove); -// POST /api/v1/projects/:projectId/documents -documentsRouter.post("/", async (req: Request, res: Response, NextFunction) => { - try { - const projectId = req.params.projectId || req.body.projeto_id; - const { nome, mime, caminho, status_processamento } = req.body; + return router; +} - if (!projectId || !nome) { - res.status(400).json({ error: "projeto_id e nome são obrigatórios" }); - return; - } - - const insertResult = await pool.query( - `INSERT INTO documento (projeto_id, nome, mime, caminho, status_processamento) - VALUES ($1, $2, $3, $4, $5) - RETURNING *`, - [projectId, nome, mime || "PDF", caminho || `/uploads/${nome}`, status_processamento || "processado"] - ); - - // Registra também um trecho em chunk para permitir busca vetorial no acervo - await pool.query( - `INSERT INTO chunk (projeto_id, entidade_tipo, entidade_id, texto, metadados_json) - VALUES ($1, 'documento', $2, $3, $4)`, - [ - projectId, - insertResult.rows[0].id, - `Documento indexado: ${nome}. Conteúdo técnico importado do acervo do projeto.`, - JSON.stringify({ documento_id: insertResult.rows[0].id, nome }), - ] - ); - - res.status(201).json(insertResult.rows[0]); - } catch (error) { - NextFunction(error); - } -}); - -// DELETE /api/v1/documents/:id -documentsRouter.delete("/:id", async (req: Request, res: Response, next: NextFunction) => { - try { - const { id } = req.params; - await pool.query("DELETE FROM documento WHERE id = $1", [id]); - res.status(204).send(); - } catch (error) { - next(error); - } -}); +export const documentsRouter = createDocumentsRouter(); diff --git a/backend/src/modules/documents/documents.service.test.ts b/backend/src/modules/documents/documents.service.test.ts new file mode 100644 index 0000000..e8c0412 --- /dev/null +++ b/backend/src/modules/documents/documents.service.test.ts @@ -0,0 +1,302 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { AppError, NotFoundError, ValidationError } from "../../shared/errors.js"; +import { ArchiveConflict } from "../projects/archive.types.js"; +import { DocumentsService } from "./documents.service.js"; +import { + ARCHIVED_PROJECT_ID, + FakeDocumentsRepository, + FakePublisher, + FakeStorage, + OTHER_PROJECT_ID, + PROJECT_ID, + USER_ID, + pdfBuffer, + projectLookup, +} from "./documents.fakes.js"; + +const LIMIT = 4096; +const DOCUMENT_ID = "c0000000-0000-4000-8000-000000000001"; + +function setup() { + const repository = new FakeDocumentsRepository(); + const storage = new FakeStorage(); + const publisher = new FakePublisher(); + const service = new DocumentsService(repository, storage, publisher, projectLookup, LIMIT); + return { repository, storage, publisher, service }; +} + +test("upload válido persiste projeto, nome original, tipo, tamanho, autor e guarda o arquivo com identificador seguro", async () => { + const { service, repository, storage } = setup(); + const content = pdfBuffer(); + const created = await service.upload({ projetoId: PROJECT_ID, usuarioId: USER_ID, fileName: "../Escopo Final.pdf", content }); + + assert.equal(created.projeto_id, PROJECT_ID); + assert.equal(created.nome, "Escopo Final.pdf"); + assert.equal(created.mime, "application/pdf"); + assert.equal(created.tamanho_bytes, content.length); + assert.equal(created.autor_id, USER_ID); + assert.equal(created.status_processamento, "pendente"); + assert.equal(repository.audit[0].acao, "ENVIAR_DOCUMENTO"); + + const [key] = [...storage.files.keys()]; + assert.match(key, /^[0-9a-f-]{36}\/[0-9a-f-]{36}$/); + assert.ok(!key.includes("Escopo")); + assert.deepEqual(storage.files.get(key), content); +}); + +test("MIME falso é recusado sem deixar arquivo nem registro", async () => { + const { service, repository, storage } = setup(); + await assert.rejects( + service.upload({ projetoId: PROJECT_ID, usuarioId: USER_ID, fileName: "falso.pdf", content: Buffer.from("texto puro") }), + ValidationError, + ); + assert.equal(storage.files.size, 0); + assert.equal(repository.rows.length, 0); +}); + +test("formato não permitido, arquivo vazio e limite excedido não deixam rastros", async () => { + const { service, repository, storage } = setup(); + await assert.rejects(service.upload({ projetoId: PROJECT_ID, usuarioId: USER_ID, fileName: "app.exe", content: Buffer.from("x") }), ValidationError); + await assert.rejects(service.upload({ projetoId: PROJECT_ID, usuarioId: USER_ID, fileName: "vazio.txt", content: Buffer.alloc(0) }), ValidationError); + await assert.rejects( + service.upload({ projetoId: PROJECT_ID, usuarioId: USER_ID, fileName: "grande.txt", content: Buffer.alloc(LIMIT + 1, 0x61) }), + (error: unknown) => error instanceof AppError && error.statusCode === 413, + ); + assert.equal(storage.files.size, 0); + assert.equal(repository.rows.length, 0); +}); + +test("falha ao gravar o arquivo não cria registro e permite nova tentativa", async () => { + const { service, repository, storage } = setup(); + storage.failSave = true; + await assert.rejects( + service.upload({ projetoId: PROJECT_ID, usuarioId: USER_ID, fileName: "ok.txt", content: Buffer.from("conteudo") }), + (error: unknown) => error instanceof AppError && error.statusCode === 503 && /tente novamente/.test(error.message), + ); + assert.equal(repository.rows.length, 0); + + storage.failSave = false; + const created = await service.upload({ projetoId: PROJECT_ID, usuarioId: USER_ID, fileName: "ok.txt", content: Buffer.from("conteudo") }); + assert.equal(created.nome, "ok.txt"); + assert.equal(repository.rows.length, 1); +}); + +test("falha ao gravar o registro remove o arquivo já armazenado", async () => { + const { service, repository, storage } = setup(); + repository.failCreate = true; + await assert.rejects(service.upload({ projetoId: PROJECT_ID, usuarioId: USER_ID, fileName: "ok.md", content: Buffer.from("# ok") })); + assert.equal(storage.files.size, 0); + assert.equal(repository.rows.length, 0); +}); + +test("projeto inexistente, inválido ou arquivado não recebe documentos", async () => { + const { service, storage } = setup(); + const payload = { usuarioId: USER_ID, fileName: "ok.txt", content: Buffer.from("conteudo") }; + await assert.rejects(service.upload({ ...payload, projetoId: "d0000000-0000-4000-8000-0000000000ff" }), NotFoundError); + await assert.rejects(service.upload({ ...payload, projetoId: "nao-e-uuid" }), ValidationError); + await assert.rejects(service.upload({ ...payload, projetoId: ARCHIVED_PROJECT_ID }), ArchiveConflict); + assert.equal(storage.files.size, 0); +}); + +test("listagem retorna apenas documentos do projeto e informa os limites", async () => { + const { service, repository } = setup(); + repository.seed({ id: DOCUMENT_ID, projeto_id: PROJECT_ID, caminho: `${PROJECT_ID}/${DOCUMENT_ID}` }); + repository.seed({ id: "c0000000-0000-4000-8000-000000000002", projeto_id: OTHER_PROJECT_ID, caminho: "x/y" }); + + const result = await service.list(PROJECT_ID); + assert.deepEqual(result.items.map((item) => item.id), [DOCUMENT_ID]); + assert.equal(result.limites.max_bytes, LIMIT); + assert.deepEqual(result.limites.extensoes_permitidas, [".pdf", ".docx", ".md", ".txt"]); + await assert.rejects(service.list("d0000000-0000-4000-8000-0000000000ff"), NotFoundError); +}); + +test("remoção apaga metadados e arquivo, audita e não gera evento para documento não indexado", async () => { + const { service, repository, storage, publisher } = setup(); + const caminho = `${PROJECT_ID}/${DOCUMENT_ID}`; + repository.seed({ id: DOCUMENT_ID, projeto_id: PROJECT_ID, caminho }); + storage.files.set(caminho, pdfBuffer()); + + await service.remove({ projetoId: PROJECT_ID, documentoId: DOCUMENT_ID, usuarioId: USER_ID }); + + assert.equal(repository.rows.length, 0); + assert.equal(storage.files.size, 0); + assert.equal(storage.staged.size, 0); + assert.deepEqual(repository.audit.map((item) => item.acao), ["REMOVER_DOCUMENTO"]); + assert.equal(repository.events.size, 0); + assert.equal(publisher.published.length, 0); +}); + +test("remoção de documento indexado publica evento com identificadores estáveis", async () => { + const { service, repository, storage, publisher } = setup(); + const caminho = `${PROJECT_ID}/${DOCUMENT_ID}`; + repository.seed({ id: DOCUMENT_ID, projeto_id: PROJECT_ID, caminho, status_processamento: "processado" }); + repository.chunks.set(DOCUMENT_ID, 7); + storage.files.set(caminho, pdfBuffer()); + + await service.remove({ projetoId: PROJECT_ID, documentoId: DOCUMENT_ID, usuarioId: USER_ID }); + assert.equal(publisher.published.length, 0, "a requisição de remoção não deve drenar a outbox"); + await service.flushPendingEvents(); + + assert.equal(publisher.published.length, 1); + assert.equal(publisher.published[0].event_id, `document.removed:${DOCUMENT_ID}`); + assert.equal(publisher.published[0].document_id, DOCUMENT_ID); + assert.equal(publisher.published[0].project_id, PROJECT_ID); + assert.equal(publisher.published[0].chunks_removed, 7); + assert.equal(repository.events.get(`document.removed:${DOCUMENT_ID}`)?.status, "publicado"); +}); + +test("repetir a remoção não falha, não duplica o evento e não afeta outro conteúdo", async () => { + const { service, repository, storage, publisher } = setup(); + const otherId = "c0000000-0000-4000-8000-000000000002"; + repository.seed({ id: DOCUMENT_ID, projeto_id: PROJECT_ID, caminho: `${PROJECT_ID}/${DOCUMENT_ID}`, status_processamento: "processado" }); + repository.seed({ id: otherId, projeto_id: PROJECT_ID, caminho: `${PROJECT_ID}/${otherId}` }); + storage.files.set(`${PROJECT_ID}/${DOCUMENT_ID}`, pdfBuffer()); + storage.files.set(`${PROJECT_ID}/${otherId}`, pdfBuffer()); + + await service.remove({ projetoId: PROJECT_ID, documentoId: DOCUMENT_ID, usuarioId: USER_ID }); + await service.remove({ projetoId: PROJECT_ID, documentoId: DOCUMENT_ID, usuarioId: USER_ID }); + await service.flushPendingEvents(); + + assert.deepEqual(repository.rows.map((row) => row.id), [otherId]); + assert.ok(storage.files.has(`${PROJECT_ID}/${otherId}`)); + assert.equal(publisher.published.length, 1); + assert.equal(repository.audit.filter((item) => item.acao === "REMOVER_DOCUMENTO").length, 1); +}); + +test("documento de outro projeto não é removido pelo caminho de um projeto alheio", async () => { + const { service, repository, storage } = setup(); + const caminho = `${OTHER_PROJECT_ID}/${DOCUMENT_ID}`; + repository.seed({ id: DOCUMENT_ID, projeto_id: OTHER_PROJECT_ID, caminho }); + storage.files.set(caminho, pdfBuffer()); + + await service.remove({ projetoId: PROJECT_ID, documentoId: DOCUMENT_ID, usuarioId: USER_ID }); + + assert.equal(repository.rows.length, 1); + assert.ok(storage.files.has(caminho)); + assert.equal(repository.audit.length, 0); +}); + +test("falha ao remover mantém o documento e restaura o arquivo", async () => { + const { service, repository, storage } = setup(); + const caminho = `${PROJECT_ID}/${DOCUMENT_ID}`; + repository.seed({ id: DOCUMENT_ID, projeto_id: PROJECT_ID, caminho }); + storage.files.set(caminho, pdfBuffer()); + repository.failRemove = true; + + await assert.rejects( + service.remove({ projetoId: PROJECT_ID, documentoId: DOCUMENT_ID, usuarioId: USER_ID }), + (error: unknown) => error instanceof AppError && /continua disponível/.test(error.message), + ); + assert.equal(repository.rows.length, 1); + assert.ok(storage.files.has(caminho)); + assert.equal(storage.staged.size, 0); +}); + +test("falha no armazenamento ao remover mantém o documento", async () => { + const { service, repository, storage } = setup(); + const caminho = `${PROJECT_ID}/${DOCUMENT_ID}`; + repository.seed({ id: DOCUMENT_ID, projeto_id: PROJECT_ID, caminho }); + storage.files.set(caminho, pdfBuffer()); + storage.failStage = true; + + await assert.rejects(service.remove({ projetoId: PROJECT_ID, documentoId: DOCUMENT_ID, usuarioId: USER_ID }), AppError); + assert.equal(repository.rows.length, 1); +}); + +test("evento não publicado fica pendente e pode ser reenviado pelo worker sem outra remoção", async () => { + const { service, repository, storage, publisher } = setup(); + const caminho = `${PROJECT_ID}/${DOCUMENT_ID}`; + repository.seed({ id: DOCUMENT_ID, projeto_id: PROJECT_ID, caminho, status_processamento: "processado" }); + storage.files.set(caminho, pdfBuffer()); + publisher.available = false; + + await service.remove({ projetoId: PROJECT_ID, documentoId: DOCUMENT_ID, usuarioId: USER_ID }); + await service.flushPendingEvents(); + assert.equal(repository.events.get(`document.removed:${DOCUMENT_ID}`)?.status, "falha"); + assert.equal(publisher.published.length, 0); + + publisher.available = true; + await service.flushPendingEvents(); + assert.equal(publisher.published.length, 1); + assert.equal(repository.events.size, 1); + assert.equal(repository.events.get(`document.removed:${DOCUMENT_ID}`)?.status, "publicado"); +}); + +test("DELETE em projeto arquivado retorna conflito e preserva documento e arquivo", async () => { + const { service, repository, storage } = setup(); + const caminho = `${ARCHIVED_PROJECT_ID}/${DOCUMENT_ID}`; + repository.seed({ id: DOCUMENT_ID, projeto_id: ARCHIVED_PROJECT_ID, caminho }); + storage.files.set(caminho, pdfBuffer()); + + await assert.rejects( + service.remove({ projetoId: ARCHIVED_PROJECT_ID, documentoId: DOCUMENT_ID, usuarioId: USER_ID }), + ArchiveConflict, + ); + assert.equal(repository.rows.length, 1); + assert.ok(storage.files.has(caminho)); + assert.equal(repository.audit.length, 0); + assert.equal(repository.events.size, 0); +}); + +test("falha ao finalizar o arquivo fica marcada e é recuperável sem repetir o cadastro", async () => { + const { service, repository, storage } = setup(); + storage.failFinalize = true; + const record = await service.upload({ + projetoId: PROJECT_ID, + usuarioId: USER_ID, + fileName: "manual.txt", + content: Buffer.from("conteúdo"), + }); + assert.equal(record.armazenamento_pendente, true); + assert.equal(repository.rows.length, 1); + + storage.failFinalize = false; + await service.processPendingStorageOperations(); + assert.equal(repository.rows[0].armazenamento_pendente, false); + assert.equal(storage.files.size, 1); +}); + +test("listagem usa cursor estável sem duplicar documentos entre páginas", async () => { + const { service, repository } = setup(); + for (let index = 0; index < 23; index += 1) { + repository.seed({ + id: `c0000000-0000-4000-8000-${String(index + 10).padStart(12, "0")}`, + projeto_id: PROJECT_ID, + caminho: `${PROJECT_ID}/${index}`, + created_at: new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString(), + }); + } + + const ids: string[] = []; + let cursor: string | undefined; + do { + const page = await service.list(PROJECT_ID, cursor, "10"); + ids.push(...page.items.map((item) => item.id)); + cursor = page.next_cursor ?? undefined; + } while (cursor); + + assert.equal(ids.length, 23); + assert.equal(new Set(ids).size, 23); + assert.deepEqual(ids, [...ids].sort((a, b) => Number(b.slice(-12)) - Number(a.slice(-12)))); + await assert.rejects(service.list(PROJECT_ID, "invalid-cursor"), ValidationError); + await assert.rejects(service.list(PROJECT_ID, undefined, "51"), ValidationError); +}); + +test("health reporta filas e alerta quando o consumidor não está configurado, o evento está velho ou o armazenamento pende", async () => { + const { service, repository } = setup(); + const healthy = await service.health(true); + assert.deepEqual(healthy.alertas, []); + assert.equal(healthy.webhook_configurado, true); + + repository.stats = { eventos_pendentes: 2, evento_mais_antigo_segundos: 4000, operacoes_armazenamento_pendentes: 1 }; + const degraded = await service.health(false); + assert.equal(degraded.alertas.length, 3); + assert.match(degraded.alertas.join(" "), /DOCUMENT_EVENTS_WEBHOOK_URL/); + assert.match(degraded.alertas.join(" "), /mais de 1 hora/); + assert.match(degraded.alertas.join(" "), /reconciliação/); + assert.ok(!JSON.stringify(degraded).includes("http"), "health não expõe URLs"); + + const configured = await service.health(true); + assert.equal(configured.alertas.length, 2); +}); diff --git a/backend/src/modules/documents/documents.service.ts b/backend/src/modules/documents/documents.service.ts new file mode 100644 index 0000000..05bbe64 --- /dev/null +++ b/backend/src/modules/documents/documents.service.ts @@ -0,0 +1,250 @@ +import { randomUUID } from "node:crypto"; +import { env } from "../../config/env.js"; +import { AppError, NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; +import { ArchiveConflict } from "../projects/archive.types.js"; +import { ProjectsRepository } from "../projects/projects.repository.js"; +import { DocumentsRepository } from "./documents.repository.js"; +import { HttpDocumentEventPublisher, type DocumentEventPublisher } from "./documents.events.js"; +import { LocalDocumentStorage, type DocumentStorage } from "./documents.storage.js"; +import { ALLOWED_EXTENSIONS, inspectDocument } from "./documents.validation.js"; +import type { DocumentLimits, DocumentList, DocumentRecord, DocumentsHealth, RemovalResult } from "./documents.types.js"; + +export interface ProjectLookup { + findById(id: string): Promise<{ id: string; status: string } | null>; +} + +export interface UploadDocumentInput { + projetoId: string; + usuarioId: string; + fileName: string; + content: Buffer; +} + +export interface RemoveDocumentCommand { + projetoId: string; + documentoId: string; + usuarioId: string; +} + +const PENDING_EVENTS_BATCH = 20; +const STALE_EVENT_SECONDS = 3600; +const DEFAULT_PAGE_SIZE = 20; +const MAX_PAGE_SIZE = 50; + +function decodeCursor(value: string | undefined): { createdAt: string; id: string } | null { + if (!value) return null; + if (value.length > 512 || !/^[A-Za-z0-9_-]+$/.test(value)) throw new ValidationError("Cursor de documentos inválido."); + try { + const parsed: unknown = JSON.parse(Buffer.from(value, "base64url").toString("utf8")); + if (typeof parsed !== "object" || parsed === null || !("created_at" in parsed) || !("id" in parsed)) { + throw new Error("invalid cursor"); + } + const candidate = parsed as { created_at: unknown; id: unknown }; + if (typeof candidate.created_at !== "string" || !Number.isFinite(Date.parse(candidate.created_at)) || typeof candidate.id !== "string") { + throw new Error("invalid cursor"); + } + validateUuid(candidate.id, "Cursor de documentos"); + return { createdAt: candidate.created_at, id: candidate.id }; + } catch { + throw new ValidationError("Cursor de documentos inválido."); + } +} + +function encodeCursor(record: DocumentRecord): string { + return Buffer.from(JSON.stringify({ created_at: record.created_at, id: record.id })).toString("base64url"); +} + +export class DocumentsService { + constructor( + private readonly repository: DocumentsRepository = new DocumentsRepository(), + private readonly storage: DocumentStorage = new LocalDocumentStorage(env.DOCUMENT_STORAGE_DIR), + private readonly events: DocumentEventPublisher = new HttpDocumentEventPublisher(), + private readonly projects: ProjectLookup = new ProjectsRepository(), + private readonly maxBytes: number = Math.floor(env.DOCUMENT_MAX_SIZE_MB * 1024 * 1024), + ) {} + + get limits(): DocumentLimits { + return { max_bytes: this.maxBytes, extensoes_permitidas: [...ALLOWED_EXTENSIONS] }; + } + + private async requireProject(projetoId: string): Promise<{ id: string; status: string }> { + validateUuid(projetoId, "ID do projeto"); + const project = await this.projects.findById(projetoId); + if (!project) throw new NotFoundError("Projeto não encontrado."); + return project; + } + + async list(projetoId: string, cursorValue?: string, pageSizeValue?: string): Promise { + await this.requireProject(projetoId); + const pageSize = pageSizeValue === undefined ? DEFAULT_PAGE_SIZE : Number(pageSizeValue); + if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > MAX_PAGE_SIZE) { + throw new ValidationError(`O limite de documentos deve ser um inteiro entre 1 e ${MAX_PAGE_SIZE}.`); + } + const page = await this.repository.listByProject(projetoId, decodeCursor(cursorValue), pageSize); + const last = page.items.at(-1); + return { + items: page.items, + next_cursor: page.hasMore && last ? encodeCursor(last) : null, + limites: this.limits, + paginacao: { tamanho_pagina: pageSize, tamanho_maximo: MAX_PAGE_SIZE }, + }; + } + + async upload(input: UploadDocumentInput): Promise { + const project = await this.requireProject(input.projetoId); + if (project.status === "arquivado") { + throw new ArchiveConflict("Projeto arquivado é somente leitura e não recebe novos documentos."); + } + const inspected = inspectDocument(input.fileName, input.content, this.maxBytes); + const id = randomUUID(); + const caminho = `${input.projetoId}/${id}`; + try { + await this.storage.save(caminho, input.content); + } catch { + throw new AppError("Não foi possível armazenar o arquivo agora. Sua seleção foi mantida; tente novamente.", 503, "STORAGE_UNAVAILABLE"); + } + let created: DocumentRecord; + try { + created = await this.repository.create({ + id, + projetoId: input.projetoId, + nome: inspected.nome, + extensao: inspected.extensao, + mime: inspected.mime, + tamanhoBytes: input.content.length, + caminho, + usuarioId: input.usuarioId, + }); + } catch (error) { + try { + await this.storage.remove(caminho); + } catch { + throw new AppError("O envio não foi registrado. Um arquivo temporário ficou pendente de limpeza; tente novamente mais tarde.", 503, "STORAGE_RECOVERY_PENDING"); + } + throw error; + } + + try { + await this.storage.finalizeUpload(caminho); + await this.repository.completeUploadOperation(id); + created.armazenamento_pendente = false; + } catch { + await this.repository.markStorageOperationFailedForDocument(id, "finalizar_upload").catch(() => undefined); + // The durable operation remains queued; the caller gets an explicit state + // and the background worker retries without creating duplicate metadata. + created.armazenamento_pendente = true; + } + return created; + } + + async remove(command: RemoveDocumentCommand): Promise { + const project = await this.requireProject(command.projetoId); + if (project.status === "arquivado") { + throw new ArchiveConflict("Projeto arquivado é somente leitura e não permite remover documentos."); + } + validateUuid(command.documentoId, "ID do documento"); + const stored = await this.repository.findById(command.projetoId, command.documentoId); + if (!stored) return; + let staged = false; + try { + staged = await this.storage.stageRemoval(stored.caminho); + } catch { + throw new AppError("Não foi possível remover o arquivo agora. O documento continua disponível; tente novamente.", 503, "STORAGE_UNAVAILABLE"); + } + let result: RemovalResult | null; + try { + result = await this.repository.remove({ id: stored.id, projetoId: command.projetoId, usuarioId: command.usuarioId }); + } catch (error) { + if (staged) { + try { + await this.storage.restore(stored.caminho); + } catch { + if (error instanceof ArchiveConflict) { + throw new ArchiveConflict(`${error.message} O arquivo está sendo recuperado e será restaurado automaticamente.`); + } + throw new AppError("A remoção foi revertida, mas o arquivo está em recuperação automática.", 503, "STORAGE_RECOVERY_PENDING"); + } + } + if (error instanceof ArchiveConflict) throw error; + throw new AppError("Não foi possível remover o documento. Ele continua disponível; tente novamente.", 500, "REMOVAL_FAILED"); + } + + if (!result) { + if (staged) await this.storage.discard(stored.caminho).catch(() => undefined); + return; + } + try { + await this.storage.discard(stored.caminho); + await this.repository.completeStorageOperation(result.storageOperationId); + } catch { + await this.repository.markStorageOperationFailed(result.storageOperationId).catch(() => undefined); + } + } + + async health(webhookConfigured: boolean = Boolean(env.DOCUMENT_EVENTS_WEBHOOK_URL?.trim())): Promise { + const stats = await this.repository.maintenanceStats(); + const alertas: string[] = []; + if (stats.eventos_pendentes > 0 && !webhookConfigured) alertas.push("Há eventos de remoção pendentes e DOCUMENT_EVENTS_WEBHOOK_URL não está configurada."); + if (stats.evento_mais_antigo_segundos > STALE_EVENT_SECONDS) alertas.push("Existe evento de remoção pendente há mais de 1 hora."); + if (stats.operacoes_armazenamento_pendentes > 0) alertas.push("Há operações de armazenamento aguardando reconciliação."); + return { ...stats, webhook_configurado: webhookConfigured, alertas }; + } + + async flushPendingEvents(): Promise { + const pending = await this.repository.listPendingEvents(PENDING_EVENTS_BATCH); + for (const item of pending) { + const published = await this.events.publish(item.payload); + if (published) await this.repository.markEventPublished(item.chave_idempotencia); + else await this.repository.markEventFailed(item.chave_idempotencia); + } + } + + async processPendingStorageOperations(): Promise { + const pending = await this.repository.listPendingStorageOperations(PENDING_EVENTS_BATCH); + for (const operation of pending) { + try { + if (operation.acao === "finalizar_upload") await this.storage.finalizeUpload(operation.caminho); + else await this.storage.discard(operation.caminho); + await this.repository.completeStorageOperation(operation.id); + } catch { + await this.repository.markStorageOperationFailed(operation.id); + } + } + } + + async reconcileStagedFiles(): Promise { + await this.storage.reconcileStaged((key) => this.repository.documentExists(key)); + } + + async runBackgroundMaintenance(): Promise { + await Promise.all([this.flushPendingEvents(), this.processPendingStorageOperations()]); + await this.reconcileStagedFiles(); + } +} + +export const documentsService = new DocumentsService(); + +export function startDocumentsBackgroundWorker(service: DocumentsService = documentsService): () => void { + let running = false; + let lastReconcile = 0; + const tick = async () => { + if (running) return; + running = true; + try { + await Promise.all([service.flushPendingEvents(), service.processPendingStorageOperations()]); + if (Date.now() - lastReconcile >= 5 * 60_000) { + await service.reconcileStagedFiles(); + lastReconcile = Date.now(); + } + } catch { + // The leased rows and staged files remain recoverable for the next pass. + console.warn("[Documents] Background maintenance pass failed; work remains queued."); + } finally { + running = false; + } + }; + const timer = setInterval(() => void tick(), 15_000); + timer.unref(); + void tick(); + return () => clearInterval(timer); +} diff --git a/backend/src/modules/documents/documents.storage.test.ts b/backend/src/modules/documents/documents.storage.test.ts new file mode 100644 index 0000000..14f7a74 --- /dev/null +++ b/backend/src/modules/documents/documents.storage.test.ts @@ -0,0 +1,52 @@ +import test, { after, before } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile, readdir, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { LocalDocumentStorage } from "./documents.storage.js"; + +const PROJECT = "a0000000-0000-4000-8000-000000000001"; +const DOCUMENT = "c0000000-0000-4000-8000-000000000001"; +const KEY = `${PROJECT}/${DOCUMENT}`; + +let directory: string; +let storage: LocalDocumentStorage; + +before(async () => { + directory = await mkdtemp(join(tmpdir(), "sinapse-docs-")); + storage = new LocalDocumentStorage(directory); +}); + +after(async () => { + await rm(directory, { recursive: true, force: true }); +}); + +test("grava, recusa sobrescrever e remove de forma idempotente", async () => { + await storage.save(KEY, Buffer.from("conteudo")); + await storage.finalizeUpload(KEY); + assert.equal((await readFile(join(directory, KEY))).toString(), "conteudo"); + await assert.rejects(storage.save(KEY, Buffer.from("outro"))); + await storage.remove(KEY); + await storage.remove(KEY); + await assert.rejects(stat(join(directory, KEY))); +}); + +test("remoção em duas fases: restaura em caso de falha e descarta após confirmar", async () => { + await storage.save(KEY, Buffer.from("conteudo")); + await storage.finalizeUpload(KEY); + assert.equal(await storage.stageRemoval(KEY), true); + await assert.rejects(stat(join(directory, KEY))); + await storage.restore(KEY); + assert.equal((await readFile(join(directory, KEY))).toString(), "conteudo"); + + assert.equal(await storage.stageRemoval(KEY), true); + await storage.discard(KEY); + assert.deepEqual(await readdir(join(directory, PROJECT)), []); + assert.equal(await storage.stageRemoval(KEY), false); +}); + +test("recusa chaves fora do formato para impedir travessia de diretório", async () => { + for (const key of ["../fora", "a/b", `${PROJECT}/../${DOCUMENT}`, `${PROJECT}/${DOCUMENT}/extra`]) { + await assert.rejects(storage.save(key, Buffer.from("x")), /inválido/, key); + } +}); diff --git a/backend/src/modules/documents/documents.storage.ts b/backend/src/modules/documents/documents.storage.ts new file mode 100644 index 0000000..bb5f12f --- /dev/null +++ b/backend/src/modules/documents/documents.storage.ts @@ -0,0 +1,144 @@ +import { access, mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; + +const STORAGE_KEY = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const STAGING_SUFFIX = ".removing"; +const UPLOAD_SUFFIX = ".uploading"; + +export interface DocumentStorage { + save(key: string, content: Buffer): Promise; + finalizeUpload(key: string): Promise; + remove(key: string): Promise; + stageRemoval(key: string): Promise; + restore(key: string): Promise; + discard(key: string): Promise; + reconcileStaged(documentExists: (key: string) => Promise, graceMs?: number): Promise; +} + +function isMissing(error: unknown): boolean { + return (error as { code?: string }).code === "ENOENT"; +} + +export class LocalDocumentStorage implements DocumentStorage { + private readonly baseDir: string; + + constructor(baseDir: string) { + this.baseDir = resolve(baseDir); + } + + private pathOf(key: string): string { + if (!STORAGE_KEY.test(key)) throw new Error("Identificador de armazenamento inválido."); + return join(this.baseDir, key); + } + + async save(key: string, content: Buffer): Promise { + const path = this.pathOf(key); + await mkdir(dirname(path), { recursive: true }); + try { + await access(path); + const duplicate = new Error("Arquivo já existe.") as NodeJS.ErrnoException; + duplicate.code = "EEXIST"; + throw duplicate; + } catch (error) { + if (!isMissing(error)) throw error; + } + await writeFile(`${path}${UPLOAD_SUFFIX}`, content, { flag: "wx", mode: 0o600 }); + } + + async finalizeUpload(key: string): Promise { + const path = this.pathOf(key); + try { + await rename(`${path}${UPLOAD_SUFFIX}`, path); + } catch (error) { + if (!isMissing(error)) throw error; + // A retry after a crash between the rename and marking the operation done + // is successful when the final file is already present. + await access(path); + } + } + + async remove(key: string): Promise { + const path = this.pathOf(key); + await Promise.all([ + rm(path, { force: true }), + rm(`${path}${UPLOAD_SUFFIX}`, { force: true }), + ]); + } + + async stageRemoval(key: string): Promise { + const path = this.pathOf(key); + try { + await rename(path, `${path}${STAGING_SUFFIX}`); + return true; + } catch (error) { + if (isMissing(error)) { + try { + await access(`${path}${STAGING_SUFFIX}`); + return true; + } catch (stagedError) { + if (isMissing(stagedError)) return false; + throw stagedError; + } + } + throw error; + } + } + + async restore(key: string): Promise { + const path = this.pathOf(key); + try { + await rename(`${path}${STAGING_SUFFIX}`, path); + } catch (error) { + if (!isMissing(error)) throw error; + await access(path); + } + } + + async discard(key: string): Promise { + await rm(`${this.pathOf(key)}${STAGING_SUFFIX}`, { force: true }); + } + + async reconcileStaged(documentExists: (key: string) => Promise, graceMs = 5 * 60_000): Promise { + let projects; + try { + projects = await readdir(this.baseDir, { withFileTypes: true }); + } catch (error) { + if (isMissing(error)) return; + throw error; + } + + for (const project of projects) { + if (!project.isDirectory() || !/^[0-9a-f-]{36}$/.test(project.name)) continue; + const directory = join(this.baseDir, project.name); + const files = await readdir(directory, { withFileTypes: true }).catch(() => []); + for (const file of files) { + if (!file.isFile()) continue; + const suffix = file.name.endsWith(UPLOAD_SUFFIX) + ? UPLOAD_SUFFIX + : file.name.endsWith(STAGING_SUFFIX) + ? STAGING_SUFFIX + : null; + if (!suffix) continue; + const name = file.name.slice(0, -suffix.length); + if (!/^[0-9a-f-]{36}$/.test(name)) continue; + const key = `${project.name}/${name}`; + const filePath = join(directory, file.name); + const fileInfo = await stat(filePath).catch(() => null); + if (!fileInfo || Date.now() - fileInfo.mtimeMs < graceMs) continue; + + try { + if (await documentExists(key)) { + if (suffix === UPLOAD_SUFFIX) await this.finalizeUpload(key); + else await this.restore(key); + } else if (suffix === UPLOAD_SUFFIX) { + await rm(filePath, { force: true }); + } else { + await this.discard(key); + } + } catch { + // Keep unresolved files for the next reconciliation pass. + } + } + } + } +} diff --git a/backend/src/modules/documents/documents.types.ts b/backend/src/modules/documents/documents.types.ts new file mode 100644 index 0000000..9fedd0b --- /dev/null +++ b/backend/src/modules/documents/documents.types.ts @@ -0,0 +1,88 @@ +export const DOCUMENT_STATUSES = ["pendente", "processando", "processado", "falha"] as const; + +export type DocumentStatus = (typeof DOCUMENT_STATUSES)[number]; + +export type DocumentKind = "pdf" | "docx" | "md" | "txt"; + +export interface DocumentRecord { + id: string; + projeto_id: string; + nome: string; + extensao: string | null; + mime: string | null; + tamanho_bytes: number | null; + status_processamento: DocumentStatus; + armazenamento_pendente: boolean; + autor_id: string | null; + autor_nome: string | null; + created_at: string; + updated_at: string; +} + +export interface DocumentLimits { + max_bytes: number; + extensoes_permitidas: string[]; +} + +export interface DocumentList { + items: DocumentRecord[]; + next_cursor: string | null; + limites: DocumentLimits; + paginacao: { tamanho_pagina: number; tamanho_maximo: number }; +} + +export interface CreateDocumentInput { + id: string; + projetoId: string; + nome: string; + extensao: string; + mime: string; + tamanhoBytes: number; + caminho: string; + usuarioId: string; +} + +export interface StoredDocument { + id: string; + projeto_id: string; + nome: string; + caminho: string; + status_processamento: DocumentStatus; +} + +export interface RemovalResult { + documento: StoredDocument; + indexado: boolean; + chunksRemovidos: number; + eventoChave: string | null; + storageOperationId: string; +} + +export interface RemoveDocumentInput { + id: string; + projetoId: string; + usuarioId: string; +} + +export interface DocumentRemovedEvent { + event_id: string; + event_type: "document.removed"; + schema_version: 1; + occurred_at: string; + project_id: string; + document_id: string; + chunks_removed: number; +} + +export const DOCUMENT_REMOVED_EVENT_TYPE = "document.removed"; + +export interface DocumentMaintenanceStats { + eventos_pendentes: number; + evento_mais_antigo_segundos: number; + operacoes_armazenamento_pendentes: number; +} + +export interface DocumentsHealth extends DocumentMaintenanceStats { + webhook_configurado: boolean; + alertas: string[]; +} diff --git a/backend/src/modules/documents/documents.validation.test.ts b/backend/src/modules/documents/documents.validation.test.ts new file mode 100644 index 0000000..4daf01f --- /dev/null +++ b/backend/src/modules/documents/documents.validation.test.ts @@ -0,0 +1,75 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { AppError, ValidationError } from "../../shared/errors.js"; +import { extensionOf, formatLimit, inspectDocument, sanitizeFileName } from "./documents.validation.js"; +import { docxBuffer, pdfBuffer } from "./documents.fakes.js"; + +const LIMIT = 1024 * 1024; + +test("aceita PDF, DOCX, MD e TXT com conteúdo coerente e define o MIME pelo conteúdo", () => { + assert.equal(inspectDocument("Escopo.pdf", pdfBuffer(), LIMIT).mime, "application/pdf"); + assert.equal( + inspectDocument("Escopo.docx", docxBuffer(), LIMIT).mime, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ); + assert.equal(inspectDocument("notas.md", Buffer.from("# Título\n\nTexto com acentuação."), LIMIT).mime, "text/markdown"); + assert.equal(inspectDocument("notas.TXT", Buffer.from("texto simples"), LIMIT).extensao, ".txt"); +}); + +test("recusa extensões fora da lista permitida", () => { + for (const name of ["programa.exe", "planilha.xlsx", "antigo.doc", "sem-extensao", ".md", "arquivo."]) { + assert.throws(() => inspectDocument(name, Buffer.from("conteudo"), LIMIT), ValidationError, name); + } +}); + +test("MIME falso: extensão .pdf com conteúdo de texto é recusada", () => { + assert.throws(() => inspectDocument("falso.pdf", Buffer.from("isto não é um PDF"), LIMIT), /não corresponde ao formato \.pdf/); +}); + +test("MIME falso: executável renomeado para .txt ou .md é recusado", () => { + const executable = Buffer.concat([Buffer.from("MZ"), Buffer.from([0x90, 0x00, 0x03, 0x00])]); + assert.throws(() => inspectDocument("instalador.txt", executable, LIMIT), ValidationError); + assert.throws(() => inspectDocument("instalador.md", executable, LIMIT), ValidationError); +}); + +test("MIME falso: ZIP genérico e PDF renomeados para .docx são recusados", () => { + const zipWithoutWordPart = Buffer.concat([Buffer.from([0x50, 0x4b, 0x03, 0x04]), Buffer.from("xl/workbook.xml", "latin1")]); + assert.throws(() => inspectDocument("planilha.docx", zipWithoutWordPart, LIMIT), ValidationError); + assert.throws(() => inspectDocument("relatorio.docx", pdfBuffer(), LIMIT), ValidationError); +}); + +test("texto com bytes inválidos em UTF-8 é recusado", () => { + assert.throws(() => inspectDocument("quebrado.txt", Buffer.from([0xff, 0xfe, 0xfd]), LIMIT), ValidationError); +}); + +test("recusa arquivo vazio e arquivo acima do limite com código 413", () => { + assert.throws(() => inspectDocument("vazio.txt", Buffer.alloc(0), LIMIT), /vazio/); + try { + inspectDocument("grande.txt", Buffer.alloc(LIMIT + 1, 0x61), LIMIT); + assert.fail("deveria recusar"); + } catch (error) { + assert.ok(error instanceof AppError); + assert.equal(error.statusCode, 413); + assert.equal(error.code, "PAYLOAD_TOO_LARGE"); + assert.match(error.message, /1 MB/); + } +}); + +test("aceita arquivo exatamente no limite", () => { + assert.equal(inspectDocument("limite.txt", Buffer.alloc(LIMIT, 0x61), LIMIT).nome, "limite.txt"); +}); + +test("sanitiza nome: remove caminho, controles e recusa nome vazio ou longo", () => { + assert.equal(sanitizeFileName("C:\\Users\\ana\\..\\Escopo final.pdf"), "Escopo final.pdf"); + assert.equal(sanitizeFileName("../../etc/passwd.txt"), "passwd.txt"); + assert.equal(sanitizeFileName("no\u0000me\u001f.md"), "nome.md"); + assert.throws(() => sanitizeFileName(" "), ValidationError); + assert.throws(() => sanitizeFileName(`${"a".repeat(256)}.txt`), /255/); +}); + +test("funções auxiliares de extensão e limite", () => { + assert.equal(extensionOf("Arquivo.Final.PDF"), ".pdf"); + assert.equal(extensionOf(".gitignore"), ""); + assert.equal(formatLimit(20 * 1024 * 1024), "20 MB"); + assert.equal(formatLimit(1.5 * 1024 * 1024), "1.5 MB"); +}); diff --git a/backend/src/modules/documents/documents.validation.ts b/backend/src/modules/documents/documents.validation.ts new file mode 100644 index 0000000..d325a3c --- /dev/null +++ b/backend/src/modules/documents/documents.validation.ts @@ -0,0 +1,93 @@ +import { AppError, ValidationError } from "../../shared/errors.js"; +import type { DocumentKind } from "./documents.types.js"; + +export const ALLOWED_EXTENSIONS = [".pdf", ".docx", ".md", ".txt"] as const; + +const KIND_BY_EXTENSION: Record = { + ".pdf": "pdf", + ".docx": "docx", + ".md": "md", + ".txt": "txt", +}; + +const MIME_BY_KIND: Record = { + pdf: "application/pdf", + docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + md: "text/markdown", + txt: "text/plain", +}; + +const MAX_FILE_NAME_LENGTH = 255; +const PDF_SIGNATURE = Buffer.from("%PDF-", "latin1"); +const ZIP_SIGNATURE = Buffer.from([0x50, 0x4b, 0x03, 0x04]); +const DOCX_MAIN_PART = Buffer.from("word/document.xml", "latin1"); +const PDF_HEADER_WINDOW = 1024; + +export interface InspectedDocument { + nome: string; + extensao: string; + mime: string; +} + +export function formatLimit(maxBytes: number): string { + const megabytes = maxBytes / (1024 * 1024); + return `${Number.isInteger(megabytes) ? megabytes : megabytes.toFixed(1)} MB`; +} + +export function sanitizeFileName(raw: string): string { + const base = raw.normalize("NFC").split(/[\\/]/).pop() ?? ""; + const cleaned = base.replace(/[\u0000-\u001f\u007f]/g, "").trim(); + if (!cleaned) throw new ValidationError("Informe o nome do arquivo."); + if (cleaned.length > MAX_FILE_NAME_LENGTH) { + throw new ValidationError(`O nome do arquivo não pode exceder ${MAX_FILE_NAME_LENGTH} caracteres.`); + } + return cleaned; +} + +export function extensionOf(fileName: string): string { + const index = fileName.lastIndexOf("."); + if (index <= 0) return ""; + return fileName.slice(index).toLowerCase(); +} + +function isPdf(content: Buffer): boolean { + return content.subarray(0, PDF_HEADER_WINDOW).includes(PDF_SIGNATURE); +} + +function isDocx(content: Buffer): boolean { + return content.subarray(0, ZIP_SIGNATURE.length).equals(ZIP_SIGNATURE) && content.includes(DOCX_MAIN_PART); +} + +function isPlainText(content: Buffer): boolean { + if (content.includes(0)) return false; + try { + new TextDecoder("utf-8", { fatal: true }).decode(content); + return true; + } catch { + return false; + } +} + +const CONTENT_CHECKS: Record boolean> = { + pdf: isPdf, + docx: isDocx, + md: isPlainText, + txt: isPlainText, +}; + +export function inspectDocument(rawName: string, content: Buffer, maxBytes: number): InspectedDocument { + const nome = sanitizeFileName(rawName); + const extensao = extensionOf(nome); + const kind = KIND_BY_EXTENSION[extensao]; + if (!kind) { + throw new ValidationError("Formato não suportado. Envie arquivos PDF, DOCX, MD ou TXT."); + } + if (content.length === 0) throw new ValidationError("O arquivo está vazio."); + if (content.length > maxBytes) { + throw new AppError(`O arquivo excede o limite de ${formatLimit(maxBytes)}.`, 413, "PAYLOAD_TOO_LARGE", { max_bytes: maxBytes }); + } + if (!CONTENT_CHECKS[kind](content)) { + throw new ValidationError(`O conteúdo do arquivo não corresponde ao formato ${extensao}. Verifique se ele não está corrompido ou foi renomeado.`); + } + return { nome, extensao, mime: MIME_BY_KIND[kind] }; +} diff --git a/backend/src/modules/repo-analyses/repo-analyses.routes.test.ts b/backend/src/modules/repo-analyses/repo-analyses.routes.test.ts new file mode 100644 index 0000000..969ee3d --- /dev/null +++ b/backend/src/modules/repo-analyses/repo-analyses.routes.test.ts @@ -0,0 +1,124 @@ +import test, { after, before } from 'node:test'; +import assert from 'node:assert/strict'; +import express from 'express'; +import { AddressInfo } from 'node:net'; +import { Server } from 'node:http'; +import { errorHandler } from '../../middleware/errorHandler'; +import { AppError, ValidationError } from '../../shared/errors'; +import { createRepoAnalysesRouter } from './repo-analyses.routes'; +import { RepoAnalysesService } from './repo-analyses.service'; +import type { RepoAnalysisRecord } from './repo-analyses.types'; + +const ACTIVE = 'a0000000-0000-4000-8000-000000000001'; +const ARCHIVED = 'a0000000-0000-4000-8000-000000000002'; +const OTHER = 'a0000000-0000-4000-8000-000000000003'; +const ANALYSIS = 'e0000000-0000-4000-8000-000000000001'; + +const record = { id: ANALYSIS, projeto_id: ACTIVE, status: 'iniciado', run_id: 'r1' } as RepoAnalysisRecord; + +class FakeService extends RepoAnalysesService { + public started: Array<{ projectId: string; userId: string; url: string }> = []; + public lookups: Array<{ projectId: string; id: string }> = []; + public engineDown = false; + + constructor() { + super({} as never, 'http://localhost:0'); + } + + async startAnalysis(projectId: string, userId: string, url: string): Promise { + if (!/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+$/.test(url)) throw new ValidationError('URL do repositório GitHub inválida.'); + if (this.engineDown) throw new AppError('Falha ao iniciar análise no motor de IA: serviço indisponível', 503, 'ANALYZER_UNAVAILABLE'); + this.started.push({ projectId, userId, url }); + return { ...record, projeto_id: projectId }; + } + + async listByProject(projectId: string): Promise { + return projectId === ACTIVE ? [record] : []; + } + + async getById(projectId: string, id: string): Promise { + this.lookups.push({ projectId, id }); + return projectId === ACTIVE && id === ANALYSIS ? record : null; + } +} + +const service = new FakeService(); +let server: Server; +let baseUrl: string; + +before(() => { + const app = express(); + app.use(express.json()); + const projects = { + async findById(id: string) { + if (id === ACTIVE || id === OTHER) return { id, status: 'ativo' }; + if (id === ARCHIVED) return { id, status: 'arquivado' }; + return null; + }, + }; + app.use('/api/v1/projects/:projectId/repo-analyses', createRepoAnalysesRouter(service, projects, (req, res, next) => { + if (req.headers['x-test-user'] !== 'ana') { + res.status(401).json({ error: 'Autenticação necessária.', code: 'UNAUTHORIZED' }); + return; + } + req.auth = { id: 'b0000000-0000-4000-8000-000000000001', nome: 'Ana', email: 'a@example.com', role: 'po' }; + next(); + })); + app.use(errorHandler); + server = app.listen(0); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/v1/projects`; +}); + +after(() => { + server.close(); +}); + +const call = (path: string, init: { method?: string; json?: unknown; auth?: boolean } = {}) => + fetch(`${baseUrl}${path}`, { + method: init.method ?? 'GET', + headers: { 'Content-Type': 'application/json', ...(init.auth === false ? {} : { 'x-test-user': 'ana' }) }, + body: init.json === undefined ? undefined : JSON.stringify(init.json), + }); + +test('exige autenticação', async () => { + assert.equal((await call(`/${ACTIVE}/repo-analyses`, { auth: false })).status, 401); +}); + +test('id de projeto malformado retorna 400 e projeto inexistente retorna 404 antes de qualquer trabalho', async () => { + assert.equal((await call('/nao-uuid/repo-analyses')).status, 400); + assert.equal((await call('/a0000000-0000-4000-8000-0000000000ff/repo-analyses')).status, 404); + assert.equal((await call('/nao-uuid/repo-analyses', { method: 'POST', json: { repositorio_url: 'https://github.com/a/b' } })).status, 400); + assert.equal(service.started.length, 0); +}); + +test('inicia análise no projeto ativo com o usuário autenticado', async () => { + const response = await call(`/${ACTIVE}/repo-analyses`, { method: 'POST', json: { repositorio_url: 'https://github.com/acme/api' } }); + assert.equal(response.status, 201); + assert.deepEqual(service.started.at(-1), { projectId: ACTIVE, userId: 'b0000000-0000-4000-8000-000000000001', url: 'https://github.com/acme/api' }); +}); + +test('URL inválida retorna 400 e falha do motor retorna 503 com mensagem legível', async () => { + assert.equal((await call(`/${ACTIVE}/repo-analyses`, { method: 'POST', json: { repositorio_url: 'https://gitlab.com/a/b' } })).status, 400); + assert.equal((await call(`/${ACTIVE}/repo-analyses`, { method: 'POST', json: {} })).status, 400); + service.engineDown = true; + const response = await call(`/${ACTIVE}/repo-analyses`, { method: 'POST', json: { repositorio_url: 'https://github.com/acme/api' } }); + service.engineDown = false; + assert.equal(response.status, 503); + assert.match(((await response.json()) as { error: string }).error, /motor de IA/); +}); + +test('projeto arquivado é somente leitura: consulta funciona e nova análise retorna 409', async () => { + const before = service.started.length; + const post = await call(`/${ARCHIVED}/repo-analyses`, { method: 'POST', json: { repositorio_url: 'https://github.com/acme/api' } }); + assert.equal(post.status, 409); + assert.equal(service.started.length, before); + assert.equal((await call(`/${ARCHIVED}/repo-analyses`)).status, 200); +}); + +test('consulta por id respeita o projeto da rota e valida o formato', async () => { + assert.equal((await call(`/${ACTIVE}/repo-analyses/${ANALYSIS}`)).status, 200); + assert.equal((await call(`/${OTHER}/repo-analyses/${ANALYSIS}`)).status, 404); + assert.deepEqual(service.lookups.at(-1), { projectId: OTHER, id: ANALYSIS }); + assert.equal((await call(`/${ACTIVE}/repo-analyses/abc`)).status, 400); + assert.deepEqual(await (await call(`/${OTHER}/repo-analyses`)).json(), []); +}); diff --git a/backend/src/modules/repo-analyses/repo-analyses.routes.ts b/backend/src/modules/repo-analyses/repo-analyses.routes.ts index 88dae96..e18b71c 100644 --- a/backend/src/modules/repo-analyses/repo-analyses.routes.ts +++ b/backend/src/modules/repo-analyses/repo-analyses.routes.ts @@ -1,201 +1,119 @@ -import { Router } from 'express'; +import { Router, type NextFunction, type Request, type RequestHandler, type Response } from 'express'; +import { requireAuth } from '../../middleware/requireAuth'; +import { NotFoundError, ValidationError, validateUuid } from '../../shared/errors'; +import { ArchiveConflict } from '../projects/archive.types'; +import { ProjectsRepository } from '../projects/projects.repository'; import { RepoAnalysesService } from './repo-analyses.service'; -import { requireAuth } from '../../middleware/requireAuth'; // Ajuste conforme o middleware de auth do projeto -export const repoAnalysesRouter = Router({ mergeParams: true }); -const service = new RepoAnalysesService(); +export interface ProjectLookup { + findById(id: string): Promise<{ id: string; status: string } | null>; +} -/** - * @swagger - * /api/v1/projects/{projectId}/repo-analyses: - * post: - * summary: Iniciar nova análise de repositório - * tags: [Repo Analyses] - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: projectId - * required: true - * schema: - * type: string - * format: uuid - * description: ID do projeto - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: - * - repositorio_url - * properties: - * repositorio_url: - * type: string - * format: uri - * description: URL do repositório a ser analisado - * responses: - * 201: - * description: Análise iniciada com sucesso - * content: - * application/json: - * schema: - * type: object - * properties: - * id: - * type: string - * format: uuid - * projeto_id: - * type: string - * format: uuid - * usuario_id: - * type: string - * format: uuid - * repositorio_url: - * type: string - * status: - * type: string - * created_at: - * type: string - * format: date-time - * 400: - * description: Dados inválidos ou erro ao iniciar análise - * 401: - * description: Não autenticado - */ -repoAnalysesRouter.post('/', requireAuth, async (req: any, res) => { - try { - const { projectId } = req.params; - const usuarioId = req.user.id; // Extraído da sessão autenticada - const { repositorio_url } = req.body; +export function createRepoAnalysesRouter( + service: RepoAnalysesService = new RepoAnalysesService(), + projects: ProjectLookup = new ProjectsRepository(), + authentication: RequestHandler = requireAuth, +): Router { + const router = Router({ mergeParams: true }); - const analysis = await service.startAnalysis(projectId, usuarioId, repositorio_url); - return res.status(201).json(analysis); - } catch (error: any) { - return res.status(400).json({ error: error.message }); - } -}); + const loadProject = async (req: Request, res: Response, next: NextFunction) => { + try { + const projectId = String(req.params.projectId); + validateUuid(projectId, 'ID do projeto'); + const project = await projects.findById(projectId); + if (!project) throw new NotFoundError('Projeto não encontrado.'); + res.locals.projectStatus = project.status; + next(); + } catch (error) { + next(error); + } + }; + + router.use(authentication, loadProject); -/** - * @swagger - * /api/v1/projects/{projectId}/repo-analyses: - * get: - * summary: Listar análises de repositório do projeto - * tags: [Repo Analyses] - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: projectId - * required: true - * schema: - * type: string - * format: uuid - * description: ID do projeto - * responses: - * 200: - * description: Lista de análises - * content: - * application/json: - * schema: - * type: array - * items: - * type: object - * properties: - * id: - * type: string - * format: uuid - * projeto_id: - * type: string - * format: uuid - * usuario_id: - * type: string - * format: uuid - * repositorio_url: - * type: string - * status: - * type: string - * created_at: - * type: string - * format: date-time - * 401: - * description: Não autenticado - * 500: - * description: Erro interno do servidor - */ -repoAnalysesRouter.get('/', requireAuth, async (req: any, res) => { - try { - const { projectId } = req.params; - const analyses = await service.listByProject(projectId); - return res.json(analyses); - } catch (error: any) { - return res.status(500).json({ error: error.message }); - } -}); + /** + * @swagger + * /api/v1/projects/{projectId}/repo-analyses: + * post: + * summary: Iniciar nova análise de repositório + * tags: [Repo Analyses] + * security: + * - bearerAuth: [] + * responses: + * 201: + * description: Análise iniciada + * 400: + * description: URL ou projeto inválido + * 404: + * description: Projeto não encontrado + * 409: + * description: Projeto arquivado é somente leitura + * 503: + * description: Motor de análise indisponível + */ + router.post('/', async (req: Request, res: Response, next: NextFunction) => { + try { + if (res.locals.projectStatus === 'arquivado') { + throw new ArchiveConflict('Projeto arquivado é somente leitura e não recebe novas análises.'); + } + const url = req.body?.repositorio_url; + if (typeof url !== 'string') throw new ValidationError('Informe a URL do repositório.'); + const analysis = await service.startAnalysis(String(req.params.projectId), req.auth?.id ?? '', url); + res.status(201).json(analysis); + } catch (error) { + next(error); + } + }); -/** - * @swagger - * /api/v1/projects/{projectId}/repo-analyses/{id}: - * get: - * summary: Obter detalhes de uma análise específica - * tags: [Repo Analyses] - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: projectId - * required: true - * schema: - * type: string - * format: uuid - * description: ID do projeto - * - in: path - * name: id - * required: true - * schema: - * type: string - * format: uuid - * description: ID da análise - * responses: - * 200: - * description: Detalhes da análise - * content: - * application/json: - * schema: - * type: object - * properties: - * id: - * type: string - * format: uuid - * projeto_id: - * type: string - * format: uuid - * usuario_id: - * type: string - * format: uuid - * repositorio_url: - * type: string - * status: - * type: string - * created_at: - * type: string - * format: date-time - * 401: - * description: Não autenticado - * 404: - * description: Análise não encontrada - * 500: - * description: Erro interno do servidor - */ -repoAnalysesRouter.get('/:id', requireAuth, async (req: any, res) => { - try { - const { id, projectId } = req.params; - const analysis = await service.getById(projectId, id); - if (!analysis) { - return res.status(404).json({ error: 'Análise não encontrada' }); + /** + * @swagger + * /api/v1/projects/{projectId}/repo-analyses: + * get: + * summary: Listar análises de repositório do projeto + * tags: [Repo Analyses] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Lista de análises do projeto + * 404: + * description: Projeto não encontrado + */ + router.get('/', async (req: Request, res: Response, next: NextFunction) => { + try { + res.json(await service.listByProject(String(req.params.projectId))); + } catch (error) { + next(error); } - return res.json(analysis); - } catch (error: any) { - return res.status(500).json({ error: error.message }); - } -}); + }); + + /** + * @swagger + * /api/v1/projects/{projectId}/repo-analyses/{id}: + * get: + * summary: Obter detalhes de uma análise específica + * tags: [Repo Analyses] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Detalhes da análise + * 404: + * description: Projeto ou análise não encontrados + */ + router.get('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const id = String(req.params.id); + validateUuid(id, 'ID da análise'); + const analysis = await service.getById(String(req.params.projectId), id); + if (!analysis) throw new NotFoundError('Análise não encontrada.'); + res.json(analysis); + } catch (error) { + next(error); + } + }); + + return router; +} + +export const repoAnalysesRouter = createRepoAnalysesRouter(); diff --git a/backend/src/modules/repo-analyses/repo-analyses.service.ts b/backend/src/modules/repo-analyses/repo-analyses.service.ts index bba62f2..06f1388 100644 --- a/backend/src/modules/repo-analyses/repo-analyses.service.ts +++ b/backend/src/modules/repo-analyses/repo-analyses.service.ts @@ -1,4 +1,5 @@ import axios from 'axios'; +import { AppError, ValidationError } from '../../shared/errors'; import { env } from '../../config/env'; import { RepoAnalysesRepository } from './repo-analyses.repository'; import { RepoAnalysisRecord, RepoAnalysisStatus, RepoAnalysisStep } from './repo-analyses.types'; @@ -35,7 +36,7 @@ export class RepoAnalysesService { async startAnalysis(projetoId: string, usuarioId: string, repositorioUrl: string): Promise { if (!this.validateGithubUrl(repositorioUrl)) { - throw new Error('URL do repositório GitHub inválida. Utilize o formato https://github.com/usuario/repositorio'); + throw new ValidationError('URL do repositório GitHub inválida. Utilize o formato https://github.com/usuario/repositorio'); } // Dispara a execução no ai-service / RepoAnalyzer @@ -52,8 +53,9 @@ export class RepoAnalysesService { }); return record; - } catch (error: any) { - throw new Error(`Falha ao iniciar análise no motor de IA: ${error.response?.data?.detail || error.message}`); + } catch (error: unknown) { + const detail = axios.isAxiosError(error) && typeof error.response?.data?.detail === 'string' ? error.response.data.detail : 'serviço indisponível'; + throw new AppError(`Falha ao iniciar análise no motor de IA: ${detail}`, 503, 'ANALYZER_UNAVAILABLE'); } } @@ -61,11 +63,9 @@ export class RepoAnalysesService { const analyses = await this.repository.findByProjectId(projetoId); // Opcional: Atualizar status das análises em andamento consultando o ai-service - for (const analysis of analyses) { - if (analysis.status === 'iniciado' || analysis.status === 'em_execucao') { - await this.syncAnalysisStatus(analysis.run_id); - } - } + await Promise.all(analyses + .filter((analysis) => analysis.status === 'iniciado' || analysis.status === 'em_execucao') + .map((analysis) => this.syncAnalysisStatus(analysis.run_id))); return await this.repository.findByProjectId(projetoId); } @@ -108,7 +108,7 @@ export class RepoAnalysesService { metadados: stats, }); } catch (error) { - console.error(`Erro ao sincronizar status do run ${runId}:`, error); + console.warn('[RepoAnalyzer] Falha ao sincronizar o status de uma análise; nova tentativa na próxima consulta.'); } } } diff --git a/database/migrations/012_document_upload.sql b/database/migrations/012_document_upload.sql new file mode 100644 index 0000000..2229128 --- /dev/null +++ b/database/migrations/012_document_upload.sql @@ -0,0 +1,60 @@ +-- S1-19/S1-22: metadados do upload seguro e outbox de eventos de integração. +ALTER TABLE documento ADD COLUMN IF NOT EXISTS extensao VARCHAR(10); +ALTER TABLE documento ADD COLUMN IF NOT EXISTS tamanho_bytes BIGINT; +ALTER TABLE documento ADD COLUMN IF NOT EXISTS usuario_id UUID REFERENCES usuario(id) ON DELETE SET NULL; + +ALTER TABLE documento DROP CONSTRAINT IF EXISTS ck_documento_tamanho; +ALTER TABLE documento ADD CONSTRAINT ck_documento_tamanho CHECK (tamanho_bytes IS NULL OR tamanho_bytes > 0); + +CREATE INDEX IF NOT EXISTS idx_documento_projeto_created + ON documento(projeto_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_chunk_documento + ON chunk(entidade_id) WHERE entidade_tipo = 'documento'; + +CREATE TABLE IF NOT EXISTS evento_integracao ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tipo VARCHAR(100) NOT NULL, + chave_idempotencia VARCHAR(200) NOT NULL, + payload JSONB NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pendente', + tentativas INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + publicado_em TIMESTAMPTZ, + CONSTRAINT uq_evento_integracao_chave UNIQUE (chave_idempotencia), + CONSTRAINT ck_evento_integracao_status CHECK (status IN ('pendente', 'publicado', 'falha')) +); + +CREATE INDEX IF NOT EXISTS idx_evento_integracao_pendente + ON evento_integracao(created_at) WHERE status <> 'publicado'; + +ALTER TABLE evento_integracao + ADD COLUMN IF NOT EXISTS next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + ADD COLUMN IF NOT EXISTS locked_until TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS last_error VARCHAR(300); + +CREATE INDEX IF NOT EXISTS idx_evento_integracao_delivery + ON evento_integracao(next_attempt_at, created_at) + WHERE status <> 'publicado'; + +-- Durable filesystem work survives process crashes between the database and +-- the local storage rename. No FK to documento: deletion must retain its cleanup. +CREATE TABLE IF NOT EXISTS documento_operacao_armazenamento ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + documento_id UUID NOT NULL, + projeto_id UUID NOT NULL, + acao VARCHAR(30) NOT NULL CHECK (acao IN ('finalizar_upload', 'descartar_remocao')), + caminho VARCHAR(600) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pendente' CHECK (status IN ('pendente', 'concluido')), + tentativas INT NOT NULL DEFAULT 0, + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + locked_until TIMESTAMPTZ, + last_error VARCHAR(300), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_documento_storage_action UNIQUE(documento_id, acao) +); + +CREATE INDEX IF NOT EXISTS idx_documento_storage_pending + ON documento_operacao_armazenamento(next_attempt_at, created_at) + WHERE status = 'pendente'; diff --git a/docker-compose.yml b/docker-compose.yml index bbaa237..d9cea1b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -82,6 +82,11 @@ services: - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-sinapse_dev_password} - POSTGRES_DB=${POSTGRES_DB:-sinapse} - AI_SERVICE_URL=http://ai-service:8000 + - DOCUMENT_MAX_SIZE_MB=${DOCUMENT_MAX_SIZE_MB:-20} + - DOCUMENT_STORAGE_DIR=/data/documents + - DOCUMENT_EVENTS_WEBHOOK_URL=${DOCUMENT_EVENTS_WEBHOOK_URL:-} + volumes: + - documents_data:/data/documents depends_on: postgres: condition: service_healthy @@ -132,6 +137,8 @@ volumes: name: sinapse_n8n_data ollama_data: name: sinapse_ollama_data + documents_data: + name: sinapse_documents_data networks: sinapse-network: diff --git a/docs/DOCUMENTOS_INTEGRACAO.md b/docs/DOCUMENTOS_INTEGRACAO.md new file mode 100644 index 0000000..06d7009 --- /dev/null +++ b/docs/DOCUMENTOS_INTEGRACAO.md @@ -0,0 +1,83 @@ +# Documentos: upload, remoção, outbox e escopo (S1-19, S1-20, S1-22) + +## Escopo desta entrega x Sprint 2 + +| Item | Entregue (Sprint 1) | Fica para a S2-01 | +|---|---|---| +| Upload seguro (PDF, DOCX, MD, TXT) com tipo real, tamanho, armazenamento e auditoria | Sim | — | +| Listagem por projeto com metadados, cursor e estado | Sim | — | +| Remoção confirmada, idempotente, com limpeza de metadados, arquivo e chunks | Sim | — | +| Evento `document.removed` gravado na mesma transação (outbox) e entregue por worker | Sim | — | +| Extração de texto, chunking, embeddings e transição `pendente → processando → processado/falha` | Não | Sim | +| Disponibilidade do conteúdo na busca e no chat | Não | Sim | + +`pendente` significa **armazenado e aguardando ingestão**. A interface e o OpenAPI não prometem indexação. +S1-21 trata de protótipos de PBIs e não tem relação com a ingestão. + +## Fluxo de upload + +1. `POST /api/v1/projects/{id}/documents` recebe o corpo binário e o nome em `X-File-Name`. Somente `admin` e `po`. +2. O tipo é decidido pelo **conteúdo** (assinatura PDF, ZIP com `word/document.xml`, texto UTF-8 sem NUL) e pela extensão; o `Content-Type` do cliente é ignorado. +3. O arquivo é gravado em área temporária (`.uploading`), o registro e a auditoria entram numa transação que trava a hierarquia (mesmo lock do arquivamento) e revalida que o projeto está ativo. +4. Após o commit, o arquivo é finalizado. Se a finalização falhar, a operação durável `finalizar_upload` fica pendente, o documento é devolvido com `armazenamento_pendente: true` e o worker conclui depois. +5. Projeto arquivado devolve 409 (`ARCHIVE_CONFLICT`) e não deixa arquivo nem registro. + +## Fluxo de remoção + +1. `DELETE /api/v1/projects/{id}/documents/{documentId}`: o arquivo é movido para `.removing`. +2. Numa transação com o lock de hierarquia: revalida projeto ativo, apaga `documento` e `chunk`, grava a operação `descartar_remocao`, o evento `document.removed` (quando havia conteúdo indexado) e a auditoria. +3. Erro ou arquivamento concorrente: rollback e o arquivo volta ao lugar (409 no arquivamento). Se a restauração falhar, a operação de reconciliação garante a recuperação e a resposta informa isso sem afirmar que o arquivo está disponível. +4. Repetir a remoção devolve 204 e não altera nada; documento de outro projeto nunca é tocado. + +## Worker de manutenção + +Iniciado com o backend (`startDocumentsBackgroundWorker`, a cada 15 s). Independe de qualquer `DELETE`. + +- **Outbox** (`evento_integracao`): reserva em lote com `FOR UPDATE SKIP LOCKED` e lease de 1 minuto (duas instâncias não pegam o mesmo evento); falha aplica backoff exponencial (15 s até 1 h) e grava só uma mensagem genérica em `last_error`. +- **Armazenamento** (`documento_operacao_armazenamento`): mesma reserva e backoff para finalizar uploads e descartar remoções. +- **Reconciliação** a cada 5 min: arquivos `.uploading` e `.removing` só são tratados após período de graça de 5 min; com documento vinculado o arquivo é finalizado/restaurado, sem vínculo é descartado. +- Logs não contêm nome de arquivo, conteúdo, caminho, SQL ou URL do webhook. + +## Observabilidade + +`GET /health` inclui `documents`: + +```json +{ "eventos_pendentes": 0, "evento_mais_antigo_segundos": 0, "operacoes_armazenamento_pendentes": 0, + "webhook_configurado": false, "alertas": [] } +``` + +`alertas` lista: webhook ausente com eventos pendentes, evento pendente há mais de 1 hora, operações de armazenamento aguardando reconciliação. Monitore `alertas.length > 0`. + +## Configuração + +| Variável | Padrão | Uso | +|---|---|---| +| `DOCUMENT_MAX_SIZE_MB` | `20` | Limite por arquivo, aplicado no backend e informado em `limites.max_bytes` | +| `DOCUMENT_STORAGE_DIR` | `storage/documents` | Diretório dos arquivos (volume `documents_data` no compose) | +| `DOCUMENT_EVENTS_WEBHOOK_URL` | vazio | Webhook do consumidor de `document.removed` | + +## Contrato do consumidor de `document.removed` + +Entrega ao menos uma vez. Cabeçalho `Idempotency-Key` = `event_id` = `document.removed:{document_id}`. Corpo em `docs/api/openapi.yaml` (`DocumentRemovedEvent`). O consumidor deve: + +1. Deduplicar por `event_id`. +2. Excluir de forma idempotente todo conteúdo externo de `document_id` no escopo de `project_id`. +3. Responder 2xx apenas após concluir (qualquer outro status mantém o evento em retentativa). + +Os `chunk` no PostgreSQL já são removidos pelo backend na mesma transação; o evento existe para índices externos. + +### Workflow n8n de exemplo + +`docs/integrations/n8n-document-removed.example.json` é um ponto de partida importável (webhook `sinapse-document-removed` que confirma o recebimento). Ele **não** está em `n8n/workflows/` porque a criação, ativação e o `n8n-sync validate` dependem da instância n8n da equipe. Pendência de integração: criar/ativar o workflow definitivo e preencher `DOCUMENT_EVENTS_WEBHOOK_URL`. Sem isso, os eventos ficam pendentes com retentativa e o `/health` alerta. + +## Como validar + +```bash +cd backend && npm test && npm run build +# Com PostgreSQL descartável (nome terminando em _test): +ARCHIVE_TEST_DATABASE_URL=postgresql://user:pass@localhost:5432/sinapse_x_test \ +BACKLOG_TREE_TEST_DATABASE_URL=$ARCHIVE_TEST_DATABASE_URL npm test +``` + +Os testes de banco cobrem migração 012 (banco limpo e já na 011, idempotência e dados legados), corrida arquivamento x upload/remoção, lease e backoff da outbox, operações de armazenamento e paginação estável isolada por projeto. Os testes E2E de navegador estão em `e2e/` (ver `e2e/README.md`). diff --git a/docs/PR34_CORRECTION_PLAN.md b/docs/PR34_CORRECTION_PLAN.md new file mode 100644 index 0000000..c43a191 --- /dev/null +++ b/docs/PR34_CORRECTION_PLAN.md @@ -0,0 +1,407 @@ +# Plano de correção — PR #34 (S1-19, S1-20, S1-22) + +## Fechamento dos gates (2026-09-25, segunda rodada) + +Validação executada localmente com PostgreSQL real (instância descartável), backend real e Chrome: + +| Gate | Resultado | Evidência | +|---|---|---| +| 1. Testes de abas do projeto | Concluído | `frontend/src/views/projects/ProjectTabs.test.tsx`: papéis ARIA, clique, setas/Home/End, hash, rota `/documents`, arquivado e perfil de leitura | +| 2. Testes do RepoAnalyzer | Concluído | `RepoAnalyzerView.test.tsx` e `models/repoAnalyzer.test.ts`; comportamentos do contrato anterior preservados e ampliados (etapas, polling, erro, URL, repetição, relatório) | +| 3. Testes da tela de documentos | Concluído | `DocumentsView.test.tsx` (19): upload binário, limite, vazio/erro/retry, cursor, remoção, permissões, arquivado, armazenamento pendente e aviso da S2-01 | +| 4. OpenAPI/documentação | Concluído | `cursor`, `limit`, `next_cursor`, `paginacao`, `armazenamento_pendente`, 409; `pendente` = aguardando ingestão; `docs/DOCUMENTOS_INTEGRACAO.md` | +| 5. Migration 012 | Concluído | `backend/src/database/migration-012.db.test.ts`: banco limpo e banco na 011, idempotência, dados legados, ordem do runner | +| 6. CI | Concluído | Etapa de PostgreSQL agora executa também backlog-tree, migrations 011/012, documentos e chat; nenhum job removido | +| 7. PostgreSQL | Concluído | 41+ testes de banco: corrida arquivamento x upload/DELETE, lease/backoff/SKIP LOCKED, operações de armazenamento, paginação estável e isolada | +| 8. Worker/n8n | Concluído no escopo | Worker independente de DELETE; `/health` expõe filas e alertas; consumidor n8n documentado com exemplo importável; workflow definitivo fica como pendência de integração (dependência da instância n8n e da S2-01) | +| 9. Navegador | Concluído | `e2e/` (12 cenários): perfis PO/dev, ativo/arquivado, upload válido/inválido/limite, remoção com confirmação e Esc, paginação, teclado, mobile 390 px sem overflow horizontal | +| 10. Suítes completas | Concluído | Backend 270+ testes e build; frontend 168+ testes, `tsc -b` e build | + +### Falhas do CI corrigidas +- `documents.repository.db.test.ts:91`: o teste assumia fila sem lease/backoff. Reescrito para provar lease, backoff, retorno da fila, expiração de lease e publicação. +- 9 testes do frontend: as suítes apontavam para telas legadas. Portadas para as views atuais; componentes órfãos (`DocumentsTab`, `RepoAnalyzerTab`) removidos. + +### Achados adicionais tratados nesta rodada +- Chat: qualquer usuário autenticado lia/escrevia em conversas alheias e o fallback devolvia trechos de todos os projetos sem relação com a pergunta. Corrigido (posse, isolamento, busca textual honesta com origem). +- Cadastro público aceitava `role: "admin"`. Agora exige sessão de administrador. +- RepoAnalyzer (API): validação de UUID, projeto inexistente (404), arquivado (409), erro do motor (503) e sincronização concorrente. +- Interface: tokens unificados com o protótipo (uma cor de marca), variáveis CSS órfãs corrigidas, responsivo do cabeçalho/abas. + +### Pendências explícitas (não bloqueiam a PR) +- Workflow n8n definitivo de `document.removed` e `DOCUMENT_EVENTS_WEBHOOK_URL`. +- Ingestão/indexação (S2-01). +- CI do GitHub no HEAD publicado e nova revisão QA. Merge só com autorização separada. + +## Estado e limites + +- PR: https://github.com/Galaticos-API/API-4/pull/34 +- Autor: `vitorpdim` (Victor Hugo) +- Commit revisado: `5cb8b2e706527495ca993954470252217574f062` +- Base atual observada: `main` em `d636620957a7cf62d95a4894bd4f2d75b412eac5` +- Estado observado no início: PR aberta, `CONFLICTING`; checks do commit da PR + antecedem a integração da main atual. +- Atualização em 2026-09-25: main integrada localmente por merge normal na branch + `codex/pr34-corrections`; conflitos resolvidos sem restaurar telas antigas. O + merge e as correções foram validados parcialmente, mas ainda não estão prontos + para merge na main. +- Não fazer merge desta PR até concluir os gates ao final deste documento e + confirmar CI verde no HEAD atualizado. +- CI do commit `9b4e445` concluiu parcialmente com falha: o teste PostgreSQL do + outbox falhou em `documents.repository.db.test.ts:91` ao verificar que o evento + deixa de aparecer após publicação; os testes do frontend falharam em 9 casos + nas suítes de abas e RepoAnalyzer (views atuais não têm o papel acessível + esperado pelas abas e o contrato visual/testado do Analyzer divergiu). + +## Ponto de parada e validação atual (2026-09-25) + +- Backend: `npm run build` passou; `npm test`: 207 passaram, 0 falharam e 9 + testes PostgreSQL foram pulados por falta de `ARCHIVE_TEST_DATABASE_URL`. +- Frontend: `npm run build` passou; `npm test`: 123 passaram e 9 falharam em + `ProjectTabs.test.tsx` e `RepoAnalyzerTab.test.tsx`, cujas expectativas ainda + correspondem à implementação antiga e precisam ser atualizadas ou a + funcionalidade correspondente precisa ser restaurada na arquitetura atual. +- CI no GitHub confirmou os bloqueios: além dos 9 testes frontend, o teste real + PostgreSQL `outbox: pendente aparece na fila e some depois de publicado` falha + na asserção de `documents.repository.db.test.ts:91`. Os testes concorrentes de + arquivamento/upload e arquivamento/DELETE passaram. O check de validação de + workflow/Docker e o build/typecheck do backend passaram; o estado do serviço + de IA estava em execução na última consulta. +- A migração de documentos foi renomeada para `012_document_upload.sql`, mas + falta teste PostgreSQL que prove aplicação em banco limpo e banco existente + após `011_unique_entity_technology.sql`. +- A rota segura de documentos está montada somente sob + `/api/v1/projects/:projectId/documents`; a rota legada permissiva foi removida. +- A checagem de projeto arquivado foi movida para transações serializadas; há + testes DB de concorrência preparados, ainda não executados localmente. +- Worker de retentativa/reconciliação e paginação por cursor foram iniciados; + contrato OpenAPI, tela ativa e testes completos ainda exigem revisão integrada. +- Esta atualização é um checkpoint de trabalho para a PR, não uma aprovação de + QA nem autorização de merge. + +## Próximas etapas obrigatórias antes do merge + +1. Atualizar os testes de navegação das abas de projeto para o comportamento + acessível da view vigente; cobrir clique/teclado, hash/URL, documentos em + projeto ativo e arquivado, sem quebrar o backlog e o RepoAnalyzer. +2. Atualizar e completar os testes do RepoAnalyzer na arquitetura atual, ou + recuperar as interações do contrato anterior que ainda forem requisito; não + remover testes apenas para fazer a suíte passar. +3. Acrescentar testes da nova `DocumentsView` para upload binário, limite e + validação, lista vazia/erro/tentar novamente, cursor/carregar mais, remoção, + roles, projeto arquivado, falha de armazenamento e aviso honesto de que a + ingestão depende da S2-01. +4. Atualizar fixtures/contratos de API e documentação OpenAPI para `cursor`, + `next_cursor`, `paginacao`, `armazenamento_pendente` e conflito HTTP 409; + descrever `pendente` como aguardando ingestão, sem prometer indexação. +5. Criar teste PostgreSQL para `012_document_upload.sql` em schema limpo e em + schema com `011_unique_entity_technology.sql` já aplicada; provar idempotência, + preservação de dados e ordem do runner. +6. Configurar CI para executar os testes de documentos, concorrência e migrações + junto dos testes atuais da main, sem retirar nenhum job existente. +7. Executar os testes PostgreSQL em banco descartável; comprovar corrida entre + arquivamento e upload/remoção, lease/retentativa do outbox, reparação de + storage e paginação estável sem vazamento entre projetos. +8. Revisar o worker e o escopo da integração n8n: verificar execução independente + de DELETE, idempotência, backoff, logs sanitizados, health/alerta e definir + claramente o que fica como dependência da S2-01 versus o que esta PR entrega. +9. Revisar UX e acessibilidade no navegador em desktop/mobile, perfis admin/PO/dev, + projeto ativo/arquivado, uploads válidos/inválidos, navegação e estados de + erro/recuperação; garantir que a rota atual renderize a tela de documentos. +10. Executar build/typecheck e testes backend/frontend completos, validação de + OpenAPI/workflows e `git diff --check`; corrigir todas as falhas e revisar o + diff contra a main para evitar regressões de autenticação, backlog, chat, + projetos, RepoAnalyzer ou documentação. +11. Confirmar que a branch da PR contém o HEAD mais recente da main sem conflitos, + aguardar CI verde no commit exato publicado e solicitar nova revisão QA/autor. +12. Só então considerar merge, mediante autorização explícita separada. Este + checkpoint não autoriza merge. + +O trabalho deve preservar a arquitetura e as regras da main atual. Como Vitor é +autor da PR, alterações na branch compartilhada devem ser coordenadas com ele; +não fazer force-push nem substituir arquivos atuais por cópias antigas da PR. + +## Achados que o plano cobre + +1. Bloqueio de integração com a main: conflitos em `.github/workflows/ci.yml`, + `backend/src/modules/documents/documents.routes.ts`, + `docs/S1-28_FRONTEND_HANDOFF.md`, `frontend/src/App.tsx`, + `frontend/src/components/DeveloperDashboard.tsx`, + `frontend/src/components/LandingPageView.tsx`, + `frontend/src/components/ui/index.tsx`, `frontend/src/projects/Projects.tsx`, + `frontend/src/projects/RepoAnalyzerTab.tsx` e + `frontend/src/projects/projects.css`. +2. A remoção não revalida nem bloqueia um projeto arquivado no backend. +3. O status do projeto é verificado antes do upload, fora do lock/transação que + serializa o arquivamento; há uma corrida que pode gravar em projeto arquivado. +4. O upload grava metadados como `pendente`, mas não aciona processamento; a + interface diz que ele começará. O workflow de ingestão disponível espera + `text_content` e está inativo. +5. O dispatcher da outbox só é chamado durante uma requisição DELETE. Eventos + que falham podem ficar sem nova tentativa se não houver outra remoção. O + handoff da PR registra que o consumidor n8n ainda precisa ser criado. +6. Arquivo e banco não formam uma única transação; falhas/queda entre gravação, + rename, commit, restore e discard podem deixar conteúdo órfão ou inacessível. +7. A listagem não tem paginação e retorna todos os documentos do projeto. + +## Princípios de implementação + +- Fazer uma etapa por vez; ao fim de cada etapa, revisar o diff e executar os + testes daquele comportamento antes de avançar. +- Usar a main atual como fonte de verdade para rotas, autenticação, navegação, + componentes e contratos já publicados. +- Manter isolamento por projeto, autorização por perfil, auditoria e idempotência. +- Não registrar conteúdo de arquivos, tokens, URLs sensíveis ou SQL com dados em + logs de erro. +- Não declarar o processamento completo enquanto não houver um fluxo ativo que + consuma o arquivo e atualize seu status. +- Nenhum merge da PR está incluído neste plano. + +## Etapa 0 — Preparação e decisões de escopo + +### Planejamento interno revisado + +Antes de alterar código, confirmar o commit exato da PR e da main, verificar que +o worktree isolado está limpo e manter intactos os checkouts com trabalho local. +Conferir no Trello/backlog se S1-21 (ingestão/indexação) é dependência separada +ou parte do aceite desta PR. Esta decisão muda o escopo da implementação e não +deve ser presumida. + +### Ações + +1. Revalidar o diff e os conflitos usando os SHAs atuais; atualizar a lista de + arquivos conflitantes se a PR ou a main tiverem avançado. +2. Confirmar os critérios de aceite das S1-19, S1-20 e S1-22 e o estado + do fluxo n8n de ingestão e remoção. +3. Definir um contrato observável para status: quando fica `pendente`, quando + muda para `processando`, `processado` ou `falha`, e quem executa cada mudança. +4. Definir limite e formato da paginação de documentos (recomendação inicial: + cursor estável por `created_at, id`, limite máximo configurado no servidor). + +### Gate de saída + +Escopo da ingestão documentado; volume esperado de documentos esclarecido; +branch/worktree e SHAs anotados; nenhuma escrita ainda feita na PR. + +## Etapa 1 — Integrar a main sem regressões + +### Planejamento interno revisado + +O diff da PR antecede a migração para as views atuais. Resolver conflitos +preservando telas e rotas da main; portar as capacidades de documentos para a +arquitetura presente em vez de restaurar `Projects.tsx` ou componentes antigos. +No backend, a main já tem uma rota legada de documentos que grava metadados +forjáveis e cria um chunk fictício; a versão segura deve substituí-la como única +implementação, sem manter rotas duplicadas ou caminhos alternativos permissivos. + +### Ações + +1. Integrar a main à branch da PR por merge normal, sem reescrever commits nem + usar force-push. Coordenar a atualização da branch compartilhada com o autor. +2. Resolver `.github/workflows/ci.yml` conservando os jobs existentes e + acrescentando as verificações específicas de documentos sem remover testes + da main. +3. Unificar `documents.routes.ts`: manter autenticação global, roles corretas, + escopo de projeto e corpo binário/validação; remover o POST legado que aceita + `caminho`, `mime` e status enviados pelo cliente e o DELETE não escopado por + projeto. +4. Portar a aba `DocumentsTab` para a view e navegação de projetos da main, + conservando backlog, chat, autenticação, RepoAnalyzer e design system atuais. +5. Atualizar OpenAPI, handoff e testes para um só contrato canônico. +6. Revisar migrações: a main já contém `011_unique_entity_technology.sql` e a PR + adiciona `011_document_upload.sql`. Embora o runner use o nome completo do + arquivo como chave e não haja colisão literal, manter a sequência inequívoca + (recomendação: nomear a nova migração `012_document_upload.sql`) e atualizar + caminho/nome nos testes e documentação. + +### Testes e gate + +- Build e testes de frontend/backend passam após os conflitos. +- A rota antiga não permite criar documentos fictícios, forjar caminhos ou + remover documento sem o `projectId` correspondente. +- Projeto, backlog, RepoAnalyzer, chat e autenticação continuam acessíveis. +- Migration runner passa em banco limpo e em banco já migrado até a main atual. +- Diff revisto para garantir que não houve remoção acidental de comportamento + recente da main. + +## Etapa 2 — Tornar arquivamento uma regra atômica para documentos + +### Planejamento interno revisado + +Uma checagem feita só em `DocumentsService` não fecha a corrida: arquivamento +pode ocorrer depois da leitura do status. Usar o lock e a política existentes +(`lockHierarchy` e `assertWritable`) na mesma transação que grava/remove +metadados. A checagem antecipada pode permanecer para resposta rápida, mas a +decisão final é do banco sob lock. A operação de filesystem deve ser compensada +se a transação final rejeitar por arquivamento. + +### Ações + +1. Upload: validar projeto e conteúdo; armazenar temporariamente; no repositório, + abrir transação, adquirir lock de hierarquia, revalidar projeto ativo e criar + metadados/auditoria. Se projeto estiver arquivado, reverter e limpar o arquivo + temporário. +2. Remoção: validar documento/projeto; preparar o arquivo para remoção; no mesmo + fluxo transacional, adquirir lock, confirmar que o projeto está gravável, + remover metadados/chunks e gravar auditoria/outbox. Se for arquivado ou houver + erro, restaurar o arquivo e retornar conflito apropriado. +3. Reutilizar o erro de conflito de arquivamento da main (HTTP 409) em vez de + classificar regra de negócio como erro genérico de validação. +4. Garantir que falha ao remover o documento não gere evento de remoção. + +### Testes e gate + +- Projeto arquivado: upload e DELETE retornam 409; metadados, chunks e arquivo + permanecem; auditoria/outbox não registram remoção. +- Projeto ativo: upload e remoção continuam funcionais e isolados por projeto. +- Teste de concorrência usa barreiras/locks para reproduzir archive-vs-upload e + archive-vs-delete; nenhum resultado final pode modificar projeto arquivado. +- Erro durante transação desfaz banco e aciona compensação de filesystem. + +## Etapa 3 — Resolver o contrato de ingestão e status + +### Planejamento interno revisado + +O backlog versionado esclarece a divisão: S1-19 cobre upload seguro, S1-20 +exibição/status, S1-22 remoção consistente; S1-21 trata de protótipos associados +a PBIs. O processamento em segundo plano é S2-01. Portanto, não implementar +ingestão/indexação neste escopo nem atribuí-la à S1-21. A UI deve manter o status +real `pendente` e informar que o arquivo foi armazenado e aguarda a integração +de ingestão; a entrega da S2-01 permanece explicitamente pendente. + +### Caminho aplicado: ingestão permanece na S2-01 + +1. Ajustar aviso de sucesso para informar armazenamento/recebimento e deixar + claro que o processamento depende da integração de ingestão. +2. Manter status `pendente` como estado real, explicando-o na UI e no OpenAPI. +3. Registrar dependência explícita para S2-01 e que documentos + ainda não estão disponíveis na busca/conversa até essa integração. + +### Fora do escopo desta correção (S2-01) + +1. Criar contrato idempotente de `document.uploaded`/ingestão; não enviar o + conteúdo integral dentro de eventos sem limite. +2. Tornar o arquivo acessível ao worker de modo autenticado e restrito ao + documento/projeto (volume compartilhado ou endpoint interno com autorização). +3. Extrair texto dos formatos suportados, acionar o fluxo n8n/AI, e persistir + transições de status e falhas; workflow deve estar ativado/configurado nos + ambientes que declaram ingestão disponível. +4. Definir retentativa e deduplicação para não indexar duas vezes o mesmo + documento após timeout/reentrega. + +### Testes e gate + +No caminho separado, a UI não afirma que iniciou indexação e o aceite mostra a +dependência. No caminho incluído, integração de ponta a ponta comprova upload → +worker → chunks vinculados ao projeto → status terminal, incluindo falhas e +retentativa. + +## Etapa 4 — Outbox com entrega e retentativa independentes de DELETE + +### Planejamento interno revisado + +A tabela outbox só é confiável se um consumidor/drainer for executado sem uma +ação incidental do usuário. Entrega deve ser ao menos uma vez, com chave estável; +consumidor precisa ser idempotente. Evitar duas instâncias processarem o mesmo +evento simultaneamente e evitar tentativas infinitas sem observabilidade. + +### Ações + +1. Escolher e documentar o dispatcher (worker/cron ou consumidor n8n ativo), com + configuração explícita e health/alerta quando webhook estiver vazio. +2. Reservar eventos em lote com estado/lease ou `FOR UPDATE SKIP LOCKED`; registrar + tentativa, próxima tentativa e erro sanitizado, com backoff. +3. Continuar marcando `publicado` só após resposta de sucesso; manter falhas + recuperáveis e usar a mesma `Idempotency-Key` em toda reentrega. +4. Retirar a responsabilidade de drenar outbox do caminho síncrono da requisição + DELETE; a remoção deve ser concluída mesmo se consumidor estiver fora do ar. +5. Criar e ativar o consumidor n8n de `document.removed` se essa integração está + no escopo; caso contrário, manter a pendência de integração explícita e não + declarar sincronização externa completa. + +### Testes e gate + +- Falha de webhook deixa evento recuperável; worker publica depois sem novo + DELETE do usuário. +- Resposta duplicada/retry com a mesma chave não repete efeito no consumidor. +- Duas instâncias não perdem nem processam indevidamente o evento em paralelo. +- Evento é criado na mesma transação que a remoção; rollback não deixa outbox. +- Métricas/logs sanitizados mostram idade da fila, tentativas e falhas. + +## Etapa 5 — Recuperação para inconsistências de filesystem/banco + +### Planejamento interno revisado + +Banco e filesystem não compartilham commit atômico. Compensação em `catch` não +cobre queda abrupta nem falha da própria compensação. Definir um estado durável +de operação e uma rotina segura de reconciliação; nunca apagar automaticamente +um arquivo recente cujo vínculo possa estar em transição. + +### Ações + +1. Dar nome a arquivos temporários e registrar operações pendentes de upload e + remoção no banco/outbox. +2. Após commit de upload, finalizar rename/estado; após falha, limpar ou marcar + para reconciliação em vez de engolir erro. +3. Após commit de remoção, deixar limpeza física como operação idempotente + pendente; retry remove `.removing` e marca concluído. +4. Se rollback ocorre e `restore()` falha, preservar diagnóstico/correlation + id, marcar inconsistência reparável e informar que disponibilidade está em + recuperação (não afirmar que arquivo continua disponível). +5. Criar tarefa de reconciliação com período de graça que compara arquivos e + registros, lista órfãos para observação e só então os remove com regra segura. + +### Testes e gate + +Injetar falhas em save, create, stage, delete, restore e discard; simular +reinício entre cada fronteira; provar que cada caso termina consistente ou em +estado recuperável e observável, sem remover conteúdo vinculado por engano. + +## Etapa 6 — Paginar lista de documentos + +### Planejamento interno revisado + +Os arquivos não vão na resposta de listagem, mas quantidade ilimitada de linhas +e renderização integral ainda podem degradar API e navegador. Usar ordenação +estável para impedir saltos/duplicatas entre páginas; manter UX acessível e +compatível com lista vazia, erro e atualização. + +### Ações + +1. Acrescentar paginação por cursor `(created_at, id)` e limite máximo no backend. +2. Retornar `items`, `next_cursor` e limites; atualizar OpenAPI e cliente tipado. +3. Adicionar “Carregar mais”/paginação responsiva na tela, sem descartar itens + ao atualizar a página atual. +4. Manter isolamento por projeto na consulta e no cursor. + +### Testes e gate + +- Dataset acima de várias páginas, ordenação repetível, sem duplicata/perda; + cursor inválido é rejeitado com erro seguro. +- Consulta de uma página não retorna registros de outro projeto. +- Navegação, empty state e retry continuam funcionais em desktop/mobile. + +## Etapa 7 — QA de integração e liberação para nova revisão + +### Revisão interna final + +1. Revisar o diff completo contra a main mais recente; procurar contratos + duplicados, autorização omitida, caminhos que aceitam metadados/caminho do + cliente, dados sensíveis em logs e regressões das telas atuais. +2. Rodar migration em banco limpo e banco existente com `011_unique_entity_technology` + já aplicada; confirmar ordem e idempotência do novo arquivo de migration. +3. Rodar testes unitários, testes de integração PostgreSQL, build/typecheck do + backend, testes/build do frontend e validação do OpenAPI/workflows. +4. Validar browser: perfis `admin`, `po`, `dev`; projeto ativo e arquivado; + upload válido/inválido, limite, listagem paginada, remoção, falha de rede e + status de processamento conforme escopo decidido. +5. Publicar commits normais na branch da PR apenas após coordenação com o autor; + aguardar CI do HEAD exato e confirmar que conflitos foram eliminados. +6. Pedir nova revisão QA/autor depois dos gates verdes. Merge fica fora deste + plano e depende de autorização própria. + +### Critério de conclusão + +PR atualizada contra a main, sem conflitos; invariantes de projeto arquivado +protegidos sob concorrência; comportamento de ingestão coerente com o escopo; +outbox e armazenamento recuperáveis; listagem escalável; testes relevantes e CI +verde no commit final; sem regressões na main. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 5643a73..20c3434 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1146,6 +1146,167 @@ paths: '404': description: Critério não encontrado + /api/v1/projects/{projectId}/documents: + parameters: + - name: projectId + in: path + required: true + schema: + type: string + format: uuid + get: + summary: Lista os documentos do projeto e os limites de envio (S1-20) + description: > + Paginação por cursor estável (created_at desc, id desc), sempre isolada + pelo projeto da rota. Use `next_cursor` da resposta em `cursor` para a + próxima página; `null` indica a última. `status_processamento` = + `pendente` significa **aguardando ingestão**: o arquivo está armazenado, + mas ainda não foi indexado (a ingestão pertence à S2-01). + operationId: listProjectDocuments + security: + - cookieAuth: [] + parameters: + - name: cursor + in: query + required: false + description: Cursor opaco devolvido em `next_cursor` + schema: {type: string, maxLength: 512} + - name: limit + in: query + required: false + schema: {type: integer, minimum: 1, maximum: 50, default: 20} + responses: + '200': + description: Documentos exclusivos do projeto, do mais recente ao mais antigo + content: + application/json: + schema: + $ref: '#/components/schemas/DocumentList' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: Projeto não encontrado + post: + summary: Envia um documento PDF, DOCX, MD ou TXT como corpo binário (S1-19) + description: > + O tipo real é verificado pelo conteúdo e pela extensão; o Content-Type + informado pelo cliente não é considerado. O limite de tamanho é + configurável (DOCUMENT_MAX_SIZE_MB) e é informado em `limites.max_bytes` + na listagem. Arquivos recusados não deixam arquivo nem registro. + operationId: uploadProjectDocument + security: + - cookieAuth: [] + parameters: + - name: X-File-Name + in: header + required: true + description: Nome original do arquivo, codificado com encodeURIComponent + schema: + type: string + maxLength: 765 + requestBody: + required: true + content: + application/octet-stream: + schema: + type: string + format: binary + responses: + '201': + description: Documento armazenado com estado de processamento `pendente` + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectDocument' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Perfil sem permissão de escrita + '404': + description: Projeto não encontrado + '409': + description: Projeto arquivado é somente leitura (ARCHIVE_CONFLICT) + '413': + description: Arquivo acima do limite configurado + '503': + description: Armazenamento indisponível ou em recuperação; o envio pode ser repetido + + /api/v1/projects/{projectId}/documents/{documentId}: + delete: + summary: Remove o documento, o arquivo e o conteúdo indexado de forma idempotente (S1-22) + description: > + Repetir a chamada retorna 204 sem afetar outro conteúdo. Documento que + não pertence ao projeto informado nunca é alterado. Quando há conteúdo + indexado, um evento `document.removed` é gravado na mesma transação e + publicado no webhook do n8n com o cabeçalho Idempotency-Key. + operationId: deleteProjectDocument + security: + - cookieAuth: [] + parameters: + - name: projectId + in: path + required: true + schema: + type: string + format: uuid + - name: documentId + in: path + required: true + schema: + type: string + format: uuid + responses: + '204': + description: Documento removido ou já inexistente + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Perfil sem permissão de escrita + '404': + description: Projeto não encontrado + '409': + description: Projeto arquivado é somente leitura (ARCHIVE_CONFLICT); nada é removido + '500': + description: Falha na remoção; o documento permanece disponível + '503': + description: Armazenamento indisponível; o documento permanece disponível + + /webhook/sinapse-document-removed: + servers: + - url: http://localhost:5678 + description: n8n (DOCUMENT_EVENTS_WEBHOOK_URL) + post: + summary: Evento publicado pelo backend quando um documento indexado é removido (S1-22 / PRE-03) + description: > + Entrega ao menos uma vez. O consumidor deve tratar `event_id` (também + enviado em Idempotency-Key) como chave de deduplicação e excluir de + forma idempotente todo o conteúdo indexado de `document_id` no escopo de + `project_id`. Eventos não confirmados ficam pendentes no backend e são + reenviados nas remoções seguintes. + operationId: documentRemovedEvent + security: [] + parameters: + - name: Idempotency-Key + in: header + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DocumentRemovedEvent' + responses: + '200': + description: Evento aceito; qualquer 2xx confirma a entrega + /ingest/document: post: summary: Solicita ingestão assíncrona de documento @@ -2207,6 +2368,76 @@ components: id: { type: string, format: uuid } nome: { type: string } + ProjectDocument: + type: object + required: [id, projeto_id, nome, status_processamento, created_at] + properties: + id: { type: string, format: uuid } + projeto_id: { type: string, format: uuid } + nome: { type: string, maxLength: 255 } + extensao: { type: string, nullable: true, enum: [.pdf, .docx, .md, .txt] } + mime: { type: string, nullable: true } + tamanho_bytes: { type: integer, nullable: true, minimum: 1 } + status_processamento: + type: string + description: pendente = armazenado e aguardando ingestão (S2-01) + enum: [pendente, processando, processado, falha] + armazenamento_pendente: + type: boolean + description: true enquanto a finalização do arquivo local ainda é reconciliada pelo worker + autor_id: { type: string, format: uuid, nullable: true } + autor_nome: { type: string, nullable: true } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + DocumentLimits: + type: object + required: [max_bytes, extensoes_permitidas] + properties: + max_bytes: { type: integer, minimum: 1 } + extensoes_permitidas: + type: array + items: { type: string } + + DocumentList: + type: object + required: [items, next_cursor, limites, paginacao] + properties: + items: + type: array + items: + $ref: '#/components/schemas/ProjectDocument' + next_cursor: + type: string + nullable: true + limites: + $ref: '#/components/schemas/DocumentLimits' + paginacao: + type: object + required: [tamanho_pagina, tamanho_maximo] + properties: + tamanho_pagina: {type: integer} + tamanho_maximo: {type: integer} + + DocumentRemovedEvent: + type: object + additionalProperties: false + required: [event_id, event_type, schema_version, occurred_at, project_id, document_id, chunks_removed] + properties: + event_id: + type: string + description: Chave estável por documento, no formato document.removed:{document_id} + event_type: + type: string + enum: [document.removed] + schema_version: + type: integer + enum: [1] + occurred_at: { type: string, format: date-time } + project_id: { type: string, format: uuid } + document_id: { type: string, format: uuid } + chunks_removed: { type: integer, minimum: 0 } + IngestDocumentRequest: type: object required: [document_id, text_content] diff --git a/docs/integrations/n8n-document-removed.example.json b/docs/integrations/n8n-document-removed.example.json new file mode 100644 index 0000000..067ccc6 --- /dev/null +++ b/docs/integrations/n8n-document-removed.example.json @@ -0,0 +1,36 @@ +{ + "name": "Sinapse - Document Removed (exemplo)", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "sinapse-document-removed", + "responseMode": "responseNode", + "options": {} + }, + "name": "Webhook Documento Removido", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [250, 300], + "webhookId": "sinapse-document-removed" + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ { status: 'received', event_id: $json.body.event_id, document_id: $json.body.document_id } }}", + "options": {} + }, + "name": "Confirmar recebimento", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "position": [480, 300] + } + ], + "connections": { + "Webhook Documento Removido": { + "main": [[{ "node": "Confirmar recebimento", "type": "main", "index": 0 }]] + } + }, + "active": false, + "settings": { "executionOrder": "v1" } +} diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..e831f9c --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,27 @@ +# Testes E2E (navegador + API real + PostgreSQL) + +Cobrem os fluxos autenticados de ponta a ponta com o backend real, o PostgreSQL e o frontend no Chrome. +Não substituem os testes de unidade de cada módulo. + +## Pré-requisitos + +- PostgreSQL com todas as migrations aplicadas (`cd backend && npm run migrate`). +- Backend em `http://localhost:3001` (`cd backend && npm run dev`) com `DOCUMENT_STORAGE_DIR` gravável. +- Frontend em `http://localhost:5173` (`cd frontend && npm run dev`). +- Google Chrome instalado (ou `E2E_CHROME_PATH` apontando para um executável Chromium). +- O serviço de IA **não** é necessário: os testes verificam o comportamento quando ele está indisponível. +- Opcional: `E2E_ADMIN_EMAIL` e `E2E_ADMIN_PASSWORD` para os cenários de administrador (o cadastro público não cria admin). + +## Execução + +```bash +cd e2e +npm ci +npm test # todos os cenários +node --test tests/documents.e2e.mjs +``` + +Variáveis: `E2E_API_URL`, `E2E_APP_URL`, `E2E_CHROME_PATH`, `E2E_TIMEOUT_MS`. + +Cada cenário cria usuários e projetos próprios (nomes únicos), então pode rodar repetidamente no mesmo banco de desenvolvimento. +Use um banco descartável em ambientes compartilhados. diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..b71b9ef --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,28 @@ +{ + "name": "sinapse-e2e", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sinapse-e2e", + "version": "0.1.0", + "devDependencies": { + "playwright-core": "^1.55.0" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..329f81d --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,13 @@ +{ + "name": "sinapse-e2e", + "version": "0.1.0", + "private": true, + "description": "Testes E2E de navegador (API real + PostgreSQL + frontend)", + "type": "module", + "scripts": { + "test": "node --test --test-concurrency=1 ./tests/" + }, + "devDependencies": { + "playwright-core": "^1.55.0" + } +} diff --git a/e2e/support.mjs b/e2e/support.mjs new file mode 100644 index 0000000..05854f0 --- /dev/null +++ b/e2e/support.mjs @@ -0,0 +1,86 @@ +import { randomUUID } from "node:crypto"; +import { chromium } from "playwright-core"; + +export const API = process.env.E2E_API_URL ?? "http://localhost:3001/api/v1"; +export const APP = process.env.E2E_APP_URL ?? "http://localhost:5173"; +export const PASSWORD = "Senha-forte-123"; + +export async function api(path, { token, method = "GET", body, headers = {} } = {}) { + const raw = body instanceof Uint8Array; + const response = await fetch(`${API}${path}`, { + method, + headers: { + ...(body && !raw ? { "Content-Type": "application/json" } : {}), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...headers, + }, + body: raw ? body : body ? JSON.stringify(body) : undefined, + }); + const text = await response.text(); + let json; + try { json = text ? JSON.parse(text) : undefined; } catch { json = text; } + return { status: response.status, json }; +} + +export async function createUser(role) { + const email = `${role}-${randomUUID().slice(0, 8)}@e2e.test`; + const registered = await api("/auth/register", { method: "POST", body: { nome: `E2E ${role}`, email, password: PASSWORD, role } }); + if (registered.status !== 201 && registered.status !== 200) throw new Error(`register ${role}: ${registered.status} ${JSON.stringify(registered.json)}`); + const login = await api("/auth/login", { method: "POST", body: { email, password: PASSWORD } }); + if (login.status !== 200) throw new Error(`login ${role}: ${login.status}`); + return { email, password: PASSWORD, token: login.json.token, user: login.json.user }; +} + +export async function createProject(account, label = "Projeto") { + const response = await api("/projects", { + method: "POST", + token: account.token, + body: { nome: `${label} ${randomUUID().slice(0, 8)}`, cliente: "Cliente E2E", descricao: "Projeto criado pelo E2E." }, + }); + if (response.status !== 201) throw new Error(`projeto: ${response.status} ${JSON.stringify(response.json)}`); + return response.json; +} + +export async function archiveProject(account, project) { + const impact = await api(`/projects/${project.id}/archive-impact`, { token: account.token }); + const archived = await api(`/projects/${project.id}/archive`, { method: "PATCH", token: account.token, body: { confirmado: true, impacto: impact.json } }); + if (archived.status !== 200) throw new Error(`arquivar: ${archived.status} ${JSON.stringify(archived.json)}`); +} + +export function uploadRaw(account, projectId, name, content) { + return api(`/projects/${projectId}/documents`, { + method: "POST", + token: account.token, + body: typeof content === "string" ? new TextEncoder().encode(content) : content, + headers: { "Content-Type": "application/octet-stream", "X-File-Name": encodeURIComponent(name) }, + }); +} + +export const pdfContent = () => `%PDF-1.7\n${" ".repeat(200)}`; + +let browser; + +export async function getBrowser() { + browser ??= await chromium.launch({ + headless: true, + ...(process.env.E2E_CHROME_PATH ? { executablePath: process.env.E2E_CHROME_PATH } : { channel: "chrome" }), + }); + return browser; +} + +export async function closeBrowser() { + await browser?.close(); + browser = undefined; +} + +export async function openPage(account, { width = 1440, height = 900 } = {}) { + const context = await (await getBrowser()).newContext({ viewport: { width, height } }); + const page = await context.newPage(); + await page.addInitScript((token) => localStorage.setItem("app_auth_token", token), account.token); + return { context, page }; +} + +export async function assertNoHorizontalOverflow(page, label) { + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); + if (overflow > 1) throw new Error(`${label}: overflow horizontal de ${overflow}px`); +} diff --git a/e2e/tests/assistants.e2e.mjs b/e2e/tests/assistants.e2e.mjs new file mode 100644 index 0000000..13cf730 --- /dev/null +++ b/e2e/tests/assistants.e2e.mjs @@ -0,0 +1,111 @@ +import test, { after } from "node:test"; +import assert from "node:assert/strict"; +import { expect } from "./expect.mjs"; +import { APP, api, archiveProject, assertNoHorizontalOverflow, closeBrowser, createProject, createUser, openPage } from "../support.mjs"; + +after(() => closeBrowser()); + +test("RepoAnalyzer: valida a URL, explica a indisponibilidade do motor e respeita o arquivamento", async () => { + const po = await createUser("po"); + const project = await createProject(po, "Analyzer"); + const { page, context } = await openPage(po); + try { + await page.goto(`${APP}/projects/${project.id}#repo-analyzer`); + await expect(page.getByText("Nenhuma análise realizada neste projeto.")).toBeVisible(); + + await page.getByRole("button", { name: "Iniciar análise" }).click(); + await expect(page.getByText("Informe a URL do repositório.")).toBeVisible(); + + await page.getByLabel(/URL do repositório/).fill("https://gitlab.com/acme/api"); + await page.getByRole("button", { name: "Iniciar análise" }).click(); + await expect(page.getByText("Use o formato https://github.com/usuario/repositorio.")).toBeVisible(); + + await page.getByLabel(/URL do repositório/).fill("https://github.com/acme/api"); + await page.getByRole("button", { name: "Iniciar análise" }).click(); + await expect(page.getByText(/Falha ao iniciar análise no motor de IA/)).toBeVisible(); + assert.equal(await page.getByLabel(/URL do repositório/).inputValue(), "https://github.com/acme/api"); + } finally { + await context.close(); + } + + await archiveProject(po, project); + const archived = await openPage(po); + try { + await archived.page.goto(`${APP}/projects/${project.id}#repo-analyzer`); + await expect(archived.page.getByText(/não pode iniciar novas/)).toBeVisible(); + assert.equal(await archived.page.getByRole("button", { name: "Iniciar análise" }).count(), 0); + } finally { + await archived.context.close(); + } + const post = await api(`/projects/${project.id}/repo-analyses`, { method: "POST", token: po.token, body: { repositorio_url: "https://github.com/acme/api" } }); + assert.equal(post.status, 409); +}); + +test("RepoAnalyzer: id malformado e projeto inexistente têm respostas seguras", async () => { + const po = await createUser("po"); + assert.equal((await api("/projects/nao-uuid/repo-analyses", { token: po.token })).status, 400); + assert.equal((await api("/projects/00000000-0000-4000-8000-000000000000/repo-analyses", { token: po.token })).status, 404); + assert.equal((await api("/projects/00000000-0000-4000-8000-000000000000/repo-analyses")).status, 401); +}); + +test("Chat: envia por Enter, mostra a origem e mantém o histórico; outra pessoa não acessa a conversa", async () => { + const ana = await createUser("po"); + const bruno = await createUser("po"); + const project = await createProject(ana, "Chat"); + const { page, context } = await openPage(ana); + let conversationId; + try { + await page.goto(`${APP}/chat`); + await expect(page.getByText("Como posso ajudar?")).toBeVisible(); + await page.getByLabel("Escopo da consulta").selectOption(project.id); + const composer = page.getByLabel("Sua pergunta"); + await composer.fill("linha 1"); + await composer.press("Shift+Enter"); + assert.match(await composer.inputValue(), /linha 1\n/); + await composer.fill("Como funciona o arquivamento?"); + await composer.press("Enter"); + await expect(page.getByText("Informação não encontrada no acervo do projeto.")).toBeVisible(); + await expect(page.getByRole("log", { name: "Mensagens da conversa" })).toBeVisible(); + + const conversations = await api("/chat/conversations", { token: ana.token }); + assert.equal(conversations.json.items.length, 1); + conversationId = conversations.json.items[0].id; + assert.equal(conversations.json.items[0].projeto_id, project.id); + + await page.reload(); + await expect(page.getByText("Informação não encontrada no acervo do projeto.")).toBeVisible(); + await assertNoHorizontalOverflow(page, "chat desktop"); + } finally { + await context.close(); + } + + assert.equal((await api(`/chat/conversations/${conversationId}/messages`, { token: bruno.token })).status, 404); + const injection = await api("/chat/query", { method: "POST", token: bruno.token, body: { pergunta: "invasão", conversa_id: conversationId } }); + assert.equal(injection.status, 404); + assert.equal((await api(`/chat/conversations/${conversationId}/messages`, { token: ana.token })).json.items.length, 2); + assert.equal((await api("/chat/conversations", { token: bruno.token })).json.items.length, 0); +}); + +test("Chat: mobile não transborda e o campo de pergunta é acessível por teclado", async () => { + const po = await createUser("po"); + const { page, context } = await openPage(po, { width: 390, height: 844 }); + try { + await page.goto(`${APP}/chat`); + await expect(page.getByLabel("Sua pergunta")).toBeVisible(); + await assertNoHorizontalOverflow(page, "chat mobile"); + await page.getByRole("button", { name: "Nova conversa" }).focus(); + await page.keyboard.press("Tab"); + await page.keyboard.press("Tab"); + const focused = await page.evaluate(() => document.activeElement?.id || document.activeElement?.tagName); + assert.ok(focused); + } finally { + await context.close(); + } +}); + +test("cadastro público não concede administrador", async () => { + const response = await api("/auth/register", { method: "POST", body: { nome: "Invasor", email: `invasor-${Date.now()}@e2e.test`, password: "Senha-forte-123", role: "admin" } }); + assert.equal(response.status, 403); + const login = await api("/auth/login", { method: "POST", body: { email: "inexistente@e2e.test", password: "x" } }); + assert.ok([401, 429].includes(login.status)); +}); diff --git a/e2e/tests/documents.e2e.mjs b/e2e/tests/documents.e2e.mjs new file mode 100644 index 0000000..58d5f42 --- /dev/null +++ b/e2e/tests/documents.e2e.mjs @@ -0,0 +1,156 @@ +import test, { after } from "node:test"; +import assert from "node:assert/strict"; +import { expect } from "./expect.mjs"; +import { APP, api, archiveProject, assertNoHorizontalOverflow, closeBrowser, createProject, createUser, openPage, pdfContent, uploadRaw } from "../support.mjs"; + +after(() => closeBrowser()); + +const file = (name, content, mimeType = "application/octet-stream") => ({ name, mimeType, buffer: Buffer.from(content) }); + +test("PO envia, lista e remove documentos com confirmação e validações claras", async () => { + const po = await createUser("po"); + const project = await createProject(po, "Docs PO"); + const { page, context } = await openPage(po); + try { + await page.goto(`${APP}/projects/${project.id}#documents`); + await expect(page.getByText("Nenhum documento neste projeto")).toBeVisible(); + await expect(page.getByText(/Máximo 20,0 MB/)).toBeVisible(); + + const input = page.getByLabel("Selecionar documento"); + + await input.setInputFiles(file("programa.exe", "MZ")); + await expect(page.getByText(/Formato não suportado/)).toBeVisible(); + + await input.setInputFiles(file("falso.pdf", "isto é apenas texto")); + await page.getByRole("button", { name: "Enviar documento" }).click(); + await expect(page.getByText(/não corresponde ao formato \.pdf/)).toBeVisible(); + await expect(page.getByText("falso.pdf")).toBeVisible(); + await page.getByRole("button", { name: "Limpar seleção" }).click(); + + await input.setInputFiles(file("Escopo funcional.md", "# Escopo\n\nTexto.")); + await page.getByRole("button", { name: "Enviar documento" }).click(); + await expect(page.getByText(/foi armazenado/)).toBeVisible(); + const row = page.getByRole("row", { name: /Escopo funcional\.md/ }); + await expect(row).toBeVisible(); + await expect(row.getByText("Aguardando ingestão")).toBeVisible(); + + await page.getByRole("button", { name: "Remover Escopo funcional.md" }).click(); + await page.getByRole("button", { name: "Cancelar" }).click(); + await expect(row).toBeVisible(); + + await page.getByRole("button", { name: "Remover Escopo funcional.md" }).click(); + await page.keyboard.press("Escape"); + await expect(row).toBeVisible(); + + await page.getByRole("button", { name: "Remover Escopo funcional.md" }).click(); + await page.getByRole("button", { name: "Confirmar remoção" }).click(); + await expect(page.getByText(/foi removido deste projeto/)).toBeVisible(); + await expect(page.getByText("Nenhum documento neste projeto")).toBeVisible(); + + const list = await api(`/projects/${project.id}/documents`, { token: po.token }); + assert.equal(list.json.items.length, 0); + } finally { + await context.close(); + } +}); + +test("perfil dev consulta mas não escreve, e a API recusa a escrita", async () => { + const po = await createUser("po"); + const dev = await createUser("dev"); + const project = await createProject(po, "Docs DEV"); + const uploaded = await uploadRaw(po, project.id, "leitura.pdf", pdfContent()); + assert.equal(uploaded.status, 201); + + const { page, context } = await openPage(dev); + try { + await page.goto(`${APP}/projects/${project.id}#documents`); + await expect(page.getByRole("row", { name: /leitura\.pdf/ })).toBeVisible(); + await expect(page.getByText(/pode consultar documentos, mas não pode enviar ou remover/)).toBeVisible(); + assert.equal(await page.getByLabel("Selecionar documento").count(), 0); + assert.equal(await page.getByRole("button", { name: /Remover/ }).count(), 0); + } finally { + await context.close(); + } + assert.equal((await uploadRaw(dev, project.id, "x.pdf", pdfContent())).status, 403); + assert.equal((await api(`/projects/${project.id}/documents/${uploaded.json.id}`, { method: "DELETE", token: dev.token })).status, 403); +}); + +test("projeto arquivado: UI somente leitura e API responde 409 sem alterar nada", async () => { + const po = await createUser("po"); + const project = await createProject(po, "Docs Arquivado"); + const uploaded = await uploadRaw(po, project.id, "preservado.pdf", pdfContent()); + assert.equal(uploaded.status, 201); + await archiveProject(po, project); + + const { page, context } = await openPage(po); + try { + await page.goto(`${APP}/projects/${project.id}#documents`); + await expect(page.getByText(/Projeto arquivado: os documentos ficam disponíveis somente para consulta/)).toBeVisible(); + await expect(page.getByRole("row", { name: /preservado\.pdf/ })).toBeVisible(); + assert.equal(await page.getByLabel("Selecionar documento").count(), 0); + assert.equal(await page.getByRole("button", { name: /Remover/ }).count(), 0); + } finally { + await context.close(); + } + assert.equal((await uploadRaw(po, project.id, "novo.pdf", pdfContent())).status, 409); + assert.equal((await api(`/projects/${project.id}/documents/${uploaded.json.id}`, { method: "DELETE", token: po.token })).status, 409); + const list = await api(`/projects/${project.id}/documents`, { token: po.token }); + assert.deepEqual(list.json.items.map((item) => item.nome), ["preservado.pdf"]); +}); + +test("isolamento por projeto: listagem e remoção nunca atravessam projetos", async () => { + const po = await createUser("po"); + const a = await createProject(po, "Isolamento A"); + const b = await createProject(po, "Isolamento B"); + const doc = await uploadRaw(po, a.id, "somente-a.pdf", pdfContent()); + assert.equal(doc.status, 201); + + assert.equal((await api(`/projects/${b.id}/documents`, { token: po.token })).json.items.length, 0); + assert.equal((await api(`/projects/${b.id}/documents/${doc.json.id}`, { method: "DELETE", token: po.token })).status, 204); + assert.equal((await api(`/projects/${a.id}/documents`, { token: po.token })).json.items.length, 1); + assert.equal((await api(`/projects/00000000-0000-4000-8000-000000000000/documents`, { token: po.token })).status, 404); + assert.equal((await api(`/projects/${a.id}/documents`)).status, 401); +}); + +test("limite de tamanho é aplicado no servidor mesmo sem a validação do cliente", async () => { + const po = await createUser("po"); + const project = await createProject(po, "Docs Limite"); + const big = new Uint8Array(21 * 1024 * 1024).fill(0x61); + const response = await uploadRaw(po, project.id, "grande.txt", big); + assert.equal(response.status, 413); + assert.equal(response.json.code, "PAYLOAD_TOO_LARGE"); + assert.equal((await api(`/projects/${project.id}/documents`, { token: po.token })).json.items.length, 0); +}); + +test("paginação por cursor carrega mais documentos sem duplicar", async () => { + const po = await createUser("po"); + const project = await createProject(po, "Docs Paginação"); + for (let index = 0; index < 3; index += 1) assert.equal((await uploadRaw(po, project.id, `doc-${index}.txt`, `conteudo ${index}`)).status, 201); + const first = await api(`/projects/${project.id}/documents?limit=2`, { token: po.token }); + assert.equal(first.json.items.length, 2); + assert.ok(first.json.next_cursor); + const second = await api(`/projects/${project.id}/documents?limit=2&cursor=${first.json.next_cursor}`, { token: po.token }); + assert.equal(second.json.items.length, 1); + assert.equal(second.json.next_cursor, null); + assert.equal((await api(`/projects/${project.id}/documents?cursor=%%%`, { token: po.token })).status, 400); +}); + +test("abas do projeto funcionam por teclado e mobile não transborda", async () => { + const po = await createUser("po"); + const project = await createProject(po, "Abas"); + const { page, context } = await openPage(po, { width: 390, height: 844 }); + try { + await page.goto(`${APP}/projects/${project.id}`); + await expect(page.getByRole("tab", { name: "Visão geral" })).toHaveAttribute("aria-selected", "true"); + await page.getByRole("tab", { name: "Visão geral" }).focus(); + await page.keyboard.press("ArrowRight"); + await page.keyboard.press("ArrowRight"); + await expect(page.getByRole("tab", { name: "Documentos" })).toHaveAttribute("aria-selected", "true"); + assert.match(page.url(), /#documents$/); + await assertNoHorizontalOverflow(page, "documentos mobile"); + await page.getByRole("tab", { name: "Análise de repositório" }).click(); + await assertNoHorizontalOverflow(page, "analisador mobile"); + } finally { + await context.close(); + } +}); diff --git a/e2e/tests/expect.mjs b/e2e/tests/expect.mjs new file mode 100644 index 0000000..bf40fd3 --- /dev/null +++ b/e2e/tests/expect.mjs @@ -0,0 +1,21 @@ +const TIMEOUT = Number(process.env.E2E_TIMEOUT_MS ?? 10000); + +export function expect(target) { + const isLocator = typeof target.waitFor === "function"; + return { + async toBeVisible() { + if (!isLocator) throw new Error("toBeVisible exige um locator"); + await target.first().waitFor({ state: "visible", timeout: TIMEOUT }); + }, + async toHaveAttribute(name, value) { + const deadline = Date.now() + TIMEOUT; + let current = null; + while (Date.now() < deadline) { + current = await target.first().getAttribute(name); + if (current === value) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`atributo ${name}: esperado "${value}", recebido "${current}"`); + }, + }; +} diff --git a/frontend/nginx.conf b/frontend/nginx.conf index f096428..eaade5a 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -10,6 +10,7 @@ server { # Reverse proxy para API do Backend location /api/ { + client_max_body_size 100m; proxy_pass http://backend:3001; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; diff --git a/frontend/src/api/api_chat.ts b/frontend/src/api/api_chat.ts new file mode 100644 index 0000000..50ce895 --- /dev/null +++ b/frontend/src/api/api_chat.ts @@ -0,0 +1,122 @@ +import { ApiError, apiRequest } from "./api_auth"; +import { serverMessage } from "./api_errors"; + +export type ChatOrigin = "assistente" | "busca_textual" | "sem_resultado"; + +export interface ChatSource { + id: string; + titulo: string; + tipo: string; +} + +export interface ChatConversation { + id: string; + titulo: string; + projeto_id: string | null; + projeto_nome: string | null; + updated_at: string; +} + +export interface ChatMessage { + id: string; + remetente: "user" | "assistant" | "system"; + conteudo: string; + fontes: ChatSource[]; + created_at: string | null; + origem?: ChatOrigin; +} + +export interface ChatAnswer { + conversa_id: string; + resposta: string; + fontes: ChatSource[]; + origem: ChatOrigin; +} + +export const MAX_QUESTION_LENGTH = 2000; +const QUERY_TIMEOUT_MS = 60_000; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function parseSources(value: unknown): ChatSource[] { + if (!Array.isArray(value)) return []; + return value.flatMap((item) => { + if (!isRecord(item) || typeof item.id !== "string") return []; + return [{ + id: item.id, + titulo: typeof item.titulo === "string" && item.titulo ? item.titulo : item.id, + tipo: typeof item.tipo === "string" ? item.tipo : "documento", + }]; + }); +} + +function parseConversation(value: unknown): ChatConversation { + if (!isRecord(value) || typeof value.id !== "string" || typeof value.titulo !== "string") { + throw new Error("Conversa inválida"); + } + return { + id: value.id, + titulo: value.titulo, + projeto_id: typeof value.projeto_id === "string" ? value.projeto_id : null, + projeto_nome: typeof value.projeto_nome === "string" ? value.projeto_nome : null, + updated_at: typeof value.updated_at === "string" ? value.updated_at : "", + }; +} + +function parseMessage(value: unknown): ChatMessage { + if (!isRecord(value) || typeof value.id !== "string" || typeof value.conteudo !== "string" + || !["user", "assistant", "system"].includes(String(value.remetente))) { + throw new Error("Mensagem inválida"); + } + return { + id: value.id, + remetente: value.remetente as ChatMessage["remetente"], + conteudo: value.conteudo, + fontes: parseSources(value.fontes_json ?? value.fontes), + created_at: typeof value.created_at === "string" ? value.created_at : null, + }; +} + +export async function listConversations(signal?: AbortSignal): Promise { + const data: unknown = await (await apiRequest("/chat/conversations", { signal })).json(); + if (!isRecord(data) || !Array.isArray(data.items)) throw new Error("Lista de conversas inválida"); + return data.items.map(parseConversation); +} + +export async function listMessages(conversationId: string, signal?: AbortSignal): Promise { + const data: unknown = await (await apiRequest(`/chat/conversations/${encodeURIComponent(conversationId)}/messages`, { signal })).json(); + if (!isRecord(data) || !Array.isArray(data.items)) throw new Error("Lista de mensagens inválida"); + return data.items.map(parseMessage); +} + +export async function askQuestion( + input: { pergunta: string; conversaId?: string | null; projetoId?: string | null }, + signal?: AbortSignal, +): Promise { + const response = await apiRequest("/chat/query", { + method: "POST", + body: JSON.stringify({ + pergunta: input.pergunta, + conversa_id: input.conversaId || undefined, + projeto_id: input.projetoId || undefined, + }), + signal: signal ?? AbortSignal.timeout(QUERY_TIMEOUT_MS), + }); + const data: unknown = await response.json(); + if (!isRecord(data) || typeof data.conversa_id !== "string" || typeof data.resposta !== "string") { + throw new Error("Resposta do assistente inválida"); + } + const origem = ["assistente", "busca_textual", "sem_resultado"].includes(String(data.origem)) ? (data.origem as ChatOrigin) : "assistente"; + return { conversa_id: data.conversa_id, resposta: data.resposta, fontes: parseSources(data.fontes), origem }; +} + +export function describeChatError(error: unknown): string { + if (error instanceof ApiError) { + if (error.status === 401) return "Sua sessão expirou. Entre novamente para continuar a conversa."; + if (error.status === 404) return serverMessage(error) ?? "Conversa ou projeto não encontrado."; + if (error.status === 400) return serverMessage(error) ?? "Revise a pergunta e tente novamente."; + } + return "Não foi possível falar com o assistente agora. Verifique a conexão e tente novamente."; +} diff --git a/frontend/src/api/api_documents.test.ts b/frontend/src/api/api_documents.test.ts new file mode 100644 index 0000000..8c50f54 --- /dev/null +++ b/frontend/src/api/api_documents.test.ts @@ -0,0 +1,83 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from "vitest"; +import { ApiError } from "./api_auth"; +import { + describeRemovalError, + describeUploadError, + fileExtension, + formatBytes, + listDocuments, + removeDocument, + uploadDocument, + validateSelection, +} from "./api_documents"; + +const limits = { max_bytes: 1024 * 1024, extensoes_permitidas: [".pdf", ".docx", ".md", ".txt"] }; +const document = { + id: "d-1", projeto_id: "p-1", nome: "Escopo.pdf", extensao: ".pdf", mime: "application/pdf", tamanho_bytes: 2048, + status_processamento: "pendente", autor_id: "u-1", autor_nome: "Ana", created_at: "2026-09-20T10:00:00Z", updated_at: "2026-09-20T10:00:00Z", +}; + +afterEach(() => vi.unstubAllGlobals()); + +it("lista documentos do projeto e valida o contrato", async () => { + const request = vi.fn(async (..._args: unknown[]) => new Response(JSON.stringify({ items: [document], limites: limits, next_cursor: null }))); + vi.stubGlobal("fetch", request); + const result = await listDocuments("p-1"); + expect(request.mock.calls[0][0]).toBe("/api/v1/projects/p-1/documents"); + expect(result.items[0].nome).toBe("Escopo.pdf"); + expect(result.limites.max_bytes).toBe(limits.max_bytes); +}); + +it("recusa respostas fora do contrato", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ items: [{ id: "x" }], limites: limits, next_cursor: null })))); + await expect(listDocuments("p-1")).rejects.toThrow("Resposta de documento inválida"); + vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ items: [], next_cursor: null })))); + await expect(listDocuments("p-1")).rejects.toThrow("Limites de envio inválidos"); +}); + +it("envia o arquivo como corpo binário com o nome codificado e sem depender do MIME do cliente", async () => { + const request = vi.fn(async (_url: RequestInfo | URL, _init?: RequestInit) => new Response(JSON.stringify(document), { status: 201 })); + vi.stubGlobal("fetch", request); + const file = new File(["conteudo"], "Especificação final.pdf", { type: "text/plain" }); + await uploadDocument("p-1", file); + const [url, init] = request.mock.calls[0]; + expect(url).toBe("/api/v1/projects/p-1/documents"); + expect(init?.method).toBe("POST"); + expect(init?.body).toBe(file); + const headers = init?.headers as Record; + expect(headers["Content-Type"]).toBe("application/octet-stream"); + expect(decodeURIComponent(headers["X-File-Name"])).toBe("Especificação final.pdf"); +}); + +it("remove com DELETE no documento do projeto", async () => { + const request = vi.fn(async (_url: RequestInfo | URL, _init?: RequestInit) => new Response(null, { status: 204 })); + vi.stubGlobal("fetch", request); + await removeDocument("p-1", "d-1"); + expect(request.mock.calls[0][0]).toBe("/api/v1/projects/p-1/documents/d-1"); + expect(request.mock.calls[0][1]?.method).toBe("DELETE"); +}); + +it("valida a seleção com os limites informados pelo backend", () => { + expect(validateSelection(new File(["a"], "ok.md"), limits)).toBeNull(); + expect(validateSelection(new File(["a"], "app.exe"), limits)).toMatch(/Formato não suportado/); + expect(validateSelection(new File([], "vazio.txt"), limits)).toBe("O arquivo está vazio."); + expect(validateSelection(new File([new Uint8Array(limits.max_bytes + 1)], "grande.txt"), limits)).toMatch(/excede o limite de 1,0 MB/); +}); + +it("formata tamanhos e extensões", () => { + expect(formatBytes(512)).toBe("512 B"); + expect(formatBytes(2048)).toBe("2,0 KB"); + expect(formatBytes(5 * 1024 * 1024)).toBe("5,0 MB"); + expect(fileExtension("Arquivo.Final.PDF")).toBe(".pdf"); + expect(fileExtension("semextensao")).toBe(""); +}); + +it("traduz erros de envio e remoção em mensagens humanas", () => { + expect(describeUploadError(new ApiError(413, { error: "O arquivo excede o limite de 20 MB." }))).toBe("O arquivo excede o limite de 20 MB."); + expect(describeUploadError(new ApiError(403))).toMatch(/perfil não permite/); + expect(describeUploadError(new ApiError(401))).toMatch(/sessão expirou/); + expect(describeUploadError(new TypeError("Failed to fetch"))).toMatch(/seleção foi mantida/); + expect(describeRemovalError(new ApiError(500, { error: "Não foi possível remover o documento. Ele continua disponível; tente novamente." }))).toMatch(/continua disponível/); + expect(describeRemovalError(new Error("x"))).toMatch(/continua disponível/); +}); diff --git a/frontend/src/api/api_documents.ts b/frontend/src/api/api_documents.ts new file mode 100644 index 0000000..f16a253 --- /dev/null +++ b/frontend/src/api/api_documents.ts @@ -0,0 +1,154 @@ +import { ApiError, apiRequest } from "./api_auth"; +import { serverMessage } from "./api_errors"; + +export const DOCUMENT_STATUSES = ["pendente", "processando", "processado", "falha"] as const; +export type DocumentStatus = (typeof DOCUMENT_STATUSES)[number]; + +export interface ProjectDocument { + id: string; + projeto_id: string; + nome: string; + extensao: string | null; + mime: string | null; + tamanho_bytes: number | null; + status_processamento: DocumentStatus; + armazenamento_pendente: boolean; + autor_id: string | null; + autor_nome: string | null; + created_at: string; + updated_at: string; +} + +export interface DocumentLimits { + max_bytes: number; + extensoes_permitidas: string[]; +} + +export interface DocumentList { + items: ProjectDocument[]; + limites: DocumentLimits; + next_cursor: string | null; +} + +const UPLOAD_TIMEOUT_MS = 120_000; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function parseDocument(value: unknown): ProjectDocument { + if (!isRecord(value) + || typeof value.id !== "string" + || typeof value.projeto_id !== "string" + || typeof value.nome !== "string" + || typeof value.created_at !== "string" + || !(DOCUMENT_STATUSES as readonly string[]).includes(String(value.status_processamento))) { + throw new Error("Resposta de documento inválida"); + } + const size = value.tamanho_bytes; + return { + id: value.id, + projeto_id: value.projeto_id, + nome: value.nome, + extensao: typeof value.extensao === "string" ? value.extensao : null, + mime: typeof value.mime === "string" ? value.mime : null, + tamanho_bytes: typeof size === "number" ? size : null, + status_processamento: value.status_processamento as DocumentStatus, + armazenamento_pendente: value.armazenamento_pendente === true, + autor_id: typeof value.autor_id === "string" ? value.autor_id : null, + autor_nome: typeof value.autor_nome === "string" ? value.autor_nome : null, + created_at: value.created_at, + updated_at: typeof value.updated_at === "string" ? value.updated_at : value.created_at, + }; +} + +function parseLimits(value: unknown): DocumentLimits { + if (!isRecord(value) + || typeof value.max_bytes !== "number" + || !Array.isArray(value.extensoes_permitidas) + || !value.extensoes_permitidas.every(item => typeof item === "string")) { + throw new Error("Limites de envio inválidos"); + } + return { max_bytes: value.max_bytes, extensoes_permitidas: value.extensoes_permitidas as string[] }; +} + +export async function listDocuments( + projectId: string, + options: { cursor?: string; signal?: AbortSignal } = {}, +): Promise { + const params = new URLSearchParams(); + if (options.cursor) params.set("cursor", options.cursor); + const query = params.size ? `?${params.toString()}` : ""; + const response = await apiRequest(`/projects/${encodeURIComponent(projectId)}/documents${query}`, { signal: options.signal }); + const data: unknown = await response.json(); + if (!isRecord(data) || !Array.isArray(data.items) + || !(data.next_cursor === null || typeof data.next_cursor === "string")) { + throw new Error("Lista de documentos inválida"); + } + return { items: data.items.map(parseDocument), limites: parseLimits(data.limites), next_cursor: data.next_cursor }; +} + +export async function uploadDocument(projectId: string, file: File, signal?: AbortSignal): Promise { + const response = await apiRequest(`/projects/${encodeURIComponent(projectId)}/documents`, { + method: "POST", + headers: { + "Content-Type": "application/octet-stream", + "X-File-Name": encodeURIComponent(file.name), + }, + body: file, + signal: signal ?? AbortSignal.timeout(UPLOAD_TIMEOUT_MS), + }); + return parseDocument(await response.json()); +} + +export async function removeDocument(projectId: string, documentId: string, signal?: AbortSignal): Promise { + await apiRequest(`/projects/${encodeURIComponent(projectId)}/documents/${encodeURIComponent(documentId)}`, { + method: "DELETE", + signal, + }); +} + +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1).replace(".", ",")} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1).replace(".", ",")} MB`; +} + +export function fileExtension(name: string): string { + const index = name.lastIndexOf("."); + return index <= 0 ? "" : name.slice(index).toLowerCase(); +} + +export function validateSelection(file: File, limits: DocumentLimits): string | null { + if (!limits.extensoes_permitidas.includes(fileExtension(file.name))) { + const formats = limits.extensoes_permitidas.map(item => item.slice(1).toUpperCase()).join(", "); + return `Formato não suportado. Envie arquivos ${formats}.`; + } + if (file.size === 0) return "O arquivo está vazio."; + if (file.size > limits.max_bytes) { + return `O arquivo tem ${formatBytes(file.size)} e excede o limite de ${formatBytes(limits.max_bytes)}.`; + } + return null; +} + +export function describeUploadError(error: unknown): string { + if (error instanceof ApiError) { + const message = serverMessage(error); + if (error.status === 401) return "Sua sessão expirou. Entre novamente para enviar o documento."; + if (error.status === 403) return "Seu perfil não permite enviar documentos neste projeto."; + if (error.status === 404) return "Projeto não encontrado. Volte à lista de projetos e tente novamente."; + if ((error.status === 400 || error.status === 409 || error.status === 413 || error.status === 503) && message) return message; + } + return "Não foi possível enviar o arquivo. Sua seleção foi mantida; tente novamente."; +} + +export function describeRemovalError(error: unknown): string { + if (error instanceof ApiError) { + const message = serverMessage(error); + if (error.status === 403) return "Seu perfil não permite remover documentos."; + if (error.status === 404) return "Projeto não encontrado. Atualize a página e tente novamente."; + if (error.status === 409) return message ?? "O projeto está arquivado e os documentos ficam somente para consulta."; + if ((error.status === 500 || error.status === 503) && message) return message; + } + return "Não foi possível remover o documento. Ele continua disponível; tente novamente."; +} diff --git a/frontend/src/api/api_errors.ts b/frontend/src/api/api_errors.ts new file mode 100644 index 0000000..d12e01a --- /dev/null +++ b/frontend/src/api/api_errors.ts @@ -0,0 +1,17 @@ +import { ApiError } from "./api_auth"; + +export function serverMessage(error: unknown): string | undefined { + if (!(error instanceof ApiError)) return undefined; + const details = error.details; + if (!details || typeof details !== "object") return undefined; + const message = (details as { error?: unknown }).error; + return typeof message === "string" && message.trim() ? message : undefined; +} + +export function isTimeout(error: unknown): boolean { + return error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError"); +} + +export function isNetworkFailure(error: unknown): boolean { + return error instanceof TypeError; +} diff --git a/frontend/src/assets/styles/chat.css b/frontend/src/assets/styles/chat.css new file mode 100644 index 0000000..5d2d11f --- /dev/null +++ b/frontend/src/assets/styles/chat.css @@ -0,0 +1,48 @@ +.chatx-layout { display: grid; grid-template-columns: minmax(250px, 300px) minmax(0, 1fr); gap: 20px; margin-top: 16px; align-items: stretch; } +.chat-sidebar { display: flex; flex-direction: column; gap: 16px; padding: 16px; min-width: 0; } +.chat-conversations { display: grid; gap: 8px; align-content: start; overflow-y: auto; max-height: 460px; border-top: 1px solid var(--line); padding-top: 12px; } +.chat-conversations ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; } +.chat-conversation { width: 100%; display: grid; gap: 2px; padding: 10px 12px; text-align: left; color: var(--text); background: transparent; border: 1px solid transparent; border-radius: 8px; transition: background .15s ease, border-color .15s ease; } +.chat-conversation:hover { background: var(--card); } +.chat-conversation[aria-current="true"] { background: color-mix(in srgb, var(--orange) 14%, transparent); border-color: var(--orange); } +.chat-conversation-title { font-size: 13.5px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.chat-conversation-meta { font-size: 12px; color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.chat-thread { display: flex; flex-direction: column; gap: 14px; padding: 18px; min-width: 0; min-height: 560px; } +.chat-thread-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; border-bottom: 1px solid var(--line); padding-bottom: 12px; } +.chat-thread-header h2 { margin: 0; font-size: 16px; overflow-wrap: anywhere; } +.chat-messages { flex: 1; display: flex; flex-direction: column; gap: 14px; min-height: 320px; max-height: 520px; overflow-y: auto; padding: 4px 6px 4px 0; } +.chat-messages:focus-visible { outline: 2px solid var(--orange); outline-offset: 2px; border-radius: 8px; } + +.chatx-welcome { margin: auto; max-width: 560px; text-align: center; display: grid; gap: 10px; color: var(--muted); } +.chatx-welcome h3 { margin: 0; color: var(--text); font-size: 18px; } +.chat-suggestions { display: grid; gap: 8px; margin-top: 6px; } +.chat-suggestions button { padding: 10px 14px; text-align: left; color: var(--text); background: var(--bg); border: 1px solid var(--line); border-radius: 10px; transition: border-color .15s ease, background .15s ease; } +.chat-suggestions button:hover { border-color: var(--orange); background: var(--card); } + +.chat-message { display: grid; gap: 8px; max-width: min(760px, 88%); padding: 12px 16px; border-radius: 12px; border: 1px solid var(--line); background: color-mix(in srgb, var(--card) 70%, transparent); } +.chat-message header { display: flex; align-items: center; gap: 10px; font-size: 12px; } +.chat-message header b { color: var(--orange); font-weight: 700; } +.chat-message header time { color: var(--dim); } +.chat-message--user { align-self: flex-end; background: color-mix(in srgb, var(--orange) 16%, var(--bg)); border-color: color-mix(in srgb, var(--orange) 45%, transparent); } +.chat-message--user header b { color: #fff; } +.chat-message--assistant { align-self: flex-start; } +.chat-message.is-failed { border-style: dashed; border-color: var(--red); } +.chat-message-failed { margin: 0; color: var(--red); font-size: 12px; font-weight: 600; } +.chat-message-text { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.6; } +.chat-message-body { font-size: 14px; } +.chat-sources { font-size: 12.5px; border-top: 1px solid var(--line); padding-top: 8px; } +.chat-sources summary { cursor: pointer; color: var(--muted); } +.chat-sources ul { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 6px; } +.chat-message-actions { display: flex; justify-content: flex-end; } +.chat-typing { align-self: flex-start; padding: 8px 14px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); font-size: 13px; } + +.chat-composer { display: grid; gap: 8px; } +.chat-composer textarea { resize: vertical; min-height: 76px; max-height: 220px; } +.chat-composer-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } + +@media (max-width: 900px) { + .chatx-layout { grid-template-columns: 1fr; } + .chat-conversations { max-height: 200px; } + .chat-message { max-width: 100%; } +} diff --git a/frontend/src/assets/styles/garakis-prototype.css b/frontend/src/assets/styles/garakis-prototype.css index 897821c..03bbe5b 100644 --- a/frontend/src/assets/styles/garakis-prototype.css +++ b/frontend/src/assets/styles/garakis-prototype.css @@ -600,3 +600,47 @@ h2 { padding-bottom: 16px; } } + +/* Responsivo: cabeçalho e abas não podem transbordar em telas estreitas */ +@media (max-width: 820px) { + .top-header { + height: auto; + min-height: 60px; + flex-wrap: wrap; + gap: 8px 16px; + padding: 10px 16px; + } + + .top-header .nav-links { + order: 3; + flex: 1 1 100%; + overflow-x: auto; + scrollbar-width: none; + padding-bottom: 2px; + } + + .top-header .nav-links button { + white-space: nowrap; + flex: 0 0 auto; + } + + .top-header > div:last-child { + margin-left: auto; + } + + .user-menu-btn { + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + } + + .project-tabs-garakis { + overflow-x: auto; + scrollbar-width: thin; + } + + .project-tabs-garakis button { + flex: 0 0 auto; + white-space: nowrap; + } +} diff --git a/frontend/src/assets/styles/repo-analyzer.css b/frontend/src/assets/styles/repo-analyzer.css new file mode 100644 index 0000000..9062655 --- /dev/null +++ b/frontend/src/assets/styles/repo-analyzer.css @@ -0,0 +1,72 @@ +.repo-analyzer { display: grid; gap: 20px; } +.repo-analyzer-head h2 { margin: 6px 0 8px; font-size: 22px; } +.repo-analyzer-head p { max-width: 70ch; } +.repo-analyzer-form { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 12px; padding: 20px; } +.repo-analyzer-form .ds-field { flex: 1 1 320px; min-width: 0; } +.repo-analyzer-form .ds-button { margin-top: 28px; } +.repo-analyzer-state { padding: 32px 24px; display: grid; justify-items: center; gap: 12px; text-align: center; } +.repo-analyzer-layout { display: grid; grid-template-columns: minmax(250px, 330px) minmax(0, 1fr); gap: 20px; align-items: start; } +.analysis-history, .analysis-details { padding: 18px; display: grid; gap: 16px; align-content: start; min-width: 0; } +.analysis-history-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--line); padding-bottom: 10px; } +.analysis-history-header h3 { margin: 0; font-size: 15px; } +.analysis-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; max-height: 560px; overflow-y: auto; } +.analysis-item { width: 100%; display: grid; gap: 8px; padding: 12px; text-align: left; color: var(--text); background: var(--bg); border: 1px solid var(--line); border-radius: var(--r); transition: border-color .15s ease, background .15s ease; } +.analysis-item:hover { border-color: var(--strong); background: var(--card); } +.analysis-item[aria-current="true"] { border-color: var(--orange); background: var(--card); } +.analysis-item-url { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.analysis-item-meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; } +.analysis-item-meta time { font-size: 12px; color: var(--dim); } +.analysis-details-header { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: 12px; } +.analysis-details-header h3 { margin: 0 0 4px; font-size: 18px; overflow-wrap: anywhere; } +.analysis-details-header a { color: var(--text); text-decoration: none; border-bottom: 1px dashed var(--strong); } +.analysis-details-header a:hover { color: var(--orange); border-color: var(--orange); } + +.analysis-stepper { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px; counter-reset: step; } +.analysis-stepper li { display: grid; justify-items: center; gap: 6px; text-align: center; position: relative; color: var(--dim); font-size: 12px; } +.analysis-stepper li:not(:last-child)::after { content: ""; position: absolute; top: 14px; left: calc(50% + 18px); right: calc(-50% + 18px); height: 2px; background: var(--line); } +.analysis-stepper li[data-state="done"]:not(:last-child)::after { background: var(--orange); } +.analysis-step-marker { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 50%; border: 2px solid var(--strong); background: var(--bg); font-size: 12px; font-weight: 700; position: relative; z-index: 1; } +.analysis-stepper li[data-state="done"] { color: var(--muted); } +.analysis-stepper li[data-state="done"] .analysis-step-marker { background: var(--orange); border-color: var(--orange); color: #fff; } +.analysis-stepper li[data-state="current"] { color: var(--text); font-weight: 600; } +.analysis-stepper li[data-state="current"] .analysis-step-marker { border-color: var(--orange); color: var(--orange); box-shadow: 0 0 0 4px color-mix(in srgb, var(--orange) 22%, transparent); } +.analysis-stepper li[data-state="failed"] { color: var(--red); font-weight: 600; } +.analysis-stepper li[data-state="failed"] .analysis-step-marker { border-color: var(--red); background: color-mix(in srgb, var(--red) 16%, transparent); color: var(--red); } + +.analysis-progress { display: grid; gap: 8px; padding: 14px; background: var(--bg); border: 1px solid var(--line); border-radius: var(--r); } +.analysis-progress-line { display: flex; justify-content: space-between; gap: 12px; font-size: 13px; font-weight: 600; } +.analysis-languages { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; } +.analysis-languages li { padding: 3px 10px; border: 1px solid var(--line); border-radius: 999px; font-size: 12px; color: var(--muted); } +.analysis-languages b { color: var(--text); font-weight: 600; } + +.analysis-report { display: grid; gap: 12px; } +.analysis-report-toolbar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; border-top: 1px solid var(--line); padding-top: 16px; } +.analysis-report-toolbar h4 { margin: 0; font-size: 15px; } +.analysis-report-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; } +.analysis-toggle { display: inline-flex; border: 1px solid var(--line); border-radius: 999px; overflow: hidden; } +.analysis-toggle button { padding: 5px 14px; background: transparent; border: 0; color: var(--muted); font-size: 13px; } +.analysis-toggle button[aria-pressed="true"] { background: var(--orange-bg); color: #fff; } +.analysis-report-body, .analysis-report-raw { max-height: 520px; overflow: auto; padding: 18px; background: var(--bg); border: 1px solid var(--line); border-radius: var(--r); } +.analysis-report-raw { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font: 13px/1.6 var(--font-mono); } + +.markdown { color: var(--text); font-size: 14px; line-height: 1.65; overflow-wrap: anywhere; } +.markdown > * + * { margin-top: 12px; } +.markdown h2, .markdown h3, .markdown h4, .markdown h5 { margin: 18px 0 6px; color: #fff; } +.markdown h2 { font-size: 18px; border-bottom: 1px solid var(--line); padding-bottom: 6px; } +.markdown h3 { font-size: 16px; } +.markdown h4, .markdown h5 { font-size: 14px; } +.markdown ul, .markdown ol { padding-left: 22px; display: grid; gap: 4px; } +.markdown code { padding: 1px 6px; border-radius: 6px; background: var(--card); font: 12.5px var(--font-mono); } +.markdown pre { margin: 0; padding: 14px; overflow: auto; border: 1px solid var(--line); border-radius: var(--r); background: var(--card); } +.markdown pre code { padding: 0; background: transparent; } +.markdown blockquote { padding: 4px 14px; border-left: 3px solid var(--orange); color: var(--muted); } +.markdown a { color: var(--orange); } +.markdown hr { border: 0; border-top: 1px solid var(--line); } + +@media (max-width: 900px) { + .repo-analyzer-layout { grid-template-columns: 1fr; } + .repo-analyzer-form .ds-button { margin-top: 0; width: 100%; } + .analysis-stepper { grid-template-columns: 1fr; } + .analysis-stepper li { grid-template-columns: 28px 1fr; justify-items: start; text-align: left; align-items: center; column-gap: 10px; } + .analysis-stepper li:not(:last-child)::after { display: none; } +} diff --git a/frontend/src/components/ui/index.tsx b/frontend/src/components/ui/index.tsx index 7bf68c0..6bd16c3 100644 --- a/frontend/src/components/ui/index.tsx +++ b/frontend/src/components/ui/index.tsx @@ -1 +1,10 @@ -export { Button, Badge, Progress, Field, AISuggestion } from "../../views/common/ui/index"; +export { + Alert, + Badge, + Button, + EmptyState, + Progress, + Field, + AISuggestion, + type BadgeTone, +} from "../../views/common/ui/index"; diff --git a/frontend/src/index.css b/frontend/src/index.css index 2dbc443..4561ab5 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,39 +1,5 @@ -:root { - --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - --font-mono: 'JetBrains Mono', monospace; - - /* PRO4TECH: off-black foundation and orange action color. */ - --bg-primary: #0b0d10; - --bg-secondary: #111318; - --bg-tertiary: #171a21; - --bg-card: rgba(23, 26, 33, .92); - --bg-card-hover: #20242d; - - --border-subtle: rgba(255, 255, 255, 0.08); - --border-active: rgba(237, 106, 50, .55); - - --text-primary: #f8fafc; - --text-secondary: #94a3b8; - --text-muted: #64748b; - - --accent-primary: #ed6a32; - --accent-glow: rgba(237, 106, 50, .28); - --accent-secondary: #f1885b; - --accent-emerald: #22b458; - --accent-amber: #c88b00; - - --radius-sm: 8px; - --radius-md: 12px; - --radius-lg: 16px; - --radius-full: 9999px; - - --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.2); - --shadow-md: 0 4px 20px rgba(0, 0, 0, 0.35); - --shadow-glow: 0 0 24px var(--accent-glow); - - --transition-fast: 0.15s ease; - --transition-normal: 0.25s cubic-bezier(0.4, 0, 0.2, 1); -} +@import "./styles/tokens.css"; +@import "./styles/components.css"; .auth-shell { min-height: 100vh; @@ -75,18 +41,21 @@ } .auth-card [role="alert"] { - color: #fca5a5; + color: var(--status-danger); } button:focus-visible, -input:focus-visible { +input:focus-visible, +select:focus-visible, +textarea:focus-visible, +summary:focus-visible { outline: 2px solid var(--accent-secondary); outline-offset: 3px; } button:disabled { - opacity: .6; - cursor: wait; + opacity: .55; + cursor: not-allowed; } @media (max-width: 900px) { @@ -109,10 +78,6 @@ body { line-height: 1.6; -webkit-font-smoothing: antialiased; min-height: 100vh; - background-image: - radial-gradient(circle at 15% 15%, rgba(99, 102, 241, 0.12) 0%, transparent 40%), - radial-gradient(circle at 85% 85%, rgba(6, 182, 212, 0.08) 0%, transparent 45%); - background-attachment: fixed; overflow-x: clip; } @@ -158,7 +123,7 @@ body, gap: 14px; padding: 12px clamp(16px, 3vw, 32px); border-bottom: 1px solid var(--border-subtle); - background: rgba(11, 13, 16, .92); + background: rgba(10, 13, 20, .92); backdrop-filter: blur(14px); } @@ -179,10 +144,47 @@ body, height: 38px; border-radius: var(--radius-md); color: #fff; - background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); + background: var(--brand-primary); box-shadow: 0 0 18px var(--accent-glow); } +.app-brand-text { + min-width: 0; +} + +.app-brand-title { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.app-brand-title h1 { + font-size: 1.25rem; + font-weight: 700; + letter-spacing: -.02em; +} + +.app-brand-text p { + font-size: .8rem; + font-weight: 400; + color: var(--text-muted); +} + +.app-env-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--status-success); +} + +@media (max-width: 1100px) { + .app-env-badge, + .app-brand-text p { + display: none; + } +} + .app-nav { display: flex; flex-wrap: wrap; @@ -190,14 +192,14 @@ body, gap: 3px; padding: 3px; border: 1px solid var(--border-subtle); - border-radius: var(--radius-md); + border-radius: var(--radius-full); background: var(--bg-secondary); } .app-nav button { min-height: 34px; padding: 7px 10px; - border-radius: 8px; + border-radius: var(--radius-full); background: transparent; color: var(--text-secondary); font: 600 .8rem/1 var(--font-sans); @@ -321,8 +323,11 @@ button { display: inline-flex; align-items: center; gap: 6px; - padding: 4px 10px; + padding: 4px 12px; border-radius: var(--radius-full); + border: 1px solid var(--border-strong); + background: transparent; + color: var(--text-secondary); font-size: 0.75rem; font-weight: 600; text-transform: uppercase; @@ -330,60 +335,64 @@ button { } .badge-success { - background: rgba(16, 185, 129, 0.15); - color: #34d399; - border: 1px solid rgba(16, 185, 129, 0.3); + background: color-mix(in srgb, var(--status-success) 12%, transparent); + color: var(--status-success); + border-color: color-mix(in srgb, var(--status-success) 60%, transparent); } .badge-info { - background: rgba(99, 102, 241, 0.15); - color: #818cf8; - border: 1px solid rgba(99, 102, 241, 0.3); + background: transparent; + color: var(--brand-primary); + border-color: var(--brand-primary); } .badge-warning { - background: rgba(245, 158, 11, 0.15); - color: #fbbf24; - border: 1px solid rgba(245, 158, 11, 0.3); + background: color-mix(in srgb, var(--status-warning) 12%, transparent); + color: var(--status-warning); + border-color: color-mix(in srgb, var(--status-warning) 60%, transparent); +} + +.badge-error { + background: color-mix(in srgb, var(--status-danger-strong) 12%, transparent); + color: var(--status-danger); + border-color: color-mix(in srgb, var(--status-danger-strong) 60%, transparent); } /* Buttons */ .btn-primary { - background: linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-secondary) 100%); - color: #fff; - padding: 10px 20px; - border-radius: var(--radius-sm); - font-weight: 600; + background: var(--brand-primary); + color: var(--text-primary); + padding: 10px 24px; + border-radius: var(--radius-full); + font-weight: 500; display: inline-flex; align-items: center; justify-content: center; gap: 8px; - box-shadow: var(--shadow-sm); - transition: all var(--transition-normal); + transition: background var(--transition-normal), box-shadow var(--transition-normal); } -.btn-primary:hover { - background: linear-gradient(135deg, var(--accent-secondary) 0%, var(--accent-primary) 100%); +.btn-primary:hover:not(:disabled) { + background: var(--brand-primary-hover); box-shadow: var(--shadow-glow); - transform: translateY(-1px); } .btn-secondary { background: var(--bg-tertiary); color: var(--text-secondary); border: 1px solid var(--border-subtle); - padding: 10px 18px; - border-radius: var(--radius-sm); + padding: 10px 20px; + border-radius: var(--radius-full); font-weight: 500; display: inline-flex; align-items: center; justify-content: center; gap: 8px; - transition: all var(--transition-normal); + transition: background var(--transition-normal), color var(--transition-normal), border-color var(--transition-normal); } -.btn-secondary:hover { +.btn-secondary:hover:not(:disabled) { background: var(--bg-card-hover); color: var(--text-primary); border-color: var(--border-active); -} \ No newline at end of file +} diff --git a/frontend/src/models/chat.test.ts b/frontend/src/models/chat.test.ts new file mode 100644 index 0000000..9fc00fd --- /dev/null +++ b/frontend/src/models/chat.test.ts @@ -0,0 +1,29 @@ +import { expect, it } from "vitest"; +import { ORIGIN_BADGE, formatClock, formatRelative, remainingCharacters } from "./chat"; + +const NOW = Date.parse("2026-09-25T12:00:00Z"); + +it("formata tempo relativo em português", () => { + expect(formatRelative("2026-09-25T11:59:40Z", NOW)).toBe("agora"); + expect(formatRelative("2026-09-25T11:30:00Z", NOW)).toBe("há 30 min"); + expect(formatRelative("2026-09-25T09:00:00Z", NOW)).toBe("há 3 h"); + expect(formatRelative("2026-09-24T09:00:00Z", NOW)).toBe("ontem"); + expect(formatRelative("2026-09-22T09:00:00Z", NOW)).toBe("há 3 dias"); + expect(formatRelative("2026-01-05T09:00:00Z", NOW)).toMatch(/\d{2}\/\d{2}\/2026/); + expect(formatRelative("", NOW)).toBe(""); + expect(formatRelative("invalida", NOW)).toBe(""); +}); + +it("formata horário e ignora datas inválidas", () => { + expect(formatClock("2026-09-25T12:34:00Z")).toMatch(/\d{2}:\d{2}/); + expect(formatClock(null)).toBe(""); + expect(formatClock("x")).toBe(""); +}); + +it("selo de origem e contagem de caracteres", () => { + expect(ORIGIN_BADGE.assistente).toBeNull(); + expect(ORIGIN_BADGE.busca_textual).toBe("Busca textual"); + expect(ORIGIN_BADGE.sem_resultado).toBeNull(); + expect(remainingCharacters("abc", 10)).toBe(7); + expect(remainingCharacters("abcd", 3)).toBe(-1); +}); diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts new file mode 100644 index 0000000..adadd6c --- /dev/null +++ b/frontend/src/models/chat.ts @@ -0,0 +1,38 @@ +import type { ChatOrigin } from "../api/api_chat"; + +export function formatRelative(value: string | null | undefined, now: number = Date.now()): string { + if (!value) return ""; + const time = Date.parse(value); + if (Number.isNaN(time)) return ""; + const minutes = Math.floor((now - time) / 60000); + if (minutes < 1) return "agora"; + if (minutes < 60) return `há ${minutes} min`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `há ${hours} h`; + const days = Math.floor(hours / 24); + if (days === 1) return "ontem"; + if (days < 7) return `há ${days} dias`; + return new Date(time).toLocaleDateString("pt-BR"); +} + +export function formatClock(value: string | null | undefined): string { + if (!value) return ""; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? "" : date.toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" }); +} + +export const ORIGIN_BADGE: Record = { + assistente: null, + busca_textual: "Busca textual", + sem_resultado: null, +}; + +export const SUGGESTED_PROMPTS: readonly string[] = [ + "Quais decisões já foram tomadas neste projeto?", + "Resuma o escopo e os critérios de aceitação principais.", + "Que riscos ou dependências aparecem na documentação?", +]; + +export function remainingCharacters(text: string, max: number): number { + return max - text.length; +} diff --git a/frontend/src/models/repoAnalyzer.test.ts b/frontend/src/models/repoAnalyzer.test.ts new file mode 100644 index 0000000..11503b4 --- /dev/null +++ b/frontend/src/models/repoAnalyzer.test.ts @@ -0,0 +1,47 @@ +import { expect, it } from "vitest"; +import { + analysisDuration, + formatDuration, + readStats, + repositoryLabel, + stageStates, + validateRepositoryUrl, +} from "./repoAnalyzer"; + +it("marca etapas concluídas, atual e pendentes conforme a etapa informada pelo analisador", () => { + expect(stageStates({ status: "em_execucao", etapa: "scan" })).toEqual(["done", "done", "current", "pending", "pending"]); + expect(stageStates({ status: "iniciado", etapa: "queued" })).toEqual(["current", "pending", "pending", "pending", "pending"]); + expect(stageStates({ status: "concluido", etapa: "done" })).toEqual(["done", "done", "done", "done", "done"]); + expect(stageStates({ status: "falha", etapa: "files" })).toEqual(["done", "done", "done", "failed", "pending"]); +}); + +it("valida somente URLs de repositório público do GitHub", () => { + expect(validateRepositoryUrl("")).toBe("Informe a URL do repositório."); + expect(validateRepositoryUrl("https://gitlab.com/a/b")).toMatch(/Use o formato/); + expect(validateRepositoryUrl("https://github.com/acme")).toMatch(/Use o formato/); + expect(validateRepositoryUrl(" https://github.com/acme/api/ ")).toBe(""); +}); + +it("formata duração e nome do repositório", () => { + expect(formatDuration(42)).toBe("42 s"); + expect(formatDuration(125)).toBe("2 min 5 s"); + expect(formatDuration(3720)).toBe("1 h 2 min"); + expect(repositoryLabel("https://github.com/acme/api/")).toBe("acme/api"); +}); + +it("calcula a duração pela conclusão, pela falha ou pelo relógio atual", () => { + const base = { created_at: "2026-09-25T10:00:00Z", updated_at: "2026-09-25T10:03:00Z" }; + expect(analysisDuration({ ...base, status: "concluido", concluido_em: "2026-09-25T10:05:00Z" })).toBe(300); + expect(analysisDuration({ ...base, status: "falha", concluido_em: null })).toBe(180); + expect(analysisDuration({ ...base, status: "em_execucao", concluido_em: null }, Date.parse("2026-09-25T10:01:00Z"))).toBe(60); + expect(analysisDuration({ created_at: "invalida", updated_at: "x", status: "em_execucao", concluido_em: null })).toBeNull(); +}); + +it("lê estatísticas de forma defensiva", () => { + expect(readStats(null)).toEqual({ filesTotal: null, filesProcessed: null, etaSeconds: null, languages: [] }); + const stats = readStats({ files_total: 40, files_processed: 12, eta_seconds: 90, language_counts: { ts: 20, py: 5, md: 0, x: "n" } }); + expect(stats.filesTotal).toBe(40); + expect(stats.filesProcessed).toBe(12); + expect(stats.etaSeconds).toBe(90); + expect(stats.languages).toEqual([{ name: "ts", count: 20 }, { name: "py", count: 5 }]); +}); diff --git a/frontend/src/models/repoAnalyzer.ts b/frontend/src/models/repoAnalyzer.ts new file mode 100644 index 0000000..0ae7b38 --- /dev/null +++ b/frontend/src/models/repoAnalyzer.ts @@ -0,0 +1,99 @@ +import type { RepoAnalysis, RepoAnalysisStatus } from "../projects/repo-analyzer.api"; + +export const ANALYSIS_STAGES: ReadonlyArray<{ key: string; label: string }> = [ + { key: "ollama", label: "Verificar IA local" }, + { key: "clone", label: "Clonar repositório" }, + { key: "scan", label: "Inventariar arquivos" }, + { key: "files", label: "Analisar arquivos" }, + { key: "synthesis", label: "Gerar síntese" }, +]; + +export type StageState = "done" | "current" | "pending" | "failed"; + +export function stageStates(analysis: Pick): StageState[] { + if (analysis.status === "concluido") return ANALYSIS_STAGES.map(() => "done"); + const current = ANALYSIS_STAGES.findIndex((stage) => stage.key === analysis.etapa); + const position = current === -1 ? 0 : current; + return ANALYSIS_STAGES.map((_, index) => { + if (index < position) return "done"; + if (index === position) return analysis.status === "falha" ? "failed" : "current"; + return "pending"; + }); +} + +export const STATUS_VIEW: Record = { + iniciado: { label: "Na fila", tone: "info" }, + em_execucao: { label: "Em execução", tone: "brand" }, + concluido: { label: "Concluída", tone: "success" }, + falha: { label: "Falhou", tone: "danger" }, +}; + +export function isActive(analysis: Pick): boolean { + return analysis.status === "iniciado" || analysis.status === "em_execucao"; +} + +export function repositoryLabel(url: string): string { + return url.replace(/^https?:\/\/(www\.)?github\.com\//i, "").replace(/\/$/, ""); +} + +export function formatDateTime(value: string | null | undefined): string { + if (!value) return "—"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "Data indisponível"; + return date.toLocaleString("pt-BR", { dateStyle: "short", timeStyle: "short" }); +} + +export function formatDuration(seconds: number): string { + const total = Math.max(0, Math.round(seconds)); + if (total < 60) return `${total} s`; + const minutes = Math.floor(total / 60); + const rest = total % 60; + if (minutes < 60) return rest ? `${minutes} min ${rest} s` : `${minutes} min`; + const hours = Math.floor(minutes / 60); + return `${hours} h ${minutes % 60} min`; +} + +export function analysisDuration(analysis: Pick, now: number = Date.now()): number | null { + const start = Date.parse(analysis.created_at); + if (Number.isNaN(start)) return null; + const finishedAt = analysis.concluido_em ? Date.parse(analysis.concluido_em) : analysis.status === "falha" ? Date.parse(analysis.updated_at) : now; + if (Number.isNaN(finishedAt)) return null; + return Math.max(0, (finishedAt - start) / 1000); +} + +export interface AnalysisStats { + filesTotal: number | null; + filesProcessed: number | null; + etaSeconds: number | null; + languages: Array<{ name: string; count: number }>; +} + +function numberOrNull(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +export function readStats(metadata: Record | null | undefined): AnalysisStats { + const languagesRaw = metadata?.language_counts; + const languages = + languagesRaw && typeof languagesRaw === "object" + ? Object.entries(languagesRaw as Record) + .flatMap(([name, count]) => (typeof count === "number" && count > 0 ? [{ name, count }] : [])) + .sort((left, right) => right.count - left.count) + .slice(0, 6) + : []; + return { + filesTotal: numberOrNull(metadata?.files_total), + filesProcessed: numberOrNull(metadata?.files_processed), + etaSeconds: numberOrNull(metadata?.eta_seconds), + languages, + }; +} + +const GITHUB_URL = /^https?:\/\/(www\.)?github\.com\/[\w.-]+\/[\w.-]+\/?$/i; + +export function validateRepositoryUrl(value: string): string { + const url = value.trim(); + if (!url) return "Informe a URL do repositório."; + if (!GITHUB_URL.test(url)) return "Use o formato https://github.com/usuario/repositorio."; + return ""; +} diff --git a/frontend/src/projects/RepoAnalyzerTab.tsx b/frontend/src/projects/RepoAnalyzerTab.tsx deleted file mode 100644 index 36bbb6a..0000000 --- a/frontend/src/projects/RepoAnalyzerTab.tsx +++ /dev/null @@ -1 +0,0 @@ -export { RepoAnalyzerView as RepoAnalyzerTab } from "../views/projects/RepoAnalyzerView"; diff --git a/frontend/src/projects/documents.css b/frontend/src/projects/documents.css new file mode 100644 index 0000000..181f084 --- /dev/null +++ b/frontend/src/projects/documents.css @@ -0,0 +1,33 @@ +.documents-tab { display: grid; gap: var(--space-6); } +.documents-upload { padding: var(--space-5); display: grid; gap: var(--space-4); } +.documents-dropzone { display: grid; justify-items: center; gap: var(--space-2); padding: var(--space-8) var(--space-6); text-align: center; border: 1px dashed var(--border-strong); border-radius: var(--radius-md); background: var(--bg-secondary); transition: border-color var(--transition-fast), background var(--transition-fast); } +.documents-dropzone.is-dragging { border-color: var(--brand-primary); background: color-mix(in srgb, var(--brand-primary) 8%, var(--bg-secondary)); } +.documents-dropzone h3 { font-size: 1.05rem; font-weight: 600; } +.documents-dropzone p { color: var(--text-secondary); font-size: .9rem; } +.documents-dropzone .ds-button { margin-top: var(--space-2); } +.documents-selection { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-3); padding: var(--space-3) var(--space-4); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--bg-tertiary); } +.documents-selection-info { display: grid; gap: 2px; min-width: 0; } +.documents-selection-info strong { overflow-wrap: anywhere; } +.documents-selection-info span { color: var(--text-muted); font-size: .8rem; } +.documents-selection-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); } +.documents-list { padding: var(--space-5); display: grid; gap: var(--space-4); } +.documents-list-header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); border-bottom: 1px solid var(--border-subtle); padding-bottom: var(--space-3); } +.documents-list-header h3 { font-size: 1.05rem; font-weight: 600; } +.documents-list-header h3:focus-visible { outline: 2px solid var(--accent-secondary); outline-offset: 4px; } +.documents-table { width: 100%; border-collapse: collapse; font-size: .88rem; } +.documents-table th { text-align: left; padding: var(--space-2) var(--space-3); color: var(--text-muted); font-size: .75rem; font-weight: 600; text-transform: uppercase; letter-spacing: .05em; border-bottom: 1px solid var(--border-subtle); } +.documents-table td { padding: var(--space-3); border-bottom: 1px solid var(--border-subtle); vertical-align: middle; color: var(--text-secondary); } +.documents-table tbody tr:hover td { background: var(--bg-card-hover); } +.documents-table .documents-name { color: var(--text-primary); font-weight: 500; overflow-wrap: anywhere; } + +@media (max-width: 800px) { + .documents-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); } + .documents-table, .documents-table tbody, .documents-table tr, .documents-table td { display: block; width: 100%; } + .documents-table tr { padding: var(--space-3); margin-bottom: var(--space-3); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--bg-secondary); } + .documents-table td { display: flex; justify-content: space-between; gap: var(--space-4); padding: var(--space-1) 0; border: 0; text-align: right; } + .documents-table td::before { content: attr(data-label); color: var(--text-muted); font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; text-align: left; } + .documents-table tbody tr:hover td { background: transparent; } +} + +.documents-head { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: 12px; } +.documents-head h2 { margin: 0 0 4px; font-size: 20px; } diff --git a/frontend/src/projects/repo-analyzer.api.ts b/frontend/src/projects/repo-analyzer.api.ts index 442faae..bbc3279 100644 --- a/frontend/src/projects/repo-analyzer.api.ts +++ b/frontend/src/projects/repo-analyzer.api.ts @@ -1,23 +1,25 @@ import { apiRequest } from "../api/api_auth"; +export type RepoAnalysisStatus = "iniciado" | "em_execucao" | "concluido" | "falha"; + export interface RepoAnalysis { id: string; projeto_id: string; repositorio_url: string; - run_id: string; - status: "iniciado" | "em_execucao" | "concluido" | "falha"; - etapa: string; - etapa_label: string; - progresso: number; - mensagem?: string; - erro?: string; - relatorio_markdown?: string; - metadados?: Record; + run_id: string | null; + status: RepoAnalysisStatus; + etapa: string | null; + etapa_label: string | null; + progresso: number | null; + mensagem?: string | null; + erro?: string | null; + relatorio_markdown?: string | null; + metadados?: Record | null; created_at: string; updated_at: string; - concluido_em?: string; - autor_nome?: string; - autor_email?: string; + concluido_em?: string | null; + autor_nome?: string | null; + autor_email?: string | null; } export async function startRepoAnalysis(projectId: string, repositoryUrl: string, signal?: AbortSignal): Promise { @@ -34,9 +36,9 @@ export async function listRepoAnalyses(projectId: string, signal?: AbortSignal): method: "GET", signal, }); - const data = await response.json(); + const data: unknown = await response.json(); if (!Array.isArray(data)) throw new Error("Lista de análises de repositório inválida"); - return data; + return data as RepoAnalysis[]; } export async function getRepoAnalysis(projectId: string, analysisId: string, signal?: AbortSignal): Promise { diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index e92b47f..a9cb600 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -1,91 +1,94 @@ -@import "./tokens.css"; - .ds-button { min-height: 40px; - padding: 0 var(--ds-space-4); - border-radius: var(--ds-radius-sm); + padding: 0 var(--space-6); + border-radius: var(--radius-full); border: 1px solid transparent; - font: 600 14px/20px var(--ds-font-sans); + font: 500 14px/20px var(--font-sans); display: inline-flex; align-items: center; justify-content: center; - gap: var(--ds-space-2); + gap: var(--space-2); cursor: pointer; - transition: background var(--ds-transition-fast), border-color var(--ds-transition-fast), color var(--ds-transition-fast); -} - -.ds-button:focus-visible, -.ds-input:focus-visible, -.ds-textarea:focus-visible { - outline: 3px solid color-mix(in srgb, var(--ds-focus) 45%, transparent); - outline-offset: 2px; + transition: background var(--transition-normal), border-color var(--transition-normal), color var(--transition-normal); } .ds-button--primary { - background: var(--ds-action); - color: var(--ds-neutral-0); + background: var(--brand-primary); + color: var(--text-primary); } -.ds-button--primary:hover { background: var(--ds-action-hover); } +.ds-button--primary:hover:not(:disabled) { background: var(--brand-primary-hover); } .ds-button--secondary { - background: var(--ds-bg-surface); - color: var(--ds-text); - border-color: var(--ds-border-strong); + background: var(--bg-tertiary); + color: var(--text-primary); + border-color: var(--border-subtle); } -.ds-button--secondary:hover { background: var(--ds-bg-subtle); } +.ds-button--secondary:hover:not(:disabled) { background: var(--bg-card-hover); border-color: var(--border-active); } .ds-button--ghost { background: transparent; - color: var(--ds-text-secondary); + color: var(--text-secondary); } -.ds-button--ghost:hover { background: var(--ds-bg-subtle); color: var(--ds-text); } +.ds-button--ghost:hover:not(:disabled) { background: var(--bg-tertiary); color: var(--text-primary); } .ds-button--danger { - background: var(--ds-danger-fg); - color: var(--ds-neutral-0); + background: transparent; + color: var(--status-danger); + border-color: color-mix(in srgb, var(--status-danger-strong) 55%, transparent); +} +.ds-button--danger:hover:not(:disabled) { background: color-mix(in srgb, var(--status-danger-strong) 16%, transparent); } + +.ds-button--sm { + min-height: 32px; + padding: 0 var(--space-4); + font-size: 13px; } .ds-button:disabled { - opacity: .5; + opacity: 0.5; cursor: not-allowed; } .ds-field { display: grid; - gap: var(--ds-space-2); + gap: var(--space-2); } .ds-label { - color: var(--ds-text); - font: 600 14px/20px var(--ds-font-sans); + color: var(--text-primary); + font: 600 14px/20px var(--font-sans); } .ds-help { - color: var(--ds-text-muted); - font: 400 12px/16px var(--ds-font-sans); + color: var(--text-muted); + font: 400 12px/16px var(--font-sans); +} + +.ds-help--error { + color: var(--status-danger); } .ds-input, .ds-textarea { width: 100%; - border: 1px solid var(--ds-border-strong); - border-radius: var(--ds-radius-sm); - background: var(--ds-bg-surface); - color: var(--ds-text); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + background: var(--bg-secondary); + color: var(--text-primary); padding: 10px 12px; - font: 400 14px/20px var(--ds-font-sans); + font: 400 14px/20px var(--font-sans); } .ds-input[aria-invalid="true"], .ds-textarea[aria-invalid="true"] { - border-color: var(--ds-danger-fg); + border-color: var(--status-danger-strong); } .ds-card { - background: var(--ds-bg-surface); - color: var(--ds-text); - border: 1px solid var(--ds-border); - border-radius: var(--ds-radius-md); - box-shadow: var(--ds-shadow-sm); + background: var(--bg-card); + color: var(--text-primary); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); } .ds-badge { @@ -93,47 +96,94 @@ align-items: center; gap: 6px; min-height: 24px; - padding: 2px 8px; - border-radius: var(--ds-radius-pill); - font: 600 12px/16px var(--ds-font-sans); - border: 1px solid var(--ds-border); - background: var(--ds-bg-subtle); - color: var(--ds-text-secondary); + padding: 2px 12px; + border-radius: var(--radius-full); + font: 600 12px/16px var(--font-sans); + letter-spacing: 0.05em; + text-transform: uppercase; + border: 1px solid var(--border-strong); + background: transparent; + color: var(--text-secondary); } -.ds-badge--must { background: var(--ds-danger-bg); color: var(--ds-danger-fg); border-color: transparent; } -.ds-badge--should { background: var(--ds-warning-bg); color: var(--ds-warning-fg); border-color: transparent; } -.ds-badge--could { background: var(--ds-info-bg); color: var(--ds-info-fg); border-color: transparent; } -.ds-badge--success { background: var(--ds-success-bg); color: var(--ds-success-fg); border-color: transparent; } -.ds-badge--ai { background: var(--ds-ai-bg); color: var(--ds-ai-fg); border-color: transparent; } +.ds-badge--ai { border-color: var(--border-strong); color: var(--text-primary); background: color-mix(in srgb, var(--brand-primary) 14%, transparent); } +.ds-badge--must { border-color: var(--status-danger); color: var(--status-danger); } +.ds-badge--should { border-color: var(--status-warning); color: var(--status-warning); } +.ds-badge--could { border-color: var(--border-strong); color: var(--text-secondary); } +.ds-badge--brand { border-color: var(--brand-primary); color: var(--brand-primary); } +.ds-badge--success { border-color: color-mix(in srgb, var(--status-success) 60%, transparent); color: var(--status-success); background: color-mix(in srgb, var(--status-success) 12%, transparent); } +.ds-badge--warning { border-color: color-mix(in srgb, var(--status-warning) 60%, transparent); color: var(--status-warning); background: color-mix(in srgb, var(--status-warning) 12%, transparent); } +.ds-badge--danger { border-color: color-mix(in srgb, var(--status-danger-strong) 60%, transparent); color: var(--status-danger); background: color-mix(in srgb, var(--status-danger-strong) 12%, transparent); } +.ds-badge--info { border-color: var(--border-strong); color: var(--text-primary); background: var(--bg-tertiary); } .ds-progress { height: 8px; overflow: hidden; - border-radius: var(--ds-radius-pill); - background: var(--ds-neutral-200); + border-radius: var(--radius-full); + background: var(--bg-tertiary); } .ds-progress > span { display: block; height: 100%; border-radius: inherit; - background: var(--ds-action); + background: var(--brand-primary); + transition: width 0.5s ease; +} + +.ds-alert { + display: grid; + gap: var(--space-2); + padding: var(--space-4); + border-radius: var(--radius-md); + border: 1px solid var(--border-subtle); + background: var(--bg-tertiary); + color: var(--text-primary); + font-size: 0.9rem; +} +.ds-alert--info { border-color: var(--border-strong); } +.ds-alert--danger { border-color: color-mix(in srgb, var(--status-danger-strong) 55%, transparent); background: color-mix(in srgb, var(--status-danger-strong) 10%, transparent); } +.ds-alert--danger strong { color: var(--status-danger); } +.ds-alert--warning { border-color: color-mix(in srgb, var(--status-warning) 55%, transparent); background: color-mix(in srgb, var(--status-warning) 10%, transparent); } +.ds-alert--success { border-color: color-mix(in srgb, var(--status-success) 55%, transparent); background: color-mix(in srgb, var(--status-success) 10%, transparent); } +.ds-alert p { color: var(--text-secondary); } + +.ds-empty { + display: grid; + justify-items: center; + gap: var(--space-3); + padding: var(--space-8) var(--space-6); + text-align: center; + border: 1px dashed var(--border-strong); + border-radius: var(--radius-md); } +.ds-empty p { color: var(--text-secondary); max-width: 46ch; } .ds-ai-suggestion { - border: 1px solid color-mix(in srgb, var(--ds-ai-fg) 25%, transparent); - border-left: 4px solid var(--ds-ai-fg); - border-radius: var(--ds-radius-md); - background: var(--ds-ai-bg); - color: var(--ds-neutral-900); - padding: var(--ds-space-4); + border: 1px solid color-mix(in srgb, var(--brand-primary) 35%, transparent); + border-left: 4px solid var(--brand-primary); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--brand-primary) 8%, var(--bg-secondary)); + color: var(--text-primary); + padding: var(--space-4); } .ds-quality-item { display: grid; grid-template-columns: 24px 1fr auto; align-items: start; - gap: var(--ds-space-3); - padding: var(--ds-space-3) 0; - border-bottom: 1px solid var(--ds-border); + gap: var(--space-3); + padding: var(--space-3) 0; + border-bottom: 1px solid var(--border-subtle); +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; } diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index b1bf139..9712dd2 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -1,106 +1,61 @@ :root { - /* PRO4TECH / Sinapse product palette */ - --ds-brand-950: #07111f; - --ds-brand-900: #0b1626; - --ds-brand-800: #102238; - --ds-brand-700: #173653; - --ds-brand-500: #0aa7c8; - --ds-brand-400: #24bedb; - --ds-brand-300: #71d7e8; - --ds-brand-100: #ddf7fb; - - --ds-neutral-0: #ffffff; - --ds-neutral-50: #f8fafc; - --ds-neutral-100: #f1f5f9; - --ds-neutral-200: #e2e8f0; - --ds-neutral-300: #cbd5e1; - --ds-neutral-400: #94a3b8; - --ds-neutral-500: #64748b; - --ds-neutral-600: #475569; - --ds-neutral-700: #334155; - --ds-neutral-800: #1e293b; - --ds-neutral-900: #0f172a; - - --ds-success-bg: #e8f7ef; - --ds-success-fg: #157347; - --ds-info-bg: #e8f6fa; - --ds-info-fg: #087e99; - --ds-warning-bg: #fff7e6; - --ds-warning-fg: #9a6700; - --ds-danger-bg: #fdecec; - --ds-danger-fg: #b42318; - --ds-ai-bg: #eef2ff; - --ds-ai-fg: #4f46e5; - - --ds-bg-app: var(--ds-neutral-50); - --ds-bg-surface: var(--ds-neutral-0); - --ds-bg-subtle: var(--ds-neutral-100); - --ds-border: var(--ds-neutral-200); - --ds-border-strong: var(--ds-neutral-300); - --ds-text: var(--ds-neutral-900); - --ds-text-secondary: var(--ds-neutral-600); - --ds-text-muted: var(--ds-neutral-500); - --ds-action: var(--ds-brand-500); - --ds-action-hover: var(--ds-brand-400); - --ds-focus: var(--ds-brand-300); - - --ds-font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - --ds-font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; - - --ds-space-1: 4px; - --ds-space-2: 8px; - --ds-space-3: 12px; - --ds-space-4: 16px; - --ds-space-5: 20px; - --ds-space-6: 24px; - --ds-space-8: 32px; - --ds-space-10: 40px; - --ds-space-12: 48px; - --ds-space-16: 64px; - - --ds-radius-sm: 8px; - --ds-radius-md: 12px; - --ds-radius-lg: 16px; - --ds-radius-pill: 999px; - - --ds-shadow-sm: 0 1px 2px rgba(15, 23, 42, .06); - --ds-shadow-md: 0 8px 24px rgba(15, 23, 42, .08); - - --ds-transition-fast: 120ms ease; - --ds-transition-normal: 180ms ease; - - /* aliases for the current frontend */ - --bg-primary: var(--ds-bg-app); - --bg-secondary: var(--ds-bg-surface); - --bg-tertiary: var(--ds-bg-subtle); - --bg-card: var(--ds-bg-surface); - --bg-card-hover: var(--ds-neutral-50); - --border-subtle: var(--ds-border); - --border-active: var(--ds-brand-300); - --text-primary: var(--ds-text); - --text-secondary: var(--ds-text-secondary); - --text-muted: var(--ds-text-muted); - --accent-primary: var(--ds-action); - --accent-secondary: var(--ds-brand-400); - --accent-emerald: var(--ds-success-fg); - --accent-amber: var(--ds-warning-fg); - --radius-sm: var(--ds-radius-sm); - --radius-md: var(--ds-radius-md); - --radius-lg: var(--ds-radius-lg); - --radius-full: var(--ds-radius-pill); - --shadow-sm: var(--ds-shadow-sm); - --shadow-md: var(--ds-shadow-md); - --transition-fast: var(--ds-transition-fast); - --transition-normal: var(--ds-transition-normal); -} - -[data-theme="dark"] { - --ds-bg-app: var(--ds-brand-950); - --ds-bg-surface: var(--ds-brand-900); - --ds-bg-subtle: var(--ds-brand-800); - --ds-border: #22364c; - --ds-border-strong: #31506f; - --ds-text: var(--ds-neutral-50); - --ds-text-secondary: var(--ds-neutral-300); - --ds-text-muted: var(--ds-neutral-400); + /* Fonte única de tokens. Os valores de marca e superfície derivam do protótipo + navegável (assets/styles/garakis-prototype.css) para que a tela tenha uma só + cor de marca; os fallbacks seguem o mesmo protótipo. */ + --surface-base: var(--bg, #0b0d10); + --brand-primary: var(--orange, #ed6a32); + --brand-primary-hover: color-mix(in srgb, var(--brand-primary) 82%, #000); + --text-primary: #ffffff; + --text-secondary: var(--muted, #a6aab3); + --text-muted: var(--dim, #8b93a1); + + --step-1: #f28c68; + --step-2: #d97354; + --step-3: #bd5a40; + --step-4: #944230; + --step-5: #6e2d22; + + --font-sans: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + + --radius-full: 9999px; + --radius-md: 8px; + --radius-sm: 6px; + --radius-lg: 12px; + + --bg-primary: var(--surface-base); + --bg-secondary: var(--surface, #111827); + --bg-tertiary: var(--card, #171a21); + --bg-card: color-mix(in srgb, var(--surface, #111827) 94%, transparent); + --bg-card-hover: color-mix(in srgb, var(--card, #171a21) 82%, #fff); + + --border-subtle: var(--line, rgba(196, 199, 204, 0.18)); + --border-strong: var(--strong, rgba(196, 199, 204, 0.3)); + --border-active: color-mix(in srgb, var(--brand-primary) 60%, transparent); + + --accent-primary: var(--brand-primary); + --accent-secondary: color-mix(in srgb, var(--brand-primary) 72%, #fff); + --accent-glow: color-mix(in srgb, var(--brand-primary) 28%, transparent); + + --status-success: var(--green, #22b458); + --status-warning: #fbbf24; + --status-danger: var(--red, #f0635d); + --status-danger-strong: var(--red, #f0635d); + --accent-emerald: var(--status-success); + --accent-amber: var(--status-warning); + + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + + --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.2); + --shadow-md: 0 4px 20px rgba(0, 0, 0, 0.35); + --shadow-glow: 0 0 24px var(--accent-glow); + + --transition-fast: 0.15s ease; + --transition-normal: 0.2s ease-in-out; } diff --git a/frontend/src/test-setup.ts b/frontend/src/test-setup.ts index f149f27..5c1a635 100644 --- a/frontend/src/test-setup.ts +++ b/frontend/src/test-setup.ts @@ -1 +1,19 @@ import "@testing-library/jest-dom/vitest"; + +// Node 26 no Windows não expõe o localStorage do jsdom como global em todas +// as execuções do Vitest. A aplicação usa somente a interface Storage, então +// este fallback mantém o contrato dos testes sem depender do localStorage nativo. +if (typeof globalThis.localStorage === "undefined") { + const values = new Map(); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(String(key), String(value)), + removeItem: (key: string) => values.delete(key), + clear: () => values.clear(), + key: (index: number) => [...values.keys()][index] ?? null, + get length() { return values.size; }, + } satisfies Storage, + }); +} diff --git a/frontend/src/views/auth/AuthView.tsx b/frontend/src/views/auth/AuthView.tsx index 507d18c..67b081c 100644 --- a/frontend/src/views/auth/AuthView.tsx +++ b/frontend/src/views/auth/AuthView.tsx @@ -149,7 +149,7 @@ export function RegisterOnCard({ onSwitch }: { onSwitch: () => void }) { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); - const [role, setRole] = useState<"po" | "dev" | "admin">("po"); + const [role, setRole] = useState<"po" | "dev">("po"); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); @@ -196,10 +196,9 @@ export function RegisterOnCard({ onSwitch }: { onSwitch: () => void }) { setEmail(e.target.value)} disabled={busy} placeholder="seu.email@empresa.com" /> - setRole(e.target.value as "po" | "dev")} disabled={busy}> - diff --git a/frontend/src/views/chat/ChatView.test.tsx b/frontend/src/views/chat/ChatView.test.tsx new file mode 100644 index 0000000..3451140 --- /dev/null +++ b/frontend/src/views/chat/ChatView.test.tsx @@ -0,0 +1,234 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { ChatView } from "./ChatView"; + +const PROJECT = { id: "p-1", nome: "Sinapse", cliente: "C", descricao: "", status: "ativo" }; +const conversation = (overrides: Record = {}) => ({ + id: "c-1", titulo: "Decisões do login", projeto_id: "p-1", projeto_nome: "Sinapse", updated_at: new Date().toISOString(), ...overrides, +}); +const message = (overrides: Record = {}) => ({ + id: "m-1", conversa_id: "c-1", remetente: "assistant", conteudo: "Resposta **importante**", fontes_json: [], created_at: "2026-09-25T10:00:00Z", ...overrides, +}); +const json = (body: unknown, status = 200) => Promise.resolve(new Response(JSON.stringify(body), { status })); + +interface Routes { + conversations?: unknown[] | "error"; + messages?: Record; + query?: (body: Record) => Promise; +} + +function api(routes: Routes = {}) { + return vi.fn((url: RequestInfo | URL, init?: RequestInit) => { + const path = String(url); + if (path.startsWith("/api/v1/projects")) return json({ items: [PROJECT], total: 1, limit: 50, offset: 0 }); + if (path === "/api/v1/chat/conversations") { + return routes.conversations === "error" ? json({ error: "x" }, 500) : json({ items: routes.conversations ?? [], total: 0 }); + } + const messages = path.match(/^\/api\/v1\/chat\/conversations\/([^/]+)\/messages$/); + if (messages) { + const items = routes.messages?.[messages[1]] ?? []; + return items === "error" ? json({ error: "x" }, 500) : json({ items, total: items.length }); + } + if (path === "/api/v1/chat/query") return (routes.query ?? (() => json({ conversa_id: "c-9", resposta: "Ok", fontes: [], origem: "assistente" })))(JSON.parse(String(init?.body))); + return json({}); + }); +} + +afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); + +const composer = () => screen.getByLabelText("Sua pergunta") as HTMLTextAreaElement; +const queryCalls = (request: ReturnType) => + request.mock.calls.filter((call) => String(call[0]) === "/api/v1/chat/query").map((call) => JSON.parse(String((call[1] as RequestInit).body))); + +it("abre a conversa mais recente, renderiza Markdown, fontes e trava o escopo", async () => { + vi.stubGlobal("fetch", api({ + conversations: [conversation()], + messages: { "c-1": [message({ remetente: "user", id: "m-0", conteudo: "Qual a decisão?" }), message({ fontes_json: [{ id: "s1", titulo: "ADR-01", tipo: "decisao" }] })] }, + })); + render(); + + expect(await screen.findByText("importante")).toBeInTheDocument(); + expect(screen.getByText("importante").tagName).toBe("STRONG"); + expect(screen.getByRole("heading", { level: 2, name: "Decisões do login" })).toBeInTheDocument(); + const log = screen.getByRole("log", { name: "Mensagens da conversa" }); + expect(within(log).getAllByRole("article")).toHaveLength(2); + expect(screen.getByText("Fontes citadas (1)")).toBeInTheDocument(); + expect(screen.getByText("ADR-01")).toBeInTheDocument(); + expect(screen.getByLabelText("Escopo da consulta")).toBeDisabled(); + expect(screen.getByRole("button", { name: /Decisões do login/ })).toHaveAttribute("aria-current", "true"); +}); + +it("sem conversas mostra orientação, sugestões preenchem o campo e o escopo é escolhido", async () => { + vi.stubGlobal("fetch", api()); + render(); + + expect(await screen.findByText("Nenhuma conversa ainda. Faça sua primeira pergunta.")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Como posso ajudar?" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /Quais decisões já foram tomadas/ })); + expect(composer().value).toMatch(/Quais decisões já foram tomadas/); + expect(await screen.findByRole("option", { name: "Sinapse" })).toBeInTheDocument(); + expect(screen.getByLabelText("Escopo da consulta")).toBeEnabled(); +}); + +it("Enter envia com o projeto do escopo, cria a conversa lazy e não recarrega as mensagens", async () => { + const request = api({ + query: () => json({ conversa_id: "c-9", resposta: "Uma **resposta**", fontes: [{ id: "s1", titulo: "PBI-1", tipo: "pbi" }], origem: "assistente" }), + }); + vi.stubGlobal("fetch", request); + render(); + await screen.findByRole("option", { name: "Sinapse" }); + + fireEvent.change(screen.getByLabelText("Escopo da consulta"), { target: { value: "p-1" } }); + fireEvent.change(composer(), { target: { value: "Como funciona o login?" } }); + fireEvent.keyDown(composer(), { key: "Enter" }); + + expect(await screen.findByText("resposta")).toBeInTheDocument(); + expect(queryCalls(request)).toEqual([{ pergunta: "Como funciona o login?", projeto_id: "p-1" }]); + expect(screen.getByText("Como funciona o login?")).toBeInTheDocument(); + expect(composer().value).toBe(""); + expect(screen.getByText("Fontes citadas (1)")).toBeInTheDocument(); + expect(request.mock.calls.some((call) => /c-9\/messages/.test(String(call[0])))).toBe(false); + expect(screen.getByLabelText("Escopo da consulta")).toBeDisabled(); +}); + +it("Shift+Enter não envia e o limite de caracteres bloqueia o envio", async () => { + const request = api(); + vi.stubGlobal("fetch", request); + render(); + await screen.findByText(/Nenhuma conversa ainda/); + + fireEvent.change(composer(), { target: { value: "linha 1" } }); + fireEvent.keyDown(composer(), { key: "Enter", shiftKey: true }); + expect(queryCalls(request)).toHaveLength(0); + expect(screen.getByRole("button", { name: "Enviar" })).toBeEnabled(); + + fireEvent.change(composer(), { target: { value: "x".repeat(2001) } }); + expect(screen.getByText("Reduza 1 caractere(s).")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Enviar" })).toBeDisabled(); + fireEvent.keyDown(composer(), { key: "Enter" }); + expect(queryCalls(request)).toHaveLength(0); +}); + +it("em conversa existente envia conversa_id sem trocar de projeto", async () => { + const request = api({ conversations: [conversation()], messages: { "c-1": [message()] } }); + vi.stubGlobal("fetch", request); + render(); + await screen.findByText("importante"); + + fireEvent.change(composer(), { target: { value: "Mais detalhes?" } }); + fireEvent.click(screen.getByRole("button", { name: "Enviar" })); + await screen.findByText("Ok"); + expect(queryCalls(request)).toEqual([{ pergunta: "Mais detalhes?", conversa_id: "c-1" }]); +}); + +it("falha ao enviar marca a mensagem, explica e permite tentar novamente sem duplicá-la", async () => { + let attempts = 0; + const request = api({ + query: () => (++attempts === 1 ? json({ error: "x" }, 500) : json({ conversa_id: "c-9", resposta: "Agora deu", fontes: [], origem: "assistente" })), + }); + vi.stubGlobal("fetch", request); + render(); + await screen.findByText(/Nenhuma conversa ainda/); + + fireEvent.change(composer(), { target: { value: "Pergunta importante" } }); + fireEvent.click(screen.getByRole("button", { name: "Enviar" })); + + expect(await screen.findByText(/Não foi possível falar com o assistente agora/)).toBeInTheDocument(); + expect(screen.getByText("Não enviada")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Tentar novamente" })); + + expect(await screen.findByText("Agora deu")).toBeInTheDocument(); + expect(screen.getAllByText("Pergunta importante")).toHaveLength(1); + expect(screen.queryByText("Não enviada")).toBeNull(); + expect(screen.queryByText(/Não foi possível falar com o assistente agora/)).toBeNull(); +}); + +it("erro 400 do servidor exibe a mensagem específica", async () => { + vi.stubGlobal("fetch", api({ query: () => json({ error: "A pergunta pode ter no máximo 2000 caracteres." }, 400) })); + render(); + await screen.findByText(/Nenhuma conversa ainda/); + fireEvent.change(composer(), { target: { value: "oi" } }); + fireEvent.click(screen.getByRole("button", { name: "Enviar" })); + expect(await screen.findByText("A pergunta pode ter no máximo 2000 caracteres.")).toBeInTheDocument(); +}); + +it("marca respostas vindas de busca textual e mantém o texto padrão quando não há resultado", async () => { + const answers = [ + { conversa_id: "c-9", resposta: "O assistente está indisponível. Estes trechos... - trecho A", fontes: [{ id: "k", titulo: "Trecho de documento", tipo: "documento" }], origem: "busca_textual" }, + { conversa_id: "c-9", resposta: "Informação não encontrada no acervo do projeto.", fontes: [], origem: "sem_resultado" }, + ]; + vi.stubGlobal("fetch", api({ query: () => json(answers.shift()) })); + render(); + await screen.findByText(/Nenhuma conversa ainda/); + + fireEvent.change(composer(), { target: { value: "primeira pergunta" } }); + fireEvent.click(screen.getByRole("button", { name: "Enviar" })); + expect(await screen.findByText("Busca textual")).toBeInTheDocument(); + expect(screen.getByText("Fontes citadas (1)")).toBeInTheDocument(); + + fireEvent.change(composer(), { target: { value: "segunda pergunta" } }); + fireEvent.click(screen.getByRole("button", { name: "Enviar" })); + expect(await screen.findByText("Informação não encontrada no acervo do projeto.")).toBeInTheDocument(); + expect(screen.getAllByText("Busca textual")).toHaveLength(1); +}); + +it("trata erro ao carregar conversas e mensagens com nova tentativa", async () => { + let conversationsCalls = 0; + const request = vi.fn((url: RequestInfo | URL) => { + const path = String(url); + if (path.startsWith("/api/v1/projects")) return json({ items: [], total: 0, limit: 50, offset: 0 }); + if (path === "/api/v1/chat/conversations") return ++conversationsCalls === 1 ? json({ error: "x" }, 500) : json({ items: [conversation()], total: 1 }); + return json({ error: "x" }, 500); + }); + vi.stubGlobal("fetch", request); + render(); + + expect(await screen.findByText(/Não foi possível carregar as conversas/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Tentar novamente" })); + expect(await screen.findByText(/Não foi possível carregar as mensagens desta conversa/)).toBeInTheDocument(); +}); + +it("alternar de conversa carrega as mensagens da escolhida e ignora respostas atrasadas", async () => { + let releaseFirst!: (response: Response) => void; + const request = vi.fn((url: RequestInfo | URL) => { + const path = String(url); + if (path.startsWith("/api/v1/projects")) return json({ items: [PROJECT], total: 1, limit: 50, offset: 0 }); + if (path === "/api/v1/chat/conversations") return json({ items: [conversation({ id: "c-1", titulo: "Primeira" }), conversation({ id: "c-2", titulo: "Segunda", projeto_id: null, projeto_nome: null })], total: 2 }); + if (path.endsWith("/c-1/messages")) return new Promise((resolve) => { releaseFirst = resolve; }); + if (path.endsWith("/c-2/messages")) return json({ items: [message({ id: "m-2", conteudo: "Conteúdo da segunda" })], total: 1 }); + return json({}); + }); + vi.stubGlobal("fetch", request); + render(); + + const second = await screen.findByRole("button", { name: /Segunda/ }); + fireEvent.click(second); + expect(await screen.findByText("Conteúdo da segunda")).toBeInTheDocument(); + releaseFirst(new Response(JSON.stringify({ items: [message({ id: "m-1", conteudo: "Conteúdo atrasado" })], total: 1 }))); + await waitFor(() => expect(screen.queryByText("Conteúdo atrasado")).toBeNull()); + expect(screen.getByRole("heading", { level: 2, name: "Segunda" })).toBeInTheDocument(); +}); + +it("Nova conversa limpa a tela e libera o escopo", async () => { + vi.stubGlobal("fetch", api({ conversations: [conversation()], messages: { "c-1": [message()] } })); + render(); + await screen.findByText("importante"); + + fireEvent.click(screen.getByRole("button", { name: "Nova conversa" })); + expect(screen.queryByText("importante")).toBeNull(); + expect(screen.getByRole("heading", { name: "Como posso ajudar?" })).toBeInTheDocument(); + expect(screen.getByLabelText("Escopo da consulta")).toBeEnabled(); + expect(document.activeElement).toBe(composer()); +}); + +it("copia a resposta do assistente", async () => { + const writeText = vi.fn(() => Promise.resolve()); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); + vi.stubGlobal("fetch", api({ conversations: [conversation()], messages: { "c-1": [message()] } })); + render(); + await screen.findByText("importante"); + fireEvent.click(screen.getByRole("button", { name: "Copiar resposta" })); + await waitFor(() => expect(writeText).toHaveBeenCalledWith("Resposta **importante**")); + expect(await screen.findByRole("button", { name: "Copiado" })).toBeInTheDocument(); +}); diff --git a/frontend/src/views/chat/ChatView.tsx b/frontend/src/views/chat/ChatView.tsx index 3bf6374..768826e 100644 --- a/frontend/src/views/chat/ChatView.tsx +++ b/frontend/src/views/chat/ChatView.tsx @@ -1,278 +1,341 @@ -import React, { useState, useEffect, useRef } from "react"; -import { apiRequest } from "../../api/api_auth"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + MAX_QUESTION_LENGTH, + askQuestion, + describeChatError, + listConversations, + listMessages, + type ChatConversation, + type ChatMessage, + type ChatOrigin, +} from "../../api/api_chat"; +import { listProjects } from "../../api/api_projects"; +import { ORIGIN_BADGE, SUGGESTED_PROMPTS, formatClock, formatRelative, remainingCharacters } from "../../models/chat"; +import { Alert, Badge, Button } from "../common/ui"; +import { Markdown } from "../common/Markdown"; import "../../assets/styles/garakis-prototype.css"; +import "../../assets/styles/chat.css"; -interface Message { +interface ProjectOption { id: string; - remetente: "user" | "assistant"; - conteudo: string; - fontes_json?: Array<{ id: string; titulo: string; tipo: string }>; - created_at?: string; + nome: string; } -interface Conversation { - id: string; - titulo: string; - projeto_id?: string; - projeto_nome?: string; - updated_at: string; +interface ThreadMessage extends ChatMessage { + failed?: boolean; } -interface ProjectOption { - id: string; - nome: string; -} +type LoadState = "idle" | "loading" | "error"; -export const ChatView: React.FC = () => { - const [conversations, setConversations] = useState([]); - const [activeConversationId, setActiveConversationId] = useState(null); - const [messages, setMessages] = useState([]); - const [input, setInput] = useState(""); - const [selectedProjectId, setSelectedProjectId] = useState(""); +export function ChatView() { + const [conversations, setConversations] = useState([]); + const [conversationsState, setConversationsState] = useState("loading"); const [projects, setProjects] = useState([]); - const [loading, setLoading] = useState(false); - const messagesEndRef = useRef(null); + const [activeId, setActiveId] = useState(null); + const [scopeId, setScopeId] = useState(""); + const [messages, setMessages] = useState([]); + const [messagesState, setMessagesState] = useState("idle"); + const [draft, setDraft] = useState(""); + const [sending, setSending] = useState(false); + const [sendError, setSendError] = useState(""); + const [copiedId, setCopiedId] = useState(null); + const bottom = useRef(null); + const composer = useRef(null); + const mounted = useRef(true); + const messagesRequest = useRef(0); + const sendLock = useRef(false); + const skipReload = useRef(null); - // Carrega a lista de projetos do backend useEffect(() => { - apiRequest("/projects") - .then((res) => res.json()) - .then((data) => { - if (Array.isArray(data.items)) { - setProjects(data.items.map((p: { id: string; nome: string }) => ({ id: p.id, nome: p.nome }))); - } - }) - .catch(() => {}); + mounted.current = true; + return () => { mounted.current = false; }; }, []); - // Carrega a lista de conversas do banco de dados - const loadConversations = async () => { + const loadConversations = useCallback(async (selectFirst: boolean) => { + setConversationsState("loading"); try { - const res = await apiRequest("/chat/conversations"); - const data = await res.json(); - const list: Conversation[] = data.items || []; - setConversations(list); - if (list.length > 0 && !activeConversationId) { - setActiveConversationId(list[0].id); + const items = await listConversations(); + if (!mounted.current) return; + setConversations(items); + setConversationsState("idle"); + if (selectFirst && items.length > 0) { + setActiveId((current) => current ?? items[0].id); + setScopeId((current) => current || items[0].projeto_id || ""); } - } catch {} - }; + } catch { + if (mounted.current) setConversationsState("error"); + } + }, []); useEffect(() => { - void loadConversations(); + void loadConversations(true); + const controller = new AbortController(); + void listProjects(AbortSignal.any([controller.signal, AbortSignal.timeout(15000)])) + .then((page) => { if (mounted.current) setProjects(page.projects.map((project) => ({ id: project.id, nome: project.nome }))); }) + .catch(() => undefined); + return () => controller.abort(); + }, [loadConversations]); + + const loadMessages = useCallback(async (conversationId: string) => { + const request = ++messagesRequest.current; + setMessagesState("loading"); + try { + const items = await listMessages(conversationId); + if (!mounted.current || request !== messagesRequest.current) return; + setMessages(items); + setMessagesState("idle"); + } catch { + if (!mounted.current || request !== messagesRequest.current) return; + setMessages([]); + setMessagesState("error"); + } }, []); - // Carrega as mensagens da conversa selecionada useEffect(() => { - if (!activeConversationId) return; - - apiRequest(`/chat/conversations/${activeConversationId}/messages`) - .then((res) => res.json()) - .then((data) => setMessages(data.items || [])) - .catch(() => setMessages([])); - }, [activeConversationId]); + if (!activeId) { + messagesRequest.current += 1; + setMessagesState("idle"); + return; + } + if (skipReload.current === activeId) { + skipReload.current = null; + return; + } + void loadMessages(activeId); + }, [activeId, loadMessages]); useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messages, loading]); + bottom.current?.scrollIntoView?.({ behavior: "smooth", block: "end" }); + }, [messages, sending]); - const handleSendMessage = async () => { - if (!input.trim() || loading) return; + const activeConversation = conversations.find((item) => item.id === activeId) ?? null; + const scopeName = projects.find((project) => project.id === scopeId)?.nome ?? activeConversation?.projeto_nome ?? null; + const scopeLocked = Boolean(activeId); - const userText = input.trim(); - setInput(""); - setLoading(true); + const startNewConversation = () => { + setActiveId(null); + setMessages([]); + setSendError(""); + setDraft(""); + composer.current?.focus(); + }; - // Adiciona temporariamente a mensagem do usuário na tela - const tempUserMsg: Message = { id: `temp-${Date.now()}`, remetente: "user", conteudo: userText }; - setMessages((prev) => [...prev, tempUserMsg]); + const openConversation = (conversation: ChatConversation) => { + setSendError(""); + setActiveId(conversation.id); + setScopeId(conversation.projeto_id ?? ""); + }; + const send = useCallback(async (text: string, retryId?: string) => { + const question = text.trim(); + if (!question || sendLock.current) return; + sendLock.current = true; + setSending(true); + setSendError(""); + const pendingId = retryId ?? `local-${Date.now()}`; + setMessages((current) => retryId + ? current.map((item) => (item.id === retryId ? { ...item, failed: false } : item)) + : [...current, { id: pendingId, remetente: "user", conteudo: question, fontes: [], created_at: new Date().toISOString() }]); + if (!retryId) setDraft(""); try { - const res = await apiRequest("/chat/query", { - method: "POST", - body: JSON.stringify({ - conversa_id: activeConversationId || undefined, - projeto_id: selectedProjectId || undefined, - pergunta: userText, - }), - }); - - const data = await res.json(); - - if (!activeConversationId && data.conversa_id) { - setActiveConversationId(data.conversa_id); - void loadConversations(); + const answer = await askQuestion({ pergunta: question, conversaId: activeId, projetoId: activeId ? null : scopeId }); + if (!mounted.current) return; + setMessages((current) => [ + ...current, + { id: `assistant-${Date.now()}`, remetente: "assistant", conteudo: answer.resposta, fontes: answer.fontes, created_at: new Date().toISOString(), origem: answer.origem }, + ]); + if (!activeId) { + skipReload.current = answer.conversa_id; + setActiveId(answer.conversa_id); } - - const assistantMsg: Message = { - id: `assistant-${Date.now()}`, - remetente: "assistant", - conteudo: data.resposta || "Informação não encontrada no acervo do projeto.", - fontes_json: data.fontes, - }; - - setMessages((prev) => [...prev, assistantMsg]); - } catch { - const errorMsg: Message = { - id: `err-${Date.now()}`, - remetente: "assistant", - conteudo: "Não foi possível comunicar com o assistente. Verifique a conexão com o servidor.", - }; - setMessages((prev) => [...prev, errorMsg]); + void loadConversations(false); + } catch (error) { + if (!mounted.current) return; + setMessages((current) => current.map((item) => (item.id === pendingId ? { ...item, failed: true } : item))); + setSendError(describeChatError(error)); } finally { - setLoading(false); + sendLock.current = false; + if (mounted.current) setSending(false); } - }; + }, [activeId, scopeId, loadConversations]); - const handleNewConversation = async () => { + const copy = async (message: ThreadMessage) => { try { - const res = await apiRequest("/chat/conversations", { - method: "POST", - body: JSON.stringify({ - titulo: "Nova conversa com Sinapse", - projeto_id: selectedProjectId || undefined, - }), - }); - const newConv = await res.json(); - setConversations((prev) => [newConv, ...prev]); - setActiveConversationId(newConv.id); - setMessages([]); - } catch {} + await navigator.clipboard.writeText(message.conteudo); + setCopiedId(message.id); + window.setTimeout(() => setCopiedId((current) => (current === message.id ? null : current)), 2000); + } catch { + setCopiedId(null); + } }; + const remaining = remainingCharacters(draft, MAX_QUESTION_LENGTH); + const canSend = draft.trim().length > 0 && remaining >= 0 && !sending; + const failedMessage = messages.find((item) => item.failed); + return ( -
-
+
+
-
COPILOTO INTELIGENTE
-

Assistente Sinapse (RAG Local)

-

- Tire dúvidas sobre decisões do projeto, arquitetura e requisitos baseando-se estritamente no acervo do projeto. -

+
COPILOTO DO PROJETO
+

Assistente Sinapse

+

Pergunte sobre requisitos, decisões e documentos. As respostas citam as fontes do acervo e devem ser conferidas por uma pessoa.

-
+ -
- {/* Sidebar de Conversas */} - - {/* Área Central da Conversa */} -
- {/* Mensagens */} -
- {messages.length === 0 ? ( -
-

Pergunte ao Sinapse

-

- Tire dúvidas sobre requisitos, regras de negócio ou decisões de arquitetura vinculadas aos projetos. -

-
- ) : ( - messages.map((msg) => ( -
-
- {msg.remetente === "user" ? "Você" : "Assistente Sinapse"} -
-
- {msg.conteudo} -
+
+ {messagesState === "loading" &&

Carregando mensagens…

} + {messagesState === "error" && activeId && ( + + Não foi possível carregar as mensagens desta conversa.{" "} + + + )} - {msg.fontes_json && msg.fontes_json.length > 0 && ( -
- Fontes citadas: {msg.fontes_json.map((f) => f.titulo || f.id).join(", ")} -
- )} + {messagesState === "idle" && messages.length === 0 && ( +
+

Como posso ajudar?

+

Faça uma pergunta sobre {scopeName ? `o projeto ${scopeName}` : "os seus projetos"} ou comece por uma sugestão.

+
+ {SUGGESTED_PROMPTS.map((prompt) => ( + + ))}
- )) - )} - {loading && ( -
- Sinapse está consultando a base de conhecimento RAG...
)} -
+ + {messages.map((message) => ( + void copy(message)} /> + ))} + + {sending &&
Consultando o acervo
} +
- {/* Campo de Entrada (Composer Box) */} -
+ {sendError && ( + + {sendError}{" "} + {failedMessage && } + + )} + +
{ event.preventDefault(); if (canSend) void send(draft); }}> +