Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,7 @@ n8n/local-files/*
.coverage
htmlcov/
.pytest_cache/

# armazenamento local de documentos enviados
backend/storage/
storage/
5 changes: 5 additions & 0 deletions backend/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
136 changes: 136 additions & 0 deletions backend/src/database/migration-012.db.test.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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<string> {
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<void> {
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<void>): Promise<void> {
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());
});
});
16 changes: 11 additions & 5 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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",
Expand All @@ -43,6 +45,7 @@ app.get("/health", async (_req: Request, res: Response) => {
database: dbHealthy ? "connected" : "disconnected",
aiService: env.AI_SERVICE_URL,
},
documents,
});
});

Expand All @@ -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);

Expand All @@ -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));

Expand Down Expand Up @@ -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`);
Expand Down
10 changes: 10 additions & 0 deletions backend/src/middleware/errorHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
19 changes: 8 additions & 11 deletions backend/src/middleware/requireAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
Expand All @@ -18,17 +25,7 @@ export function createRequireAuth(
next: NextFunction,
): Promise<void> => {
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({
Expand Down
13 changes: 13 additions & 0 deletions backend/src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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) {
Expand Down
Loading
Loading