From ec893199f705467ab6dd31fee7e1191ae9b840dd Mon Sep 17 00:00:00 2001 From: Moretto <72614882+Giomoret@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:15:46 -0300 Subject: [PATCH 01/46] =?UTF-8?q?refactor:=20consolidar=20arquitetura=20e?= =?UTF-8?q?=20corrigir=20autentica=C3=A7=C3=A3o,=20chat=20e=20ingest=C3=A3?= =?UTF-8?q?o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Separar a aplicação do bootstrap e organizar controllers, services e repositories; dividir o repositório de projetos por responsabilidade. - Centralizar transações e corrigir concorrência, conclusão de itens e bloqueios da hierarquia do backlog por projeto. - Unificar a interface em tokens e componentes ds-*, remover estilos e caminhos legados e decompor telas de projetos, PBIs e árvore do backlog. - Restringir cadastro privilegiado e acesso a projetos e seus descendentes, incluindo filtros de coleções, chat e busca por alocação ativa. - Validar a sessão inicialmente e em segundo plano, evitando nova tela de verificação a cada navegação e tratando respostas 401 das APIs protegidas. - Vincular conversas a projetos, persistir estados de processamento, proteger envios concorrentes e validar referências às fontes recuperadas. - Implementar fila de ingestão com leases, tentativas, reprocessamento, diagnóstico de erros e persistência atômica de chunks e embeddings. - Extrair TXT, Markdown, DOCX e PDF no serviço Python e alinhar contratos entre backend, n8n e serviço de processamento. - Autenticar chamadas internas com token, restringir exposição de serviços no Docker e atualizar os workflows de ingestão e remoção de documentos. - Acrescentar migrações, testes de regressão, documentação técnica, especificação OpenAPI e ajustes de CI. Validação realizada durante as alterações: - Backend: 340 testes aprovados, incluindo testes com PostgreSQL. - Frontend: 200 testes aprovados, verificação de tipos e build aprovados. - Python: 16 testes aprovados em Docker. - Integração Node/n8n/Python/Ollama verificada para autenticação, rejeição de documento inválido e geração de embedding de 1024 dimensões. - Diff preparado validado sem erros de whitespace e sem o token local. A suíte E2E completa permanece pendente de atualização do cadastro de usuários privilegiados. --- .env.example | 8 +- .github/workflows/ci.yml | 4 +- README.md | 5 + ai-service/config.py | 1 + ai-service/document_text.py | 38 + ai-service/main.py | 71 +- ai-service/requirements.txt | 2 + ai-service/tests/test_document_ingestion.py | 38 + ai-service/tests/test_internal_security.py | 35 + backend/README.md | 55 + backend/src/app.ts | 127 ++ backend/src/config/env.ts | 2 + backend/src/database/transaction.test.ts | 36 + backend/src/database/transaction.ts | 28 + backend/src/index.ts | 123 +- backend/src/middleware/projectAccess.ts | 22 + backend/src/modules/admin/admin.controller.ts | 22 + backend/src/modules/admin/admin.repository.ts | 59 + backend/src/modules/admin/admin.routes.ts | 73 +- backend/src/modules/admin/admin.service.ts | 23 + .../src/modules/architecture.routes.test.ts | 112 ++ backend/src/modules/auth/auth.controller.ts | 4 +- .../modules/auth/auth.register-role.test.ts | 9 +- backend/src/modules/auth/auth.routes.ts | 2 +- backend/src/modules/auth/auth.types.ts | 2 +- backend/src/modules/chat/chat.fakes.ts | 9 +- .../modules/chat/chat.repository.db.test.ts | 30 +- backend/src/modules/chat/chat.repository.ts | 48 +- backend/src/modules/chat/chat.routes.ts | 2 + backend/src/modules/chat/chat.service.test.ts | 20 +- backend/src/modules/chat/chat.service.ts | 93 +- backend/src/modules/chat/chat.sources.test.ts | 10 + backend/src/modules/chat/chat.types.ts | 1 + .../modules/criteria/criteria.repository.ts | 67 +- .../modules/decisions/decisions.db.test.ts | 2 +- .../modules/decisions/decisions.repository.ts | 2 +- .../developers/developers.controller.ts | 14 + .../developers/developers.repository.ts | 28 + .../modules/developers/developers.routes.ts | 41 +- .../modules/developers/developers.service.ts | 15 + .../modules/developers/developers.types.ts | 31 + .../modules/documents/documents.controller.ts | 7 + .../documents/documents.diagnostics.test.ts | 12 + .../documents/documents.ingestion.db.test.ts | 74 + .../modules/documents/documents.ingestion.ts | 113 ++ .../documents/documents.repository.db.test.ts | 8 +- .../modules/documents/documents.repository.ts | 8 +- .../src/modules/documents/documents.routes.ts | 2 + .../modules/documents/documents.service.ts | 8 + .../modules/documents/documents.storage.ts | 4 +- .../src/modules/documents/documents.types.ts | 4 + .../src/modules/epics/epics.compat.routes.ts | 2 +- backend/src/modules/epics/epics.controller.ts | 2 +- backend/src/modules/epics/epics.repository.ts | 90 +- .../src/modules/epics/epics.routes.test.ts | 1 + backend/src/modules/epics/epics.service.ts | 4 +- .../modules/features/features.controller.ts | 2 +- .../modules/features/features.repository.ts | 89 +- .../src/modules/features/features.service.ts | 4 +- backend/src/modules/pbis/pbis.controller.ts | 2 +- backend/src/modules/pbis/pbis.repository.ts | 87 +- backend/src/modules/pbis/pbis.routes.test.ts | 9 + backend/src/modules/pbis/pbis.service.test.ts | 6 +- backend/src/modules/pbis/pbis.service.ts | 59 +- backend/src/modules/priority-fixes.db.test.ts | 225 +++ .../projects/hierarchy-archive.db.test.ts | 2 +- .../src/modules/projects/hierarchy-archive.ts | 13 +- .../projects/project-access.db.test.ts | 62 + .../src/modules/projects/project-access.ts | 33 + .../src/modules/projects/projects.archive.ts | 234 +++ .../src/modules/projects/projects.backlog.ts | 321 ++++ .../src/modules/projects/projects.commands.ts | 301 ++++ .../modules/projects/projects.controller.ts | 2 +- .../src/modules/projects/projects.queries.ts | 248 +++ .../modules/projects/projects.repository.ts | 1120 +------------- .../modules/projects/projects.routes.test.ts | 1 + .../src/modules/projects/projects.service.ts | 3 +- .../src/modules/quality/quality.service.ts | 15 +- backend/src/modules/quality/quality.types.ts | 3 +- .../repo-analyses/repo-analyses.service.ts | 7 +- .../src/modules/search/search.controller.ts | 14 + .../src/modules/search/search.repository.ts | 30 + backend/src/modules/search/search.routes.ts | 64 +- backend/src/modules/search/search.service.ts | 20 + backend/src/modules/search/search.types.ts | 21 + .../technologies/technologies.controller.ts | 14 + .../technologies/technologies.repository.ts | 13 + .../technologies/technologies.routes.ts | 18 +- backend/src/shared/service-auth.ts | 4 + .../migrations/014_chat_and_ingestion.sql | 11 + .../migrations/015_ingestion_diagnostics.sql | 2 + docker-compose.yml | 14 +- docs/api/openapi.yaml | 110 +- frontend/README.md | 17 + frontend/src/App.test.tsx | 2 +- frontend/src/App.tsx | 27 +- .../api_backlog.completude.test.ts} | 2 +- frontend/src/api/api_chat.ts | 8 + frontend/src/api/api_documents.ts | 12 + .../api_repo_analyzer.test.ts} | 2 +- .../api_repo_analyzer.ts} | 2 +- frontend/src/assets/styles/backlog-search.css | 8 +- frontend/src/assets/styles/chat.css | 40 +- frontend/src/assets/styles/decisions.css | 14 +- .../{projects => assets/styles}/documents.css | 0 frontend/src/assets/styles/repo-analyzer.css | 64 +- frontend/src/auth/Auth.tsx | 3 - frontend/src/auth/navigation.ts | 1 - frontend/src/backlog/Backlog.tsx | 1 - frontend/src/backlog/Criteria.tsx | 1 - frontend/src/backlog/Epics.tsx | 1 - frontend/src/backlog/Features.tsx | 1 - frontend/src/backlog/ItemArchive.tsx | 1 - frontend/src/backlog/Pbis.tsx | 1 - frontend/src/backlog/QualityPanel.tsx | 2 - frontend/src/backlog/ReadOnlyContext.ts | 1 - frontend/src/backlog/fields.ts | 1 - frontend/src/backlog/navigation.ts | 2 - frontend/src/backlog/qualityEngine.ts | 1 - .../src/backlog/useUnsavedChangesGuard.ts | 1 - .../src/components/DeveloperDashboard.tsx | 1 - frontend/src/components/LandingPageView.tsx | 1 - frontend/src/components/SearchField.tsx | 1 - frontend/src/components/ui/index.tsx | 10 - frontend/src/index.css | 95 +- frontend/src/main.tsx | 3 +- frontend/src/models/repoAnalyzer.ts | 2 +- frontend/src/projects/ProjectArchive.tsx | 1 - frontend/src/projects/Projects.tsx | 1 - frontend/src/projects/navigation.ts | 1 - frontend/src/projects/projects.css | 1 - frontend/src/styles/README.md | 15 + frontend/src/styles/components.css | 21 + .../layout.css} | 265 +--- frontend/src/styles/tokens.css | 32 +- frontend/src/viewmodels/useAuthViewModel.tsx | 19 +- frontend/src/views/admin/AdminView.test.tsx | 48 + frontend/src/views/admin/AdminView.tsx | 87 +- frontend/src/{ => views}/auth/Auth.test.tsx | 55 +- frontend/src/views/auth/AuthView.tsx | 36 +- .../src/{ => views}/backlog/Backlog.test.tsx | 4 +- .../views/backlog/BacklogSearchResults.tsx | 10 +- .../BacklogTechnologySelector.test.tsx | 2 +- .../backlog/BacklogTechnologySelector.tsx | 5 +- .../src/views/backlog/BacklogTreeNodes.tsx | 341 ++++ .../src/views/backlog/BacklogTreeView.tsx | 594 +------ frontend/src/views/backlog/BacklogView.tsx | 11 +- frontend/src/views/backlog/CriteriaView.tsx | 89 +- frontend/src/views/backlog/DecisionsPanel.tsx | 10 +- .../backlog/EditAndCriteria.test.tsx | 2 +- .../src/{ => views}/backlog/Epics.test.tsx | 4 +- frontend/src/views/backlog/EpicsView.tsx | 67 +- frontend/src/views/backlog/FeaturesView.tsx | 67 +- .../{ => views}/backlog/Hierarchy.test.tsx | 6 +- .../{ => views}/backlog/ItemArchive.test.tsx | 2 +- .../src/views/backlog/ItemArchiveView.tsx | 9 +- .../src/views/backlog/ItemHistoryView.tsx | 127 +- .../backlog/Justificativa.test.tsx | 8 +- frontend/src/views/backlog/PbiDetail.tsx | 784 ++++++++++ frontend/src/views/backlog/PbiForm.tsx | 367 +++++ frontend/src/views/backlog/PbiList.tsx | 242 +++ frontend/src/views/backlog/PbisView.tsx | 1374 ----------------- .../{ => views}/backlog/QualityPanel.test.tsx | 3 +- .../src/views/backlog/QualityPanelView.tsx | 40 +- .../src/views/backlog/RequirementsView.tsx | 28 +- .../src/views/backlog/backlogTreeFilters.ts | 182 +++ frontend/src/views/chat/ChatView.test.tsx | 11 +- frontend/src/views/chat/ChatView.tsx | 24 +- .../dashboard/DeveloperDashboardView.test.tsx | 25 + .../dashboard/DeveloperDashboardView.tsx | 56 +- .../views/documents/DocumentsView.test.tsx | 18 +- .../src/views/documents/DocumentsView.tsx | 27 +- .../src/views/knowledge/KnowledgeView.tsx | 24 +- .../src/views/landing/LandingPageView.tsx | 17 +- .../projects/ProjectArchive.test.tsx | 4 +- .../src/views/projects/ProjectArchiveView.tsx | 9 +- frontend/src/views/projects/ProjectDetail.tsx | 267 ++++ frontend/src/views/projects/ProjectForm.tsx | 353 +++++ .../projects/Projects.integration.test.tsx} | 7 +- .../{ => views}/projects/Projects.test.tsx | 4 +- frontend/src/views/projects/ProjectsView.tsx | 685 +------- .../src/views/projects/RepoAnalyzerView.tsx | 16 +- n8n/README.md | 38 + ...65-sinapse-document-ingestion-trigger.json | 58 +- n8n/workflows/sinapse-document-removed.json | 28 + 185 files changed, 6662 insertions(+), 5134 deletions(-) create mode 100644 ai-service/document_text.py create mode 100644 ai-service/tests/test_document_ingestion.py create mode 100644 ai-service/tests/test_internal_security.py create mode 100644 backend/README.md create mode 100644 backend/src/app.ts create mode 100644 backend/src/database/transaction.test.ts create mode 100644 backend/src/database/transaction.ts create mode 100644 backend/src/middleware/projectAccess.ts create mode 100644 backend/src/modules/admin/admin.controller.ts create mode 100644 backend/src/modules/admin/admin.repository.ts create mode 100644 backend/src/modules/admin/admin.service.ts create mode 100644 backend/src/modules/architecture.routes.test.ts create mode 100644 backend/src/modules/chat/chat.sources.test.ts create mode 100644 backend/src/modules/developers/developers.controller.ts create mode 100644 backend/src/modules/developers/developers.repository.ts create mode 100644 backend/src/modules/developers/developers.service.ts create mode 100644 backend/src/modules/developers/developers.types.ts create mode 100644 backend/src/modules/documents/documents.diagnostics.test.ts create mode 100644 backend/src/modules/documents/documents.ingestion.db.test.ts create mode 100644 backend/src/modules/documents/documents.ingestion.ts create mode 100644 backend/src/modules/priority-fixes.db.test.ts create mode 100644 backend/src/modules/projects/project-access.db.test.ts create mode 100644 backend/src/modules/projects/project-access.ts create mode 100644 backend/src/modules/projects/projects.archive.ts create mode 100644 backend/src/modules/projects/projects.backlog.ts create mode 100644 backend/src/modules/projects/projects.commands.ts create mode 100644 backend/src/modules/projects/projects.queries.ts create mode 100644 backend/src/modules/search/search.controller.ts create mode 100644 backend/src/modules/search/search.repository.ts create mode 100644 backend/src/modules/search/search.service.ts create mode 100644 backend/src/modules/search/search.types.ts create mode 100644 backend/src/modules/technologies/technologies.controller.ts create mode 100644 backend/src/modules/technologies/technologies.repository.ts create mode 100644 backend/src/shared/service-auth.ts create mode 100644 database/migrations/014_chat_and_ingestion.sql create mode 100644 database/migrations/015_ingestion_diagnostics.sql create mode 100644 frontend/README.md rename frontend/src/{backlog/api.completude.test.ts => api/api_backlog.completude.test.ts} (82%) rename frontend/src/{projects/repo-analyzer.api.test.ts => api/api_repo_analyzer.test.ts} (93%) rename frontend/src/{projects/repo-analyzer.api.ts => api/api_repo_analyzer.ts} (97%) rename frontend/src/{projects => assets/styles}/documents.css (100%) delete mode 100644 frontend/src/auth/Auth.tsx delete mode 100644 frontend/src/auth/navigation.ts delete mode 100644 frontend/src/backlog/Backlog.tsx delete mode 100644 frontend/src/backlog/Criteria.tsx delete mode 100644 frontend/src/backlog/Epics.tsx delete mode 100644 frontend/src/backlog/Features.tsx delete mode 100644 frontend/src/backlog/ItemArchive.tsx delete mode 100644 frontend/src/backlog/Pbis.tsx delete mode 100644 frontend/src/backlog/QualityPanel.tsx delete mode 100644 frontend/src/backlog/ReadOnlyContext.ts delete mode 100644 frontend/src/backlog/fields.ts delete mode 100644 frontend/src/backlog/navigation.ts delete mode 100644 frontend/src/backlog/qualityEngine.ts delete mode 100644 frontend/src/backlog/useUnsavedChangesGuard.ts delete mode 100644 frontend/src/components/DeveloperDashboard.tsx delete mode 100644 frontend/src/components/LandingPageView.tsx delete mode 100644 frontend/src/components/SearchField.tsx delete mode 100644 frontend/src/components/ui/index.tsx delete mode 100644 frontend/src/projects/ProjectArchive.tsx delete mode 100644 frontend/src/projects/Projects.tsx delete mode 100644 frontend/src/projects/navigation.ts delete mode 100644 frontend/src/projects/projects.css create mode 100644 frontend/src/styles/README.md rename frontend/src/{assets/styles/garakis-prototype.css => styles/layout.css} (60%) create mode 100644 frontend/src/views/admin/AdminView.test.tsx rename frontend/src/{ => views}/auth/Auth.test.tsx (77%) rename frontend/src/{ => views}/backlog/Backlog.test.tsx (98%) rename frontend/src/{ => views}/backlog/BacklogTechnologySelector.test.tsx (95%) create mode 100644 frontend/src/views/backlog/BacklogTreeNodes.tsx rename frontend/src/{ => views}/backlog/EditAndCriteria.test.tsx (99%) rename frontend/src/{ => views}/backlog/Epics.test.tsx (97%) rename frontend/src/{ => views}/backlog/Hierarchy.test.tsx (98%) rename frontend/src/{ => views}/backlog/ItemArchive.test.tsx (98%) rename frontend/src/{ => views}/backlog/Justificativa.test.tsx (97%) create mode 100644 frontend/src/views/backlog/PbiDetail.tsx create mode 100644 frontend/src/views/backlog/PbiForm.tsx create mode 100644 frontend/src/views/backlog/PbiList.tsx delete mode 100644 frontend/src/views/backlog/PbisView.tsx rename frontend/src/{ => views}/backlog/QualityPanel.test.tsx (99%) create mode 100644 frontend/src/views/backlog/backlogTreeFilters.ts create mode 100644 frontend/src/views/dashboard/DeveloperDashboardView.test.tsx rename frontend/src/{ => views}/projects/ProjectArchive.test.tsx (97%) create mode 100644 frontend/src/views/projects/ProjectDetail.tsx create mode 100644 frontend/src/views/projects/ProjectForm.tsx rename frontend/src/{projects/Integration.test.tsx => views/projects/Projects.integration.test.tsx} (94%) rename frontend/src/{ => views}/projects/Projects.test.tsx (98%) create mode 100644 n8n/README.md create mode 100644 n8n/workflows/sinapse-document-removed.json diff --git a/.env.example b/.env.example index b06bf70..b0a9cd9 100644 --- a/.env.example +++ b/.env.example @@ -33,8 +33,9 @@ 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= +# Webhooks dos workflows n8n versionados; ative ambos antes de processar documentos. +DOCUMENT_EVENTS_WEBHOOK_URL=http://n8n:5678/webhook/sinapse-document-removed +DOCUMENT_INGEST_WEBHOOK_URL=http://n8n:5678/webhook/sinapse-ingest # --- Aplicações e Microsserviços --- BACKEND_PORT=3001 @@ -45,3 +46,6 @@ AI_SERVICE_PORT=8000 GENERIC_TIMEZONE=America/Sao_Paulo TZ=America/Sao_Paulo + +# Shared by Node and Python. Set a random secret before starting Compose. +AI_SERVICE_TOKEN= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ea3aff..7884e5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,7 +55,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 src/database/migration-012.db.test.ts src/modules/documents/documents.repository.db.test.ts src/modules/chat/chat.repository.db.test.ts src/modules/backlog-search/backlog-search.db.test.ts src/modules/decisions/decisions.db.test.ts src/database/migration-013.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 src/modules/backlog-search/backlog-search.db.test.ts src/modules/decisions/decisions.db.test.ts src/database/migration-013.db.test.ts src/modules/priority-fixes.db.test.ts src/modules/documents/documents.ingestion.db.test.ts src/modules/projects/project-access.db.test.ts working-directory: backend env: ARCHIVE_TEST_DATABASE_URL: postgresql://seed_test:seed_test_only@localhost:5432/sinapse_seed_test @@ -86,7 +86,7 @@ jobs: - name: Validate Docker Compose configuration run: | cp .env.example .env - docker compose config --quiet + AI_SERVICE_TOKEN=ci-compose-validation-only docker compose config --quiet validate-backend: name: Build & Typecheck Backend (Node.js) diff --git a/README.md b/README.md index 4a0d131..7e21681 100644 --- a/README.md +++ b/README.md @@ -426,3 +426,8 @@ O **Sinapse** é um **Projeto de Aprendizagem Interdisciplinar (API)** desenvolv **Grupo Galáticos · Fatec São José dos Campos · 2026** + + +### Segredo da comunicação interna + +Antes de iniciar o Compose, configure `AI_SERVICE_TOKEN` em `.env` com um segredo aleatório (por exemplo, 32 bytes gerados com `crypto.randomBytes` do Node). Não versione o valor. O mesmo segredo é usado pelo backend e pelo serviço Python. A porta 8000 não é publicada; acessos ao Python passam pelo backend e pelo workflow autenticado descrito em [n8n/README.md](n8n/README.md). diff --git a/ai-service/config.py b/ai-service/config.py index 226168f..1d6ab14 100644 --- a/ai-service/config.py +++ b/ai-service/config.py @@ -7,6 +7,7 @@ class Settings(BaseSettings): + AI_SERVICE_TOKEN: str = "" AI_SERVICE_PORT: int = 8000 AI_SERVICE_HOST: str = "0.0.0.0" diff --git a/ai-service/document_text.py b/ai-service/document_text.py new file mode 100644 index 0000000..1fc9414 --- /dev/null +++ b/ai-service/document_text.py @@ -0,0 +1,38 @@ +"""Extract supported uploads without filesystem paths or database access.""" +import base64 +import io +import zipfile +import xml.etree.ElementTree as ET +from pathlib import PurePath + +MAX_BYTES = 100 * 1024 * 1024 + + +def extract_document_text(file_name: str, content_base64: str) -> str: + if len(content_base64) > (MAX_BYTES * 4 // 3 + 4): + raise ValueError("Arquivo muito grande") + content = base64.b64decode(content_base64, validate=True) + if len(content) > MAX_BYTES: + raise ValueError("Arquivo muito grande") + extension = PurePath(file_name).suffix.lower() + if extension in (".txt", ".md"): + text = content.decode("utf-8-sig") + elif extension == ".docx": + with zipfile.ZipFile(io.BytesIO(content)) as archive: + info = archive.getinfo("word/document.xml") + if info.file_size > MAX_BYTES: + raise ValueError("Documento descompactado muito grande") + root = ET.fromstring(archive.read(info)) + namespace = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}" + text = "\n".join("".join(node.itertext()) for node in root.iter(namespace + "p")) + elif extension == ".pdf": + from pypdf import PdfReader + reader = PdfReader(io.BytesIO(content)) + if reader.is_encrypted: + raise ValueError("PDF protegido não é suportado") + text = "\n".join(page.extract_text() or "" for page in reader.pages) + else: + raise ValueError("Formato não suportado") + if not text.strip() or len(text) > MAX_BYTES: + raise ValueError("Documento sem texto extraível ou acima do limite") + return text diff --git a/ai-service/main.py b/ai-service/main.py index c3626bd..50bec75 100644 --- a/ai-service/main.py +++ b/ai-service/main.py @@ -1,3 +1,8 @@ +import hmac +import math +from fastapi import Request +from fastapi.responses import JSONResponse +from document_text import extract_document_text from contextlib import asynccontextmanager from typing import Any from fastapi import FastAPI, HTTPException, status @@ -26,6 +31,18 @@ async def lifespan(app: FastAPI): lifespan=lifespan, ) +@app.middleware("http") +async def authenticate_service(request: Request, call_next): + if request.url.path != "/health": + expected = settings.AI_SERVICE_TOKEN + supplied = request.headers.get("x-service-token", "") + if not expected: + return JSONResponse(status_code=503, content={"detail": "Autenticação interna não configurada"}) + if not hmac.compare_digest(supplied.encode(), expected.encode()): + return JSONResponse(status_code=401, content={"detail": "Autenticação interna necessária"}) + return await call_next(request) + + app.add_middleware( CORSMiddleware, allow_origins=["*"], @@ -86,28 +103,37 @@ async def ingest_document(req: IngestDocumentRequest): """ chunks = chunk_document_text(req.text_content) - # Process each chunk with embedding + if not req.project_id or not chunks: + raise HTTPException(status_code=422, detail="Projeto e texto são obrigatórios") processed = [] for idx, chunk in enumerate(chunks): try: vector = await ollama_client.get_embedding(chunk) - vector_dim = len(vector) + if len(vector) != 1024: + raise ValueError("Dimensão de vetor inválida") except Exception: - vector_dim = 0 - - processed.append({ - "chunk_index": idx, - "text": chunk, - "vector_dimension": vector_dim, - "project_id": req.project_id, - }) - - return { - "document_id": req.document_id, - "total_chunks": len(chunks), - "status": "chunked_and_indexed", - "chunks": processed, - } + raise HTTPException(status_code=502, detail="Não foi possível gerar embeddings") + processed.append({"chunk_index": idx, "text": chunk, "embedding": vector, + "vector_dimension": len(vector), "project_id": req.project_id}) + return {"document_id": req.document_id, "project_id": req.project_id, + "total_chunks": len(chunks), "status": "prepared", "chunks": processed} + + +class IngestFileRequest(BaseModel): + document_id: str + project_id: str + file_name: str + content_base64: str + + +@app.post("/ingest/file") +async def ingest_file(req: IngestFileRequest): + try: + text = extract_document_text(req.file_name, req.content_base64) + except Exception: + raise HTTPException(status_code=422, detail="Não foi possível extrair texto do documento") + return await ingest_document(IngestDocumentRequest(document_id=req.document_id, + project_id=req.project_id, text_content=text)) @app.post("/ingest/entity", status_code=status.HTTP_200_OK) @@ -119,15 +145,18 @@ async def ingest_structured_entity(req: IngestEntityRequest): chunk = create_structured_chunk(req.entity_type, req.data) try: vector = await ollama_client.get_embedding(chunk["content"]) + if len(vector) != 1024 or not all(math.isfinite(v) for v in vector) or not any(vector): + raise ValueError("Embedding inválido") vector_dim = len(vector) except Exception: - vector_dim = 0 + raise HTTPException(status_code=502, detail="Não foi possível gerar embeddings") return { "entity_type": req.entity_type, "content": chunk["content"], "metadata": chunk["metadata"], "vector_dimension": vector_dim, + "embedding": vector, } @@ -155,9 +184,13 @@ async def query_rag(req: RagQueryRequest): "Você é o assistente inteligente do Sinapse (memória da fábrica de software PRO4TECH). " "Suas respostas devem ser estritamente baseadas no contexto fornecido. " "Se a evidência não estiver no contexto, responda honestamente que a informação não foi encontrada no acervo. " - "Sempre cite a fonte (ID do projeto, requisito ou documento) ao justificar uma resposta." + "Cite os trechos usados com seus IDs exatamente entre colchetes, como [ID], conforme o contexto. Nunca invente IDs." ) + if not req.project_id: + raise HTTPException(status_code=422, detail="Projeto obrigatório") + if not req.context_chunks: + return {"response": "Informação não encontrada no acervo do projeto.", "project_id": req.project_id} context_str = "\n\n---\n\n".join(req.context_chunks) if req.context_chunks else "Nenhum contexto recuperado." user_prompt = f"Contexto do Acervo:\n{context_str}\n\nPergunta do Product Owner:\n{req.query}" diff --git a/ai-service/requirements.txt b/ai-service/requirements.txt index 17c68bb..62ea87a 100644 --- a/ai-service/requirements.txt +++ b/ai-service/requirements.txt @@ -4,3 +4,5 @@ httpx>=0.28.0 pydantic>=2.10.0 pydantic-settings>=2.7.0 python-dotenv>=1.0.1 + +pypdf>=5.0,<7 diff --git a/ai-service/tests/test_document_ingestion.py b/ai-service/tests/test_document_ingestion.py new file mode 100644 index 0000000..8594231 --- /dev/null +++ b/ai-service/tests/test_document_ingestion.py @@ -0,0 +1,38 @@ +import base64 +import io +import unittest +import zipfile +from unittest.mock import AsyncMock, patch +from fastapi import HTTPException +from document_text import extract_document_text +from main import IngestDocumentRequest, ingest_document + + +class ExtractionTests(unittest.TestCase): + def test_utf8_and_docx(self): + text = "Decisão: preservar o histórico." + self.assertEqual(extract_document_text("decisao.md", base64.b64encode(text.encode()).decode()), text) + content = io.BytesIO() + with zipfile.ZipFile(content, "w") as archive: + archive.writestr("word/document.xml", 'Requisito') + self.assertEqual(extract_document_text("teste.docx", base64.b64encode(content.getvalue()).decode()), "Requisito") + + def test_rejects_invalid_empty_and_unsupported(self): + for name, content in [("a.txt", "!"), ("a.txt", ""), ("a.exe", "YWJj")]: + with self.assertRaises(Exception): + extract_document_text(name, content) + + +class IngestionTests(unittest.IsolatedAsyncioTestCase): + async def test_returns_vectors_for_node_to_persist(self): + with patch("main.ollama_client.get_embedding", new=AsyncMock(return_value=[0.1] * 1024)): + result = await ingest_document(IngestDocumentRequest(document_id="doc", project_id="project", text_content="Um requisito completo para cadastro de usuários.")) + self.assertEqual(result["status"], "prepared") + self.assertEqual(len(result["chunks"][0]["embedding"]), 1024) + self.assertEqual(result["project_id"], "project") + + async def test_embedding_failure_is_not_reported_as_indexed(self): + with patch("main.ollama_client.get_embedding", new=AsyncMock(side_effect=RuntimeError("offline"))): + with self.assertRaises(HTTPException) as error: + await ingest_document(IngestDocumentRequest(document_id="doc", project_id="project", text_content="Requisito de teste.")) + self.assertEqual(error.exception.status_code, 502) diff --git a/ai-service/tests/test_internal_security.py b/ai-service/tests/test_internal_security.py new file mode 100644 index 0000000..eb813bd --- /dev/null +++ b/ai-service/tests/test_internal_security.py @@ -0,0 +1,35 @@ +import unittest +from unittest.mock import AsyncMock, patch +import httpx +from fastapi import HTTPException +from main import app, settings, ingest_structured_entity, IngestEntityRequest + +class InternalSecurityTests(unittest.IsolatedAsyncioTestCase): + async def test_requires_token_for_analysis_and_ingestion(self): + with patch.object(settings, "AI_SERVICE_TOKEN", "test-secret"): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app),base_url="http://test") as client: + for path in ["/api/runs", "/api/runs/example/report", "/docs"]: + self.assertEqual((await client.get(path)).status_code,401) + for path in ["/api/analyze", "/ingest/file", "/ingest/entity", "/rag/query"]: + self.assertEqual((await client.post(path,json={})).status_code,401) + self.assertEqual((await client.get("/api/runs",headers={"X-Service-Token":"wrong"})).status_code,401) + with patch("main.analyzer.list_runs",return_value=[]): + self.assertEqual((await client.get("/api/runs",headers={"X-Service-Token":"test-secret"})).status_code,200) + async def test_missing_configuration_fails_closed(self): + with patch.object(settings,"AI_SERVICE_TOKEN",""): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app),base_url="http://test") as client: + self.assertEqual((await client.get("/api/runs")).status_code,503) + async def test_entity_embedding_failure_and_invalid_vector(self): + for vector in [[],[0.0]*1024,[float("nan")]*1024]: + with patch("main.ollama_client.get_embedding",new=AsyncMock(return_value=vector)): + with self.assertRaises(HTTPException) as error: + await ingest_structured_entity(IngestEntityRequest(entity_type="feature",data={"titulo":"Teste"})) + self.assertEqual(error.exception.status_code,502) + with patch("main.ollama_client.get_embedding",new=AsyncMock(side_effect=RuntimeError("secret"))): + with self.assertRaises(HTTPException) as error: + await ingest_structured_entity(IngestEntityRequest(entity_type="feature",data={"titulo":"Teste"})) + self.assertNotIn("secret",error.exception.detail) + async def test_entity_returns_real_vector(self): + with patch("main.ollama_client.get_embedding",new=AsyncMock(return_value=[0.1]*1024)): + result=await ingest_structured_entity(IngestEntityRequest(entity_type="feature",data={"titulo":"Teste"})) + self.assertEqual(len(result["embedding"]),1024) diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..563c84d --- /dev/null +++ b/backend/README.md @@ -0,0 +1,55 @@ +# Organização do backend + +A aplicação mantém os contratos HTTP e os aliases existentes. O código é organizado por domínio em src/modules. + +- app.ts configura Express, middlewares, rotas e tratamento de erros. Importá-lo não inicia o servidor ou o worker. +- index.ts é o ponto de entrada do processo: inicia o servidor e o worker de documentos. +- *.routes.ts registra endpoints e permissões. +- *.controller.ts adapta requisições e respostas HTTP e encaminha erros ao middleware global. +- *.service.ts contém validações e coordena operações do domínio. +- *.repository.ts concentra SQL e acesso ao PostgreSQL; aceita a dependência de banco pelo construtor. +- *.types.ts declara os contratos de dados do módulo quando necessário. + +Administração, desenvolvedores e busca seguem essas camadas. O catálogo de tecnologias faz apenas uma leitura: seu controller usa o repository diretamente, evitando um service que somente repassaria a chamada. + +A busca preserva os parâmetros projeto_id e projectId e o acervo global quando nenhum projeto é informado, conforme o contrato existente. Valores repetidos/objetos são rejeitados com HTTP 400, em vez de provocar erro interno ao chamar trim. Filtros SQL continuam parametrizados. + +As estatísticas administrativas usam uma única consulta. As três leituras independentes do painel de desenvolvedores executam em paralelo. A carga demonstrativa usa POST /api/v1/admin/demo-seed com projeto_id obrigatório. O alias /ingest-seed continua disponível com o mesmo corpo obrigatório. Não existe escolha implícita do primeiro projeto, geração de vetores ou reindexação nessa ação. Identificadores estáveis por projeto e versão impedem duplicação; a carga é auditada na mesma transação e rejeita projetos arquivados. Dados demonstrativos antigos não são removidos automaticamente. chunksIndexados conta somente trechos com embedding não nulo. + +## Validação + +Execute npm test e npm run typecheck nesta pasta. architecture.routes.test.ts cobre permissões administrativas, carga sem projeto, aliases e validação da busca, parâmetros SQL e propagação segura de erros. O teste de tecnologias e a suíte OpenAPI verificam os contratos existentes. + +Os testes PostgreSQL usam bancos descartáveis identificados como teste. Execute a suíte com ARCHIVE_TEST_DATABASE_URL, BACKLOG_TREE_TEST_DATABASE_URL e SEED_TEST_DATABASE_URL configuradas para o banco de teste para incluir as verificações de integração. + +## Limites das transações + +`database/transaction.ts` centraliza commit, rollback e liberação da conexão nos repositórios de projetos, épicos, features, PBIs, critérios e administração. Uma falha no rollback descarta a conexão e preserva o erro original. A leitura da árvore mantém uma transação somente de leitura com snapshot REPEATABLE READ. + +`ProjectsRepository` preserva o contrato público e delega para `projects.queries.ts` (consultas), `projects.commands.ts` (cadastro/edição), `projects.backlog.ts` (árvore) e `projects.archive.ts` (impacto/arquivamento). Consultas de resposta em edição e arquivamento usam a conexão da transação, sem adquirir uma segunda conexão do pool. + +Consultas de épicos, features e PBIs aceitam um executor opcional. Dentro de transações, use sempre o mesmo PoolClient, inclusive para ler a resposta antes do COMMIT. Isso permite executar atualizações e conclusões com um pool de uma única conexão. + +A conclusão de PBI recebe uma validação obrigatória executada depois dos locks da hierarquia e antes de alterar o status. QualityService propaga o client às consultas do PBI, critérios e configuração; a configuração usa FOR SHARE até o commit. A ordem de locks da hierarquia é a mesma usada pelas escritas de critérios. Uma conclusão repetida não gera nova auditoria. + +priority-fixes.db.test.ts verifica pool unitário, rollback por falha de auditoria, exclusão concorrente de critério, bloqueio da configuração e cargas simultâneas sem duplicação. O workflow de CI executa esse arquivo junto aos testes PostgreSQL. + +As escritas na hierarquia adquirem `FOR UPDATE` na linha do projeto antes dos locks dos descendentes. Escritas de critérios, documentos, decisões e arquivamentos usam a mesma ordem. Projetos diferentes não compartilham esse bloqueio. A transação da leitura da árvore continua usando snapshot REPEATABLE READ. + +## Chat e ingestão + +Novas conversas exigem projeto. O projeto é imutável durante a conversa. Admins e POs podem consultar projetos; desenvolvedores precisam de alocação ativa, considerando início e fim. O endpoint `/chat/projects` lista as opções permitidas. Conversas antigas sem projeto são preservadas no banco, mas não participam de consultas globais nem aparecem na lista ativa do chat. + +Perguntas são persistidas como `pending`, com atualização da conversa na mesma transação. A resposta e a transição para `completed` também são atômicas. Falhas ficam como `failed`; tentativas interrompidas há mais de dois minutos aparecem como falhas no histórico. Somente uma pergunta fica em processamento por conversa. Chamadas de rede não mantêm uma transação aberta. + +O cliente Python recebe `query`, `project_id` e trechos selecionados pelo backend somente no projeto autorizado. As fontes retornadas ao usuário vêm desses trechos, não de identificadores inventados pelo modelo. Sem evidências, o modelo não é chamado. A recuperação atual do chat usa busca textual. + +A migração `014_chat_and_ingestion.sql` adiciona estados de mensagens e leases de processamento de documentos. A fila de ingestão e seu worker ficam em `documents.ingestion.ts`. Consulte [a configuração dos workflows](../n8n/README.md). Os testes de ingestão cobrem falha, retentativa, lease concorrente, dimensão dos vetores, idempotência e remoção durante processamento. + +### Acesso por projeto e comunicação interna + +PO/admin podem consultar todos os projetos. Desenvolvedores precisam de alocação ativa; coleções são filtradas antes da paginação e detalhes sem acesso retornam 404. A regra é compartilhada por projetos, backlog, documentos, decisões, histórico, busca e chat, incluindo aliases `/api`. + +Defina `AI_SERVICE_TOKEN` com um segredo aleatório em `.env`. O Compose exige a configuração e não publica a porta do Python. O workflow n8n deve ser atualizado para encaminhar `X-Service-Token`. Não versione esse segredo. + +O chat somente apresenta IDs explicitamente citados que pertencem ao contexto recuperado. Respostas sem citações válidas usam o fallback textual; essa validação não comprova semanticamente cada afirmação. diff --git a/backend/src/app.ts b/backend/src/app.ts new file mode 100644 index 0000000..69f6b8d --- /dev/null +++ b/backend/src/app.ts @@ -0,0 +1,127 @@ +import { createProjectAccessRouter } from "./middleware/projectAccess.js"; +import express, { Request, Response } from "express"; +import cors from "cors"; +import swaggerUi from 'swagger-ui-express'; +import { env } from "./config/env.js"; +import { checkDatabaseConnection } from "./database/db.js"; +import { swaggerSpec } from "./config/swagger.config.js"; +import { projectsRouter } from "./modules/projects/projects.routes.js"; +import { epicsRouter } from "./modules/epics/epics.routes.js"; +import { featuresRouter } from "./modules/features/features.routes.js"; +import { pbisRouter } from "./modules/pbis/pbis.routes.js"; +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 { epicDecisionsRouter, featureDecisionsRouter, pbiDecisionsRouter, projectDecisionsRouter } from "./modules/decisions/decisions.routes.js"; +import { backlogSearchRouter } from "./modules/backlog-search/backlog-search.routes.js"; +import { documentsRouter } from "./modules/documents/documents.routes.js"; +import { documentsService } 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"; +import { technologiesRouter } from "./modules/technologies/technologies.routes.js"; +import { adminRouter } from "./modules/admin/admin.routes.js"; +import { auditRouter } from "./modules/audit/audit.routes.js"; +import { errorHandler } from "./middleware/errorHandler.js"; +import { requireAuth } from "./middleware/requireAuth.js"; + +export const app = express(); + +app.use(cors()); +app.use(express.json()); + +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", + timestamp: new Date().toISOString(), + service: "sinapse-backend", + version: "0.1.0", + dependencies: { + database: dbHealthy ? "connected" : "disconnected", + aiService: env.AI_SERVICE_URL, + }, + documents, + }); +}); + +app.use("/api/v1", createProjectAccessRouter()); +app.use("/api", createProjectAccessRouter()); + +// Projects API Endpoints (v1 e alias) +app.use("/api/v1/projects", requireAuth, projectsRouter); +app.use("/api/projects", requireAuth, projectsRouter); +// Compatibilidade das rotas de épicos da implementação anterior +app.use("/api/v1", requireAuth, epicsCompatRouter); +app.use("/api", requireAuth, epicsCompatRouter); + +// Hierarquia do backlog: épicos, features, PBIs e critérios de aceitação (S1-05/06/07/10) +app.use("/api/v1/epics", requireAuth, epicsRouter); +app.use("/api/v1/features", requireAuth, featuresRouter); +app.use("/api/v1/pbis", requireAuth, pbisRouter); +app.use("/api/v1/criteria", requireAuth, criteriaRouter); +app.use("/api/v1/quality", qualityRouter); +app.use("/api/v1/audit", auditRouter); + +// Busca híbrida e acervo +app.use("/api/v1/search", searchRouter); + +// Chat assistivo e conversas +app.use("/api/v1/chat", chatRouter); + +// Desenvolvedores e competências +app.use("/api/v1/developers", developersRouter); +app.use("/api/v1/technologies", requireAuth, technologiesRouter); + +// Painel administrativo +app.use("/api/v1/admin", adminRouter); + +// Repo analyzer +app.use('/api/v1/projects/:projectId/repo-analyses', repoAnalysesRouter); + +// Decisões em qualquer nível da hierarquia (S1-18) +app.use("/api/v1/projects/:entityId/decisions", projectDecisionsRouter); +app.use("/api/v1/epics/:entityId/decisions", epicDecisionsRouter); +app.use("/api/v1/features/:entityId/decisions", featureDecisionsRouter); +app.use("/api/v1/pbis/:entityId/decisions", pbiDecisionsRouter); + +// Busca textual no backlog (S1-17) +app.use("/api/v1/projects/:projectId/backlog-search", backlogSearchRouter); + +// 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)); + +// Root Information Endpoint +app.get("/api/v1", (_req: Request, res: Response) => { + res.json({ + name: "Sinapse API", + version: "v1", + description: "API de Backend do Sinapse - Base Inteligente de Requisitos", + documentation: "/docs", + modules: [ + { name: "projects", status: "ready" }, + { name: "epics", status: "ready" }, + { name: "features", status: "ready" }, + { name: "pbis", status: "ready" }, + { name: "criteria", status: "ready" }, + { name: "decisions", status: "in_development" }, + { name: "ai-bridge", status: "ready" }, + ], + }); +}); + +// Global Error Handler +app.use(errorHandler); + + +export default app; diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index d2c7adb..1962dd4 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -17,6 +17,7 @@ const envSchema = z.object({ POSTGRES_DB: z.string().default("sinapse"), POSTGRES_HOST: z.string().default("localhost"), POSTGRES_PORT: z.coerce.number().default(5432), + AI_SERVICE_TOKEN: z.string().default(""), AI_SERVICE_URL: z.string().default("http://localhost:8000"), REPO_ANALYZER_URL: z.string().default('http://localhost:8000'), @@ -29,6 +30,7 @@ const envSchema = z.object({ // 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_INGEST_WEBHOOK_URL: z.string().optional(), DOCUMENT_EVENTS_WEBHOOK_URL: z.string().optional(), }); diff --git a/backend/src/database/transaction.test.ts b/backend/src/database/transaction.test.ts new file mode 100644 index 0000000..6cac7b1 --- /dev/null +++ b/backend/src/database/transaction.test.ts @@ -0,0 +1,36 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import type { Pool, PoolClient } from "pg"; +import { withTransaction } from "./transaction.js"; + +test("a failed rollback discards the connection without hiding the original error", async () => { + const original = new Error("write failed"); + const disconnected = new Error("connection lost"); + const releases: unknown[] = []; + const client = { + query: async (sql: string) => { if (sql === "ROLLBACK") throw disconnected; }, + release: (error?: unknown) => { releases.push(error); }, + } as unknown as PoolClient; + const pool = { connect: async () => client } as unknown as Pool; + + await assert.rejects(withTransaction(pool, async () => { throw original; }), error => error === original); + assert.deepEqual(releases, [disconnected]); +}); + +test("a commit failure rejects the operation and releases its connection", async () => { + const failure = new Error("commit failed"); + const queries: string[] = []; + let releases = 0; + const client = { + query: async (sql: string) => { + queries.push(sql); + if (sql === "COMMIT") throw failure; + }, + release: () => { releases++; }, + } as unknown as PoolClient; + const pool = { connect: async () => client } as unknown as Pool; + + await assert.rejects(withTransaction(pool, async () => "result"), error => error === failure); + assert.equal(queries.at(-1), "ROLLBACK"); + assert.equal(releases, 1); +}); diff --git a/backend/src/database/transaction.ts b/backend/src/database/transaction.ts new file mode 100644 index 0000000..5de27ab --- /dev/null +++ b/backend/src/database/transaction.ts @@ -0,0 +1,28 @@ +import type { Pool, PoolClient } from "pg"; + +/** Keeps every query in a unit of work on the same connection. */ +export async function withTransaction( + pool: Pool, + work: (client: PoolClient) => Promise, + mode: "write" | "snapshot" = "write", +): Promise { + const client = await pool.connect(); + let connectionError: Error | boolean | undefined; + try { + await client.query(mode === "snapshot" ? "BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY" : "BEGIN"); + const result = await work(client); + await client.query("COMMIT"); + return result; + } catch (error) { + try { + await client.query("ROLLBACK"); + } catch (rollbackError) { + // A broken connection must not be returned to the pool for reuse. + connectionError = rollbackError instanceof Error ? rollbackError : true; + throw error; + } + throw error; + } finally { + client.release(connectionError); + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 2bcfaa1..d662105 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,127 +1,14 @@ -import express, { Request, Response } from "express"; -import cors from "cors"; -import swaggerUi from 'swagger-ui-express'; +import { startDocumentIngestionWorker } from "./modules/documents/documents.ingestion.js"; +import app from "./app.js"; import { env } from "./config/env.js"; -import { checkDatabaseConnection } from "./database/db.js"; -import { swaggerSpec } from "./config/swagger.config.js"; -import { projectsRouter } from "./modules/projects/projects.routes.js"; -import { epicsRouter } from "./modules/epics/epics.routes.js"; -import { featuresRouter } from "./modules/features/features.routes.js"; -import { pbisRouter } from "./modules/pbis/pbis.routes.js"; -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 { epicDecisionsRouter, featureDecisionsRouter, pbiDecisionsRouter, projectDecisionsRouter } from "./modules/decisions/decisions.routes.js"; -import { backlogSearchRouter } from "./modules/backlog-search/backlog-search.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"; -import { technologiesRouter } from "./modules/technologies/technologies.routes.js"; -import { adminRouter } from "./modules/admin/admin.routes.js"; -import { auditRouter } from "./modules/audit/audit.routes.js"; -import { errorHandler } from "./middleware/errorHandler.js"; -import { requireAuth } from "./middleware/requireAuth.js"; +import { startDocumentsBackgroundWorker } from "./modules/documents/documents.service.js"; -export const app = express(); - -app.use(cors()); -app.use(express.json()); - -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", - timestamp: new Date().toISOString(), - service: "sinapse-backend", - version: "0.1.0", - dependencies: { - database: dbHealthy ? "connected" : "disconnected", - aiService: env.AI_SERVICE_URL, - }, - documents, - }); -}); - -// Projects API Endpoints (v1 e alias) -app.use("/api/v1/projects", requireAuth, projectsRouter); -app.use("/api/projects", requireAuth, projectsRouter); -// Compatibilidade das rotas de épicos da implementação anterior -app.use("/api/v1", requireAuth, epicsCompatRouter); -app.use("/api", requireAuth, epicsCompatRouter); - -// Hierarquia do backlog: épicos, features, PBIs e critérios de aceitação (S1-05/06/07/10) -app.use("/api/v1/epics", requireAuth, epicsRouter); -app.use("/api/v1/features", requireAuth, featuresRouter); -app.use("/api/v1/pbis", requireAuth, pbisRouter); -app.use("/api/v1/criteria", requireAuth, criteriaRouter); -app.use("/api/v1/quality", qualityRouter); -app.use("/api/v1/audit", auditRouter); - -// Busca híbrida e acervo -app.use("/api/v1/search", searchRouter); - -// Chat assistivo e conversas -app.use("/api/v1/chat", chatRouter); - -// Desenvolvedores e competências -app.use("/api/v1/developers", developersRouter); -app.use("/api/v1/technologies", requireAuth, technologiesRouter); - -// Painel administrativo -app.use("/api/v1/admin", adminRouter); - -// Repo analyzer -app.use('/api/v1/projects/:projectId/repo-analyses', repoAnalysesRouter); - -// Decisões em qualquer nível da hierarquia (S1-18) -app.use("/api/v1/projects/:entityId/decisions", projectDecisionsRouter); -app.use("/api/v1/epics/:entityId/decisions", epicDecisionsRouter); -app.use("/api/v1/features/:entityId/decisions", featureDecisionsRouter); -app.use("/api/v1/pbis/:entityId/decisions", pbiDecisionsRouter); - -// Busca textual no backlog (S1-17) -app.use("/api/v1/projects/:projectId/backlog-search", backlogSearchRouter); - -// 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)); - -// Root Information Endpoint -app.get("/api/v1", (_req: Request, res: Response) => { - res.json({ - name: "Sinapse API", - version: "v1", - description: "API de Backend do Sinapse - Base Inteligente de Requisitos", - documentation: "/docs", - modules: [ - { name: "projects", status: "ready" }, - { name: "epics", status: "ready" }, - { name: "features", status: "ready" }, - { name: "pbis", status: "ready" }, - { name: "criteria", status: "ready" }, - { name: "decisions", status: "in_development" }, - { name: "ai-bridge", status: "ready" }, - ], - }); -}); - -// Global Error Handler -app.use(errorHandler); +export { app }; const PORT = env.PORT; if (env.NODE_ENV !== "test") { startDocumentsBackgroundWorker(); + startDocumentIngestionWorker(); 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."); } diff --git a/backend/src/middleware/projectAccess.ts b/backend/src/middleware/projectAccess.ts new file mode 100644 index 0000000..b502417 --- /dev/null +++ b/backend/src/middleware/projectAccess.ts @@ -0,0 +1,22 @@ +import { Router, type RequestHandler } from "express"; +import { requireAuth } from "./requireAuth.js"; +import { ProjectAccess } from "../modules/projects/project-access.js"; + +// Mounted before business routers, on both /api/v1 and the legacy /api prefix. +export function createProjectAccessRouter(access = new ProjectAccess(), authenticate: RequestHandler = requireAuth) { + const router = Router(); + const guard = (kind: string, idParam = "id"): RequestHandler => async (req,_res,next) => { + try { await access.assertEntity(req.auth!.id,kind || String(req.params.kind),String(req.params[idParam])); next(); } + catch(error) { next(error); } + }; + for (const [path,kind] of [["projects","projeto"],["epics","epico"],["epicos","epico"],["features","feature"],["pbis","pbi"],["criteria","criterio"]]) { + router.use('/'+path+'/:id',authenticate,guard(kind)); + } + router.use('/quality/pbis/:id',authenticate,guard('pbi')); + router.use('/audit/:kind/:id',authenticate,guard('')); + router.get('/criteria',authenticate,async (req,_res,next) => { + try { await access.assertEntity(req.auth!.id,String(req.query.entidade_tipo),String(req.query.entidade_id)); next(); } + catch(error) { next(error); } + }); + return router; +} diff --git a/backend/src/modules/admin/admin.controller.ts b/backend/src/modules/admin/admin.controller.ts new file mode 100644 index 0000000..debe91b --- /dev/null +++ b/backend/src/modules/admin/admin.controller.ts @@ -0,0 +1,22 @@ +import type { NextFunction, Request, Response } from "express"; +import { AdminService } from "./admin.service.js"; + +export class AdminController { + constructor(private readonly service: AdminService = new AdminService()) {} + + stats = async (_req: Request, res: Response, next: NextFunction): Promise => { + try { + res.json(await this.service.stats()); + } catch (error) { + next(error); + } + }; + + loadDemo = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + res.json(await this.service.loadDemo(req.body, req.auth!.id)); + } catch (error) { + next(error); + } + }; +} diff --git a/backend/src/modules/admin/admin.repository.ts b/backend/src/modules/admin/admin.repository.ts new file mode 100644 index 0000000..17de41c --- /dev/null +++ b/backend/src/modules/admin/admin.repository.ts @@ -0,0 +1,59 @@ +import { Pool } from "pg"; +import { pool } from "../../database/db.js"; +import { withTransaction } from "../../database/transaction.js"; +import { auditService } from "../audit/audit.service.js"; +import { assertWritable, lockHierarchy } from "../projects/hierarchy-archive.js"; + +export interface AdminCounts { + projetos: number; + epicos: number; + features: number; + pbis: number; + documentos: number; + chunksIndexados: number; +} + +export class AdminRepository { + constructor(private readonly db: Pool = pool) { } + + async counts(): Promise { + const { rows } = await this.db.query( + `SELECT + (SELECT COUNT(*)::int FROM projeto) AS projetos, + (SELECT COUNT(*)::int FROM epico) AS epicos, + (SELECT COUNT(*)::int FROM feature) AS features, + (SELECT COUNT(*)::int FROM pbi) AS pbis, + (SELECT COUNT(*)::int FROM documento) AS documentos, + (SELECT COUNT(*)::int FROM chunk WHERE embedding IS NOT NULL) AS "chunksIndexados"`, + ); + return rows[0]; + } + + async insertDemoChunks(projectId: string, userId: string): Promise { + return withTransaction(this.db, async (client) => { + + await lockHierarchy(client, "projeto", projectId); + await assertWritable(client, "projeto", projectId); + const result = await client.query( + `INSERT INTO chunk (id, projeto_id, entidade_tipo, entidade_id, texto, metadados_json) + SELECT md5($1::text || ':sinapse-demo-v1:' || demo.key)::uuid, + $1::uuid, 'demonstracao', md5($1::text || ':sinapse-demo-v1:' || demo.key)::uuid, + demo.texto, jsonb_build_object('fonte', 'sinapse-demo-v1', 'demonstracao', true, 'chave', demo.key) + FROM (VALUES + ('arquitetura', 'Exemplo demonstrativo: uma base de requisitos pode organizar projetos, épicos, features e PBIs.'), + ('decisao', 'Exemplo demonstrativo: registre o contexto, as alternativas e a justificativa de cada decisão técnica.') + ) AS demo(key, texto) + ON CONFLICT (id) DO NOTHING`, + [projectId], + ); + const inserted = result.rowCount ?? 0; + if (inserted > 0) await auditService.record({ + usuario_id: userId, entidade_tipo: "projeto", entidade_id: projectId, + acao: "CARREGAR_DEMONSTRACAO", dados_json: { versao: "sinapse-demo-v1", inseridos: inserted }, + }, client); + + return inserted; + + }); + } +} diff --git a/backend/src/modules/admin/admin.routes.ts b/backend/src/modules/admin/admin.routes.ts index 5359d2e..b3ced3e 100644 --- a/backend/src/modules/admin/admin.routes.ts +++ b/backend/src/modules/admin/admin.routes.ts @@ -1,60 +1,17 @@ -import { Router, Request, Response, NextFunction } from "express"; -import { pool } from "../../database/db.js"; +import { Router, type RequestHandler } from "express"; import { requireAuth } from "../../middleware/requireAuth.js"; import { requireRole } from "../../middleware/requireRole.js"; - -export const adminRouter = Router(); - -adminRouter.use(requireAuth); -adminRouter.use(requireRole("admin")); - -// GET /api/v1/admin/stats - Estatísticas reais do banco PostgreSQL -adminRouter.get("/stats", async (_req: Request, res: Response, next: NextFunction) => { - try { - const projectsCount = await pool.query("SELECT COUNT(*) FROM projeto"); - const epicsCount = await pool.query("SELECT COUNT(*) FROM epico"); - const featuresCount = await pool.query("SELECT COUNT(*) FROM feature"); - const pbisCount = await pool.query("SELECT COUNT(*) FROM pbi"); - const documentsCount = await pool.query("SELECT COUNT(*) FROM documento"); - const chunksCount = await pool.query("SELECT COUNT(*) FROM chunk"); - - res.json({ - projetos: parseInt(projectsCount.rows[0].count, 10), - epicos: parseInt(epicsCount.rows[0].count, 10), - features: parseInt(featuresCount.rows[0].count, 10), - pbis: parseInt(pbisCount.rows[0].count, 10), - documentos: parseInt(documentsCount.rows[0].count, 10), - chunksIndexados: parseInt(chunksCount.rows[0].count, 10), - statusSistema: "operacional", - }); - } catch (error) { - next(error); - } -}); - -// POST /api/v1/admin/ingest-seed - Reindexação de acervo do sistema -adminRouter.post("/ingest-seed", async (_req: Request, res: Response, next: NextFunction) => { - try { - // Insere registros demonstrativos reais de acervo se o acervo estiver zerado - const projectRes = await pool.query("SELECT id FROM projeto LIMIT 1"); - if (projectRes.rows.length === 0) { - res.status(400).json({ error: "Crie ao menos um projeto antes de reindexar." }); - return; - } - - const projectId = projectRes.rows[0].id; - - await pool.query( - `INSERT INTO chunk (projeto_id, entidade_tipo, entidade_id, texto, metadados_json) - VALUES - ($1, 'documento', $1, 'Arquitetura Sinapse: O sistema utiliza PostgreSQL com extensão pgvector para busca vetorial híbrida.', '{"fonte":"docs/Architecture/README.md"}'), - ($1, 'decisao', $1, 'Decisão de Arquitetura: O runtime de IA roda 100% local com Ollama (bge-m3 + qwen2.5:1.5b).', '{"fonte":"docs/PRD-PRO4TECH.md"}') - ON CONFLICT DO NOTHING`, - [projectId] - ); - - res.json({ message: "Reindexação e carga inicial do acervo concluídas com sucesso!", status: "sucesso" }); - } catch (error) { - next(error); - } -}); +import { AdminController } from "./admin.controller.js"; + +export function createAdminRouter(controller = new AdminController(), authenticate: RequestHandler = requireAuth) { + const router = Router(); + router.use(authenticate); + router.use(requireRole("admin")); + router.get("/stats", controller.stats); + router.post("/demo-seed", controller.loadDemo); + // Compatibility alias: explicit project is required on both routes. + router.post("/ingest-seed", controller.loadDemo); + return router; +} + +export const adminRouter = createAdminRouter(); diff --git a/backend/src/modules/admin/admin.service.ts b/backend/src/modules/admin/admin.service.ts new file mode 100644 index 0000000..a58f0bd --- /dev/null +++ b/backend/src/modules/admin/admin.service.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; +import { ValidationError } from "../../shared/errors.js"; +import { AdminRepository } from "./admin.repository.js"; + +const demoSeedSchema = z.object({ projeto_id: z.string().uuid("Selecione um projeto válido para a carga demonstrativa.") }).strict(); + +export class AdminService { + constructor(private readonly repository: Pick = new AdminRepository()) {} + + async stats() { + return { ...await this.repository.counts(), statusSistema: "operacional" }; + } + + async loadDemo(input: unknown, userId: string) { + const parsed = demoSeedSchema.safeParse(input); + if (!parsed.success) throw new ValidationError("Informe o projeto de destino da carga demonstrativa.", parsed.error.flatten()); + const inseridos = await this.repository.insertDemoChunks(parsed.data.projeto_id, userId); + return { + projeto_id: parsed.data.projeto_id, inseridos, status: "sucesso", + message: inseridos ? "Dados demonstrativos carregados. Nenhuma indexação vetorial foi executada." : "Este projeto já possui a carga demonstrativa. Nenhum trecho foi duplicado.", + }; + } +} diff --git a/backend/src/modules/architecture.routes.test.ts b/backend/src/modules/architecture.routes.test.ts new file mode 100644 index 0000000..dbd83c3 --- /dev/null +++ b/backend/src/modules/architecture.routes.test.ts @@ -0,0 +1,112 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import express, { type RequestHandler, type Router } from "express"; +import type { AddressInfo } from "node:net"; +import type { Pool } from "pg"; +import { createAdminRouter } from "./admin/admin.routes.js"; +import { AdminController } from "./admin/admin.controller.js"; +import { AdminService } from "./admin/admin.service.js"; +import { createSearchRouter } from "./search/search.routes.js"; +import { SearchController } from "./search/search.controller.js"; +import { SearchService } from "./search/search.service.js"; +import { SearchRepository } from "./search/search.repository.js"; +import { createDevelopersRouter } from "./developers/developers.routes.js"; +import { DevelopersController } from "./developers/developers.controller.js"; +import { DevelopersService } from "./developers/developers.service.js"; +import { errorHandler } from "../middleware/errorHandler.js"; + +const authenticate: RequestHandler = (req, res, next) => { + const role = req.headers["x-role"]; + if (role !== "admin" && role !== "po" && role !== "dev") { + res.status(401).json({ code: "UNAUTHORIZED" }); + return; + } + req.auth = { id: "user", nome: "Teste", email: "teste@example.com", role }; + next(); +}; + +async function withRouter(router: Router, run: (url: string) => Promise) { + const app = express(); + app.use(express.json(), router, errorHandler); + const server = app.listen(0, "127.0.0.1"); + await new Promise((resolve) => server.once("listening", resolve)); + try { await run("http://127.0.0.1:" + (server.address() as AddressInfo).port); } + finally { await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); } +} + +const projectId = "a0000000-0000-4000-8000-000000000001"; + +test("admin mantém permissões, estatísticas e o contrato da carga sem projeto", async () => { + let calls = 0; + const inserted: string[] = []; + const counts = { projetos: 1, epicos: 2, features: 3, pbis: 4, documentos: 5, chunksIndexados: 6 }; + const service = new AdminService({ + async counts() { calls++; return counts; }, + async insertDemoChunks(id) { inserted.push(id); return 2; }, + }); + await withRouter(createAdminRouter(new AdminController(service), authenticate), async url => { + assert.equal((await fetch(url + "/stats")).status, 401); + for (const role of ["dev", "po"]) { + assert.equal((await fetch(url + "/stats", { headers: { "x-role": role } })).status, 403); + assert.equal((await fetch(url + "/ingest-seed", { method: "POST", headers: { "x-role": role } })).status, 403); + } + assert.equal(calls, 0); + const headers = { "x-role": "admin" }; + assert.deepEqual(await (await fetch(url + "/stats", { headers })).json(), { ...counts, statusSistema: "operacional" }); + const empty = await fetch(url + "/ingest-seed", { method: "POST", headers }); + assert.equal(empty.status, 400); + assert.equal((await empty.json() as { code: string }).code, "VALIDATION_ERROR"); + assert.deepEqual(inserted, []); + const seeded = await fetch(url + "/demo-seed", { method: "POST", headers: { ...headers, "Content-Type": "application/json" }, body: JSON.stringify({ projeto_id: projectId }) }); + assert.equal(seeded.status, 200); + assert.equal(((await seeded.json()) as { status: string }).status, "sucesso"); + assert.deepEqual(inserted, [projectId]); + }); +}); + +test("busca rejeita arrays/objetos, valida UUID e preserva aliases e parâmetros SQL", async () => { + const queries: Array<{ sql: string; params: unknown[] }> = []; + const db = { async query(sql: string, params: unknown[]) { + queries.push({ sql, params }); return { rows: [], rowCount: 0 }; + } } as unknown as Pick; + const controller = new SearchController(new SearchService(new SearchRepository(db))); + await withRouter(createSearchRouter(controller, authenticate), async url => { + const headers = { "x-role": "po" }; + assert.equal((await fetch(url)).status, 401); + for (const query of ["q=a&q=b", "q[x]=a", "projeto_id=bad", "projectId[]=bad", "q=" + "a".repeat(201)]) { + assert.equal((await fetch(url + "?" + query, { headers })).status, 400, query); + } + assert.equal(queries.length, 0); + for (const alias of ["projectId", "projeto_id"]) { + const response = await fetch(url + "?q=100%25_test&" + alias + "=" + projectId, { headers }); + assert.deepEqual(await response.json(), { items: [], total: 0 }); + assert.deepEqual(queries.at(-1)?.params, ["user", "%100\\%\\_test%", projectId]); + assert.match(queries.at(-1)!.sql, /c.projeto_id = \$3/); + } + await fetch(url + "?projeto_id=" + projectId, { headers }); + assert.deepEqual(queries.at(-1)?.params, ["user", projectId]); + assert.match(queries.at(-1)!.sql, /c.projeto_id = \$2/); + await fetch(url, { headers }); + assert.deepEqual(queries.at(-1)?.params, ["user"]); + assert.match(queries.at(-1)!.sql, /access_user.id=\$1/); + }); +}); + +test("desenvolvedores preserva o formato e encaminha erros ao handler global", async () => { + let fail = false; + const service = new DevelopersService({ + async listDevelopers() { if (fail) throw new Error("internal database detail"); return []; }, + async listTechnologies() { return [{ id: "tech", nome: "React", categoria: "frontend" }]; }, + async listCompetencies() { return []; }, + }); + await withRouter(createDevelopersRouter(new DevelopersController(service), authenticate), async url => { + assert.equal((await fetch(url)).status, 401); + const headers = { "x-role": "dev" }; + const response = await fetch(url, { headers }); + assert.deepEqual(await response.json(), { desenvolvedores: [], tecnologias: [{ id: "tech", nome: "React", categoria: "frontend" }], competencias: [] }); + fail = true; + const error = await fetch(url, { headers }); + assert.equal(error.status, 500); + assert.doesNotMatch(await error.text(), /internal database detail/); + }); +}); diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 7a4a72d..90471c9 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -40,12 +40,12 @@ export class AuthController { return; } - if (parsed.data.role === "admin") { + if (parsed.data.role !== "dev") { 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.", + error: "Somente administradores podem criar contas de PO ou administrador.", code: "FORBIDDEN", }); return; diff --git a/backend/src/modules/auth/auth.register-role.test.ts b/backend/src/modules/auth/auth.register-role.test.ts index 9d51e32..1467e1c 100644 --- a/backend/src/modules/auth/auth.register-role.test.ts +++ b/backend/src/modules/auth/auth.register-role.test.ts @@ -74,11 +74,14 @@ test("somente uma sessão de administrador cria outra conta de administrador", a 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); +test("cadastro público concede somente dev e PO exige administrador", async () => { + assert.equal((await register("po")).status, 403); + assert.equal((await register("po", "token-po")).status, 403); + assert.equal((await register("po", "token-dev")).status, 403); + assert.equal((await register("po", "token-admin")).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"]); + assert.deepEqual(service.registered.map((item) => item.role).slice(-3), ["po", "dev", "dev"]); }); test("perfil inválido é recusado com 400", async () => { diff --git a/backend/src/modules/auth/auth.routes.ts b/backend/src/modules/auth/auth.routes.ts index 8f2de95..ed0f206 100644 --- a/backend/src/modules/auth/auth.routes.ts +++ b/backend/src/modules/auth/auth.routes.ts @@ -37,7 +37,7 @@ export const authRouter = Router(); * role: * type: string * enum: [admin, po, dev] - * default: po + * default: dev * description: Função do usuário * responses: * 201: diff --git a/backend/src/modules/auth/auth.types.ts b/backend/src/modules/auth/auth.types.ts index 326a354..615a78c 100644 --- a/backend/src/modules/auth/auth.types.ts +++ b/backend/src/modules/auth/auth.types.ts @@ -37,7 +37,7 @@ export const registerSchema = z.object({ role: z .enum(USER_ROLES, { invalid_type_error: "Função inválida." }) .optional() - .default("po"), + .default("dev"), }); export type RegisterDTO = z.infer; diff --git a/backend/src/modules/chat/chat.fakes.ts b/backend/src/modules/chat/chat.fakes.ts index 450e014..f22b3b6 100644 --- a/backend/src/modules/chat/chat.fakes.ts +++ b/backend/src/modules/chat/chat.fakes.ts @@ -46,10 +46,15 @@ export class FakeChatRepository extends ChatRepository { 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 addMessage(conversaId: string, remetente: "user" | "assistant", conteudo: string, fontes: ChatSource[] = []): Promise { + const id = this.id("d"); + this.messages.push({ id, conversa_id: conversaId, remetente, conteudo, fontes_json: fontes, created_at: new Date().toISOString(), processing_status: remetente === "user" ? "pending" : "completed" }); + return id; } + async accessibleProjects() { return [{id:PROJECT_A,nome:"A"},{id:PROJECT_B,nome:"B"}]; } + async finishMessage(conversaId: string, messageId: string, conteudo: string, fontes: ChatSource[]) { await this.addMessage(conversaId, "assistant", conteudo, fontes); this.messages.find(m=>m.id===messageId)!.processing_status="completed"; } + async failMessage(id: string) { this.messages.find(m=>m.id===id)!.processing_status="failed"; } async searchChunks(projetoId: string | null, patterns: string[], limit: number): Promise { this.searches.push({ projetoId, patterns }); if (patterns.length === 0) return []; diff --git a/backend/src/modules/chat/chat.repository.db.test.ts b/backend/src/modules/chat/chat.repository.db.test.ts index e678ce1..dd7d0d2 100644 --- a/backend/src/modules/chat/chat.repository.db.test.ts +++ b/backend/src/modules/chat/chat.repository.db.test.ts @@ -7,6 +7,8 @@ import { ChatRepository } from "./chat.repository.js"; import { ChatService, searchPatterns, type AssistantClient } from "./chat.service.js"; import { NotFoundError } from "../../shared/errors.js"; +import { SearchRepository } from "../search/search.repository.js"; + class Offline implements AssistantClient { async ask(): Promise { throw new Error("offline"); @@ -25,11 +27,29 @@ test("chat: posse da conversa e busca textual isolada por projeto no PostgreSQL" 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()); + await assert.rejects(service.query(ana, { pergunta: "arquivamento" }), /Escolha um projeto/); assert.deepEqual(await repository.searchChunks(projectA, searchPatterns("inexistente"), 5), []); assert.deepEqual(await repository.searchChunks(projectA, [], 5), []); + // An unallocated developer cannot query a project, including via an old conversation. + await pool.query("UPDATE usuario SET role='dev' WHERE id=$1",[bruno]); + assert.equal(await repository.projectExists(projectA,bruno),false); + await assert.rejects(service.query(bruno,{pergunta:"arquivamento",projetoId:projectA}),NotFoundError); + const developer = (await pool.query("INSERT INTO desenvolvedor(usuario_id) VALUES($1) RETURNING id",[bruno])).rows[0].id; + await pool.query("INSERT INTO alocacao(desenvolvedor_id,projeto_id) VALUES($1,$2)",[developer,projectA]); + assert.equal(await repository.projectExists(projectA,bruno),true); + assert.equal(await repository.projectExists(projectB,bruno),false); + assert.deepEqual((await repository.accessibleProjects(bruno)).map(p=>p.id),[projectA]); + + const search = new SearchRepository(pool); + assert.deepEqual((await search.search({userId: bruno, query: ""})).items.map(c=>c.id), [chunkA]); + assert.deepEqual((await search.search({userId: bruno, query: "", projectId: projectB})).items, []); + assert.equal((await search.search({userId: ana, query: "", projectId: projectB})).items[0].id, chunkB); + await pool.query("UPDATE alocacao SET data_fim=CURRENT_TIMESTAMP WHERE desenvolvedor_id=$1",[developer]); + assert.deepEqual((await search.search({userId: bruno, query: ""})).items, []); + await pool.query("UPDATE alocacao SET data_fim=NULL, data_inicio=CURRENT_TIMESTAMP + INTERVAL '1 day' WHERE desenvolvedor_id=$1",[developer]); + assert.deepEqual((await search.search({userId: bruno, query: ""})).items, []); + 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")); @@ -38,6 +58,12 @@ test("chat: posse da conversa e busca textual isolada por projeto no PostgreSQL" 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"]); + const pending = await repository.addMessage(result.conversa_id,"user","Outra pergunta"); + await assert.rejects(repository.addMessage(result.conversa_id,"user","Concorrente"), /Aguarde/); + await assert.rejects(repository.finishMessage(result.conversa_id,pending,"Inválido\u0000",[])); + assert.equal((await repository.listMessages(result.conversa_id)).find(m=>m.id===pending)?.processing_status,"pending"); + await repository.failMessage(pending); + assert.equal((await repository.listMessages(result.conversa_id)).find(m=>m.id===pending)?.processing_status,"failed"); assert.equal((await service.listConversations(bruno)).length, 0); assert.equal((await service.listConversations(ana))[0].projeto_nome, projectA); } finally { diff --git a/backend/src/modules/chat/chat.repository.ts b/backend/src/modules/chat/chat.repository.ts index 51168b0..c123d4f 100644 --- a/backend/src/modules/chat/chat.repository.ts +++ b/backend/src/modules/chat/chat.repository.ts @@ -1,3 +1,6 @@ +import { projectAccessSql } from "../projects/project-access.js"; +import { withTransaction } from "../../database/transaction.js"; +import { ConflictError } from "../../shared/errors.js"; import { Pool } from "pg"; import { pool } from "../../database/db.js"; import type { ChatMessage, ChatSource, ChunkMatch, Conversation } from "./chat.types.js"; @@ -21,11 +24,17 @@ export class ChatRepository { return result.rows; } - async projectExists(projetoId: string): Promise { - const result = await this.pool.query("SELECT 1 FROM projeto WHERE id = $1", [projetoId]); + async projectExists(projetoId: string, usuarioId: string): Promise { + const result = await this.pool.query(`SELECT p.id FROM projeto p WHERE p.id=$1 AND ${projectAccessSql("p.id","$2")}`, [projetoId, usuarioId]); return (result.rowCount ?? 0) > 0; } + async accessibleProjects(usuarioId: string): Promise> { + const result = await this.pool.query<{ id: string; nome: string }>( + `SELECT p.id,p.nome FROM projeto p WHERE ${projectAccessSql("p.id","$1")} ORDER BY p.nome,p.id`, [usuarioId]); + return result.rows; + } + 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) @@ -47,26 +56,43 @@ export class ChatRepository { 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 + `SELECT id, conversa_id, remetente, conteudo, COALESCE(fontes_json, '[]'::jsonb) AS fontes_json, CASE WHEN processing_status='pending' AND created_at < CURRENT_TIMESTAMP - INTERVAL '2 minutes' THEN 'failed' ELSE processing_status END AS processing_status, 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 addMessage(conversaId: string, remetente: "user" | "assistant", conteudo: string, fontes: ChatSource[] = []): Promise { + return withTransaction(this.pool, async client => { + await client.query("SELECT id FROM conversa WHERE id=$1 FOR UPDATE", [conversaId]); + await client.query("UPDATE mensagem SET processing_status='failed' WHERE conversa_id=$1 AND processing_status='pending' AND created_at < CURRENT_TIMESTAMP - INTERVAL '2 minutes'", [conversaId]); + if (remetente === "user" && (await client.query("SELECT 1 FROM mensagem WHERE conversa_id=$1 AND processing_status='pending'", [conversaId])).rowCount) throw new ConflictError("Aguarde a resposta em andamento nesta conversa."); + const result = await client.query<{ id: string }>("INSERT INTO mensagem (conversa_id,remetente,conteudo,fontes_json,processing_status) VALUES ($1,$2,$3,$4::jsonb,$5) RETURNING id", [conversaId, remetente, conteudo, JSON.stringify(fontes), remetente === "user" ? "pending" : "completed"]); + await client.query("UPDATE conversa SET updated_at=CURRENT_TIMESTAMP WHERE id=$1", [conversaId]); + return result.rows[0].id; + }); + } + + async finishMessage(conversaId: string, messageId: string, conteudo: string, fontes: ChatSource[]): Promise { + await withTransaction(this.pool, async client => { + await client.query("SELECT id FROM conversa WHERE id=$1 FOR UPDATE", [conversaId]); + const result = await client.query("UPDATE mensagem SET processing_status='completed' WHERE id=$1 AND conversa_id=$2 AND processing_status='pending' RETURNING id", [messageId, conversaId]); + if (!result.rowCount) throw new ConflictError("A pergunta não está mais em processamento."); + await client.query("INSERT INTO mensagem(conversa_id,remetente,conteudo,fontes_json) VALUES ($1,'assistant',$2,$3::jsonb)", [conversaId, conteudo, JSON.stringify(fontes)]); + await client.query("UPDATE conversa SET updated_at=CURRENT_TIMESTAMP WHERE id=$1", [conversaId]); + }); + } + + async failMessage(messageId: string): Promise { + await this.pool.query("UPDATE mensagem SET processing_status='failed' WHERE id=$1 AND processing_status='pending'", [messageId]); } - async searchChunks(projetoId: string | null, patterns: string[], limit: number): Promise { + async searchChunks(projetoId: string, 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[]) + WHERE projeto_id = $1 AND texto ILIKE ANY($2::text[]) ORDER BY created_at DESC, id LIMIT $3`, [projetoId, patterns, limit], ); diff --git a/backend/src/modules/chat/chat.routes.ts b/backend/src/modules/chat/chat.routes.ts index 2415646..3e2b9ce 100644 --- a/backend/src/modules/chat/chat.routes.ts +++ b/backend/src/modules/chat/chat.routes.ts @@ -16,6 +16,8 @@ export function createChatRouter(service: ChatService = new ChatService(), authe const router = Router(); router.use(authentication); + router.get("/projects", async (req, res, next) => { try { res.json({ items: await service.accessibleProjects(userId(req)) }); } catch (error) { next(error); } }); + router.get("/conversations", async (req: Request, res: Response, next: NextFunction) => { try { const items = await service.listConversations(userId(req)); diff --git a/backend/src/modules/chat/chat.service.test.ts b/backend/src/modules/chat/chat.service.test.ts index 446f960..ae7ca2e 100644 --- a/backend/src/modules/chat/chat.service.test.ts +++ b/backend/src/modules/chat/chat.service.test.ts @@ -10,6 +10,24 @@ function setup() { return { repository, assistant, service: new ChatService(repository, assistant) }; } +test("recusa consultas globais e troca de projeto antes de gravar mensagens", async () => { + const { service, repository, assistant } = setup(); + await assert.rejects(service.query(ANA,{pergunta:"Consultar decisões"}),ValidationError); + const conversation = await repository.createConversation(ANA,PROJECT_A,"A"); + await assert.rejects(service.query(ANA,{pergunta:"Consultar decisões",conversaId:conversation.id,projetoId:PROJECT_B}),ValidationError); + assert.equal(repository.messages.length,0); + assert.equal(assistant.calls.length,0); +}); + +test("falha do processamento fica registrada no histórico", async () => { + const { service, repository, assistant } = setup(); + assistant.available=false; + repository.searchChunks=async () => { throw new Error("banco indisponível"); }; + await assert.rejects(service.query(ANA,{pergunta:"Consultar decisões",projetoId:PROJECT_A}), /Não foi possível processar/); + assert.equal(repository.messages.length,1); + assert.equal(repository.messages[0].processing_status,"failed"); +}); + 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 }); @@ -99,7 +117,7 @@ test("fontes do assistente são saneadas e limitadas", () => { 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, { projetoId: PROJECT_A })).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 index 7f16993..0621b14 100644 --- a/backend/src/modules/chat/chat.service.ts +++ b/backend/src/modules/chat/chat.service.ts @@ -1,6 +1,7 @@ +import { serviceHeaders } from "../../shared/service-auth.js"; import axios from "axios"; import { env } from "../../config/env.js"; -import { NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; +import { AppError, NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; import { ChatRepository } from "./chat.repository.js"; import { MAX_QUESTION_LENGTH, @@ -32,16 +33,26 @@ export function sanitizeSources(value: unknown): ChatSource[] { } export class HttpAssistantClient implements AssistantClient { - constructor(private readonly baseUrl: string = env.AI_SERVICE_URL) {} + constructor(private readonly baseUrl: string = env.AI_SERVICE_URL, private readonly repository = new ChatRepository()) { } 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 (!projetoId) throw new ValidationError("Escolha um projeto."); + const chunks = await this.repository.searchChunks(projetoId, searchPatterns(pergunta), 5); + if (!chunks.length) return { resposta: NOT_FOUND_ANSWER, fontes: [] }; + const response = await axios.post(this.baseUrl + "/rag/query", { query: pergunta, project_id: projetoId, context_chunks: chunks.map(c => "[" + c.id + "] " + c.texto) }, { headers: serviceHeaders(), timeout: 45000 }); + const text = response.data?.response; if (typeof text !== "string" || !text.trim()) throw new Error("Resposta vazia do assistente"); - return { resposta: text, fontes: sanitizeSources(response.data?.fontes) }; + return { resposta: text, fontes: citedSources(text, chunks) }; } } +export function citedSources(text: string, chunks: Array<{id: string; entidade_tipo: string}>): ChatSource[] { + const ids = [...text.matchAll(/\[([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\]/gi)].map(match => match[1].toLowerCase()); + const byId = new Map(chunks.map(chunk => [chunk.id.toLowerCase(), chunk])); + if (!ids.length || ids.some(id => !byId.has(id))) throw new Error("Resposta sem citações válidas"); + return [...new Set(ids)].map(id => ({ id, titulo: "Trecho citado de " + byId.get(id)!.entidade_tipo, tipo: byId.get(id)!.entidade_tipo })); +} + export function searchPatterns(question: string): string[] { const terms = question .toLocaleLowerCase("pt-BR") @@ -58,22 +69,26 @@ function makeTitle(question: string): string { export class ChatService { constructor( private readonly repository: ChatRepository = new ChatRepository(), - private readonly assistant: AssistantClient = new HttpAssistantClient(), - ) {} + private readonly assistant: AssistantClient = new HttpAssistantClient(env.AI_SERVICE_URL, repository), + ) { } - listConversations(usuarioId: string): Promise { - return this.repository.listConversations(usuarioId); + accessibleProjects(usuarioId: string) { return this.repository.accessibleProjects(usuarioId); } + + async listConversations(usuarioId: string): Promise { + const items = await this.repository.listConversations(usuarioId); + const permitted = new Set((await this.repository.accessibleProjects(usuarioId)).map(p => p.id)); + return items.filter(c => c.projeto_id !== null && permitted.has(c.projeto_id)); } - private async requireProject(projetoId: string | undefined): Promise { - if (!projetoId) return null; + private async requireProject(projetoId: string | undefined, usuarioId: string): Promise { + if (!projetoId) throw new ValidationError("Escolha um projeto para iniciar a conversa."); validateUuid(projetoId, "ID do projeto"); - if (!(await this.repository.projectExists(projetoId))) throw new NotFoundError("Projeto não encontrado."); + if (!(await this.repository.projectExists(projetoId, usuarioId))) 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 projetoId = await this.requireProject(input.projetoId, usuarioId); const titulo = typeof input.titulo === "string" && input.titulo.trim() ? input.titulo.trim().slice(0, 120) : "Nova conversa"; return this.repository.createConversation(usuarioId, projetoId, titulo); } @@ -82,6 +97,7 @@ export class ChatService { validateUuid(conversaId, "ID da conversa"); const conversation = await this.repository.findOwnedConversation(conversaId, usuarioId); if (!conversation) throw new NotFoundError("Conversa não encontrada."); + await this.requireProject(conversation.projeto_id ?? undefined, usuarioId); return this.repository.listMessages(conversaId); } @@ -90,39 +106,46 @@ export class ChatService { 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 projetoId: string; 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; + if (input.projetoId && input.projetoId !== conversation.projeto_id) throw new ValidationError("O projeto da conversa não pode ser alterado."); + projetoId = await this.requireProject(conversation.projeto_id ?? undefined, usuarioId); } else { + projetoId = await this.requireProject(input.projetoId, usuarioId); conversaId = (await this.repository.createConversation(usuarioId, projetoId, makeTitle(pergunta))).id; } - await this.repository.addMessage(conversaId, "user", pergunta); - - let answer: AssistantAnswer; - let origem: QueryResult["origem"]; + const messageId = await this.repository.addMessage(conversaId, "user", pergunta); 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"; + + let answer: AssistantAnswer; + let origem: QueryResult["origem"]; + try { + answer = await this.assistant.ask(pergunta, projetoId ?? undefined); + origem = answer.resposta === NOT_FOUND_ANSWER ? "sem_resultado" : "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 }; + await this.repository.finishMessage(conversaId, messageId, answer.resposta, answer.fontes); + return { conversa_id: conversaId, resposta: answer.resposta, fontes: answer.fontes, origem }; + } catch (error) { + await this.repository.failMessage(messageId).catch(() => undefined); + throw new AppError("Não foi possível processar a pergunta. Tente novamente.", 503, "CHAT_FAILED", { conversa_id: conversaId, mensagem_id: messageId }); + } } } diff --git a/backend/src/modules/chat/chat.sources.test.ts b/backend/src/modules/chat/chat.sources.test.ts new file mode 100644 index 0000000..7a0b77f --- /dev/null +++ b/backend/src/modules/chat/chat.sources.test.ts @@ -0,0 +1,10 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { citedSources } from "./chat.service.js"; +const a="10000000-0000-4000-8000-000000000001", b="10000000-0000-4000-8000-000000000002"; +test("fontes incluem apenas IDs citados presentes no contexto",()=>{ + const chunks=[{id:a,entidade_tipo:"documento"},{id:b,entidade_tipo:"pbi"}]; + assert.deepEqual(citedSources('Resposta ['+a+'] ['+a+']',chunks).map(s=>s.id),[a]); + assert.throws(()=>citedSources("Resposta sem referência",chunks)); + assert.throws(()=>citedSources('Resposta ['+b+']',chunks.slice(0,1))); +}); diff --git a/backend/src/modules/chat/chat.types.ts b/backend/src/modules/chat/chat.types.ts index 8720581..658d1e5 100644 --- a/backend/src/modules/chat/chat.types.ts +++ b/backend/src/modules/chat/chat.types.ts @@ -22,6 +22,7 @@ export interface ChatMessage { remetente: "user" | "assistant" | "system"; conteudo: string; fontes_json: ChatSource[]; + processing_status?: "pending" | "completed" | "failed"; created_at: string; } diff --git a/backend/src/modules/criteria/criteria.repository.ts b/backend/src/modules/criteria/criteria.repository.ts index 7187a91..6d89bac 100644 --- a/backend/src/modules/criteria/criteria.repository.ts +++ b/backend/src/modules/criteria/criteria.repository.ts @@ -1,13 +1,14 @@ -import { lockHierarchy, assertWritable } from "../projects/hierarchy-archive.js"; import { Pool, PoolClient } from "pg"; import { pool } from "../../database/db.js"; -import { CreateCriterionDTO, Criterion, CriterionEntityType } from "./criteria.types.js"; -import { auditService } from "../audit/audit.service.js"; +import { withTransaction } from "../../database/transaction.js"; import { ValidationError } from "../../shared/errors.js"; +import { auditService } from "../audit/audit.service.js"; +import { assertWritable, lockHierarchy } from "../projects/hierarchy-archive.js"; import { assertJustificationForCompletedItem, normalizeJustification, } from "../quality/completed-item-policy.js"; +import { CreateCriterionDTO, Criterion, CriterionEntityType } from "./criteria.types.js"; const ENTITY_TABLE: Record = { epico: "epico", @@ -100,6 +101,7 @@ export class CriteriaRepository { // entre movimentos concorrentes em direções opostas (dois SELECT ... FOR UPDATE em ordens // diferentes causariam espera circular). O lock é liberado automaticamente no COMMIT/ROLLBACK. private async lockEntity(client: PoolClient, tipo: CriterionEntityType, entidadeId: string): Promise { + await lockHierarchy(client, tipo, entidadeId); await client.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [`${tipo}:${entidadeId}`]); await assertWritable(client, tipo, entidadeId); } @@ -109,8 +111,8 @@ export class CriteriaRepository { return result.rows[0] ?? null; } - async listByEntity(tipo: CriterionEntityType, entidadeId: string): Promise { - const result = await this.pool.query( + async listByEntity(tipo: CriterionEntityType, entidadeId: string, executor: Pool | PoolClient = this.pool): Promise { + const result = await executor.query( `SELECT * FROM criterio_aceitacao WHERE entidade_tipo = $1 AND entidade_id = $2 ORDER BY ordem ASC`, [tipo, entidadeId], ); @@ -135,11 +137,7 @@ export class CriteriaRepository { } async create(dto: CreateCriterionDTO, usuarioId?: string | null): Promise { - const client: PoolClient = await this.pool.connect(); - - try { - await client.query("BEGIN"); - await lockHierarchy(client); + return withTransaction(this.pool, async (client) => { await this.lockEntity(client, dto.entidade_tipo, dto.entidade_id); await assertJustificationForCompletedItem( client, @@ -186,14 +184,9 @@ export class CriteriaRepository { client, ); - await client.query("COMMIT"); return created; - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } + + }); } async delete( @@ -201,15 +194,11 @@ export class CriteriaRepository { usuarioId?: string | null, justificativa?: string | null, ): Promise { - const client: PoolClient = await this.pool.connect(); - - try { - await client.query("BEGIN"); - await lockHierarchy(client); + return withTransaction(this.pool, async (client) => { const peek = await client.query(`SELECT entidade_tipo, entidade_id FROM criterio_aceitacao WHERE id = $1`, [id]); if (!peek.rows[0]) { - await client.query("ROLLBACK"); + return null; } await this.lockEntity(client, peek.rows[0].entidade_tipo, peek.rows[0].entidade_id); @@ -217,7 +206,7 @@ export class CriteriaRepository { const existing = await client.query(`SELECT * FROM criterio_aceitacao WHERE id = $1`, [id]); const removed = existing.rows[0]; if (!removed) { - await client.query("ROLLBACK"); + return null; } @@ -255,14 +244,9 @@ export class CriteriaRepository { client, ); - await client.query("COMMIT"); return removed; - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } + + }); } async move( @@ -271,15 +255,11 @@ export class CriteriaRepository { usuarioId?: string | null, justificativa?: string | null, ): Promise { - const client: PoolClient = await this.pool.connect(); - - try { - await client.query("BEGIN"); - await lockHierarchy(client); + return withTransaction(this.pool, async (client) => { const peek = await client.query(`SELECT entidade_tipo, entidade_id FROM criterio_aceitacao WHERE id = $1`, [id]); if (!peek.rows[0]) { - await client.query("ROLLBACK"); + return null; } await this.lockEntity(client, peek.rows[0].entidade_tipo, peek.rows[0].entidade_id); @@ -287,7 +267,7 @@ export class CriteriaRepository { const currentResult = await client.query(`SELECT * FROM criterio_aceitacao WHERE id = $1`, [id]); const current = currentResult.rows[0]; if (!current) { - await client.query("ROLLBACK"); + return null; } @@ -306,7 +286,7 @@ export class CriteriaRepository { `SELECT * FROM criterio_aceitacao WHERE entidade_tipo = $1 AND entidade_id = $2 ORDER BY ordem ASC`, [current.entidade_tipo, current.entidade_id], ); - await client.query("COMMIT"); + return listaSemAlteracao.rows; } @@ -339,14 +319,9 @@ export class CriteriaRepository { [current.entidade_tipo, current.entidade_id], ); - await client.query("COMMIT"); return listaAtualizada.rows; - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } + + }); } } diff --git a/backend/src/modules/decisions/decisions.db.test.ts b/backend/src/modules/decisions/decisions.db.test.ts index b9cfc0f..ecc3bdd 100644 --- a/backend/src/modules/decisions/decisions.db.test.ts +++ b/backend/src/modules/decisions/decisions.db.test.ts @@ -63,7 +63,7 @@ test("S1-18: decisões em qualquer nível, herança, isolamento e arquivamento n const archiver = await pool.connect(); try { await archiver.query("BEGIN"); - await lockHierarchy(archiver); + await lockHierarchy(archiver, "projeto", archivedProject); await archiver.query("UPDATE projeto SET status='arquivado' WHERE id=$1", [archivedProject]); const attempt = service.create("projeto", archivedProject, user, body("Corrida")); await new Promise((resolve) => setTimeout(resolve, 150)); diff --git a/backend/src/modules/decisions/decisions.repository.ts b/backend/src/modules/decisions/decisions.repository.ts index 140507d..11dcbcf 100644 --- a/backend/src/modules/decisions/decisions.repository.ts +++ b/backend/src/modules/decisions/decisions.repository.ts @@ -68,7 +68,7 @@ export class DecisionsRepository { const client = await this.pool.connect(); try { await client.query("BEGIN"); - await lockHierarchy(client); + await lockHierarchy(client, input.entidadeTipo, input.entidadeId); await assertWritable(client, input.entidadeTipo, input.entidadeId); const inserted = await client.query<{ id: string }>( `INSERT INTO decisao (entidade_tipo, entidade_id, titulo, contexto, decisao, justificativa, alternativas, autor_id) diff --git a/backend/src/modules/developers/developers.controller.ts b/backend/src/modules/developers/developers.controller.ts new file mode 100644 index 0000000..ac0f189 --- /dev/null +++ b/backend/src/modules/developers/developers.controller.ts @@ -0,0 +1,14 @@ +import type { NextFunction, Request, Response } from "express"; +import { DevelopersService } from "./developers.service.js"; + +export class DevelopersController { + constructor(private readonly service: DevelopersService = new DevelopersService()) {} + + list = async (_req: Request, res: Response, next: NextFunction): Promise => { + try { + res.json(await this.service.overview()); + } catch (error) { + next(error); + } + }; +} diff --git a/backend/src/modules/developers/developers.repository.ts b/backend/src/modules/developers/developers.repository.ts new file mode 100644 index 0000000..3fa82da --- /dev/null +++ b/backend/src/modules/developers/developers.repository.ts @@ -0,0 +1,28 @@ +import type { Developer, DeveloperTechnology, Competency } from "./developers.types.js"; +import type { Pool } from "pg"; +import { pool } from "../../database/db.js"; + +export class DevelopersRepository { + constructor(private readonly db: Pick = pool) {} + + async listDevelopers(): Promise { + const { rows } = await this.db.query( + `SELECT d.id, d.senioridade, d.bio, u.nome, u.email, u.role + FROM desenvolvedor d JOIN usuario u ON d.usuario_id = u.id ORDER BY u.nome ASC`, + ); + return rows; + } + + async listTechnologies(): Promise { + const { rows } = await this.db.query("SELECT id, nome, categoria FROM tecnologia ORDER BY nome ASC"); + return rows; + } + + async listCompetencies(): Promise { + const { rows } = await this.db.query( + `SELECT c.id, c.desenvolvedor_id, c.tecnologia_id, c.nivel, c.evidencia, t.nome AS tecnologia_nome + FROM competencia c JOIN tecnologia t ON c.tecnologia_id = t.id`, + ); + return rows; + } +} diff --git a/backend/src/modules/developers/developers.routes.ts b/backend/src/modules/developers/developers.routes.ts index 68a069c..645ca49 100644 --- a/backend/src/modules/developers/developers.routes.ts +++ b/backend/src/modules/developers/developers.routes.ts @@ -1,35 +1,12 @@ -import { Router, Request, Response, NextFunction } from "express"; -import { pool } from "../../database/db.js"; +import { Router, type RequestHandler } from "express"; import { requireAuth } from "../../middleware/requireAuth.js"; +import { DevelopersController } from "./developers.controller.js"; -export const developersRouter = Router(); +export function createDevelopersRouter(controller = new DevelopersController(), authenticate: RequestHandler = requireAuth) { + const router = Router(); + router.use(authenticate); + router.get("/", controller.list); + return router; +} -developersRouter.use(requireAuth); - -// GET /api/v1/developers - Mapeamento de Desenvolvedores e Competências -developersRouter.get("/", async (_req: Request, res: Response, next: NextFunction) => { - try { - const devsRes = await pool.query(` - SELECT d.id, d.senioridade, d.bio, u.nome, u.email, u.role - FROM desenvolvedor d - JOIN usuario u ON d.usuario_id = u.id - ORDER BY u.nome ASC - `); - - const techRes = await pool.query("SELECT id, nome, categoria FROM tecnologia ORDER BY nome ASC"); - - const compsRes = await pool.query(` - SELECT c.id, c.desenvolvedor_id, c.tecnologia_id, c.nivel, c.evidencia, t.nome as tecnologia_nome - FROM competencia c - JOIN tecnologia t ON c.tecnologia_id = t.id - `); - - res.json({ - desenvolvedores: devsRes.rows, - tecnologias: techRes.rows, - competencias: compsRes.rows, - }); - } catch (error) { - next(error); - } -}); +export const developersRouter = createDevelopersRouter(); diff --git a/backend/src/modules/developers/developers.service.ts b/backend/src/modules/developers/developers.service.ts new file mode 100644 index 0000000..a0b6e98 --- /dev/null +++ b/backend/src/modules/developers/developers.service.ts @@ -0,0 +1,15 @@ +import type { DevelopersOverview } from "./developers.types.js"; +import { DevelopersRepository } from "./developers.repository.js"; + +export class DevelopersService { + constructor(private readonly repository: Pick = new DevelopersRepository()) {} + + async overview(): Promise { + const [desenvolvedores, tecnologias, competencias] = await Promise.all([ + this.repository.listDevelopers(), + this.repository.listTechnologies(), + this.repository.listCompetencies(), + ]); + return { desenvolvedores, tecnologias, competencias }; + } +} diff --git a/backend/src/modules/developers/developers.types.ts b/backend/src/modules/developers/developers.types.ts new file mode 100644 index 0000000..180906c --- /dev/null +++ b/backend/src/modules/developers/developers.types.ts @@ -0,0 +1,31 @@ +import type { UserRole } from "../auth/auth.types.js"; + +export interface Developer { + id: string; + senioridade: string | null; + bio: string | null; + nome: string; + email: string; + role: UserRole; +} + +export interface DeveloperTechnology { + id: string; + nome: string; + categoria: string | null; +} + +export interface Competency { + id: string; + desenvolvedor_id: string; + tecnologia_id: string; + nivel: string | null; + evidencia: string | null; + tecnologia_nome: string; +} + +export interface DevelopersOverview { + desenvolvedores: Developer[]; + tecnologias: DeveloperTechnology[]; + competencias: Competency[]; +} diff --git a/backend/src/modules/documents/documents.controller.ts b/backend/src/modules/documents/documents.controller.ts index 5a60830..8f88d0c 100644 --- a/backend/src/modules/documents/documents.controller.ts +++ b/backend/src/modules/documents/documents.controller.ts @@ -45,6 +45,13 @@ export class DocumentsController { } }; + reprocess = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + await this.service.reprocess(paramOf(req.params.projectId), paramOf(req.params.documentId)); + res.status(202).json({ status: "pendente" }); + } catch (error) { next(error); } + }; + remove = async (req: Request, res: Response, next: NextFunction): Promise => { try { await this.service.remove({ diff --git a/backend/src/modules/documents/documents.diagnostics.test.ts b/backend/src/modules/documents/documents.diagnostics.test.ts new file mode 100644 index 0000000..9c5febe --- /dev/null +++ b/backend/src/modules/documents/documents.diagnostics.test.ts @@ -0,0 +1,12 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { classifyIngestionFailure } from "./documents.ingestion.js"; +test("diagnósticos não vazam corpo, token ou mensagem da exceção",()=>{ + for(const [status,retry] of [[422,false],[401,false],[429,true],[503,true]] as const) { + const result=classifyIngestionFailure({isAxiosError:true,message:"segredo",response:{status,data:"conteúdo privado"}}); + assert.equal(result.retryable,retry);assert.equal(result.httpStatus,status); + assert.doesNotMatch(JSON.stringify(result),/segredo|privado/); + } + assert.equal(classifyIngestionFailure({isAxiosError:true,code:"ECONNABORTED"}).code,"TIMEOUT"); + assert.equal(classifyIngestionFailure({code:"ENOENT"}).retryable,false); +}); diff --git a/backend/src/modules/documents/documents.ingestion.db.test.ts b/backend/src/modules/documents/documents.ingestion.db.test.ts new file mode 100644 index 0000000..fb39011 --- /dev/null +++ b/backend/src/modules/documents/documents.ingestion.db.test.ts @@ -0,0 +1,74 @@ +import { DocumentsRepository } from "./documents.repository.js"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve, sep } from "node:path"; +import { createServer } from "node:http"; +import { Pool } from "pg"; +import { validateTarget } from "../../database/seed-lib.js"; +import { LocalDocumentStorage } from "./documents.storage.js"; +import { DocumentIngestionRepository, DocumentIngestionWorker } from "./documents.ingestion.js"; + +test("ingestão: falha, retry, lease, vetores e remoção sem ressuscitar documento", {skip:!process.env.ARCHIVE_TEST_DATABASE_URL}, async () => { + const db = new Pool({connectionString:validateTarget(process.env.ARCHIVE_TEST_DATABASE_URL,"test"),max:1}); + const [project,id] = [randomUUID(),randomUUID()]; + const folder=await mkdtemp(join(tmpdir(),"sinapse-ingestion-")); + assert.ok(resolve(folder).startsWith(resolve(tmpdir())+sep)); + const storage=new LocalDocumentStorage(folder); + const repository=new DocumentIngestionRepository(db); + let fail=true; + const server=createServer(async (req,res) => { + const buffers=[]; for await(const part of req) buffers.push(part); + const request=JSON.parse(Buffer.concat(buffers).toString()); + assert.equal(request.project_id,project); + assert.equal(Buffer.from(request.content_base64,"base64").toString(),"Documento de teste"); + res.setHeader("Content-Type","application/json"); + res.statusCode=fail?503:200; + res.end(JSON.stringify(fail?{error:"offline"}:{document_id:id,project_id:project,chunks:[{text:"Documento de teste",embedding:Array(1024).fill(0.1)}]})); + }); + await new Promise(resolve=>server.listen(0,"127.0.0.1",resolve)); + const address=server.address() as {port:number}; + const worker=new DocumentIngestionWorker(repository,storage,`http://127.0.0.1:${address.port}`); + const count=async()=>Number((await db.query("SELECT count(*) FROM chunk WHERE projeto_id=$1",[project])).rows[0].count); + try { + await db.query("INSERT INTO projeto(id,nome,cliente,status) VALUES($1::uuid,$1::text,'Teste','ativo')",[project]); + await storage.save(`${project}/${id}`,Buffer.from("Documento de teste")); + await storage.finalizeUpload(`${project}/${id}`); + await db.query("INSERT INTO documento(id,projeto_id,nome,caminho,status_processamento) VALUES($1,$2,'teste.txt',$3,'pendente')",[id,project,`${project}/${id}`]); + await worker.tick(project); + assert.equal((await db.query("SELECT status_processamento FROM documento WHERE id=$1",[id])).rows[0].status_processamento,"falha"); + assert.equal(await count(),0); + const listing = new DocumentsRepository(db); + const failed = (await listing.listByProject(project,null,20)).items[0]; + assert.equal(failed.nova_tentativa_pendente,true); + assert.equal(failed.erro_processamento_codigo,"SERVICE_HTTP_ERROR"); + assert.equal(failed.tentativas_processamento,1); + await repository.retry(project,id); + const job=(await repository.claim(project))!; + assert.equal(await repository.claim(project),null); + await assert.rejects(repository.finish(job,[{text:"Inválido",embedding:[1]}])); + assert.equal(await count(),0); + await repository.fail(job,{code:"INVALID_DOCUMENT",retryable:false}); + assert.equal((await listing.listByProject(project,null,20)).items[0].nova_tentativa_pendente,false); + assert.equal(await repository.claim(project),null); + await repository.retry(project,id); + fail=false; await worker.tick(project); + assert.equal(await count(),1); + assert.equal((await db.query("SELECT vector_dims(embedding) n FROM chunk WHERE projeto_id=$1",[project])).rows[0].n,1024); + assert.equal((await db.query("SELECT status_processamento FROM documento WHERE id=$1",[id])).rows[0].status_processamento,"processado"); + await repository.retry(project,id); await worker.tick(project); + assert.equal(await count(),1); + await repository.retry(project,id); + const stale=(await repository.claim(project))!; + await db.query("DELETE FROM documento WHERE id=$1",[id]); + await db.query("DELETE FROM chunk WHERE projeto_id=$1",[project]); + await repository.finish(stale,[{text:"Resposta atrasada",embedding:Array(1024).fill(0.1)}]); + assert.equal(await count(),0); + } finally { + server.closeAllConnections(); await new Promise(resolve=>server.close(()=>resolve())); + await db.query("DELETE FROM projeto WHERE id=$1",[project]); await db.end(); + await rm(folder,{recursive:true,force:true}); + } +}); diff --git a/backend/src/modules/documents/documents.ingestion.ts b/backend/src/modules/documents/documents.ingestion.ts new file mode 100644 index 0000000..f790c6d --- /dev/null +++ b/backend/src/modules/documents/documents.ingestion.ts @@ -0,0 +1,113 @@ +import { serviceHeaders } from "../../shared/service-auth.js"; +import axios from "axios"; +import { randomUUID } from "node:crypto"; +import { Pool } from "pg"; +import { z } from "zod"; +import { env } from "../../config/env.js"; +import { pool } from "../../database/db.js"; +import { withTransaction } from "../../database/transaction.js"; +import { ConflictError, NotFoundError } from "../../shared/errors.js"; +import { assertWritable, lockHierarchy } from "../projects/hierarchy-archive.js"; +import { LocalDocumentStorage } from "./documents.storage.js"; + +export interface IngestionFailure { code: string; retryable: boolean; httpStatus?: number } +class IngestionScopeError extends Error {} +export function classifyIngestionFailure(error: unknown): IngestionFailure { + if (error instanceof IngestionScopeError) return {code: "INVALID_SCOPE", retryable: false}; + if (error instanceof z.ZodError) return {code: "INVALID_RESPONSE", retryable: false}; + if (axios.isAxiosError(error)) { + if (["ECONNABORTED","ETIMEDOUT"].includes(error.code ?? "")) return {code: "TIMEOUT", retryable: true}; + const status = error.response?.status; + if (status) return {code: status === 422 || status === 400 ? "INVALID_DOCUMENT" : "SERVICE_HTTP_ERROR", retryable: status >= 500 || status === 429, httpStatus: status}; + return {code: "SERVICE_UNAVAILABLE", retryable: true}; + } + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return {code: "FILE_NOT_FOUND", retryable: false}; + return {code: "PROCESSING_ERROR", retryable: true}; +} + +interface IngestionJob { id: string; projeto_id: string; caminho: string; nome: string; ingest_lease: string } +const chunksSchema = z.array(z.object({ text: z.string().trim().min(1).max(50000), embedding: z.array(z.number().finite()).length(1024).refine(v => v.some(n => n !== 0)) })).min(1).max(5000); +export type IngestedChunk = z.infer[number]; + +export class DocumentIngestionRepository { + constructor(private readonly db: Pool = pool) { } + + async claim(projectId?: string): Promise { + await this.db.query("UPDATE documento SET status_processamento='falha',ingest_lease=NULL,ingest_locked_until=NULL WHERE status_processamento='processando' AND ingest_attempts>=5 AND ingest_locked_until<=CURRENT_TIMESTAMP"); + const result = await this.db.query( + `WITH picked AS ( + SELECT d.id FROM documento d JOIN projeto p ON p.id=d.projeto_id + WHERE d.status_processamento <> 'processado' AND p.status <> 'arquivado' AND ($2::uuid IS NULL OR d.projeto_id=$2) + AND d.ingest_retryable AND d.ingest_attempts < 5 AND d.ingest_next_attempt_at <= CURRENT_TIMESTAMP + AND (d.ingest_locked_until IS NULL OR d.ingest_locked_until <= CURRENT_TIMESTAMP) + AND NOT EXISTS (SELECT 1 FROM documento_operacao_armazenamento op WHERE op.documento_id=d.id AND op.acao='finalizar_upload' AND op.status='pendente') + ORDER BY d.ingest_next_attempt_at,d.id LIMIT 1 FOR UPDATE OF d SKIP LOCKED + ) UPDATE documento d SET status_processamento='processando', ingest_lease=$1, + ingest_locked_until=CURRENT_TIMESTAMP + INTERVAL '3 minutes', ingest_attempts=ingest_attempts+1 + FROM picked WHERE d.id=picked.id RETURNING d.id,d.projeto_id,d.caminho,d.nome,d.ingest_lease`, [randomUUID(), projectId ?? null]); + return result.rows[0] ?? null; + } + + async finish(job: IngestionJob, chunks: IngestedChunk[]): Promise { + const validated = chunksSchema.parse(chunks); + await withTransaction(this.db, async client => { + await lockHierarchy(client, "projeto", job.projeto_id); + await assertWritable(client, "projeto", job.projeto_id); + const current = await client.query("SELECT id FROM documento WHERE id=$1 AND projeto_id=$2 AND ingest_lease=$3 FOR UPDATE", [job.id, job.projeto_id, job.ingest_lease]); + // A removed document or superseded lease must never be resurrected. + if (!current.rowCount) return; + await client.query("DELETE FROM chunk WHERE projeto_id=$1 AND entidade_tipo='documento' AND entidade_id=$2", [job.projeto_id, job.id]); + for (const [index, chunk] of validated.entries()) { + await client.query(`INSERT INTO chunk(id,projeto_id,entidade_tipo,entidade_id,texto,embedding,metadados_json) + VALUES (md5($1::text || ':' || $2::text)::uuid,$3,'documento',$1::uuid,$4,$5::vector,$6::jsonb)`, + [job.id, index, job.projeto_id, chunk.text, JSON.stringify(chunk.embedding), JSON.stringify({ document_id: job.id, project_id: job.projeto_id, chunk_index: index, nome: job.nome })]); + } + await client.query("UPDATE documento SET status_processamento='processado',ingest_lease=NULL,ingest_locked_until=NULL,ingest_error=NULL,ingest_error_code=NULL,updated_at=CURRENT_TIMESTAMP WHERE id=$1", [job.id]); + }); + } + + async fail(job: IngestionJob, failure: IngestionFailure = { code: "UNKNOWN", retryable: true }): Promise { + const result = await this.db.query(`UPDATE documento SET status_processamento='falha',ingest_lease=NULL,ingest_locked_until=NULL, + ingest_error='Não foi possível processar o documento.', ingest_error_code=$3,ingest_retryable=$4, ingest_next_attempt_at=CURRENT_TIMESTAMP + make_interval(secs => LEAST(3600,15 * power(2,ingest_attempts))::int),updated_at=CURRENT_TIMESTAMP + WHERE id=$1 AND ingest_lease=$2 RETURNING ingest_attempts`, [job.id, job.ingest_lease, failure.code, failure.retryable]); + if (result.rowCount) console.warn("[Documents] Ingestion failure", { documentId: job.id, projectId: job.projeto_id, attempt: result.rows[0].ingest_attempts, code: failure.code, retryable: failure.retryable, httpStatus: failure.httpStatus }); + } + + async retry(projectId: string, documentId: string): Promise { + await withTransaction(this.db, async client => { + await lockHierarchy(client, "projeto", projectId); + await assertWritable(client, "projeto", projectId); + const row = (await client.query("SELECT status_processamento,ingest_locked_until FROM documento WHERE id=$1 AND projeto_id=$2 FOR UPDATE", [documentId, projectId])).rows[0]; + if (!row) throw new NotFoundError("Documento não encontrado."); + if (row.ingest_locked_until && new Date(row.ingest_locked_until).getTime() > Date.now()) throw new ConflictError("Documento em processamento."); + await client.query("UPDATE documento SET status_processamento='pendente',ingest_attempts=0,ingest_lease=NULL,ingest_locked_until=NULL,ingest_next_attempt_at=CURRENT_TIMESTAMP,ingest_error=NULL,ingest_error_code=NULL,ingest_retryable=TRUE WHERE id=$1", [documentId]); + }); + } +} + +export class DocumentIngestionWorker { + constructor( + private readonly repository = new DocumentIngestionRepository(), + private readonly storage = new LocalDocumentStorage(env.DOCUMENT_STORAGE_DIR), + private readonly endpoint = env.DOCUMENT_INGEST_WEBHOOK_URL || `${env.AI_SERVICE_URL}/ingest/file`, + ) { } + + async tick(projectId?: string): Promise { + const job = await this.repository.claim(projectId); + if (!job) return; + try { + const content = await this.storage.read(job.caminho); + const { data } = await axios.post(this.endpoint, { document_id: job.id, project_id: job.projeto_id, file_name: job.nome, content_base64: content.toString("base64") }, { headers: serviceHeaders(), timeout: 120000, maxBodyLength: 150 * 1024 * 1024, maxContentLength: 64 * 1024 * 1024 }); + if (data.document_id !== job.id || data.project_id !== job.projeto_id) throw new IngestionScopeError(); + await this.repository.finish(job, chunksSchema.parse(data.chunks)); + } catch (error) { await this.repository.fail(job, classifyIngestionFailure(error)); } + } +} + +export function startDocumentIngestionWorker(worker = new DocumentIngestionWorker()): () => void { + let running = false; + const tick = async () => { if (running) return; running = true; try { await worker.tick(); } catch { console.warn("[Documents] Ingestion remains queued for retry."); } finally { running = false; } }; + const timer = setInterval(() => void tick(), 15000); + timer.unref(); void tick(); + return () => clearInterval(timer); +} diff --git a/backend/src/modules/documents/documents.repository.db.test.ts b/backend/src/modules/documents/documents.repository.db.test.ts index f77b660..800d558 100644 --- a/backend/src/modules/documents/documents.repository.db.test.ts +++ b/backend/src/modules/documents/documents.repository.db.test.ts @@ -207,7 +207,7 @@ test("S1-19/S1-22: documentos, auditoria e outbox no PostgreSQL", { skip: !proce const archiver = await pool.connect(); try { await archiver.query("BEGIN"); - await lockHierarchy(archiver); + await lockHierarchy(archiver, "projeto", archivedProject); await archiver.query("UPDATE projeto SET status='arquivado' WHERE id=$1", [archivedProject]); const write = repo.create({ id: attemptedDocument, @@ -223,7 +223,7 @@ test("S1-19/S1-22: documentos, auditoria e outbox no PostgreSQL", { skip: !proce 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", + "SELECT EXISTS (SELECT 1 FROM pg_stat_activity WHERE wait_event_type='Lock' AND query LIKE '%FOR UPDATE OF p%') AS waiting", ); waiting = result.rows[0].waiting; if (!waiting) await new Promise((resolve) => setTimeout(resolve, 10)); @@ -260,14 +260,14 @@ test("S1-19/S1-22: documentos, auditoria e outbox no PostgreSQL", { skip: !proce const archiver = await pool.connect(); try { await archiver.query("BEGIN"); - await lockHierarchy(archiver); + await lockHierarchy(archiver, "projeto", archivedProject); 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", + "SELECT EXISTS (SELECT 1 FROM pg_stat_activity WHERE wait_event_type='Lock' AND query LIKE '%FOR UPDATE OF p%') AS waiting", ); waiting = result.rows[0].waiting; if (!waiting) await new Promise((resolve) => setTimeout(resolve, 10)); diff --git a/backend/src/modules/documents/documents.repository.ts b/backend/src/modules/documents/documents.repository.ts index d13cafe..9827873 100644 --- a/backend/src/modules/documents/documents.repository.ts +++ b/backend/src/modules/documents/documents.repository.ts @@ -37,6 +37,10 @@ export interface DocumentPage { const SELECT_COLUMNS = ` d.id, d.projeto_id, d.nome, d.extensao, d.mime, d.tamanho_bytes, d.status_processamento, + d.ingest_attempts AS tentativas_processamento, d.ingest_error_code AS erro_processamento_codigo, + (d.status_processamento <> 'processado' AND d.ingest_retryable AND d.ingest_attempts < 5 + AND EXISTS (SELECT 1 FROM projeto processing_project WHERE processing_project.id=d.projeto_id AND processing_project.status <> 'arquivado')) AS nova_tentativa_pendente, + d.ingest_next_attempt_at::text AS proxima_tentativa_em, d.usuario_id AS autor_id, u.nome AS autor_nome, EXISTS ( SELECT 1 FROM documento_operacao_armazenamento op @@ -89,7 +93,7 @@ export class DocumentsRepository { const client = await this.pool.connect(); try { await client.query("BEGIN"); - await lockHierarchy(client); + await lockHierarchy(client, "projeto", input.projetoId); 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) @@ -128,7 +132,7 @@ export class DocumentsRepository { const client = await this.pool.connect(); try { await client.query("BEGIN"); - await lockHierarchy(client); + await lockHierarchy(client, "projeto", input.projetoId); await assertWritable(client, "projeto", input.projetoId); const deleted = await client.query( `DELETE FROM documento WHERE id = $1 AND projeto_id = $2 diff --git a/backend/src/modules/documents/documents.routes.ts b/backend/src/modules/documents/documents.routes.ts index eb258ae..81b585c 100644 --- a/backend/src/modules/documents/documents.routes.ts +++ b/backend/src/modules/documents/documents.routes.ts @@ -68,6 +68,8 @@ export function createDocumentsRouter( * 404: * description: Projeto não encontrado */ + router.post("/:documentId/reprocess", canWrite, controller.reprocess); + router.delete("/:documentId", canWrite, controller.remove); return router; diff --git a/backend/src/modules/documents/documents.service.ts b/backend/src/modules/documents/documents.service.ts index 05bbe64..53aff70 100644 --- a/backend/src/modules/documents/documents.service.ts +++ b/backend/src/modules/documents/documents.service.ts @@ -4,6 +4,7 @@ import { AppError, NotFoundError, ValidationError, validateUuid } from "../../sh import { ArchiveConflict } from "../projects/archive.types.js"; import { ProjectsRepository } from "../projects/projects.repository.js"; import { DocumentsRepository } from "./documents.repository.js"; +import { DocumentIngestionRepository } from "./documents.ingestion.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"; @@ -61,12 +62,19 @@ export class DocumentsService { 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), + private readonly ingestion: DocumentIngestionRepository = new DocumentIngestionRepository(), ) {} get limits(): DocumentLimits { return { max_bytes: this.maxBytes, extensoes_permitidas: [...ALLOWED_EXTENSIONS] }; } + async reprocess(projetoId: string, documentoId: string): Promise { + validateUuid(projetoId, "Projeto"); + validateUuid(documentoId, "Documento"); + await this.ingestion.retry(projetoId, documentoId); + } + private async requireProject(projetoId: string): Promise<{ id: string; status: string }> { validateUuid(projetoId, "ID do projeto"); const project = await this.projects.findById(projetoId); diff --git a/backend/src/modules/documents/documents.storage.ts b/backend/src/modules/documents/documents.storage.ts index bb5f12f..05b2e74 100644 --- a/backend/src/modules/documents/documents.storage.ts +++ b/backend/src/modules/documents/documents.storage.ts @@ -1,4 +1,4 @@ -import { access, mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; +import { readFile, 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}$/; @@ -31,6 +31,8 @@ export class LocalDocumentStorage implements DocumentStorage { return join(this.baseDir, key); } + async read(key: string): Promise { return readFile(this.pathOf(key)); } + async save(key: string, content: Buffer): Promise { const path = this.pathOf(key); await mkdir(dirname(path), { recursive: true }); diff --git a/backend/src/modules/documents/documents.types.ts b/backend/src/modules/documents/documents.types.ts index 9fedd0b..d393701 100644 --- a/backend/src/modules/documents/documents.types.ts +++ b/backend/src/modules/documents/documents.types.ts @@ -13,6 +13,10 @@ export interface DocumentRecord { tamanho_bytes: number | null; status_processamento: DocumentStatus; armazenamento_pendente: boolean; + nova_tentativa_pendente?: boolean; + tentativas_processamento?: number; + proxima_tentativa_em?: string | null; + erro_processamento_codigo?: string | null; autor_id: string | null; autor_nome: string | null; created_at: string; diff --git a/backend/src/modules/epics/epics.compat.routes.ts b/backend/src/modules/epics/epics.compat.routes.ts index ba454b7..e14b2c5 100644 --- a/backend/src/modules/epics/epics.compat.routes.ts +++ b/backend/src/modules/epics/epics.compat.routes.ts @@ -48,7 +48,7 @@ export function createEpicsCompatRouter(service: EpicsService = epicsService) { const items: EpicWithStats[] = []; // The old contract is an unpaginated array; never silently truncate it to 100 records. for (let offset = 0; ; offset += 100) { - const page = await service.list({ projeto_id: projectId, limit: 100, offset }); + const page = await service.list({ projeto_id: projectId, limit: 100, offset }, req.auth!.id); items.push(...page.items); if (page.items.length < 100 || items.length >= page.total) break; } diff --git a/backend/src/modules/epics/epics.controller.ts b/backend/src/modules/epics/epics.controller.ts index 375b4dd..70faa79 100644 --- a/backend/src/modules/epics/epics.controller.ts +++ b/backend/src/modules/epics/epics.controller.ts @@ -21,7 +21,7 @@ export class EpicsController { list = async (req: Request, res: Response, next: NextFunction): Promise => { try { - const result = await this.service.list(req.query); + const result = await this.service.list(req.query, req.auth!.id); res.status(200).json(result); } catch (error) { next(error); diff --git a/backend/src/modules/epics/epics.repository.ts b/backend/src/modules/epics/epics.repository.ts index 3a02af6..740d239 100644 --- a/backend/src/modules/epics/epics.repository.ts +++ b/backend/src/modules/epics/epics.repository.ts @@ -1,11 +1,14 @@ -import { lockHierarchy, assertWritable } from "../projects/hierarchy-archive.js"; +import { projectAccessSql } from "../projects/project-access.js"; +import { ValidationError } from "../../shared/errors.js"; import { Pool, PoolClient } from "pg"; import { pool } from "../../database/db.js"; -import { CreateEpicDTO, UpdateEpicDTO, EpicQueryDTO, Epic, EpicWithStats, PaginatedEpics } from "./epics.types.js"; +import { withTransaction } from "../../database/transaction.js"; +import { buildAuditChangeData } from "../audit/audit.payloads.js"; import { auditService } from "../audit/audit.service.js"; +import { assertWritable, lockHierarchy } from "../projects/hierarchy-archive.js"; import { assertJustificationForCompletedItem } from "../quality/completed-item-policy.js"; -import { buildAuditChangeData } from "../audit/audit.payloads.js"; import { getEntityTechnologyIds, replaceEntityTechnologies } from "../technologies/entity-technologies.js"; +import { EPIC_REQUIRED_FIELDS, CreateEpicDTO, Epic, EpicQueryDTO, EpicWithStats, PaginatedEpics, UpdateEpicDTO } from "./epics.types.js"; export class EpicsRepository { private pool: Pool; @@ -14,7 +17,7 @@ export class EpicsRepository { this.pool = customPool ?? pool; } - async findById(id: string): Promise { + async findById(id: string, executor: Pool | PoolClient = this.pool): Promise { const query = ` SELECT e.*, @@ -26,16 +29,14 @@ export class EpicsRepository { JOIN projeto p ON p.id = e.projeto_id WHERE e.id = $1 `; - const result = await this.pool.query(query, [id]); + const result = await executor.query(query, [id]); return result.rows[0] ?? null; } async create(data: CreateEpicDTO, usuarioId?: string | null): Promise { - const client: PoolClient = await this.pool.connect(); + return withTransaction(this.pool, async (client) => { - try { - await client.query("BEGIN"); - await lockHierarchy(client); + await lockHierarchy(client, "projeto", data.projeto_id); await assertWritable(client, "projeto", data.projeto_id); const insertQuery = ` @@ -68,20 +69,19 @@ export class EpicsRepository { client, ); - await client.query("COMMIT"); return created; - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } + + }); } - async findAll(query: EpicQueryDTO): Promise { + async findAll(query: EpicQueryDTO, userId?: string): Promise { const whereConditions: string[] = []; const params: unknown[] = []; let paramIndex = 1; + if (userId) { + whereConditions.push(projectAccessSql("e.projeto_id", "$" + paramIndex++)); + params.push(userId); + } if (query.projeto_id) { whereConditions.push(`e.projeto_id = $${paramIndex}`); @@ -120,18 +120,21 @@ export class EpicsRepository { } async update(id: string, data: UpdateEpicDTO, usuarioId?: string | null): Promise { - const client: PoolClient = await this.pool.connect(); + return withTransaction(this.pool, async (client) => { - try { - await client.query("BEGIN"); - await lockHierarchy(client); + await lockHierarchy(client, "epico", id); await assertWritable(client, "epico", id); - const existing = await this.findById(id); + const existing = await this.findById(id, client); if (!existing) { - await client.query("ROLLBACK"); + return null; } + if (existing.status === "concluido") { + const merged = { ...existing, ...data }; + const missing = EPIC_REQUIRED_FIELDS.filter(field => !String(merged[field] ?? "").trim()); + if (missing.length) throw new ValidationError("Não é possível remover campos obrigatórios de um item concluído.", { campos_faltantes: missing }); + } await assertJustificationForCompletedItem( client, "epico", @@ -178,30 +181,30 @@ export class EpicsRepository { client, ); - await client.query("COMMIT"); - return await this.findById(id); - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } + const saved = await this.findById(id, client); + + return saved; + + }); } async markConcluded(id: string, usuarioId?: string | null): Promise { - const client: PoolClient = await this.pool.connect(); + return withTransaction(this.pool, async (client) => { - try { - await client.query("BEGIN"); - await lockHierarchy(client); + await lockHierarchy(client, "epico", id); await assertWritable(client, "epico", id); - const existing = await this.findById(id); + const existing = await this.findById(id, client); if (!existing) { - await client.query("ROLLBACK"); + return null; } + if (existing.status === "concluido") return existing; + const missing: string[] = EPIC_REQUIRED_FIELDS.filter(field => !String(existing[field] ?? "").trim()); + if (!existing.criterios_count) missing.push("criterios_aceitacao"); + if (missing.length) throw new ValidationError("Preencha os campos obrigatórios antes de concluir.", { campos_faltantes: missing }); + await client.query(`UPDATE epico SET status = 'concluido', updated_at = CURRENT_TIMESTAMP WHERE id = $1`, [id]); await auditService.record( @@ -215,14 +218,11 @@ export class EpicsRepository { client, ); - await client.query("COMMIT"); - return await this.findById(id); - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } + const saved = await this.findById(id, client); + + return saved; + + }); } } diff --git a/backend/src/modules/epics/epics.routes.test.ts b/backend/src/modules/epics/epics.routes.test.ts index 1539375..e37e92d 100644 --- a/backend/src/modules/epics/epics.routes.test.ts +++ b/backend/src/modules/epics/epics.routes.test.ts @@ -69,6 +69,7 @@ test("Testes de integração HTTP - Rotas de Épicos", async (t) => { const testApp = express(); testApp.use(express.json()); + testApp.use((req,_res,next)=>{req.auth={id:"10000000-0000-4000-8000-000000000001",nome:"PO",email:"po@test",role:"po"};next()}); const router = express.Router(); router.post("/", controller.create); diff --git a/backend/src/modules/epics/epics.service.ts b/backend/src/modules/epics/epics.service.ts index 5f218d8..1b0476f 100644 --- a/backend/src/modules/epics/epics.service.ts +++ b/backend/src/modules/epics/epics.service.ts @@ -42,14 +42,14 @@ export class EpicsService { return await this.repository.create(dto, usuarioId); } - async list(queryInput: unknown): Promise { + async list(queryInput: unknown, userId?: string): Promise { const parseResult = epicQuerySchema.safeParse(queryInput); if (!parseResult.success) { const issue = parseResult.error.issues[0]; throw new ValidationError(issue.message, parseResult.error.format()); } - return await this.repository.findAll(parseResult.data); + return await this.repository.findAll(parseResult.data, userId); } async getById(id: string): Promise { diff --git a/backend/src/modules/features/features.controller.ts b/backend/src/modules/features/features.controller.ts index 7ad75eb..296ddcc 100644 --- a/backend/src/modules/features/features.controller.ts +++ b/backend/src/modules/features/features.controller.ts @@ -21,7 +21,7 @@ export class FeaturesController { list = async (req: Request, res: Response, next: NextFunction): Promise => { try { - const result = await this.service.list(req.query); + const result = await this.service.list(req.query, req.auth!.id); res.status(200).json(result); } catch (error) { next(error); diff --git a/backend/src/modules/features/features.repository.ts b/backend/src/modules/features/features.repository.ts index ffdb31c..6342f97 100644 --- a/backend/src/modules/features/features.repository.ts +++ b/backend/src/modules/features/features.repository.ts @@ -1,11 +1,14 @@ -import { lockHierarchy, assertWritable } from "../projects/hierarchy-archive.js"; +import { projectAccessSql } from "../projects/project-access.js"; +import { ValidationError } from "../../shared/errors.js"; import { Pool, PoolClient } from "pg"; import { pool } from "../../database/db.js"; -import { CreateFeatureDTO, UpdateFeatureDTO, FeatureQueryDTO, Feature, FeatureWithStats, PaginatedFeatures } from "./features.types.js"; +import { withTransaction } from "../../database/transaction.js"; +import { buildAuditChangeData } from "../audit/audit.payloads.js"; import { auditService } from "../audit/audit.service.js"; +import { assertWritable, lockHierarchy } from "../projects/hierarchy-archive.js"; import { assertJustificationForCompletedItem } from "../quality/completed-item-policy.js"; -import { buildAuditChangeData } from "../audit/audit.payloads.js"; import { getEntityTechnologyIds, replaceEntityTechnologies } from "../technologies/entity-technologies.js"; +import { FEATURE_REQUIRED_FIELDS, CreateFeatureDTO, Feature, FeatureQueryDTO, FeatureWithStats, PaginatedFeatures, UpdateFeatureDTO } from "./features.types.js"; const SELECT_WITH_STATS = ` SELECT @@ -28,17 +31,15 @@ export class FeaturesRepository { this.pool = customPool ?? pool; } - async findById(id: string): Promise { - const result = await this.pool.query(`${SELECT_WITH_STATS} WHERE f.id = $1`, [id]); + async findById(id: string, executor: Pool | PoolClient = this.pool): Promise { + const result = await executor.query(`${SELECT_WITH_STATS} WHERE f.id = $1`, [id]); return result.rows[0] ?? null; } async create(data: CreateFeatureDTO, usuarioId?: string | null): Promise { - const client: PoolClient = await this.pool.connect(); + return withTransaction(this.pool, async (client) => { - try { - await client.query("BEGIN"); - await lockHierarchy(client); + await lockHierarchy(client, "epico", data.epico_id); await assertWritable(client, "epico", data.epico_id); const insertQuery = ` @@ -69,20 +70,19 @@ export class FeaturesRepository { client, ); - await client.query("COMMIT"); return created; - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } + + }); } - async findAll(query: FeatureQueryDTO): Promise { + async findAll(query: FeatureQueryDTO, userId?: string): Promise { const whereConditions: string[] = []; const params: unknown[] = []; let paramIndex = 1; + if (userId) { + whereConditions.push(projectAccessSql("(SELECT access_epic.projeto_id FROM epico access_epic WHERE access_epic.id=f.epico_id)", "$" + paramIndex++)); + params.push(userId); + } if (query.epico_id) { whereConditions.push(`f.epico_id = $${paramIndex}`); @@ -115,18 +115,21 @@ export class FeaturesRepository { } async update(id: string, data: UpdateFeatureDTO, usuarioId?: string | null): Promise { - const client: PoolClient = await this.pool.connect(); + return withTransaction(this.pool, async (client) => { - try { - await client.query("BEGIN"); - await lockHierarchy(client); + await lockHierarchy(client, "feature", id); await assertWritable(client, "feature", id); - const existing = await this.findById(id); + const existing = await this.findById(id, client); if (!existing) { - await client.query("ROLLBACK"); + return null; } + if (existing.status === "concluido") { + const merged = { ...existing, ...data }; + const missing = FEATURE_REQUIRED_FIELDS.filter(field => !String(merged[field] ?? "").trim()); + if (missing.length) throw new ValidationError("Não é possível remover campos obrigatórios de um item concluído.", { campos_faltantes: missing }); + } await assertJustificationForCompletedItem( client, "feature", @@ -171,30 +174,29 @@ export class FeaturesRepository { client, ); - await client.query("COMMIT"); - return await this.findById(id); - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } + const saved = await this.findById(id, client); + + return saved; + + }); } async markConcluded(id: string, usuarioId?: string | null): Promise { - const client: PoolClient = await this.pool.connect(); + return withTransaction(this.pool, async (client) => { - try { - await client.query("BEGIN"); - await lockHierarchy(client); + await lockHierarchy(client, "feature", id); await assertWritable(client, "feature", id); - const existing = await this.findById(id); + const existing = await this.findById(id, client); if (!existing) { - await client.query("ROLLBACK"); + return null; } + if (existing.status === "concluido") return existing; + const missing: string[] = FEATURE_REQUIRED_FIELDS.filter(field => !String(existing[field] ?? "").trim()); + if (missing.length) throw new ValidationError("Preencha os campos obrigatórios antes de concluir.", { campos_faltantes: missing }); + await client.query(`UPDATE feature SET status = 'concluido', updated_at = CURRENT_TIMESTAMP WHERE id = $1`, [id]); await auditService.record( @@ -208,14 +210,11 @@ export class FeaturesRepository { client, ); - await client.query("COMMIT"); - return await this.findById(id); - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } + const saved = await this.findById(id, client); + + return saved; + + }); } } diff --git a/backend/src/modules/features/features.service.ts b/backend/src/modules/features/features.service.ts index d032fd8..032c366 100644 --- a/backend/src/modules/features/features.service.ts +++ b/backend/src/modules/features/features.service.ts @@ -34,14 +34,14 @@ export class FeaturesService { return await this.repository.create(dto, usuarioId); } - async list(queryInput: unknown): Promise { + async list(queryInput: unknown, userId?: string): Promise { const parseResult = featureQuerySchema.safeParse(queryInput); if (!parseResult.success) { const issue = parseResult.error.issues[0]; throw new ValidationError(issue.message, parseResult.error.format()); } - return await this.repository.findAll(parseResult.data); + return await this.repository.findAll(parseResult.data, userId); } async getById(id: string): Promise { diff --git a/backend/src/modules/pbis/pbis.controller.ts b/backend/src/modules/pbis/pbis.controller.ts index ef8692c..9b32def 100644 --- a/backend/src/modules/pbis/pbis.controller.ts +++ b/backend/src/modules/pbis/pbis.controller.ts @@ -21,7 +21,7 @@ export class PbisController { list = async (req: Request, res: Response, next: NextFunction): Promise => { try { - const result = await this.service.list(req.query); + const result = await this.service.list(req.query, req.auth!.id); res.status(200).json(result); } catch (error) { next(error); diff --git a/backend/src/modules/pbis/pbis.repository.ts b/backend/src/modules/pbis/pbis.repository.ts index 07f3e16..af66776 100644 --- a/backend/src/modules/pbis/pbis.repository.ts +++ b/backend/src/modules/pbis/pbis.repository.ts @@ -1,11 +1,13 @@ -import { lockHierarchy, assertWritable } from "../projects/hierarchy-archive.js"; +import { projectAccessSql } from "../projects/project-access.js"; import { Pool, PoolClient } from "pg"; import { pool } from "../../database/db.js"; -import { CreatePbiDTO, UpdatePbiDTO, PbiQueryDTO, Pbi, PbiWithContext, PaginatedPbis } from "./pbis.types.js"; +import { withTransaction } from "../../database/transaction.js"; +import { buildAuditChangeData } from "../audit/audit.payloads.js"; import { auditService } from "../audit/audit.service.js"; +import { assertWritable, lockHierarchy } from "../projects/hierarchy-archive.js"; import { assertJustificationForCompletedItem, normalizeJustification } from "../quality/completed-item-policy.js"; -import { buildAuditChangeData } from "../audit/audit.payloads.js"; import { getEntityTechnologyIds, replaceEntityTechnologies } from "../technologies/entity-technologies.js"; +import { CreatePbiDTO, PaginatedPbis, Pbi, PbiQueryDTO, PbiWithContext, UpdatePbiDTO } from "./pbis.types.js"; const SELECT_WITH_CONTEXT = ` SELECT @@ -31,17 +33,15 @@ export class PbisRepository { this.pool = customPool ?? pool; } - async findById(id: string): Promise { - const result = await this.pool.query(`${SELECT_WITH_CONTEXT} WHERE p.id = $1`, [id]); + async findById(id: string, executor: Pool | PoolClient = this.pool): Promise { + const result = await executor.query(`${SELECT_WITH_CONTEXT} WHERE p.id = $1`, [id]); return result.rows[0] ?? null; } async create(data: CreatePbiDTO, usuarioId?: string | null): Promise { - const client: PoolClient = await this.pool.connect(); + return withTransaction(this.pool, async (client) => { - try { - await client.query("BEGIN"); - await lockHierarchy(client); + await lockHierarchy(client, "feature", data.feature_id); await assertWritable(client, "feature", data.feature_id); const seqResult = await client.query<{ proxima_sequencia: number }>( @@ -84,20 +84,19 @@ export class PbisRepository { client, ); - await client.query("COMMIT"); return created; - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } + + }); } - async findAll(query: PbiQueryDTO): Promise { + async findAll(query: PbiQueryDTO, userId?: string): Promise { const whereConditions: string[] = []; const params: unknown[] = []; let paramIndex = 1; + if (userId) { + whereConditions.push(projectAccessSql("(SELECT access_epic.projeto_id FROM feature access_feature JOIN epico access_epic ON access_epic.id=access_feature.epico_id WHERE access_feature.id=p.feature_id)", "$" + paramIndex++)); + params.push(userId); + } if (query.feature_id) { whereConditions.push(`p.feature_id = $${paramIndex}`); @@ -130,16 +129,14 @@ export class PbisRepository { } async update(id: string, data: UpdatePbiDTO, usuarioId?: string | null): Promise { - const client: PoolClient = await this.pool.connect(); + return withTransaction(this.pool, async (client) => { - try { - await client.query("BEGIN"); - await lockHierarchy(client); + await lockHierarchy(client, "pbi", id); await assertWritable(client, "pbi", id); - const existing = await this.findById(id); + const existing = await this.findById(id, client); if (!existing) { - await client.query("ROLLBACK"); + return null; } await assertJustificationForCompletedItem( @@ -196,30 +193,31 @@ export class PbisRepository { [id, JSON.stringify(updated), normalizeJustification(data.justificativa) ?? "", usuarioId ?? null], ); - await client.query("COMMIT"); - return await this.findById(id); - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } + const saved = await this.findById(id, client); + + return saved; + + }); } - async markConcluded(id: string, usuarioId?: string | null): Promise { - const client: PoolClient = await this.pool.connect(); + async markConcluded(id: string, validate: (client: PoolClient) => Promise, usuarioId?: string | null): Promise { + return withTransaction(this.pool, async (client) => { - try { - await client.query("BEGIN"); - await lockHierarchy(client); + await lockHierarchy(client, "pbi", id); await assertWritable(client, "pbi", id); - const existing = await this.findById(id); + const existing = await this.findById(id, client); if (!existing) { - await client.query("ROLLBACK"); + return null; } + if (existing.status === "concluido") { + + return existing; + } + await validate(client); + await client.query(`UPDATE pbi SET status = 'concluido', updated_at = CURRENT_TIMESTAMP WHERE id = $1`, [id]); await auditService.record( @@ -233,14 +231,11 @@ export class PbisRepository { client, ); - await client.query("COMMIT"); - return await this.findById(id); - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } + const saved = await this.findById(id, client); + + return saved; + + }); } } diff --git a/backend/src/modules/pbis/pbis.routes.test.ts b/backend/src/modules/pbis/pbis.routes.test.ts index 6d61ff5..59420c7 100644 --- a/backend/src/modules/pbis/pbis.routes.test.ts +++ b/backend/src/modules/pbis/pbis.routes.test.ts @@ -1,3 +1,4 @@ +import type { PoolClient } from "pg"; import test, { after, before } from "node:test"; import assert from "node:assert/strict"; import express from "express"; @@ -50,6 +51,14 @@ class MockPbisRepo extends PbisRepository { return created; } + async markConcluded(id: string, validate: (client: PoolClient) => Promise): Promise { + const item = await this.findById(id); + if (!item) return null; + if (item.status !== "concluido") await validate({} as PoolClient); + item.status = "concluido"; + return item; + } + async findAll(query: PbiQueryDTO): Promise { return { items: this.pbis, total: this.pbis.length, limit: query.limit, offset: query.offset }; } diff --git a/backend/src/modules/pbis/pbis.service.test.ts b/backend/src/modules/pbis/pbis.service.test.ts index bf27165..77bc2e3 100644 --- a/backend/src/modules/pbis/pbis.service.test.ts +++ b/backend/src/modules/pbis/pbis.service.test.ts @@ -1,3 +1,4 @@ +import type { PoolClient } from "pg"; import test from "node:test"; import assert from "node:assert/strict"; import { PbisService } from "./pbis.service.js"; @@ -94,13 +95,16 @@ class InMemoryPbisRepository extends PbisRepository { } } - async markConcluded(id: string): Promise { + async markConcluded(id: string, validate: (client: PoolClient) => Promise): Promise { const index = this.pbis.findIndex((p) => p.id === id); if (index === -1) { return null; } + if (this.statusProjeto === "arquivado") throw new ValidationError("Não é possível alterar PBIs de um projeto arquivado."); + if (this.pbis[index].status !== "concluido") await validate({} as PoolClient); + this.pbis[index] = { ...this.pbis[index], status: "concluido", diff --git a/backend/src/modules/pbis/pbis.service.ts b/backend/src/modules/pbis/pbis.service.ts index 5d981cd..eacc8c3 100644 --- a/backend/src/modules/pbis/pbis.service.ts +++ b/backend/src/modules/pbis/pbis.service.ts @@ -45,14 +45,14 @@ export class PbisService { return created; } - async list(queryInput: unknown): Promise { + async list(queryInput: unknown, userId?: string): Promise { const parseResult = pbiQuerySchema.safeParse(queryInput); if (!parseResult.success) { const issue = parseResult.error.issues[0]; throw new ValidationError(issue.message, parseResult.error.format()); } - const page = await this.repository.findAll(parseResult.data); + const page = await this.repository.findAll(parseResult.data, userId); const reports = await this.qualityChecker.validatePbis(page.items); const items = page.items.map((pbi) => ({ @@ -128,41 +128,28 @@ export class PbisService { ): Promise { validateUuid(id, "ID do PBI"); - const existing = await this.repository.findById(id); - if (!existing) { - throw new NotFoundError("PBI não encontrado."); - } - - this.assertProjetoAtivo(existing); - - if (existing.status === "concluido") { - return existing; - } - - // O relatório contém somente as verificações ativas e aplicáveis da - // configuração vigente. Alertas informativos não fazem parte deste mapa e, - // portanto, nunca impedem a conclusão. - const qualityReport = await this.qualityChecker.validatePbi(id); - - const camposFaltantes = qualityReport.checks - .filter( - (check) => - !check.passed - && COMPLETION_BLOCKING_FIELDS[check.check_id], - ) - .map( - (check) => - COMPLETION_BLOCKING_FIELDS[check.check_id], - ); - - if (camposFaltantes.length > 0) { - throw new ValidationError( - "Não é possível concluir o PBI: corrija os itens de conformidade com o guia antes de concluir.", - { campos_faltantes: camposFaltantes }, - ); - } + const completed = await this.repository.markConcluded(id, async (client) => { + const qualityReport = await this.qualityChecker.validatePbi(id, client); + + const camposFaltantes = qualityReport.checks + .filter( + (check) => + !check.passed + && COMPLETION_BLOCKING_FIELDS[check.check_id], + ) + .map( + (check) => + COMPLETION_BLOCKING_FIELDS[check.check_id], + ); + + if (camposFaltantes.length > 0) { + throw new ValidationError( + "Não é possível concluir o PBI: corrija os itens de conformidade com o guia antes de concluir.", + { campos_faltantes: camposFaltantes }, + ); + } - const completed = await this.repository.markConcluded(id, usuarioId); + }, usuarioId); if (!completed) { throw new NotFoundError("PBI não encontrado."); diff --git a/backend/src/modules/priority-fixes.db.test.ts b/backend/src/modules/priority-fixes.db.test.ts new file mode 100644 index 0000000..72de780 --- /dev/null +++ b/backend/src/modules/priority-fixes.db.test.ts @@ -0,0 +1,225 @@ +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 { PbisRepository } from "./pbis/pbis.repository.js"; +import { PbisService } from "./pbis/pbis.service.js"; +import { EpicsRepository } from "./epics/epics.repository.js"; +import { FeaturesRepository } from "./features/features.repository.js"; +import { CriteriaRepository } from "./criteria/criteria.repository.js"; +import { QualityService, DatabaseQualityRuleConfigurationProvider } from "./quality/quality.service.js"; +import { QualityConfigurationRepository } from "./quality/quality.configuration.repository.js"; +import { AdminRepository } from "./admin/admin.repository.js"; +import { lockHierarchy } from "./projects/hierarchy-archive.js"; +import { ValidationError, NotFoundError } from "../shared/errors.js"; +import { ArchiveConflict } from "./projects/archive.types.js"; +import { ProjectsRepository } from "./projects/projects.repository.js"; + +const target = process.env.ARCHIVE_TEST_DATABASE_URL; + +test("correções prioritárias no PostgreSQL: transações, conclusão e demonstração", { skip: !target, timeout: 30000 }, async t => { + const connectionString = validateTarget(target, "test"); + const applicationName = "priority_" + randomUUID(); + const db = new Pool({ connectionString, max: 1, connectionTimeoutMillis: 1000, application_name: applicationName }); + const control = new Pool({ connectionString, max: 3 }); + const [project, otherProject, epic, feature, pbi, criterion, user] = Array.from({ length: 7 }, randomUUID); + const pbis = new PbisRepository(db); + const epics = new EpicsRepository(db); + const features = new FeaturesRepository(db); + const config = new QualityConfigurationRepository(db); + const quality = new QualityService(new CriteriaRepository(db), pbis, new DatabaseQualityRuleConfigurationProvider(config)); + const service = new PbisService(pbis, features, quality, config); + const admin = new AdminRepository(db); + async function waitForLock() { + for (let attempt = 0; attempt < 100; attempt++) { + const result = await control.query("SELECT 1 FROM pg_stat_activity WHERE application_name=$1 AND wait_event_type='Lock'", [applicationName]); + if (result.rowCount) return; + await new Promise(resolve => setTimeout(resolve, 20)); + } + throw new Error("A operação não aguardou o lock da transação concorrente"); + } + try { + await db.query("INSERT INTO usuario(id,nome,email,senha_hash,role) VALUES ($1,'Admin teste',$2,'hash','admin')", [user, user + "@test.local"]); + await db.query("INSERT INTO projeto(id,nome,cliente,status) VALUES ($1::uuid,$1::text,'Teste','ativo'),($2::uuid,$2::text,'Teste','ativo')", [project, otherProject]); + await db.query("INSERT INTO epico(id,projeto_id,titulo,status) VALUES ($1,$2,'Cadastrar acesso','rascunho')", [epic, project]); + await db.query("INSERT INTO feature(id,epico_id,titulo,status) VALUES ($1,$2,'Cadastrar acesso','rascunho')", [feature, epic]); + await db.query("INSERT INTO pbi(id,feature_id,codigo,titulo,historia_como_um,historia_eu_quero,historia_para_que,status) VALUES ($1::uuid,$2,$1::text,'Cadastrar acesso','PO','cadastrar acesso','organizar usuários','rascunho')", [pbi, feature]); + await db.query("INSERT INTO criterio_aceitacao(id,entidade_tipo,entidade_id,nome,dado,quando,entao,ordem) VALUES ($1,'pbi',$2,'Acesso','usuário válido','confirmar','acesso criado',1)", [criterion, pbi]); + + await t.test("alterações em outro projeto não esperam o projeto bloqueado", async () => { + const blocker = await control.connect(); + let updating: Promise | undefined; + let timer: ReturnType | undefined; + try { + await blocker.query("BEGIN"); + await lockHierarchy(blocker, "projeto", project); + updating = new AdminRepository(db).insertDemoChunks(otherProject, user); + const completed = await Promise.race([updating.then(() => true), new Promise(resolve => { timer=setTimeout(() => resolve(false), 1500); })]); + assert.equal(completed, true, "outro projeto ficou bloqueado"); + } finally { + clearTimeout(timer); + await blocker.query("ROLLBACK"); blocker.release(); + await updating; + await db.query("DELETE FROM chunk WHERE projeto_id=$1", [otherProject]); + await db.query("DELETE FROM auditoria WHERE entidade_id=$1", [otherProject]); + } + }); + + await db.query("UPDATE epico SET descricao='Descrição',objetivo='Objetivo',escopo_macro='Escopo',resultado_esperado='Resultado' WHERE id=$1",[epic]); + await db.query("UPDATE feature SET descricao='Descrição',objetivo='Objetivo' WHERE id=$1",[feature]); + await db.query("INSERT INTO criterio_aceitacao(entidade_tipo,entidade_id,texto,ordem) VALUES ('epico',$1,'Critério do épico',1)",[epic]); + + await t.test("conclusões revalidam os campos após aguardar o bloqueio", async () => { + for (const [entity, id, repository] of [["epico",epic,epics],["feature",feature,features]] as const) { + const blocker = await control.connect(); + let pending: Promise | undefined; + try { + await blocker.query("BEGIN"); + await lockHierarchy(blocker,"projeto",project); + pending = assert.rejects(repository.markConcluded(id,user),ValidationError); + await waitForLock(); + await blocker.query("UPDATE " + entity + " SET objetivo=NULL WHERE id=$1",[id]); + await blocker.query("COMMIT"); + await pending; + assert.equal((await repository.findById(id))?.status,"rascunho"); + } finally { + await blocker.query("ROLLBACK"); blocker.release(); + await pending; + await db.query("UPDATE " + entity + " SET objetivo='Objetivo' WHERE id=$1",[id]); + } + } + }); + + await t.test("épico revalida o último critério após aguardar o bloqueio", async () => { + const blocker = await control.connect(); + let pending: Promise | undefined; + try { + await blocker.query("BEGIN"); + await lockHierarchy(blocker,"projeto",project); + pending = assert.rejects(epics.markConcluded(epic,user),ValidationError); + await waitForLock(); + await blocker.query("DELETE FROM criterio_aceitacao WHERE entidade_tipo='epico' AND entidade_id=$1",[epic]); + await blocker.query("COMMIT"); + await pending; + assert.equal((await epics.findById(epic))?.status,"rascunho"); + } finally { + await blocker.query("ROLLBACK"); blocker.release(); + await pending; + await db.query("INSERT INTO criterio_aceitacao(entidade_tipo,entidade_id,texto,ordem) VALUES ('epico',$1,'Critério restaurado',1)",[epic]); + } + }); + + await t.test("updates e conclusões funcionam com pool de uma única conexão", async () => { + assert.equal((await epics.update(epic, { titulo: "Cadastrar clientes" }, user))?.titulo, "Cadastrar clientes"); + assert.equal((await features.update(feature, { titulo: "Cadastrar usuários" }, user))?.titulo, "Cadastrar usuários"); + assert.equal((await pbis.update(pbi, { titulo: "Cadastrar usuários" }, user))?.titulo, "Cadastrar usuários"); + assert.equal((await epics.markConcluded(epic, user))?.status, "concluido"); + assert.equal((await features.markConcluded(feature, user))?.status, "concluido"); + assert.equal((await service.complete(pbi, user)).status, "concluido"); + await service.complete(pbi, user); + assert.equal((await db.query("SELECT count(*)::int AS n FROM auditoria WHERE entidade_id=$1 AND acao='CONCLUIR_PBI'", [pbi])).rows[0].n, 1); + }); + + await t.test("campos obrigatórios não podem ser apagados após conclusão", async () => { + for (const value of [null, "", " "]) { + await assert.rejects(features.update(feature,{objetivo:value,justificativa:"Teste"},user),ValidationError); + await assert.rejects(features.update(feature,{descricao:value,justificativa:"Teste"},user),ValidationError); + await assert.rejects(epics.update(epic,{objetivo:value,justificativa:"Teste"},user),ValidationError); + } + assert.equal((await features.findById(feature))?.objetivo,"Objetivo"); + }); + + await t.test("falha de auditoria reverte atualização e libera a conexão", async () => { + const before = await pbis.findById(pbi); + await assert.rejects(pbis.update(pbi, { titulo: "Cadastrar outro acesso", justificativa: "Teste de rollback" }, randomUUID())); + assert.equal((await pbis.findById(pbi))?.titulo, before?.titulo); + }); + + await t.test("conclusão revalida critérios alterados enquanto espera o lock", async () => { + await db.query("UPDATE pbi SET status='rascunho' WHERE id=$1", [pbi]); + const blocker = await control.connect(); + let pending: Promise | undefined; + try { + await blocker.query("BEGIN"); + await lockHierarchy(blocker, "projeto", project); + pending = assert.rejects(service.complete(pbi, user), ValidationError); + await waitForLock(); + await blocker.query("DELETE FROM criterio_aceitacao WHERE id=$1", [criterion]); + await blocker.query("COMMIT"); + await pending; + assert.equal((await pbis.findById(pbi))?.status, "rascunho"); + assert.equal((await db.query("SELECT count(*)::int AS n FROM auditoria WHERE entidade_id=$1 AND acao='CONCLUIR_PBI'", [pbi])).rows[0].n, 1); + } finally { + await blocker.query("ROLLBACK"); + blocker.release(); + await pending?.catch(() => undefined); + } + }); + + await t.test("configuração de qualidade fica bloqueada até o commit", async () => { + await db.query("INSERT INTO criterio_aceitacao(id,entidade_tipo,entidade_id,nome,dado,quando,entao,ordem) VALUES ($1,'pbi',$2,'Acesso','usuário válido','confirmar','acesso criado',1)", [criterion, pbi]); + let release!: () => void; + let validated!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const ready = new Promise(resolve => { validated = resolve; }); + const completing = pbis.markConcluded(pbi, async client => { + await quality.validatePbi(pbi, client); + validated(); + await gate; + }, user); + try { + await Promise.race([ready, completing.then(() => { throw new Error("A validação não foi executada"); })]); + const editor = await control.connect(); + try { + await editor.query("BEGIN"); + await editor.query("SET LOCAL lock_timeout='100ms'"); + await assert.rejects(editor.query("UPDATE quality_configuration SET version=version+1"), (error: unknown) => (error as { code: string }).code === "55P03"); + } finally { await editor.query("ROLLBACK"); editor.release(); } + } finally { release(); await completing; } + }); + + await t.test("projetos preservam CRUD, snapshot e arquivamento com uma conexão", async () => { + const projects = new ProjectsRepository(db); + const created = await projects.create({ nome: randomUUID(), cliente: "Teste", status: "ativo" }, user); + try { + assert.equal((await projects.update(created.id, { nome: "Projeto organizado" }, user))?.nome, "Projeto organizado"); + await assert.rejects(projects.update(created.id, { nome: "Não persistir" }, randomUUID())); + assert.equal((await projects.findById(created.id))?.nome, "Projeto organizado"); + assert.equal((await projects.findBacklogTree(created.id))?.project.id, created.id); + const impact = (await projects.archiveImpact(created.id))!; + assert.equal((await projects.archive(created.id, user, "Encerrar teste", impact))?.status, "arquivado"); + assert.equal((await projects.archive(created.id, user))?.status, "arquivado"); + assert.equal(await projects.update(randomUUID(), { nome: "Ausente" }, user), null); + assert.equal(await projects.archive(randomUUID(), user), null); + assert.equal(await projects.findBacklogTree(randomUUID()), null); + } finally { + await db.query("DELETE FROM auditoria WHERE entidade_id=$1", [created.id]); + await db.query("DELETE FROM projeto WHERE id=$1", [created.id]); + } + }); + + await t.test("carga explícita é idempotente, auditada e não conta exemplos como vetores", async () => { + const before = await admin.counts(); + const peer = new AdminRepository(control); + const inserted = await Promise.all([admin.insertDemoChunks(project, user), peer.insertDemoChunks(project, user)]); + assert.deepEqual(inserted.sort(), [0, 2]); + assert.equal(await admin.insertDemoChunks(project, user), 0); + assert.equal((await db.query("SELECT count(*)::int n FROM chunk WHERE projeto_id=$1", [project])).rows[0].n, 2); + assert.equal((await db.query("SELECT count(*)::int n FROM chunk WHERE projeto_id=$1", [otherProject])).rows[0].n, 0); + assert.equal((await admin.counts()).chunksIndexados, before.chunksIndexados); + assert.equal((await db.query("SELECT count(*)::int n FROM auditoria WHERE entidade_id=$1 AND acao='CARREGAR_DEMONSTRACAO'", [project])).rows[0].n, 1); + await assert.rejects(admin.insertDemoChunks(otherProject, randomUUID())); + assert.equal((await db.query("SELECT count(*)::int n FROM chunk WHERE projeto_id=$1", [otherProject])).rows[0].n, 0); + await db.query("UPDATE projeto SET status='arquivado' WHERE id=$1", [otherProject]); + await assert.rejects(admin.insertDemoChunks(otherProject, user), ArchiveConflict); + await assert.rejects(admin.insertDemoChunks(randomUUID(), user), NotFoundError); + }); + } finally { + await control.query("DELETE FROM auditoria WHERE usuario_id=$1", [user]); + await control.query("DELETE FROM projeto WHERE id=ANY($1::uuid[])", [[project, otherProject]]); + await control.query("DELETE FROM usuario WHERE id=$1", [user]); + await db.end(); + await control.end(); + } +}); diff --git a/backend/src/modules/projects/hierarchy-archive.db.test.ts b/backend/src/modules/projects/hierarchy-archive.db.test.ts index 8f43c3c..0e52a92 100644 --- a/backend/src/modules/projects/hierarchy-archive.db.test.ts +++ b/backend/src/modules/projects/hierarchy-archive.db.test.ts @@ -60,7 +60,7 @@ test("S1-09: arquivamento direto, preservação, filtros e escrita concorrente", // Simulate a request that passed its service check before an archive committed. const blocker = await db.connect(); await blocker.query("BEGIN"); - await lockHierarchy(blocker); + await lockHierarchy(blocker, "projeto", project); const writer = new FeaturesRepository(db).create({ epico_id: epic, titulo: "Tardia", prioridade: "Must" }); const rejected = assert.rejects(writer, ArchiveConflict); await blocker.query("COMMIT"); blocker.release(); diff --git a/backend/src/modules/projects/hierarchy-archive.ts b/backend/src/modules/projects/hierarchy-archive.ts index 72ffb91..072ee47 100644 --- a/backend/src/modules/projects/hierarchy-archive.ts +++ b/backend/src/modules/projects/hierarchy-archive.ts @@ -20,8 +20,15 @@ export const hierarchyArchiveSchema = z.object({ }); // The same lock order is used by archive and ordinary hierarchy writers. -export async function lockHierarchy(client: PoolClient) { - await client.query("LOCK TABLE projeto, epico, feature, pbi IN SHARE ROW EXCLUSIVE MODE"); +export async function lockHierarchy(client: PoolClient, kind: Kind | "projeto", id: string) { + const queries = { + projeto: "SELECT p.id FROM projeto p WHERE p.id=$1 FOR UPDATE OF p", + epico: "SELECT p.id FROM projeto p JOIN epico e ON e.projeto_id=p.id WHERE e.id=$1 FOR UPDATE OF p", + feature: "SELECT p.id FROM projeto p JOIN epico e ON e.projeto_id=p.id JOIN feature f ON f.epico_id=e.id WHERE f.id=$1 FOR UPDATE OF p", + pbi: "SELECT p.id FROM projeto p JOIN epico e ON e.projeto_id=p.id JOIN feature f ON f.epico_id=e.id JOIN pbi b ON b.feature_id=f.id WHERE b.id=$1 FOR UPDATE OF p", + }; + // Lock only the owning project. All hierarchy writers acquire this before child locks. + await client.query(queries[kind], [id]); } export async function assertWritable(client: PoolClient, kind: Kind | "projeto", id: string) { @@ -51,7 +58,7 @@ export class HierarchyArchiveRepository { const client = await this.db.connect(); try { await client.query("BEGIN"); - await lockHierarchy(client); + await lockHierarchy(client, kind, id); const root = (await client.query(`SELECT * FROM ${kind} WHERE id=$1`, [id])).rows[0]; if (!root) throw new NotFoundError(); if (root.status === "arquivado") { await client.query("COMMIT"); return root; } diff --git a/backend/src/modules/projects/project-access.db.test.ts b/backend/src/modules/projects/project-access.db.test.ts new file mode 100644 index 0000000..28928f2 --- /dev/null +++ b/backend/src/modules/projects/project-access.db.test.ts @@ -0,0 +1,62 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import express from "express"; +import type { AddressInfo } from "node:net"; +import { Pool } from "pg"; +import { ProjectAccess } from "./project-access.js"; +import { createProjectAccessRouter } from "../../middleware/projectAccess.js"; +import { errorHandler } from "../../middleware/errorHandler.js"; +import { validateTarget } from "../../database/seed-lib.js"; +import { ProjectsService } from "./projects.service.js"; +import { ProjectsRepository } from "./projects.repository.js"; +import { EpicsRepository } from "../epics/epics.repository.js"; +import { FeaturesRepository } from "../features/features.repository.js"; +import { PbisRepository } from "../pbis/pbis.repository.js"; + +test("isolamento: coleções, detalhes, descendentes e aliases", {skip:!process.env.ARCHIVE_TEST_DATABASE_URL}, async () => { + const db=new Pool({connectionString:validateTarget(process.env.ARCHIVE_TEST_DATABASE_URL,"test")}); + const ids=Array.from({length:12},randomUUID); + const [user,developer,project,other,epic,otherEpic,feature,otherFeature,pbi,otherPbi,criterion,otherCriterion]=ids; + const app=express(); + for(const prefix of ["/api/v1","/api"]) app.use(prefix,createProjectAccessRouter(new ProjectAccess(db),(req,_res,next)=>{req.auth={id:user,nome:"Teste",email:"test@test",role:"dev"};next()})); + app.use((_req,res)=>res.sendStatus(200));app.use(errorHandler); + const server=app.listen(0,"127.0.0.1");await new Promise(r=>server.once("listening",r)); + const base="http://127.0.0.1:"+(server.address() as AddressInfo).port; + try { + await db.query("INSERT INTO usuario(id,nome,email,senha_hash,role) VALUES ($1,'Dev',$2,'h','dev')",[user,user+"@test.local"]); + await db.query("INSERT INTO desenvolvedor(id,usuario_id) VALUES ($1,$2)",[developer,user]); + for(const [pr,e,f,b,c] of [[project,epic,feature,pbi,criterion],[other,otherEpic,otherFeature,otherPbi,otherCriterion]]) { + await db.query("INSERT INTO projeto(id,nome,cliente) VALUES ($1::uuid,$1::text,'Teste')",[pr]); + await db.query("INSERT INTO epico(id,projeto_id,titulo) VALUES ($1,$2,'Épico')",[e,pr]); + await db.query("INSERT INTO feature(id,epico_id,titulo) VALUES ($1,$2,'Feature')",[f,e]); + await db.query("INSERT INTO pbi(id,feature_id,codigo,titulo,historia_como_um,historia_eu_quero,historia_para_que) VALUES ($1::uuid,$2,$1::text,'PBI','PO','testar','validar')",[b,f]); + await db.query("INSERT INTO criterio_aceitacao(id,entidade_tipo,entidade_id,texto,ordem) VALUES ($1,'epico',$2,'Critério',1)",[c,e]); + } + await db.query("INSERT INTO alocacao(desenvolvedor_id,projeto_id) VALUES ($1,$2)",[developer,project]); + const paths=(pr:string,e:string,f:string,b:string,c:string)=>[ + '/projects/'+pr, '/projects/'+pr+'/documents', '/projects/'+pr+'/epicos', '/projects/'+pr+'/backlog-tree', + '/projects/'+pr+'/repo-analyses', '/projects/'+pr+'/decisions','/projects/'+pr+'/backlog-search', + '/epics/'+e,'/epicos/'+e,'/features/'+f,'/pbis/'+b,'/criteria/'+c, + '/epics/'+e+'/decisions','/features/'+f+'/decisions','/pbis/'+b+'/decisions', + '/quality/pbis/'+b+'/quality','/audit/epico/'+e+'/history', '/criteria?entidade_tipo=epico&entidade_id='+e]; + for(const prefix of ['/api/v1','/api']) { + for(const path of paths(project,epic,feature,pbi,criterion)) assert.equal((await fetch(base+prefix+path)).status,200,path); + for(const path of paths(other,otherEpic,otherFeature,otherPbi,otherCriterion)) assert.equal((await fetch(base+prefix+path)).status,404,path); + } + const queries=[ + ()=>new ProjectsService(new ProjectsRepository(db)).list({},user), + ()=>new EpicsRepository(db).findAll({limit:50,offset:0},user), + ()=>new FeaturesRepository(db).findAll({limit:50,offset:0},user), + ()=>new PbisRepository(db).findAll({limit:50,offset:0},user), + ]; + for(const [index,query] of queries.entries()) { const result=await query();assert.equal(result.total,1);assert.equal(result.items[0].id,[project,epic,feature,pbi][index]); } + await db.query("UPDATE alocacao SET data_fim=CURRENT_TIMESTAMP WHERE desenvolvedor_id=$1",[developer]); + for(const query of queries) assert.equal((await query()).total,0); + assert.equal((await fetch(base+'/api/v1/projects/'+project+'/documents')).status,404); + } finally { + server.closeAllConnections();await new Promise(r=>server.close(()=>r())); + await db.query("DELETE FROM projeto WHERE id=ANY($1::uuid[])",[[project,other]]); + await db.query("DELETE FROM usuario WHERE id=$1",[user]);await db.end(); + } +}); diff --git a/backend/src/modules/projects/project-access.ts b/backend/src/modules/projects/project-access.ts new file mode 100644 index 0000000..80dccb5 --- /dev/null +++ b/backend/src/modules/projects/project-access.ts @@ -0,0 +1,33 @@ +import type { Pool } from "pg"; +import { pool } from "../../database/db.js"; +import { NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; + +// Arguments are SQL expressions controlled by repositories, never request input. +export function projectAccessSql(project: string, user: string): string { + return `EXISTS (SELECT 1 FROM usuario access_user WHERE access_user.id=${user} AND + (access_user.role IN ('admin','po') OR EXISTS ( + SELECT 1 FROM desenvolvedor access_dev JOIN alocacao access_allocation ON access_allocation.desenvolvedor_id=access_dev.id + WHERE access_dev.usuario_id=access_user.id AND access_allocation.projeto_id=${project} + AND access_allocation.data_inicio<=CURRENT_TIMESTAMP + AND (access_allocation.data_fim IS NULL OR access_allocation.data_fim>CURRENT_TIMESTAMP))))`; +} +const entities: Record = { + projeto: "SELECT id AS projeto_id FROM projeto WHERE id=$1", + epico: "SELECT projeto_id FROM epico WHERE id=$1", + feature: "SELECT e.projeto_id FROM feature f JOIN epico e ON e.id=f.epico_id WHERE f.id=$1", + pbi: "SELECT e.projeto_id FROM pbi b JOIN feature f ON f.id=b.feature_id JOIN epico e ON e.id=f.epico_id WHERE b.id=$1", +}; +export class ProjectAccess { + constructor(private readonly db: Pick = pool) {} + async assertEntity(userId: string, kind: string, id: string): Promise { + validateUuid(id,"ID da entidade"); + if (kind === "criterio") { + const row = (await this.db.query("SELECT entidade_tipo,entidade_id FROM criterio_aceitacao WHERE id=$1",[id])).rows[0]; + if (!row) throw new NotFoundError("Item não encontrado."); + return this.assertEntity(userId,row.entidade_tipo,row.entidade_id); + } + if (!Object.hasOwn(entities,kind)) throw new ValidationError("Tipo de entidade inválido."); + const result = await this.db.query(`SELECT scope.projeto_id FROM (${entities[kind]}) scope WHERE ${projectAccessSql("scope.projeto_id","$2")}`,[id,userId]); + if (!result.rowCount) throw new NotFoundError("Item não encontrado."); + } +} diff --git a/backend/src/modules/projects/projects.archive.ts b/backend/src/modules/projects/projects.archive.ts new file mode 100644 index 0000000..434ffae --- /dev/null +++ b/backend/src/modules/projects/projects.archive.ts @@ -0,0 +1,234 @@ +import { lockHierarchy } from "./hierarchy-archive.js"; +import { + Pool, + PoolClient, +} from "pg"; +import { withTransaction } from "../../database/transaction.js"; +import { auditService } from "../audit/audit.service.js"; +import { + ArchiveConflict, + type ArchiveImpact, +} from "./archive.types.js"; +import { ProjectQueries } from "./projects.queries.js"; +import { + Project, + ProjectWithStats +} from "./projects.types.js"; + +export class ProjectArchiveRepository { + constructor(private readonly pool: Pool) { } + + async archiveImpact( + id: string, + connection: + | Pool + | PoolClient = this.pool, + ): Promise { + const result = + await connection + .query( + ` + SELECT + CASE + WHEN root.status = 'arquivado' + THEN 0 + ELSE 1 + END AS projeto, + + ( + SELECT COUNT(*)::int + FROM epico + WHERE projeto_id = $1 + AND status != 'arquivado' + ) AS epicos, + + ( + SELECT COUNT(*)::int + FROM feature f + JOIN epico e + ON e.id = f.epico_id + WHERE e.projeto_id = $1 + AND f.status != 'arquivado' + ) AS features, + + ( + SELECT COUNT(*)::int + FROM pbi p + JOIN feature f + ON f.id = p.feature_id + JOIN epico e + ON e.id = f.epico_id + WHERE e.projeto_id = $1 + AND p.status != 'arquivado' + ) AS pbis + + FROM projeto root + WHERE root.id = $1 + `, + [id], + ); + + return result.rows[0] ?? null; + } + + async archive( + id: string, + usuarioId?: string | null, + justificativa?: string, + expected?: ArchiveImpact, + ): Promise { + return withTransaction(this.pool, async (client) => { + + await lockHierarchy(client, "projeto", id); + + const existingResult = + await client.query( + ` + SELECT * + FROM projeto + WHERE id = $1 + FOR UPDATE + `, + [id], + ); + + const existingProject = + existingResult.rows[0]; + + if (!existingProject) { + + return null; + } + + if ( + existingProject.status + === "arquivado" + ) { + + return existingProject; + } + + const impact = ( + await this.archiveImpact( + id, + client, + ) + )!; + + if ( + expected && + ( + Object.keys( + impact, + ) as ( + keyof ArchiveImpact + )[] + ).some( + (key) => + impact[key] + !== expected[key], + ) + ) { + throw new ArchiveConflict( + "A quantidade de itens mudou. Consulte a prévia e confirme novamente.", + ); + } + + const updateQuery = ` + UPDATE projeto + SET + status = 'arquivado', + archived_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + RETURNING * + `; + + await client.query( + updateQuery, + [id], + ); + + await client.query( + ` + UPDATE epico + SET + status = 'arquivado', + archived_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE projeto_id = $1 + AND status != 'arquivado' + `, + [id], + ); + + await client.query( + ` + UPDATE feature + SET + status = 'arquivado', + archived_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE epico_id IN ( + SELECT id + FROM epico + WHERE projeto_id = $1 + ) + AND status != 'arquivado' + `, + [id], + ); + + await client.query( + ` + UPDATE pbi + SET + status = 'arquivado', + archived_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE feature_id IN ( + SELECT f.id + FROM feature f + JOIN epico e + ON e.id = f.epico_id + WHERE e.projeto_id = $1 + ) + AND status != 'arquivado' + `, + [id], + ); + + await auditService.record( + { + usuario_id: + usuarioId ?? null, + + entidade_tipo: + "projeto", + + entidade_id: id, + + acao: + "ARQUIVAR_PROJETO", + + justificativa: + justificativa ?? null, + + dados_json: { + status_anterior: + existingProject.status, + + status_novo: + "arquivado", + + impacto: impact, + }, + }, + client, + ); + + return await new ProjectQueries(this.pool).findById(id, client); + + }); + } +} diff --git a/backend/src/modules/projects/projects.backlog.ts b/backend/src/modules/projects/projects.backlog.ts new file mode 100644 index 0000000..77aea7f --- /dev/null +++ b/backend/src/modules/projects/projects.backlog.ts @@ -0,0 +1,321 @@ +import { + Pool +} from "pg"; +import { withTransaction } from "../../database/transaction.js"; +import { + BacklogTechnology, + Project, + ProjectBacklogTree +} from "./projects.types.js"; + +export class ProjectBacklogRepository { + constructor(private readonly pool: Pool) { } + + async findBacklogTree( + id: string, + ): Promise { + return withTransaction(this.pool, async (client) => { + + const projectResult = + await client.query< + Pick< + Project, + "id" | "nome" | "status" + > + >( + ` + SELECT + id, + nome, + status + FROM projeto + WHERE id = $1 + `, + [id], + ); + + const project = + projectResult.rows[0]; + + if (!project) { + + return null; + } + + type ItemRow = { + id: string; + titulo: string; + status: string; + tecnologias: BacklogTechnology[]; + }; + + type FeatureRow = + ItemRow & { + epico_id: string; + }; + + type PbiRow = + ItemRow & { + feature_id: string; + codigo: string; + }; + + const technologyJoin = ( + entityAlias: string, + entityType: string, + ) => ` + LEFT JOIN entidade_tecnologia et + ON et.entidade_id = ${entityAlias}.id + AND et.entidade_tipo = '${entityType}' + LEFT JOIN tecnologia t + ON t.id = et.tecnologia_id + `; + + const technologyAggregate = ` + COALESCE( + jsonb_agg( + DISTINCT jsonb_build_object( + 'id', + t.id, + 'nome', + t.nome + ) + ) FILTER ( + WHERE t.id IS NOT NULL + ), + '[]'::jsonb + ) AS tecnologias + `; + + const epicsResult = await client.query( + ` + SELECT + e.id, + e.titulo, + e.status, + ${technologyAggregate} + FROM epico e + ${technologyJoin( + "e", + "epico", + )} + WHERE e.projeto_id = $1 + GROUP BY e.id + ORDER BY + e.created_at ASC, + e.id ASC + `, + [id], + ); + + const featuresResult = await client.query( + ` + SELECT + f.id, + f.epico_id, + f.titulo, + f.status, + ${technologyAggregate} + FROM feature f + JOIN epico e + ON e.id = f.epico_id + ${technologyJoin( + "f", + "feature", + )} + WHERE e.projeto_id = $1 + GROUP BY f.id + ORDER BY + f.created_at ASC, + f.id ASC + `, + [id], + ); + + const pbisResult = await client.query( + ` + SELECT + p.id, + p.feature_id, + p.codigo, + p.titulo, + p.status, + ${technologyAggregate} + FROM pbi p + JOIN feature f + ON f.id = p.feature_id + JOIN epico e + ON e.id = f.epico_id + ${technologyJoin( + "p", + "pbi", + )} + WHERE e.projeto_id = $1 + GROUP BY p.id + ORDER BY + p.created_at ASC, + p.id ASC + `, + [id], + ); + + const pbisByFeature = + new Map< + string, + PbiRow[] + >(); + + for (const pbi of pbisResult.rows) { + const current = + pbisByFeature.get( + pbi.feature_id, + ) ?? []; + + current.push(pbi); + + pbisByFeature.set( + pbi.feature_id, + current, + ); + } + + const featuresByEpic = + new Map< + string, + Array< + FeatureRow & { + pbis: PbiRow[]; + } + > + >(); + + for ( + const feature + of featuresResult.rows + ) { + const current = + featuresByEpic.get( + feature.epico_id, + ) ?? []; + + current.push({ + ...feature, + + pbis: + pbisByFeature.get( + feature.id, + ) ?? [], + }); + + featuresByEpic.set( + feature.epico_id, + current, + ); + } + + const technologies = + new Map< + string, + BacklogTechnology + >(); + + const collectTechnologies = ( + items: Array<{ + tecnologias: + BacklogTechnology[]; + }>, + ) => { + for (const item of items) { + for ( + const technology + of item.tecnologias + ) { + technologies.set( + technology.id, + technology, + ); + } + } + }; + + collectTechnologies( + epicsResult.rows, + ); + + collectTechnologies( + featuresResult.rows, + ); + + collectTechnologies( + pbisResult.rows, + ); + + const tree: ProjectBacklogTree = { + project, + + epics: + epicsResult.rows.map( + (epic) => ({ + id: epic.id, + titulo: epic.titulo, + status: epic.status, + + tecnologias: + epic.tecnologias, + + features: ( + featuresByEpic.get( + epic.id, + ) ?? [] + ).map( + (feature) => ({ + id: feature.id, + + titulo: + feature.titulo, + + status: + feature.status, + + tecnologias: + feature.tecnologias, + + pbis: + feature.pbis.map( + (pbi) => ({ + id: pbi.id, + + codigo: + pbi.codigo, + + titulo: + pbi.titulo, + + status: + pbi.status, + + tecnologias: + pbi.tecnologias, + }), + ), + }), + ), + }), + ), + + technologies: [ + ...technologies.values(), + ].sort( + (a, b) => + a.nome.localeCompare( + b.nome, + "pt-BR", + ), + ), + }; + + return tree; + + }, "snapshot"); + } +} diff --git a/backend/src/modules/projects/projects.commands.ts b/backend/src/modules/projects/projects.commands.ts new file mode 100644 index 0000000..8d69804 --- /dev/null +++ b/backend/src/modules/projects/projects.commands.ts @@ -0,0 +1,301 @@ +import { + Pool +} from "pg"; +import { withTransaction } from "../../database/transaction.js"; +import { auditService } from "../audit/audit.service.js"; +import { + ArchiveConflict +} from "./archive.types.js"; +import { ProjectQueries } from "./projects.queries.js"; +import { + CreateProjectDTO, + Project, + ProjectWithStats, + UpdateProjectDTO +} from "./projects.types.js"; + +export class ProjectCommands { + constructor(private readonly pool: Pool) { } + + async create( + data: CreateProjectDTO, + usuarioId?: string | null, + ): Promise { + return withTransaction(this.pool, async (client) => { + + const insertProjectQuery = ` + INSERT INTO projeto ( + nome, + cliente, + descricao, + status, + data_inicio + ) + VALUES ( + $1, + $2, + $3, + $4, + COALESCE( + $5, + CURRENT_TIMESTAMP + ) + ) + RETURNING * + `; + + const projectValues = [ + data.nome.trim(), + data.cliente.trim(), + data.descricao?.trim() + ?? null, + data.status ?? "ativo", + data.data_inicio ?? null, + ]; + + const projectResult = + await client.query( + insertProjectQuery, + projectValues, + ); + + const createdProject = + projectResult.rows[0]; + + await auditService.record( + { + usuario_id: + usuarioId ?? null, + + entidade_tipo: + "projeto", + + entidade_id: + createdProject.id, + + acao: + "CRIAR_PROJETO", + + dados_json: { + nome: + createdProject.nome, + + cliente: + createdProject.cliente, + + descricao: + createdProject.descricao, + + status: + createdProject.status, + + data_inicio: + createdProject.data_inicio, + }, + }, + client, + ); + + return createdProject; + + }); + } + + async update( + id: string, + data: UpdateProjectDTO, + usuarioId?: string | null, + ): Promise { + return withTransaction(this.pool, async (client) => { + + const existingResult = + await client.query( + ` + SELECT * + FROM projeto + WHERE id = $1 + FOR UPDATE + `, + [id], + ); + + const existingProject = + existingResult.rows[0]; + + if (!existingProject) { + + return null; + } + + if ( + existingProject.status + === "arquivado" + ) { + throw new ArchiveConflict( + "Projeto arquivado está disponível apenas para leitura.", + ); + } + + if ( + String(data.status) + === "arquivado" + ) { + throw new ArchiveConflict( + "Use a ação de arquivamento com prévia e confirmação.", + ); + } + + const updates: string[] = []; + const values: unknown[] = []; + + let valIndex = 1; + + if ( + data.nome !== undefined + ) { + updates.push( + `nome = $${valIndex}`, + ); + + values.push( + data.nome.trim(), + ); + + valIndex++; + } + + if ( + data.cliente !== undefined + ) { + updates.push( + `cliente = $${valIndex}`, + ); + + values.push( + data.cliente.trim(), + ); + + valIndex++; + } + + if ( + data.descricao + !== undefined + ) { + updates.push( + `descricao = $${valIndex}`, + ); + + values.push( + data.descricao?.trim() + ?? null, + ); + + valIndex++; + } + + if ( + data.status !== undefined + ) { + updates.push( + `status = $${valIndex}`, + ); + + values.push(data.status); + valIndex++; + } + + if ( + data.data_inicio + !== undefined + ) { + updates.push( + `data_inicio = $${valIndex}`, + ); + + values.push( + data.data_inicio ?? null, + ); + + valIndex++; + } + + updates.push( + "updated_at = CURRENT_TIMESTAMP", + ); + + values.push(id); + + const updateQuery = ` + UPDATE projeto + SET ${updates.join(", ")} + WHERE id = $${valIndex} + RETURNING * + `; + + const result = + await client.query( + updateQuery, + values, + ); + + const updated = + result.rows[0]; + + await auditService.record( + { + usuario_id: + usuarioId ?? null, + + entidade_tipo: + "projeto", + + entidade_id: id, + + acao: + "ATUALIZAR_PROJETO", + + justificativa: + data.justificativa + ?? null, + + dados_json: { + alteracoes: data, + + anterior: { + nome: + existingProject.nome, + + cliente: + existingProject.cliente, + + descricao: + existingProject.descricao, + + status: + existingProject.status, + }, + + novo: { + nome: + updated.nome, + + cliente: + updated.cliente, + + descricao: + updated.descricao, + + status: + updated.status, + }, + }, + }, + client, + ); + + return await new ProjectQueries(this.pool).findById(id, client); + + }); + } +} diff --git a/backend/src/modules/projects/projects.controller.ts b/backend/src/modules/projects/projects.controller.ts index 4f4be07..30a0690 100644 --- a/backend/src/modules/projects/projects.controller.ts +++ b/backend/src/modules/projects/projects.controller.ts @@ -90,7 +90,7 @@ export class ProjectsController { try { const result = await this.service.list( - req.query, + req.query, req.auth!.id, ); res.status(200).json(result); diff --git a/backend/src/modules/projects/projects.queries.ts b/backend/src/modules/projects/projects.queries.ts new file mode 100644 index 0000000..a344e9c --- /dev/null +++ b/backend/src/modules/projects/projects.queries.ts @@ -0,0 +1,248 @@ +import { projectAccessSql } from "../projects/project-access.js"; +import { + Pool, + PoolClient, +} from "pg"; +import { + PaginatedProjects, + Project, + ProjectQueryDTO, + ProjectWithStats +} from "./projects.types.js"; + +export class ProjectQueries { + constructor(private readonly pool: Pool) { } + + async findActiveByName( + nome: string, + excludeId?: string, + ): Promise { + const params: unknown[] = [ + nome.trim(), + ]; + + let query = ` + SELECT * + FROM projeto + WHERE LOWER(TRIM(nome)) = LOWER(TRIM($1)) + AND status != 'arquivado' + `; + + if (excludeId) { + params.push(excludeId); + query += ` AND id != $2`; + } + + query += ` LIMIT 1`; + + const result = + await this.pool.query( + query, + params, + ); + + return result.rows[0] ?? null; + } + + async findById( + id: string, + connection: Pool | PoolClient = this.pool, + ): Promise { + const query = ` + SELECT + p.id, + p.nome, + p.cliente, + p.descricao, + p.status, + p.data_inicio, + p.created_at, + p.updated_at, + p.archived_at, + COALESCE( + ( + SELECT COUNT(*)::int + FROM epico e + WHERE e.projeto_id = p.id + ), + 0 + ) AS epicos_count, + COALESCE( + ( + SELECT COUNT(*)::int + FROM documento d + WHERE d.projeto_id = p.id + ), + 0 + ) AS documentos_count + FROM projeto p + WHERE p.id = $1 + `; + + const result = + await connection + .query( + query, + [id], + ); + + return result.rows[0] ?? null; + } + + async findAll( + query: ProjectQueryDTO, + userId?: string, + ): Promise { + const whereConditions: + string[] = []; + + const params: unknown[] = []; + + let paramIndex = 1; + if (userId) { + whereConditions.push(projectAccessSql("p.id", "$" + paramIndex++)); + params.push(userId); + } + + if (!query.status) { + whereConditions.push( + "p.status != 'arquivado'", + ); + } + + if ( + query.status && + query.status !== "todos" + ) { + whereConditions.push( + `p.status = $${paramIndex}`, + ); + + params.push(query.status); + paramIndex++; + } + + if ( + query.busca && + query.busca.trim().length > 0 + ) { + whereConditions.push( + `( + p.nome ILIKE $${paramIndex} + OR p.cliente ILIKE $${paramIndex} + OR p.descricao ILIKE $${paramIndex} + )`, + ); + + params.push( + `%${query.busca.trim()}%`, + ); + + paramIndex++; + } + + const whereClause = + whereConditions.length > 0 + ? `WHERE ${whereConditions.join( + " AND ", + )}` + : ""; + + const countQuery = ` + SELECT + COUNT(*)::int AS total + FROM projeto p + ${whereClause} + `; + + const countResult = + await this.pool.query<{ + total: number; + }>( + countQuery, + params, + ); + + const total = + countResult.rows[0]?.total + ?? 0; + + let orderByClause = + "ORDER BY p.created_at DESC"; + + if ( + query.order + === "created_at_asc" + ) { + orderByClause = + "ORDER BY p.created_at ASC"; + } else if ( + query.order === "nome_asc" + ) { + orderByClause = + "ORDER BY p.nome ASC"; + } else if ( + query.order === "nome_desc" + ) { + orderByClause = + "ORDER BY p.nome DESC"; + } + + const dataParams = [ + ...params, + query.limit, + query.offset, + ]; + + const dataQuery = ` + SELECT + p.id, + p.nome, + p.cliente, + p.descricao, + p.status, + p.data_inicio, + p.created_at, + p.updated_at, + p.archived_at, + + COALESCE( + ( + SELECT COUNT(*)::int + FROM epico e + WHERE e.projeto_id = p.id + ), + 0 + ) AS epicos_count, + + COALESCE( + ( + SELECT COUNT(*)::int + FROM documento d + WHERE d.projeto_id = p.id + ), + 0 + ) AS documentos_count + + FROM projeto p + ${whereClause} + ${orderByClause} + LIMIT $${paramIndex} + OFFSET $${paramIndex + 1} + `; + + const dataResult = + await this.pool + .query( + dataQuery, + dataParams, + ); + + return { + items: dataResult.rows, + total, + limit: query.limit, + offset: query.offset, + }; + } +} diff --git a/backend/src/modules/projects/projects.repository.ts b/backend/src/modules/projects/projects.repository.ts index 2945162..e2677c0 100644 --- a/backend/src/modules/projects/projects.repository.ts +++ b/backend/src/modules/projects/projects.repository.ts @@ -3,720 +3,84 @@ import { PoolClient, } from "pg"; import { pool } from "../../database/db.js"; +import { + type ArchiveImpact +} from "./archive.types.js"; +import { ProjectArchiveRepository } from "./projects.archive.js"; +import { ProjectBacklogRepository } from "./projects.backlog.js"; +import { ProjectCommands } from "./projects.commands.js"; +import { ProjectQueries } from "./projects.queries.js"; import { CreateProjectDTO, - UpdateProjectDTO, - ProjectQueryDTO, - Project, - ProjectWithStats, PaginatedProjects, + Project, ProjectBacklogTree, - BacklogTechnology, + ProjectQueryDTO, + ProjectWithStats, + UpdateProjectDTO } from "./projects.types.js"; -import { auditService } from "../audit/audit.service.js"; -import { - ArchiveConflict, - type ArchiveImpact, -} from "./archive.types.js"; export class ProjectsRepository { - private pool: Pool; - - constructor(customPool?: Pool) { - this.pool = customPool ?? pool; + private readonly queries: ProjectQueries; + private readonly backlog: ProjectBacklogRepository; + private readonly archiver: ProjectArchiveRepository; + private readonly commands: ProjectCommands; + + constructor(customPool: Pool = pool) { + this.queries = new ProjectQueries(customPool); + this.backlog = new ProjectBacklogRepository(customPool); + this.archiver = new ProjectArchiveRepository(customPool); + this.commands = new ProjectCommands(customPool); } async findActiveByName( nome: string, excludeId?: string, ): Promise { - const params: unknown[] = [ - nome.trim(), - ]; - - let query = ` - SELECT * - FROM projeto - WHERE LOWER(TRIM(nome)) = LOWER(TRIM($1)) - AND status != 'arquivado' - `; - - if (excludeId) { - params.push(excludeId); - query += ` AND id != $2`; - } - - query += ` LIMIT 1`; - - const result = - await this.pool.query( - query, - params, - ); - - return result.rows[0] ?? null; + return this.queries.findActiveByName(nome, excludeId); } async findById( id: string, + connection?: Pool | PoolClient, ): Promise { - const query = ` - SELECT - p.id, - p.nome, - p.cliente, - p.descricao, - p.status, - p.data_inicio, - p.created_at, - p.updated_at, - p.archived_at, - COALESCE( - ( - SELECT COUNT(*)::int - FROM epico e - WHERE e.projeto_id = p.id - ), - 0 - ) AS epicos_count, - COALESCE( - ( - SELECT COUNT(*)::int - FROM documento d - WHERE d.projeto_id = p.id - ), - 0 - ) AS documentos_count - FROM projeto p - WHERE p.id = $1 - `; - - const result = - await this.pool - .query( - query, - [id], - ); + return this.queries.findById(id, connection); + } - return result.rows[0] ?? null; + async findAll( + query: ProjectQueryDTO, + userId?: string, + ): Promise { + return this.queries.findAll(query, userId); } async findBacklogTree( id: string, ): Promise { - const client = await this.pool.connect(); - - try { - await client.query( - "BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY", - ); - const projectResult = - await client.query< - Pick< - Project, - "id" | "nome" | "status" - > - >( - ` - SELECT - id, - nome, - status - FROM projeto - WHERE id = $1 - `, - [id], - ); - - const project = - projectResult.rows[0]; - - if (!project) { - await client.query("COMMIT"); - return null; - } - - type ItemRow = { - id: string; - titulo: string; - status: string; - tecnologias: BacklogTechnology[]; - }; - - type FeatureRow = - ItemRow & { - epico_id: string; - }; - - type PbiRow = - ItemRow & { - feature_id: string; - codigo: string; - }; - - const technologyJoin = ( - entityAlias: string, - entityType: string, - ) => ` - LEFT JOIN entidade_tecnologia et - ON et.entidade_id = ${entityAlias}.id - AND et.entidade_tipo = '${entityType}' - LEFT JOIN tecnologia t - ON t.id = et.tecnologia_id - `; - - const technologyAggregate = ` - COALESCE( - jsonb_agg( - DISTINCT jsonb_build_object( - 'id', - t.id, - 'nome', - t.nome - ) - ) FILTER ( - WHERE t.id IS NOT NULL - ), - '[]'::jsonb - ) AS tecnologias - `; - - const epicsResult = await client.query( - ` - SELECT - e.id, - e.titulo, - e.status, - ${technologyAggregate} - FROM epico e - ${technologyJoin( - "e", - "epico", - )} - WHERE e.projeto_id = $1 - GROUP BY e.id - ORDER BY - e.created_at ASC, - e.id ASC - `, - [id], - ); - - const featuresResult = await client.query( - ` - SELECT - f.id, - f.epico_id, - f.titulo, - f.status, - ${technologyAggregate} - FROM feature f - JOIN epico e - ON e.id = f.epico_id - ${technologyJoin( - "f", - "feature", - )} - WHERE e.projeto_id = $1 - GROUP BY f.id - ORDER BY - f.created_at ASC, - f.id ASC - `, - [id], - ); - - const pbisResult = await client.query( - ` - SELECT - p.id, - p.feature_id, - p.codigo, - p.titulo, - p.status, - ${technologyAggregate} - FROM pbi p - JOIN feature f - ON f.id = p.feature_id - JOIN epico e - ON e.id = f.epico_id - ${technologyJoin( - "p", - "pbi", - )} - WHERE e.projeto_id = $1 - GROUP BY p.id - ORDER BY - p.created_at ASC, - p.id ASC - `, - [id], - ); - - const pbisByFeature = - new Map< - string, - PbiRow[] - >(); - - for (const pbi of pbisResult.rows) { - const current = - pbisByFeature.get( - pbi.feature_id, - ) ?? []; - - current.push(pbi); - - pbisByFeature.set( - pbi.feature_id, - current, - ); - } - - const featuresByEpic = - new Map< - string, - Array< - FeatureRow & { - pbis: PbiRow[]; - } - > - >(); - - for ( - const feature - of featuresResult.rows - ) { - const current = - featuresByEpic.get( - feature.epico_id, - ) ?? []; - - current.push({ - ...feature, - - pbis: - pbisByFeature.get( - feature.id, - ) ?? [], - }); - - featuresByEpic.set( - feature.epico_id, - current, - ); - } - - const technologies = - new Map< - string, - BacklogTechnology - >(); - - const collectTechnologies = ( - items: Array<{ - tecnologias: - BacklogTechnology[]; - }>, - ) => { - for (const item of items) { - for ( - const technology - of item.tecnologias - ) { - technologies.set( - technology.id, - technology, - ); - } - } - }; - - collectTechnologies( - epicsResult.rows, - ); - - collectTechnologies( - featuresResult.rows, - ); - - collectTechnologies( - pbisResult.rows, - ); - - const tree: ProjectBacklogTree = { - project, - - epics: - epicsResult.rows.map( - (epic) => ({ - id: epic.id, - titulo: epic.titulo, - status: epic.status, - - tecnologias: - epic.tecnologias, - - features: ( - featuresByEpic.get( - epic.id, - ) ?? [] - ).map( - (feature) => ({ - id: feature.id, - - titulo: - feature.titulo, - - status: - feature.status, - - tecnologias: - feature.tecnologias, - - pbis: - feature.pbis.map( - (pbi) => ({ - id: pbi.id, - - codigo: - pbi.codigo, - - titulo: - pbi.titulo, - - status: - pbi.status, - - tecnologias: - pbi.tecnologias, - }), - ), - }), - ), - }), - ), - - technologies: [ - ...technologies.values(), - ].sort( - (a, b) => - a.nome.localeCompare( - b.nome, - "pt-BR", - ), - ), - }; - await client.query("COMMIT"); - return tree; - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } + return this.backlog.findBacklogTree(id); } async archiveImpact( id: string, - connection: - | Pool - | PoolClient = this.pool, + connection?: Pool | PoolClient, ): Promise { - const result = - await connection - .query( - ` - SELECT - CASE - WHEN root.status = 'arquivado' - THEN 0 - ELSE 1 - END AS projeto, - - ( - SELECT COUNT(*)::int - FROM epico - WHERE projeto_id = $1 - AND status != 'arquivado' - ) AS epicos, - - ( - SELECT COUNT(*)::int - FROM feature f - JOIN epico e - ON e.id = f.epico_id - WHERE e.projeto_id = $1 - AND f.status != 'arquivado' - ) AS features, - - ( - SELECT COUNT(*)::int - FROM pbi p - JOIN feature f - ON f.id = p.feature_id - JOIN epico e - ON e.id = f.epico_id - WHERE e.projeto_id = $1 - AND p.status != 'arquivado' - ) AS pbis - - FROM projeto root - WHERE root.id = $1 - `, - [id], - ); + return this.archiver.archiveImpact(id, connection); + } - return result.rows[0] ?? null; + async archive( + id: string, + usuarioId?: string | null, + justificativa?: string, + expected?: ArchiveImpact, + ): Promise { + return this.archiver.archive(id, usuarioId, justificativa, expected); } async create( data: CreateProjectDTO, usuarioId?: string | null, ): Promise { - const client: PoolClient = - await this.pool.connect(); - - try { - await client.query("BEGIN"); - - const insertProjectQuery = ` - INSERT INTO projeto ( - nome, - cliente, - descricao, - status, - data_inicio - ) - VALUES ( - $1, - $2, - $3, - $4, - COALESCE( - $5, - CURRENT_TIMESTAMP - ) - ) - RETURNING * - `; - - const projectValues = [ - data.nome.trim(), - data.cliente.trim(), - data.descricao?.trim() - ?? null, - data.status ?? "ativo", - data.data_inicio ?? null, - ]; - - const projectResult = - await client.query( - insertProjectQuery, - projectValues, - ); - - const createdProject = - projectResult.rows[0]; - - await auditService.record( - { - usuario_id: - usuarioId ?? null, - - entidade_tipo: - "projeto", - - entidade_id: - createdProject.id, - - acao: - "CRIAR_PROJETO", - - dados_json: { - nome: - createdProject.nome, - - cliente: - createdProject.cliente, - - descricao: - createdProject.descricao, - - status: - createdProject.status, - - data_inicio: - createdProject.data_inicio, - }, - }, - client, - ); - - await client.query("COMMIT"); - - return createdProject; - } catch (error) { - await client.query( - "ROLLBACK", - ); - - throw error; - } finally { - client.release(); - } - } - - async findAll( - query: ProjectQueryDTO, - ): Promise { - const whereConditions: - string[] = []; - - const params: unknown[] = []; - - let paramIndex = 1; - - if (!query.status) { - whereConditions.push( - "p.status != 'arquivado'", - ); - } - - if ( - query.status && - query.status !== "todos" - ) { - whereConditions.push( - `p.status = $${paramIndex}`, - ); - - params.push(query.status); - paramIndex++; - } - - if ( - query.busca && - query.busca.trim().length > 0 - ) { - whereConditions.push( - `( - p.nome ILIKE $${paramIndex} - OR p.cliente ILIKE $${paramIndex} - OR p.descricao ILIKE $${paramIndex} - )`, - ); - - params.push( - `%${query.busca.trim()}%`, - ); - - paramIndex++; - } - - const whereClause = - whereConditions.length > 0 - ? `WHERE ${whereConditions.join( - " AND ", - )}` - : ""; - - const countQuery = ` - SELECT - COUNT(*)::int AS total - FROM projeto p - ${whereClause} - `; - - const countResult = - await this.pool.query<{ - total: number; - }>( - countQuery, - params, - ); - - const total = - countResult.rows[0]?.total - ?? 0; - - let orderByClause = - "ORDER BY p.created_at DESC"; - - if ( - query.order - === "created_at_asc" - ) { - orderByClause = - "ORDER BY p.created_at ASC"; - } else if ( - query.order === "nome_asc" - ) { - orderByClause = - "ORDER BY p.nome ASC"; - } else if ( - query.order === "nome_desc" - ) { - orderByClause = - "ORDER BY p.nome DESC"; - } - - const dataParams = [ - ...params, - query.limit, - query.offset, - ]; - - const dataQuery = ` - SELECT - p.id, - p.nome, - p.cliente, - p.descricao, - p.status, - p.data_inicio, - p.created_at, - p.updated_at, - p.archived_at, - - COALESCE( - ( - SELECT COUNT(*)::int - FROM epico e - WHERE e.projeto_id = p.id - ), - 0 - ) AS epicos_count, - - COALESCE( - ( - SELECT COUNT(*)::int - FROM documento d - WHERE d.projeto_id = p.id - ), - 0 - ) AS documentos_count - - FROM projeto p - ${whereClause} - ${orderByClause} - LIMIT $${paramIndex} - OFFSET $${paramIndex + 1} - `; - - const dataResult = - await this.pool - .query( - dataQuery, - dataParams, - ); - - return { - items: dataResult.rows, - total, - limit: query.limit, - offset: query.offset, - }; + return this.commands.create(data, usuarioId); } async update( @@ -724,404 +88,8 @@ export class ProjectsRepository { data: UpdateProjectDTO, usuarioId?: string | null, ): Promise { - const client: PoolClient = - await this.pool.connect(); - - try { - await client.query("BEGIN"); - - const existingResult = - await client.query( - ` - SELECT * - FROM projeto - WHERE id = $1 - FOR UPDATE - `, - [id], - ); - - const existingProject = - existingResult.rows[0]; - - if (!existingProject) { - await client.query( - "ROLLBACK", - ); - - return null; - } - - if ( - existingProject.status - === "arquivado" - ) { - throw new ArchiveConflict( - "Projeto arquivado está disponível apenas para leitura.", - ); - } - - if ( - String(data.status) - === "arquivado" - ) { - throw new ArchiveConflict( - "Use a ação de arquivamento com prévia e confirmação.", - ); - } - - const updates: string[] = []; - const values: unknown[] = []; - - let valIndex = 1; - - if ( - data.nome !== undefined - ) { - updates.push( - `nome = $${valIndex}`, - ); - - values.push( - data.nome.trim(), - ); - - valIndex++; - } - - if ( - data.cliente !== undefined - ) { - updates.push( - `cliente = $${valIndex}`, - ); - - values.push( - data.cliente.trim(), - ); - - valIndex++; - } - - if ( - data.descricao - !== undefined - ) { - updates.push( - `descricao = $${valIndex}`, - ); - - values.push( - data.descricao?.trim() - ?? null, - ); - - valIndex++; - } - - if ( - data.status !== undefined - ) { - updates.push( - `status = $${valIndex}`, - ); - - values.push(data.status); - valIndex++; - } - - if ( - data.data_inicio - !== undefined - ) { - updates.push( - `data_inicio = $${valIndex}`, - ); - - values.push( - data.data_inicio ?? null, - ); - - valIndex++; - } - - updates.push( - "updated_at = CURRENT_TIMESTAMP", - ); - - values.push(id); - - const updateQuery = ` - UPDATE projeto - SET ${updates.join(", ")} - WHERE id = $${valIndex} - RETURNING * - `; - - const result = - await client.query( - updateQuery, - values, - ); - - const updated = - result.rows[0]; - - await auditService.record( - { - usuario_id: - usuarioId ?? null, - - entidade_tipo: - "projeto", - - entidade_id: id, - - acao: - "ATUALIZAR_PROJETO", - - justificativa: - data.justificativa - ?? null, - - dados_json: { - alteracoes: data, - - anterior: { - nome: - existingProject.nome, - - cliente: - existingProject.cliente, - - descricao: - existingProject.descricao, - - status: - existingProject.status, - }, - - novo: { - nome: - updated.nome, - - cliente: - updated.cliente, - - descricao: - updated.descricao, - - status: - updated.status, - }, - }, - }, - client, - ); - - await client.query("COMMIT"); - - return await this.findById(id); - } catch (error) { - await client.query( - "ROLLBACK", - ); - - throw error; - } finally { - client.release(); - } - } - - async archive( - id: string, - usuarioId?: string | null, - justificativa?: string, - expected?: ArchiveImpact, - ): Promise { - const client: PoolClient = - await this.pool.connect(); - - try { - await client.query("BEGIN"); - - await client.query( - ` - LOCK TABLE - projeto, - epico, - feature, - pbi - IN SHARE ROW EXCLUSIVE MODE - `, - ); - - const existingResult = - await client.query( - ` - SELECT * - FROM projeto - WHERE id = $1 - FOR UPDATE - `, - [id], - ); - - const existingProject = - existingResult.rows[0]; - - if (!existingProject) { - await client.query( - "ROLLBACK", - ); - - return null; - } - - if ( - existingProject.status - === "arquivado" - ) { - await client.query( - "COMMIT", - ); - - return existingProject; - } - - const impact = ( - await this.archiveImpact( - id, - client, - ) - )!; - - if ( - expected && - ( - Object.keys( - impact, - ) as ( - keyof ArchiveImpact - )[] - ).some( - (key) => - impact[key] - !== expected[key], - ) - ) { - throw new ArchiveConflict( - "A quantidade de itens mudou. Consulte a prévia e confirme novamente.", - ); - } - - const updateQuery = ` - UPDATE projeto - SET - status = 'arquivado', - archived_at = CURRENT_TIMESTAMP, - updated_at = CURRENT_TIMESTAMP - WHERE id = $1 - RETURNING * - `; - - await client.query( - updateQuery, - [id], - ); - - await client.query( - ` - UPDATE epico - SET - status = 'arquivado', - archived_at = CURRENT_TIMESTAMP, - updated_at = CURRENT_TIMESTAMP - WHERE projeto_id = $1 - AND status != 'arquivado' - `, - [id], - ); - - await client.query( - ` - UPDATE feature - SET - status = 'arquivado', - archived_at = CURRENT_TIMESTAMP, - updated_at = CURRENT_TIMESTAMP - WHERE epico_id IN ( - SELECT id - FROM epico - WHERE projeto_id = $1 - ) - AND status != 'arquivado' - `, - [id], - ); - - await client.query( - ` - UPDATE pbi - SET - status = 'arquivado', - archived_at = CURRENT_TIMESTAMP, - updated_at = CURRENT_TIMESTAMP - WHERE feature_id IN ( - SELECT f.id - FROM feature f - JOIN epico e - ON e.id = f.epico_id - WHERE e.projeto_id = $1 - ) - AND status != 'arquivado' - `, - [id], - ); - - await auditService.record( - { - usuario_id: - usuarioId ?? null, - - entidade_tipo: - "projeto", - - entidade_id: id, - - acao: - "ARQUIVAR_PROJETO", - - justificativa: - justificativa ?? null, - - dados_json: { - status_anterior: - existingProject.status, - - status_novo: - "arquivado", - - impacto: impact, - }, - }, - client, - ); - - await client.query("COMMIT"); - - return await this.findById(id); - } catch (error) { - await client.query( - "ROLLBACK", - ); - - throw error; - } finally { - client.release(); - } + return this.commands.update(id, data, usuarioId); } } -export const projectsRepository = - new ProjectsRepository(); +export const projectsRepository = new ProjectsRepository(); diff --git a/backend/src/modules/projects/projects.routes.test.ts b/backend/src/modules/projects/projects.routes.test.ts index 1e12dec..4571d67 100644 --- a/backend/src/modules/projects/projects.routes.test.ts +++ b/backend/src/modules/projects/projects.routes.test.ts @@ -279,6 +279,7 @@ test( const testApp = express(); testApp.use(express.json()); + testApp.use((req,_res,next)=>{req.auth={id:"10000000-0000-4000-8000-000000000001",nome:"PO",email:"po@test",role:"po"};next()}); const router = express.Router(); diff --git a/backend/src/modules/projects/projects.service.ts b/backend/src/modules/projects/projects.service.ts index f091600..9b99597 100644 --- a/backend/src/modules/projects/projects.service.ts +++ b/backend/src/modules/projects/projects.service.ts @@ -69,6 +69,7 @@ export class ProjectsService { async list( queryInput: unknown, + userId?: string, ): Promise { const parseResult = projectQuerySchema.safeParse( @@ -86,7 +87,7 @@ export class ProjectsService { } return await this.repository.findAll( - parseResult.data, + parseResult.data, userId, ); } diff --git a/backend/src/modules/quality/quality.service.ts b/backend/src/modules/quality/quality.service.ts index a4c173d..35e9686 100644 --- a/backend/src/modules/quality/quality.service.ts +++ b/backend/src/modules/quality/quality.service.ts @@ -1,3 +1,4 @@ +import type { PoolClient } from "pg"; import { CriteriaRepository, criteriaRepository } from "../criteria/criteria.repository.js"; import { PbisRepository, pbisRepository } from "../pbis/pbis.repository.js"; import { Pbi } from "../pbis/pbis.types.js"; @@ -41,7 +42,7 @@ export function calculateCompleteness(checks: QualityCheckResult[]): number | nu * singleton below uses the persisted, organization-wide S2-19 configuration. */ export class DefaultQualityRuleConfigurationProvider implements QualityRuleConfigurationProvider { - async getCurrentPbiConfiguration(): Promise { + async getCurrentPbiConfiguration(client?: PoolClient): Promise { return { rule_version: "pbi-quality-v1", checks: PBI_QUALITY_CHECKS.map((check_id) => ({ @@ -56,8 +57,8 @@ export class DefaultQualityRuleConfigurationProvider implements QualityRuleConfi export class DatabaseQualityRuleConfigurationProvider implements QualityRuleConfigurationProvider { constructor(private readonly configurationRepository = qualityConfigurationRepository) {} - async getCurrentPbiConfiguration(): Promise { - const current = await this.configurationRepository.getPbiConfiguration(); + async getCurrentPbiConfiguration(client?: PoolClient): Promise { + const current = await this.configurationRepository.getPbiConfiguration(client, { lock: Boolean(client) }); return { rule_version: current.rule_version, checks: PBI_QUALITY_CHECKS @@ -110,12 +111,12 @@ export class QualityService { } /** Computes the backlog indicator from the same rules used by the detail report. */ - async validatePbi(pbiId: string): Promise { - const pbi = await this.pbisRepo.findById(pbiId); + async validatePbi(pbiId: string, client?: PoolClient): Promise { + const pbi = await this.pbisRepo.findById(pbiId, client); if (!pbi) throw new NotFoundError("PBI não encontrado."); - const criteria = await this.criteriaRepo.listByEntity("pbi", pbiId); - const configuration = await this.rulesProvider.getCurrentPbiConfiguration(); + const criteria = await this.criteriaRepo.listByEntity("pbi", pbiId, client); + const configuration = await this.rulesProvider.getCurrentPbiConfiguration(client); return this.buildCompletenessReport(pbi, this.buildDetailedReport(pbi, criteria, configuration.vague_terms ?? TERMOS_VAGOS_PADRAO), configuration); } diff --git a/backend/src/modules/quality/quality.types.ts b/backend/src/modules/quality/quality.types.ts index 962111b..da5c3e4 100644 --- a/backend/src/modules/quality/quality.types.ts +++ b/backend/src/modules/quality/quality.types.ts @@ -1,3 +1,4 @@ +import type { PoolClient } from "pg"; import type { Pbi } from "../pbis/pbis.types.js"; export const ENTITY_TYPES = ["epico", "feature", "pbi"] as const; @@ -44,7 +45,7 @@ export interface PbiQualityConfigurationRecord extends PbiQualityConfigurationIn } export interface QualityRuleConfigurationProvider { - getCurrentPbiConfiguration(): Promise; + getCurrentPbiConfiguration(client?: PoolClient): Promise; } export interface QualityReport { diff --git a/backend/src/modules/repo-analyses/repo-analyses.service.ts b/backend/src/modules/repo-analyses/repo-analyses.service.ts index 06f1388..c8f7f02 100644 --- a/backend/src/modules/repo-analyses/repo-analyses.service.ts +++ b/backend/src/modules/repo-analyses/repo-analyses.service.ts @@ -1,3 +1,4 @@ +import { serviceHeaders } from "../../shared/service-auth.js"; import axios from 'axios'; import { AppError, ValidationError } from '../../shared/errors'; import { env } from '../../config/env'; @@ -41,7 +42,7 @@ export class RepoAnalysesService { // Dispara a execução no ai-service / RepoAnalyzer try { - const response = await axios.post(`${this.analyzerBaseUrl}/api/analyze`, { url: repositorioUrl }, { timeout: 15_000 }); + const response = await axios.post(`${this.analyzerBaseUrl}/api/analyze`, { url: repositorioUrl }, { headers: serviceHeaders(), timeout: 15_000 }); const { run_id } = response.data; @@ -81,7 +82,7 @@ export class RepoAnalysesService { private async syncAnalysisStatus(runId: string): Promise { try { - const response = await axios.get(`${this.analyzerBaseUrl}/api/runs/${runId}`, { timeout: 15_000 }); + const response = await axios.get(`${this.analyzerBaseUrl}/api/runs/${runId}`, { headers: serviceHeaders(), timeout: 15_000 }); const data = response.data; const status = mapAnalyzerStatus(data.status); const etapa = (data.stage || 'queued') as RepoAnalysisStep; @@ -90,7 +91,7 @@ export class RepoAnalysesService { let relatorioMarkdown = undefined; if (status === 'concluido') { try { - const reportRes = await axios.get(`${this.analyzerBaseUrl}/api/runs/${runId}/report`, { timeout: 15_000 }); + const reportRes = await axios.get(`${this.analyzerBaseUrl}/api/runs/${runId}/report`, { headers: serviceHeaders(), timeout: 15_000 }); relatorioMarkdown = reportRes.data.report || reportRes.data; } catch (e) { // Ignora se o relatório ainda não estiver pronto diff --git a/backend/src/modules/search/search.controller.ts b/backend/src/modules/search/search.controller.ts new file mode 100644 index 0000000..59665f4 --- /dev/null +++ b/backend/src/modules/search/search.controller.ts @@ -0,0 +1,14 @@ +import type { NextFunction, Request, Response } from "express"; +import { SearchService } from "./search.service.js"; + +export class SearchController { + constructor(private readonly service: SearchService = new SearchService()) {} + + search = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + res.json(await this.service.search(req.query, req.auth!.id)); + } catch (error) { + next(error); + } + }; +} diff --git a/backend/src/modules/search/search.repository.ts b/backend/src/modules/search/search.repository.ts new file mode 100644 index 0000000..c6858c8 --- /dev/null +++ b/backend/src/modules/search/search.repository.ts @@ -0,0 +1,30 @@ +import { projectAccessSql } from "../projects/project-access.js"; +import type { Pool } from "pg"; +import { pool } from "../../database/db.js"; + +import type { SearchFilter, SearchItem, SearchResult } from "./search.types.js"; + +export class SearchRepository { + constructor(private readonly db: Pick = pool) {} + + async search({ query, projectId, userId }: SearchFilter): Promise { + const conditions: string[] = [projectAccessSql("c.projeto_id", "$1")]; + const params: string[] = [userId]; + if (query) { + params.push("%" + query.replace(/[\\%_]/g, "\\$&") + "%"); + conditions.push("c.texto ILIKE $" + params.length); + } + if (projectId) { + params.push(projectId); + conditions.push("c.projeto_id = $" + params.length); + } + const result = await this.db.query( + "SELECT c.id, c.projeto_id, c.entidade_tipo, c.entidade_id, c.texto, " + + "c.metadados_json, c.created_at, p.nome AS projeto_nome " + + "FROM chunk c JOIN projeto p ON c.projeto_id = p.id " + + (conditions.length ? "WHERE " + conditions.join(" AND ") : "") + + " ORDER BY c.created_at DESC LIMIT 50", params, + ); + return { items: result.rows, total: result.rowCount }; + } +} diff --git a/backend/src/modules/search/search.routes.ts b/backend/src/modules/search/search.routes.ts index c08008a..a66d49d 100644 --- a/backend/src/modules/search/search.routes.ts +++ b/backend/src/modules/search/search.routes.ts @@ -1,58 +1,12 @@ -import { Router, Request, Response, NextFunction } from "express"; -import { ValidationError, validateUuid } from "../../shared/errors.js"; -import { pool } from "../../database/db.js"; +import { Router, type RequestHandler } from "express"; import { requireAuth } from "../../middleware/requireAuth.js"; +import { SearchController } from "./search.controller.js"; -export const searchRouter = Router(); +export function createSearchRouter(controller = new SearchController(), authenticate: RequestHandler = requireAuth) { + const router = Router(); + router.use(authenticate); + router.get("/", controller.search); + return router; +} -searchRouter.use(requireAuth); - -// GET /api/v1/search?q=...&projeto_id=... -searchRouter.get("/", async (req: Request, res: Response, next: NextFunction) => { - try { - const q = ((req.query.q as string) || "").trim(); - const projectId = (req.query.projeto_id as string) || (req.query.projectId as string); - - if (projectId) validateUuid(projectId, "ID do projeto"); - if (q.length > 200) throw new ValidationError("A busca pode ter no máximo 200 caracteres."); - - if (!q) { - // Se a query estiver vazia, retorna os chunks mais recentes - let sql = ` - SELECT c.id, c.projeto_id, c.entidade_tipo, c.entidade_id, c.texto, c.metadados_json, c.created_at, p.nome as projeto_nome - FROM chunk c - JOIN projeto p ON c.projeto_id = p.id - `; - const params: unknown[] = []; - if (projectId) { - sql += " WHERE c.projeto_id = $1"; - params.push(projectId); - } - sql += " ORDER BY c.created_at DESC LIMIT 50"; - const result = await pool.query(sql, params); - res.json({ items: result.rows, total: result.rowCount }); - return; - } - - // Busca textual / ilike sobre a tabela chunk com escopo por projeto (RNF-03) - let sql = ` - SELECT c.id, c.projeto_id, c.entidade_tipo, c.entidade_id, c.texto, c.metadados_json, c.created_at, p.nome as projeto_nome - FROM chunk c - JOIN projeto p ON c.projeto_id = p.id - WHERE c.texto ILIKE $1 - `; - const params: unknown[] = [`%${q.replace(/[\\%_]/g, "\\$&")}%`]; - - if (projectId) { - sql += " AND c.projeto_id = $2"; - params.push(projectId); - } - - sql += " ORDER BY c.created_at DESC LIMIT 50"; - - const result = await pool.query(sql, params); - res.json({ items: result.rows, total: result.rowCount }); - } catch (error) { - next(error); - } -}); +export const searchRouter = createSearchRouter(); diff --git a/backend/src/modules/search/search.service.ts b/backend/src/modules/search/search.service.ts new file mode 100644 index 0000000..bf92107 --- /dev/null +++ b/backend/src/modules/search/search.service.ts @@ -0,0 +1,20 @@ +import { ValidationError, validateUuid } from "../../shared/errors.js"; +import { SearchRepository } from "./search.repository.js"; + +function optionalText(value: unknown, field: string): string { + if (value === undefined) return ""; + if (typeof value !== "string") throw new ValidationError(field + " deve ser um texto."); + return value.trim(); +} + +export class SearchService { + constructor(private readonly repository: Pick = new SearchRepository()) {} + + search(input: Record, userId: string) { + const query = optionalText(input.q, "A busca"); + const projectId = optionalText(input.projeto_id, "ID do projeto") || optionalText(input.projectId, "ID do projeto"); + if (projectId) validateUuid(projectId, "ID do projeto"); + if (query.length > 200) throw new ValidationError("A busca pode ter no máximo 200 caracteres."); + return this.repository.search({ userId, query, projectId: projectId || undefined }); + } +} diff --git a/backend/src/modules/search/search.types.ts b/backend/src/modules/search/search.types.ts new file mode 100644 index 0000000..eb17181 --- /dev/null +++ b/backend/src/modules/search/search.types.ts @@ -0,0 +1,21 @@ +export interface SearchFilter { + query: string; + userId: string; + projectId?: string; +} + +export interface SearchItem { + id: string; + projeto_id: string; + entidade_tipo: string; + entidade_id: string; + texto: string; + metadados_json: Record | null; + created_at: Date | null; + projeto_nome: string; +} + +export interface SearchResult { + items: SearchItem[]; + total: number | null; +} diff --git a/backend/src/modules/technologies/technologies.controller.ts b/backend/src/modules/technologies/technologies.controller.ts new file mode 100644 index 0000000..fdef099 --- /dev/null +++ b/backend/src/modules/technologies/technologies.controller.ts @@ -0,0 +1,14 @@ +import type { NextFunction, Request, Response } from "express"; +import { TechnologiesRepository } from "./technologies.repository.js"; + +export class TechnologiesController { + constructor(private readonly repository: Pick = new TechnologiesRepository()) {} + + list = async (_req: Request, res: Response, next: NextFunction): Promise => { + try { + res.json({ items: await this.repository.list() }); + } catch (error) { + next(error); + } + }; +} diff --git a/backend/src/modules/technologies/technologies.repository.ts b/backend/src/modules/technologies/technologies.repository.ts new file mode 100644 index 0000000..be68896 --- /dev/null +++ b/backend/src/modules/technologies/technologies.repository.ts @@ -0,0 +1,13 @@ +import type { Pool } from "pg"; +import { pool } from "../../database/db.js"; + +export class TechnologiesRepository { + constructor(private readonly db: Pick = pool) {} + + async list(): Promise> { + const { rows } = await this.db.query<{ id: string; nome: string }>( + "SELECT id, nome FROM tecnologia ORDER BY nome ASC, id ASC", + ); + return rows; + } +} diff --git a/backend/src/modules/technologies/technologies.routes.ts b/backend/src/modules/technologies/technologies.routes.ts index 1830e18..7c5c712 100644 --- a/backend/src/modules/technologies/technologies.routes.ts +++ b/backend/src/modules/technologies/technologies.routes.ts @@ -1,21 +1,13 @@ -import { Router, type NextFunction, type Request, type Response } from "express"; +import { Router } from "express"; import type { Pool } from "pg"; import { pool } from "../../database/db.js"; +import { TechnologiesRepository } from "./technologies.repository.js"; +import { TechnologiesController } from "./technologies.controller.js"; export function createTechnologiesRouter(db: Pick = pool) { const router = Router(); - - router.get("/", async (_req: Request, res: Response, next: NextFunction) => { - try { - const result = await db.query<{ id: string; nome: string }>( - "SELECT id, nome FROM tecnologia ORDER BY nome ASC, id ASC", - ); - res.json({ items: result.rows }); - } catch (error) { - next(error); - } - }); - + const controller = new TechnologiesController(new TechnologiesRepository(db)); + router.get("/", controller.list); return router; } diff --git a/backend/src/shared/service-auth.ts b/backend/src/shared/service-auth.ts new file mode 100644 index 0000000..272cb5c --- /dev/null +++ b/backend/src/shared/service-auth.ts @@ -0,0 +1,4 @@ +import { env } from "../config/env.js"; +export function serviceHeaders(): Record { + return { "X-Service-Token": env.AI_SERVICE_TOKEN }; +} diff --git a/database/migrations/014_chat_and_ingestion.sql b/database/migrations/014_chat_and_ingestion.sql new file mode 100644 index 0000000..f0a1e93 --- /dev/null +++ b/database/migrations/014_chat_and_ingestion.sql @@ -0,0 +1,11 @@ +ALTER TABLE mensagem ADD COLUMN IF NOT EXISTS processing_status TEXT NOT NULL DEFAULT 'completed' + CHECK (processing_status IN ('pending', 'completed', 'failed')); +CREATE INDEX IF NOT EXISTS idx_mensagem_pending ON mensagem(conversa_id) WHERE processing_status = 'pending'; + +ALTER TABLE documento ADD COLUMN IF NOT EXISTS ingest_attempts INTEGER NOT NULL DEFAULT 0; +ALTER TABLE documento ADD COLUMN IF NOT EXISTS ingest_next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP; +ALTER TABLE documento ADD COLUMN IF NOT EXISTS ingest_lease UUID; +ALTER TABLE documento ADD COLUMN IF NOT EXISTS ingest_locked_until TIMESTAMPTZ; +ALTER TABLE documento ADD COLUMN IF NOT EXISTS ingest_error TEXT; +CREATE INDEX IF NOT EXISTS idx_documento_ingestion ON documento(ingest_next_attempt_at) + WHERE status_processamento <> 'processado'; diff --git a/database/migrations/015_ingestion_diagnostics.sql b/database/migrations/015_ingestion_diagnostics.sql new file mode 100644 index 0000000..cbbeed3 --- /dev/null +++ b/database/migrations/015_ingestion_diagnostics.sql @@ -0,0 +1,2 @@ +ALTER TABLE documento ADD COLUMN IF NOT EXISTS ingest_error_code TEXT; +ALTER TABLE documento ADD COLUMN IF NOT EXISTS ingest_retryable BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/docker-compose.yml b/docker-compose.yml index 7a4c79a..25042bf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,8 @@ services: - "${N8N_PORT:-5678}:5678" environment: - N8N_PORT=5678 + # Supports the base64 envelope for uploads up to the backend maximum (100 MiB). + - N8N_PAYLOAD_SIZE_MAX=150 - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY:-sinapse-shared-dev-encryption-key-2026} - GENERIC_TIMEZONE=${GENERIC_TIMEZONE:-America/Sao_Paulo} - TZ=${TZ:-America/Sao_Paulo} @@ -53,7 +55,7 @@ services: container_name: sinapse-ollama restart: unless-stopped ports: - - "${OLLAMA_PORT:-11434}:11434" + - "127.0.0.1:${OLLAMA_PORT:-11434}:11434" volumes: - ollama_data:/root/.ollama environment: @@ -84,9 +86,12 @@ services: - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-sinapse_dev_password} - POSTGRES_DB=${POSTGRES_DB:-sinapse} - AI_SERVICE_URL=http://ai-service:8000 + - REPO_ANALYZER_URL=http://ai-service:8000 + - AI_SERVICE_TOKEN=${AI_SERVICE_TOKEN:?Configure AI_SERVICE_TOKEN in .env} - DOCUMENT_MAX_SIZE_MB=${DOCUMENT_MAX_SIZE_MB:-20} - DOCUMENT_STORAGE_DIR=/data/documents - - DOCUMENT_EVENTS_WEBHOOK_URL=${DOCUMENT_EVENTS_WEBHOOK_URL:-} + - DOCUMENT_EVENTS_WEBHOOK_URL=${DOCUMENT_EVENTS_WEBHOOK_URL:-http://n8n:5678/webhook/sinapse-document-removed} + - DOCUMENT_INGEST_WEBHOOK_URL=${DOCUMENT_INGEST_WEBHOOK_URL:-http://n8n:5678/webhook/sinapse-ingest} - MIGRATIONS_DIR=/database/migrations - INIT_SQL_PATH=/database/init.sql volumes: @@ -105,9 +110,10 @@ services: build: ./ai-service container_name: sinapse-ai-service restart: unless-stopped - ports: - - "${AI_SERVICE_PORT:-8000}:8000" + expose: + - "8000" environment: + - AI_SERVICE_TOKEN=${AI_SERVICE_TOKEN:?Configure AI_SERVICE_TOKEN in .env} - AI_SERVICE_PORT=8000 - AI_SERVICE_HOST=0.0.0.0 - OLLAMA_BASE_URL=http://ollama:11434 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index df38dd8..164bb75 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1544,7 +1544,7 @@ paths: nome: {type: string, minLength: 2} email: {type: string, format: email} password: {type: string, minLength: 6} - role: {type: string, enum: [po, dev, admin], default: po, description: "admin só é aceito com sessão de administrador"} + role: {type: string, enum: [po, dev, admin], default: dev, description: "po e admin só são aceitos com sessão de administrador"} responses: '201': description: Conta criada e sessão iniciada @@ -1555,6 +1555,30 @@ paths: '409': description: E-mail já cadastrado + /api/v1/chat/projects: + get: + summary: Lista projetos permitidos no chat (admin/PO ou alocação ativa do desenvolvedor) + operationId: listChatProjects + security: [{cookieAuth: []}] + responses: + '200': {description: Lista items com id e nome dos projetos} + '401': {$ref: '#/components/responses/Unauthorized'} + /api/v1/projects/{projectId}/documents/{documentId}/reprocess: + parameters: + - {name: projectId, in: path, required: true, schema: {type: string, format: uuid}} + - {name: documentId, in: path, required: true, schema: {type: string, format: uuid}} + post: + summary: Reenfileira a ingestão de um documento (admin ou PO) + operationId: reprocessDocument + security: [{cookieAuth: []}] + responses: + '202': {description: Documento pendente para reprocessamento} + '400': {$ref: '#/components/responses/ValidationError'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {description: Sem permissão de escrita} + '404': {description: Projeto ou documento inexistente} + '409': {description: Projeto arquivado ou documento ainda em processamento} + /api/v1/chat/conversations: get: summary: Lista as conversas do usuário autenticado @@ -1609,6 +1633,8 @@ paths: post: summary: Envia uma pergunta ao assistente e registra a troca description: > + Projeto obrigatório em novas conversas; em conversas existentes o projeto é imutável. + Somente admin/PO ou desenvolvedor com alocação ativa pode consultar o projeto. Sem evidência no acervo, responde "Informação não encontrada no acervo do projeto." sem inventar fontes. `origem` informa se a resposta veio do assistente, de busca textual (assistente indisponível) ou sem resultado. @@ -1753,18 +1779,84 @@ paths: '403': description: Somente administrador + /api/v1/admin/demo-seed: + post: + summary: Carrega exemplos demonstrativos em um projeto explícito (somente administrador) + description: Carga idempotente e auditada. Não gera vetores nem reindexa documentos. Projetos arquivados são rejeitados. + operationId: adminLoadDemo + security: [{cookieAuth: []}] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [projeto_id] + additionalProperties: false + properties: + projeto_id: {type: string, format: uuid} + responses: + '200': + description: Carga concluída; inseridos é zero quando já aplicada + content: + application/json: + schema: + type: object + properties: + projeto_id: {type: string, format: uuid} + inseridos: {type: integer, minimum: 0, maximum: 2} + status: {type: string, enum: [sucesso]} + message: {type: string} + '400': + description: Projeto de destino ausente ou inválido + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Somente administrador + '404': + description: Projeto não encontrado + '409': + description: Projeto arquivado + /api/v1/admin/ingest-seed: post: - summary: Carrega dados de demonstração no acervo (somente administrador) + deprecated: true + summary: Carrega exemplos demonstrativos em um projeto explícito (somente administrador) + description: Carga idempotente e auditada. Não gera vetores nem reindexa documentos. Projetos arquivados são rejeitados. operationId: adminIngestSeed security: [{cookieAuth: []}] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [projeto_id] + additionalProperties: false + properties: + projeto_id: {type: string, format: uuid} responses: '200': - description: Dados carregados + description: Carga concluída; inseridos é zero quando já aplicada + content: + application/json: + schema: + type: object + properties: + projeto_id: {type: string, format: uuid} + inseridos: {type: integer, minimum: 0, maximum: 2} + status: {type: string, enum: [sucesso]} + message: {type: string} + '400': + description: Projeto de destino ausente ou inválido '401': $ref: '#/components/responses/Unauthorized' '403': description: Somente administrador + '404': + description: Projeto não encontrado + '409': + description: Projeto arquivado /api/v1: get: @@ -3172,6 +3264,18 @@ components: type: string description: pendente = armazenado e aguardando ingestão (S2-01) enum: [pendente, processando, processado, falha] + nova_tentativa_pendente: + type: boolean + description: Existe nova tentativa automática pendente em projeto ativo. + tentativas_processamento: + type: integer + proxima_tentativa_em: + type: string + nullable: true + erro_processamento_codigo: + type: string + nullable: true + description: Código técnico sanitizado; não contém o conteúdo do documento. armazenamento_pendente: type: boolean description: true enquanto a finalização do arquivo local ainda é reconciliada pelo worker diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..b3ac69e --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,17 @@ +# Organização do frontend + +- `src/api`: clientes HTTP e contratos de transporte. +- `src/models`: modelos, navegação e funções puras do domínio. +- `src/viewmodels`: hooks de estado compartilhado e autenticação. +- `src/views`: telas e componentes organizados por funcionalidade. +- `src/views/common`: componentes compartilhados; primitivas visuais em `ui`. +- `src/styles`: tokens, componentes e layout globais. +- `src/assets/styles`: estilos específicos de funcionalidades, incluindo documentos. + +Os imports apontam diretamente para a implementação. Os antigos módulos de reexportação em `auth`, `backlog`, `components` e `projects` foram removidos; seus testes agora ficam junto às funcionalidades. + +Projetos têm componentes separados para listagem, formulário e detalhe. PBIs seguem a mesma divisão. A árvore do backlog separa filtros, renderização dos nós e carregamento/interações da tela. As funções de filtro não dependem de React. + +O dashboard distingue serviços verificados de serviços sem diagnóstico. O estado do banco vem do healthcheck do backend; falhas de acesso pelo navegador não são tratadas como prova de indisponibilidade dos serviços não verificados. + +Execute `npm test` e `npm run build` nesta pasta. diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 52d3db5..ad1d215 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -6,7 +6,7 @@ const { mockUser } = vi.hoisted(() => ({ mockUser: { id: "test-user", name: "Test User", role: "po" as "admin" | "po" | "dev" }, })); -vi.mock("./auth/Auth", () => ({ +vi.mock("./viewmodels/useAuthViewModel", () => ({ useAuth: () => ({ session: { status: "authenticated", user: mockUser }, logout: vi.fn(), diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 001eee3..888d7e7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ +import { Button } from "./views/common/ui"; import React, { useState, useEffect } from "react"; import { isProjectPath, navigate, routes } from "./models/navigation"; -import { useAuth } from "./auth/Auth"; +import { useAuth } from "./viewmodels/useAuthViewModel"; import { ProjectsView } from "./views/projects/ProjectsView"; import { LandingPageView } from "./views/landing/LandingPageView"; import { DeveloperDashboardView } from "./views/dashboard/DeveloperDashboardView"; @@ -9,7 +10,6 @@ import { KnowledgeView } from "./views/knowledge/KnowledgeView"; import { ChatView } from "./views/chat/ChatView"; import { AdminView } from "./views/admin/AdminView"; import { RequirementsView } from "./views/backlog/RequirementsView"; -import "./assets/styles/garakis-prototype.css"; export const App: React.FC = () => { const { session, logout } = useAuth(); @@ -29,8 +29,7 @@ export const App: React.FC = () => { }, []); return ( -
- {/* Top Header Bar (Protótipo Garakis) */} +
{ event.preventDefault(); navigate("/projects"); }}> S•NAPSE @@ -63,28 +62,28 @@ export const App: React.FC = () => { -
+
{isAdmin ? ( ) : ( - {session.user?.nome || session.user?.name || "Cauan Gabriel"} · {session.user?.role?.toUpperCase() || "PO"} + {session.user?.nome || session.user?.name || "Usuário"} · {session.user?.role?.toUpperCase() || "PO"} )} - +
{/* Main Content Area */} -
+
{activeTab === "projects" && } {activeTab === "requirements" && } {activeTab === "documents" && } @@ -94,7 +93,7 @@ export const App: React.FC = () => {

Acesso restrito

Esta área está disponível somente para administradores.

- +
))} {activeTab === "architecture" && } @@ -102,7 +101,7 @@ export const App: React.FC = () => {
{/* Footer */} -
+
Sinapse · PRO4TECH · Fatec São José dos Campos · Grupo Galáticos · 2º Semestre/2026
diff --git a/frontend/src/backlog/api.completude.test.ts b/frontend/src/api/api_backlog.completude.test.ts similarity index 82% rename from frontend/src/backlog/api.completude.test.ts rename to frontend/src/api/api_backlog.completude.test.ts index 11c2276..8d868ff 100644 --- a/frontend/src/backlog/api.completude.test.ts +++ b/frontend/src/api/api_backlog.completude.test.ts @@ -1,5 +1,5 @@ import { expect, it } from "vitest"; -import { hasCompletudeIndicator } from "../api/api_backlog"; +import { hasCompletudeIndicator } from "./api_backlog"; it("considera 0% como indicador válido e oculta somente quando a pontuação é nula", () => { expect(hasCompletudeIndicator(0)).toBe(true); diff --git a/frontend/src/api/api_chat.ts b/frontend/src/api/api_chat.ts index 50ce895..906f573 100644 --- a/frontend/src/api/api_chat.ts +++ b/frontend/src/api/api_chat.ts @@ -24,6 +24,7 @@ export interface ChatMessage { fontes: ChatSource[]; created_at: string | null; origem?: ChatOrigin; + failed?: boolean; } export interface ChatAnswer { @@ -74,6 +75,7 @@ function parseMessage(value: unknown): ChatMessage { id: value.id, remetente: value.remetente as ChatMessage["remetente"], conteudo: value.conteudo, + failed: value.processing_status === "failed", fontes: parseSources(value.fontes_json ?? value.fontes), created_at: typeof value.created_at === "string" ? value.created_at : null, }; @@ -120,3 +122,9 @@ export function describeChatError(error: unknown): string { } return "Não foi possível falar com o assistente agora. Verifique a conexão e tente novamente."; } + +export async function listChatProjects(signal?: AbortSignal): Promise> { + const data = await (await apiRequest("/chat/projects", {signal})).json(); + if (!Array.isArray(data.items)) throw new Error("Projetos inválidos"); + return data.items; +} diff --git a/frontend/src/api/api_documents.ts b/frontend/src/api/api_documents.ts index f16a253..5baa349 100644 --- a/frontend/src/api/api_documents.ts +++ b/frontend/src/api/api_documents.ts @@ -13,6 +13,10 @@ export interface ProjectDocument { tamanho_bytes: number | null; status_processamento: DocumentStatus; armazenamento_pendente: boolean; + nova_tentativa_pendente?: boolean; + tentativas_processamento?: number; + proxima_tentativa_em?: string | null; + erro_processamento_codigo?: string | null; autor_id: string | null; autor_nome: string | null; created_at: string; @@ -55,6 +59,10 @@ function parseDocument(value: unknown): ProjectDocument { tamanho_bytes: typeof size === "number" ? size : null, status_processamento: value.status_processamento as DocumentStatus, armazenamento_pendente: value.armazenamento_pendente === true, + nova_tentativa_pendente: value.nova_tentativa_pendente === true, + tentativas_processamento: typeof value.tentativas_processamento === "number" ? value.tentativas_processamento : 0, + proxima_tentativa_em: typeof value.proxima_tentativa_em === "string" ? value.proxima_tentativa_em : null, + erro_processamento_codigo: typeof value.erro_processamento_codigo === "string" ? value.erro_processamento_codigo : null, 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, @@ -152,3 +160,7 @@ export function describeRemovalError(error: unknown): string { } return "Não foi possível remover o documento. Ele continua disponível; tente novamente."; } + +export async function reprocessDocument(projectId: string, documentId: string): Promise { + await apiRequest(`/projects/${encodeURIComponent(projectId)}/documents/${encodeURIComponent(documentId)}/reprocess`, { method: "POST" }); +} diff --git a/frontend/src/projects/repo-analyzer.api.test.ts b/frontend/src/api/api_repo_analyzer.test.ts similarity index 93% rename from frontend/src/projects/repo-analyzer.api.test.ts rename to frontend/src/api/api_repo_analyzer.test.ts index 56ae534..5acde2d 100644 --- a/frontend/src/projects/repo-analyzer.api.test.ts +++ b/frontend/src/api/api_repo_analyzer.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, it, vi } from 'vitest'; -import { startRepoAnalysis } from './repo-analyzer.api'; +import { startRepoAnalysis } from "./api_repo_analyzer"; afterEach(() => vi.unstubAllGlobals()); diff --git a/frontend/src/projects/repo-analyzer.api.ts b/frontend/src/api/api_repo_analyzer.ts similarity index 97% rename from frontend/src/projects/repo-analyzer.api.ts rename to frontend/src/api/api_repo_analyzer.ts index bbc3279..f1bc962 100644 --- a/frontend/src/projects/repo-analyzer.api.ts +++ b/frontend/src/api/api_repo_analyzer.ts @@ -1,4 +1,4 @@ -import { apiRequest } from "../api/api_auth"; +import { apiRequest } from "./api_auth"; export type RepoAnalysisStatus = "iniciado" | "em_execucao" | "concluido" | "falha"; diff --git a/frontend/src/assets/styles/backlog-search.css b/frontend/src/assets/styles/backlog-search.css index a02f883..08698f0 100644 --- a/frontend/src/assets/styles/backlog-search.css +++ b/frontend/src/assets/styles/backlog-search.css @@ -1,6 +1,6 @@ .backlog-search-field { flex: 2 1 260px; min-width: 0; } .backlog-search-field input { width: 100%; padding: 10px 12px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--bg-secondary); color: var(--text-primary); font: inherit; } -.backlog-search-back { padding: 0; background: none; border: 0; color: var(--orange); text-decoration: underline; font: inherit; } +.backlog-search-back { padding: 0; background: none; border: 0; color: var(--brand-primary); text-decoration: underline; font: inherit; } .backlog-search-results { display: grid; gap: 12px; } .backlog-search-summary { color: var(--text-secondary); font-size: .9rem; margin: 0; } .backlog-search-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; } @@ -8,13 +8,13 @@ .backlog-search-item-head { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; } .backlog-search-code { font: 12px var(--font-mono); color: var(--text-muted); } .backlog-search-title { padding: 0; background: none; border: 0; color: #fff; font-size: 1rem; font-weight: 600; text-align: left; overflow-wrap: anywhere; } -.backlog-search-title:hover { color: var(--orange); text-decoration: underline; } +.backlog-search-title:hover { color: var(--brand-primary); text-decoration: underline; } .backlog-search-snippet { margin: 0; color: var(--text-secondary); font-size: .9rem; line-height: 1.6; overflow-wrap: anywhere; } -.backlog-search-snippet mark { background: color-mix(in srgb, var(--orange) 30%, transparent); color: #fff; border-radius: 3px; padding: 0 2px; } +.backlog-search-snippet mark { background: color-mix(in srgb, var(--brand-primary) 30%, transparent); color: #fff; border-radius: 3px; padding: 0 2px; } .backlog-search-path ol { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 4px; font-size: .8rem; color: var(--text-muted); } .backlog-search-path li:not(:last-child)::after { content: "›"; margin-left: 6px; color: var(--text-muted); } .backlog-search-path button { padding: 0; background: none; border: 0; color: var(--text-secondary); font: inherit; text-decoration: underline; text-decoration-color: transparent; } -.backlog-search-path button:hover { color: var(--orange); text-decoration-color: currentColor; } +.backlog-search-path button:hover { color: var(--brand-primary); text-decoration-color: currentColor; } .backlog-search-path [aria-current="page"] { color: var(--text-primary); } .backlog-search-tech { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; } .backlog-search-tech li { padding: 2px 10px; border: 1px solid var(--border-subtle); border-radius: 999px; font-size: .75rem; color: var(--text-secondary); } diff --git a/frontend/src/assets/styles/chat.css b/frontend/src/assets/styles/chat.css index 5d2d11f..a926404 100644 --- a/frontend/src/assets/styles/chat.css +++ b/frontend/src/assets/styles/chat.css @@ -1,41 +1,41 @@ .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 { display: grid; gap: 8px; align-content: start; overflow-y: auto; max-height: 460px; border-top: 1px solid var(--border-subtle); 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 { width: 100%; display: grid; gap: 2px; padding: 10px 12px; text-align: left; color: var(--text-primary); background: transparent; border: 1px solid transparent; border-radius: 8px; transition: background .15s ease, border-color .15s ease; } +.chat-conversation:hover { background: var(--bg-tertiary); } +.chat-conversation[aria-current="true"] { background: color-mix(in srgb, var(--brand-primary) 14%, transparent); border-color: var(--brand-primary); } .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-conversation-meta { font-size: 12px; color: var(--text-muted); 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 { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; border-bottom: 1px solid var(--border-subtle); 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; } +.chat-messages:focus-visible { outline: 2px solid var(--brand-primary); 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; } +.chatx-welcome { margin: auto; max-width: 560px; text-align: center; display: grid; gap: 10px; color: var(--text-secondary); } +.chatx-welcome h3 { margin: 0; color: var(--text-primary); 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-suggestions button { padding: 10px 14px; text-align: left; color: var(--text-primary); background: var(--surface-base); border: 1px solid var(--border-subtle); border-radius: 10px; transition: border-color .15s ease, background .15s ease; } +.chat-suggestions button:hover { border-color: var(--brand-primary); background: var(--bg-tertiary); } -.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 { display: grid; gap: 8px; max-width: min(760px, 88%); padding: 12px 16px; border-radius: 12px; border: 1px solid var(--border-subtle); background: color-mix(in srgb, var(--bg-tertiary) 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 header b { color: var(--brand-primary); font-weight: 700; } +.chat-message header time { color: var(--text-muted); } +.chat-message--user { align-self: flex-end; background: color-mix(in srgb, var(--brand-primary) 16%, var(--surface-base)); border-color: color-mix(in srgb, var(--brand-primary) 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.is-failed { border-style: dashed; border-color: var(--status-danger); } +.chat-message-failed { margin: 0; color: var(--status-danger); 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 { font-size: 12.5px; border-top: 1px solid var(--border-subtle); padding-top: 8px; } +.chat-sources summary { cursor: pointer; color: var(--text-secondary); } .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-typing { align-self: flex-start; padding: 8px 14px; border-radius: 999px; border: 1px solid var(--border-subtle); color: var(--text-secondary); font-size: 13px; } .chat-composer { display: grid; gap: 8px; } .chat-composer textarea { resize: vertical; min-height: 76px; max-height: 220px; } diff --git a/frontend/src/assets/styles/decisions.css b/frontend/src/assets/styles/decisions.css index b4587ec..996c1fd 100644 --- a/frontend/src/assets/styles/decisions.css +++ b/frontend/src/assets/styles/decisions.css @@ -2,19 +2,19 @@ .decisions-head { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: 12px; } .decisions-head h3 { margin: 0 0 4px; font-size: 1.1rem; } .decisions-head p { max-width: 62ch; font-size: .88rem; } -.decisions-group { margin: 0 0 10px; font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); } +.decisions-group { margin: 0 0 10px; font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; color: var(--text-secondary); } .decision-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; } -.decision-card { padding: 14px 16px; border: 1px solid var(--line); border-left: 4px solid var(--orange); border-radius: var(--r); background: var(--bg); display: grid; gap: 8px; } +.decision-card { padding: 14px 16px; border: 1px solid var(--border-subtle); border-left: 4px solid var(--brand-primary); border-radius: var(--radius-md); background: var(--surface-base); display: grid; gap: 8px; } .decision-card header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; } .decision-card h4 { margin: 0; font-size: 1rem; overflow-wrap: anywhere; } -.decision-card--inherited { border-left-style: dashed; background: color-mix(in srgb, var(--card) 60%, transparent); } +.decision-card--inherited { border-left-style: dashed; background: color-mix(in srgb, var(--bg-tertiary) 60%, transparent); } .decision-card--inherited[data-level="projeto"] { border-left-color: var(--step-2); } .decision-card--inherited[data-level="epico"] { border-left-color: var(--step-3); } .decision-card--inherited[data-level="feature"] { border-left-color: var(--step-4); } -.decision-meta { margin: 0; font-size: .8rem; color: var(--dim); } +.decision-meta { margin: 0; font-size: .8rem; color: var(--text-muted); } .decision-card dl { margin: 0; display: grid; gap: 4px; } -.decision-card dt { font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); margin-top: 6px; } +.decision-card dt { font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; color: var(--text-secondary); margin-top: 6px; } .decision-card dd { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.6; } -.decision-form { display: grid; gap: 12px; padding: 16px; border: 1px solid var(--line); border-radius: var(--r); background: var(--bg); } +.decision-form { display: grid; gap: 12px; padding: 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface-base); } .decision-form h4 { margin: 0; } -.decision-error { margin: 2px 0 0; color: var(--red); font-size: 12px; } +.decision-error { margin: 2px 0 0; color: var(--status-danger); font-size: 12px; } diff --git a/frontend/src/projects/documents.css b/frontend/src/assets/styles/documents.css similarity index 100% rename from frontend/src/projects/documents.css rename to frontend/src/assets/styles/documents.css diff --git a/frontend/src/assets/styles/repo-analyzer.css b/frontend/src/assets/styles/repo-analyzer.css index 9062655..97345e8 100644 --- a/frontend/src/assets/styles/repo-analyzer.css +++ b/frontend/src/assets/styles/repo-analyzer.css @@ -7,61 +7,61 @@ .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 { display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--border-subtle); 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 { width: 100%; display: grid; gap: 8px; padding: 12px; text-align: left; color: var(--text-primary); background: var(--surface-base); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); transition: border-color .15s ease, background .15s ease; } +.analysis-item:hover { border-color: var(--border-strong); background: var(--bg-tertiary); } +.analysis-item[aria-current="true"] { border-color: var(--brand-primary); background: var(--bg-tertiary); } .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-item-meta time { font-size: 12px; color: var(--text-muted); } .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-details-header a { color: var(--text-primary); text-decoration: none; border-bottom: 1px dashed var(--border-strong); } +.analysis-details-header a:hover { color: var(--brand-primary); border-color: var(--brand-primary); } .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-stepper li { display: grid; justify-items: center; gap: 6px; text-align: center; position: relative; color: var(--text-muted); 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(--border-subtle); } +.analysis-stepper li[data-state="done"]:not(:last-child)::after { background: var(--brand-primary); } +.analysis-step-marker { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 50%; border: 2px solid var(--border-strong); background: var(--surface-base); font-size: 12px; font-weight: 700; position: relative; z-index: 1; } +.analysis-stepper li[data-state="done"] { color: var(--text-secondary); } +.analysis-stepper li[data-state="done"] .analysis-step-marker { background: var(--brand-primary); border-color: var(--brand-primary); color: #fff; } +.analysis-stepper li[data-state="current"] { color: var(--text-primary); font-weight: 600; } +.analysis-stepper li[data-state="current"] .analysis-step-marker { border-color: var(--brand-primary); color: var(--brand-primary); box-shadow: 0 0 0 4px color-mix(in srgb, var(--brand-primary) 22%, transparent); } +.analysis-stepper li[data-state="failed"] { color: var(--status-danger); font-weight: 600; } +.analysis-stepper li[data-state="failed"] .analysis-step-marker { border-color: var(--status-danger); background: color-mix(in srgb, var(--status-danger) 16%, transparent); color: var(--status-danger); } -.analysis-progress { display: grid; gap: 8px; padding: 14px; background: var(--bg); border: 1px solid var(--line); border-radius: var(--r); } +.analysis-progress { display: grid; gap: 8px; padding: 14px; background: var(--surface-base); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); } .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-languages li { padding: 3px 10px; border: 1px solid var(--border-subtle); border-radius: 999px; font-size: 12px; color: var(--text-secondary); } +.analysis-languages b { color: var(--text-primary); 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 { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; border-top: 1px solid var(--border-subtle); 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-toggle { display: inline-flex; border: 1px solid var(--border-subtle); border-radius: 999px; overflow: hidden; } +.analysis-toggle button { padding: 5px 14px; background: transparent; border: 0; color: var(--text-secondary); font-size: 13px; } +.analysis-toggle button[aria-pressed="true"] { background: var(--brand-surface); color: #fff; } +.analysis-report-body, .analysis-report-raw { max-height: 520px; overflow: auto; padding: 18px; background: var(--surface-base); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); } .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 { color: var(--text-primary); 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 h2 { font-size: 18px; border-bottom: 1px solid var(--border-subtle); 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 code { padding: 1px 6px; border-radius: 6px; background: var(--bg-tertiary); font: 12.5px var(--font-mono); } +.markdown pre { margin: 0; padding: 14px; overflow: auto; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--bg-tertiary); } .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); } +.markdown blockquote { padding: 4px 14px; border-left: 3px solid var(--brand-primary); color: var(--text-secondary); } +.markdown a { color: var(--brand-primary); } +.markdown hr { border: 0; border-top: 1px solid var(--border-subtle); } @media (max-width: 900px) { .repo-analyzer-layout { grid-template-columns: 1fr; } diff --git a/frontend/src/auth/Auth.tsx b/frontend/src/auth/Auth.tsx deleted file mode 100644 index 5046a73..0000000 --- a/frontend/src/auth/Auth.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export { AuthProvider, useAuth, AuthContext } from "../viewmodels/useAuthViewModel"; -export type { Session, AuthContextType } from "../viewmodels/useAuthViewModel"; -export { AuthGate, AuthScreen, LoginOnCard, RegisterOnCard } from "../views/auth/AuthView"; diff --git a/frontend/src/auth/navigation.ts b/frontend/src/auth/navigation.ts deleted file mode 100644 index 35eed08..0000000 --- a/frontend/src/auth/navigation.ts +++ /dev/null @@ -1 +0,0 @@ -export { routes, isProjectPath, safeDestination, navigate, loginDestination } from "../models/navigation"; diff --git a/frontend/src/backlog/Backlog.tsx b/frontend/src/backlog/Backlog.tsx deleted file mode 100644 index 83fc1da..0000000 --- a/frontend/src/backlog/Backlog.tsx +++ /dev/null @@ -1 +0,0 @@ -export { BacklogScreen } from "../views/backlog/BacklogView"; diff --git a/frontend/src/backlog/Criteria.tsx b/frontend/src/backlog/Criteria.tsx deleted file mode 100644 index 22deb59..0000000 --- a/frontend/src/backlog/Criteria.tsx +++ /dev/null @@ -1 +0,0 @@ -export { CriteriaEditor } from "../views/backlog/CriteriaView"; \ No newline at end of file diff --git a/frontend/src/backlog/Epics.tsx b/frontend/src/backlog/Epics.tsx deleted file mode 100644 index 006e295..0000000 --- a/frontend/src/backlog/Epics.tsx +++ /dev/null @@ -1 +0,0 @@ -export { EpicList, EpicForm, EpicDetail } from "../views/backlog/EpicsView"; diff --git a/frontend/src/backlog/Features.tsx b/frontend/src/backlog/Features.tsx deleted file mode 100644 index 20559bf..0000000 --- a/frontend/src/backlog/Features.tsx +++ /dev/null @@ -1 +0,0 @@ -export { FeatureList, FeatureForm, FeatureDetail } from "../views/backlog/FeaturesView"; diff --git a/frontend/src/backlog/ItemArchive.tsx b/frontend/src/backlog/ItemArchive.tsx deleted file mode 100644 index 6ae52d3..0000000 --- a/frontend/src/backlog/ItemArchive.tsx +++ /dev/null @@ -1 +0,0 @@ -export { ItemArchiveView as ItemArchive } from "../views/backlog/ItemArchiveView"; diff --git a/frontend/src/backlog/Pbis.tsx b/frontend/src/backlog/Pbis.tsx deleted file mode 100644 index 7d90d29..0000000 --- a/frontend/src/backlog/Pbis.tsx +++ /dev/null @@ -1 +0,0 @@ -export { PbiList, PbiForm, PbiDetail } from "../views/backlog/PbisView"; \ No newline at end of file diff --git a/frontend/src/backlog/QualityPanel.tsx b/frontend/src/backlog/QualityPanel.tsx deleted file mode 100644 index b95a868..0000000 --- a/frontend/src/backlog/QualityPanel.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export { QualityPanelView as QualityPanel, scrollToField } from "../views/backlog/QualityPanelView"; -export type { QualityPanelProps } from "../views/backlog/QualityPanelView"; \ No newline at end of file diff --git a/frontend/src/backlog/ReadOnlyContext.ts b/frontend/src/backlog/ReadOnlyContext.ts deleted file mode 100644 index 1dca542..0000000 --- a/frontend/src/backlog/ReadOnlyContext.ts +++ /dev/null @@ -1 +0,0 @@ -export { ReadOnlyContext } from "../models/ReadOnlyContext"; diff --git a/frontend/src/backlog/fields.ts b/frontend/src/backlog/fields.ts deleted file mode 100644 index 8b8d21e..0000000 --- a/frontend/src/backlog/fields.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "../models/fields"; diff --git a/frontend/src/backlog/navigation.ts b/frontend/src/backlog/navigation.ts deleted file mode 100644 index 41e8013..0000000 --- a/frontend/src/backlog/navigation.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { navigate, isBacklogPath, parseBacklogRoute } from "../models/navigation"; -export type { BacklogRoute } from "../models/navigation"; diff --git a/frontend/src/backlog/qualityEngine.ts b/frontend/src/backlog/qualityEngine.ts deleted file mode 100644 index 5c872df..0000000 --- a/frontend/src/backlog/qualityEngine.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "../models/qualityEngine"; \ No newline at end of file diff --git a/frontend/src/backlog/useUnsavedChangesGuard.ts b/frontend/src/backlog/useUnsavedChangesGuard.ts deleted file mode 100644 index 11d95de..0000000 --- a/frontend/src/backlog/useUnsavedChangesGuard.ts +++ /dev/null @@ -1 +0,0 @@ -export { useUnsavedChangesGuard } from "../viewmodels/useUnsavedChangesGuard"; diff --git a/frontend/src/components/DeveloperDashboard.tsx b/frontend/src/components/DeveloperDashboard.tsx deleted file mode 100644 index f1caac4..0000000 --- a/frontend/src/components/DeveloperDashboard.tsx +++ /dev/null @@ -1 +0,0 @@ -export { DeveloperDashboardView as DeveloperDashboard } from "../views/dashboard/DeveloperDashboardView"; \ No newline at end of file diff --git a/frontend/src/components/LandingPageView.tsx b/frontend/src/components/LandingPageView.tsx deleted file mode 100644 index 72ced54..0000000 --- a/frontend/src/components/LandingPageView.tsx +++ /dev/null @@ -1 +0,0 @@ -export { LandingPageView } from "../views/landing/LandingPageView"; \ No newline at end of file diff --git a/frontend/src/components/SearchField.tsx b/frontend/src/components/SearchField.tsx deleted file mode 100644 index b53f8ec..0000000 --- a/frontend/src/components/SearchField.tsx +++ /dev/null @@ -1 +0,0 @@ -export { SearchField } from "../views/common/SearchField"; diff --git a/frontend/src/components/ui/index.tsx b/frontend/src/components/ui/index.tsx deleted file mode 100644 index 6bd16c3..0000000 --- a/frontend/src/components/ui/index.tsx +++ /dev/null @@ -1,10 +0,0 @@ -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 3a6cc70..42c03f1 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,5 +1,6 @@ @import "./styles/tokens.css"; @import "./styles/components.css"; +@import "./styles/layout.css"; .auth-shell { min-height: 100vh; @@ -302,97 +303,3 @@ button { border: none; outline: none; } - -/* Glassmorphic Container */ -.glass-panel { - background: var(--bg-card); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-md); - box-shadow: var(--shadow-md); - transition: transform var(--transition-normal), border-color var(--transition-normal); -} - -.glass-panel:hover { - border-color: var(--border-active); -} - -/* Badges */ -.badge { - display: inline-flex; - align-items: center; - gap: 6px; - 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; - letter-spacing: 0.05em; -} - -.badge-success { - 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: transparent; - color: var(--brand-primary); - border-color: var(--brand-primary); -} - -.badge-warning { - 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: var(--brand-gradient); - color: var(--on-brand); - padding: 10px 24px; - border-radius: var(--radius-full); - font-weight: 500; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - transition: background var(--transition-normal), box-shadow var(--transition-normal); -} - -.btn-primary:hover:not(:disabled) { - opacity: 0.92; - box-shadow: var(--shadow-glow); -} - -.btn-secondary { - background: var(--bg-tertiary); - color: var(--text-secondary); - border: 1px solid var(--border-subtle); - padding: 10px 20px; - border-radius: var(--radius-full); - font-weight: 500; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - transition: background var(--transition-normal), color var(--transition-normal), border-color var(--transition-normal); -} - -.btn-secondary:hover:not(:disabled) { - background: var(--bg-card-hover); - color: var(--text-primary); - border-color: var(--border-active); -} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 5dff244..024dafc 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,7 +2,8 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { App } from "./App.js"; import "./index.css"; -import { AuthGate, AuthProvider } from "./auth/Auth"; +import { AuthGate } from "./views/auth/AuthView"; +import { AuthProvider } from "./viewmodels/useAuthViewModel"; ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/frontend/src/models/repoAnalyzer.ts b/frontend/src/models/repoAnalyzer.ts index 0ae7b38..ad543ac 100644 --- a/frontend/src/models/repoAnalyzer.ts +++ b/frontend/src/models/repoAnalyzer.ts @@ -1,4 +1,4 @@ -import type { RepoAnalysis, RepoAnalysisStatus } from "../projects/repo-analyzer.api"; +import type { RepoAnalysis, RepoAnalysisStatus } from "../api/api_repo_analyzer"; export const ANALYSIS_STAGES: ReadonlyArray<{ key: string; label: string }> = [ { key: "ollama", label: "Verificar IA local" }, diff --git a/frontend/src/projects/ProjectArchive.tsx b/frontend/src/projects/ProjectArchive.tsx deleted file mode 100644 index 9e1324f..0000000 --- a/frontend/src/projects/ProjectArchive.tsx +++ /dev/null @@ -1 +0,0 @@ -export { ProjectArchiveView as ProjectArchive } from "../views/projects/ProjectArchiveView"; diff --git a/frontend/src/projects/Projects.tsx b/frontend/src/projects/Projects.tsx deleted file mode 100644 index 1f81746..0000000 --- a/frontend/src/projects/Projects.tsx +++ /dev/null @@ -1 +0,0 @@ -export { ProjectsView as Projects } from "../views/projects/ProjectsView"; diff --git a/frontend/src/projects/navigation.ts b/frontend/src/projects/navigation.ts deleted file mode 100644 index ea8c3ea..0000000 --- a/frontend/src/projects/navigation.ts +++ /dev/null @@ -1 +0,0 @@ -export { routes, isProjectPath, navigate } from "../models/navigation"; diff --git a/frontend/src/projects/projects.css b/frontend/src/projects/projects.css deleted file mode 100644 index 8ac11d0..0000000 --- a/frontend/src/projects/projects.css +++ /dev/null @@ -1 +0,0 @@ -@import "../assets/styles/projects.css"; diff --git a/frontend/src/styles/README.md b/frontend/src/styles/README.md new file mode 100644 index 0000000..6583791 --- /dev/null +++ b/frontend/src/styles/README.md @@ -0,0 +1,15 @@ +# Sistema visual do Sinapse + +O ponto de entrada global é src/index.css. Ele importa, nesta ordem: + +1. tokens.css: fonte única de cores, tipografia, espaçamento, raios e estados. +2. components.css: primitivas ds-* e suas variantes. +3. layout.css: cabeçalho, navegação, estrutura das páginas e regras responsivas. + +Estilos específicos de funcionalidades ficam em src/assets/styles e usam os mesmos tokens. Não devem redefinir botões, badges ou uma paleta paralela. + +Para ações, use Button de views/common/ui com variant primary, secondary, ghost ou danger. Ele compartilha os mesmos estilos em autenticação, projetos, backlog, conhecimento e administração. Use Badge, Field, Alert e os demais componentes compartilhados quando aplicáveis. ds-card--glass é uma variante do mesmo card, sem espaçamento interno, para contêineres que já controlam seu próprio layout. + +As classes garakis, btn-primary, btn-secondary, badge-* e glass-panel foram migradas para ds-*. O stylesheet do protótipo foi removido; a paleta foi preservada nos tokens canônicos. Os imports e testes foram migrados para os caminhos canônicos, permitindo remover os reexports dos diretórios antigos. + +Validação: 195 testes passaram; build aprovado. Inspeção com Chromium/Edge headless em 1440 e 390 px, usando respostas de API simuladas, cobriu projetos, novo projeto, backlog, conhecimento e administração. Nessas páginas não houve erros JavaScript nem overflow horizontal do documento, e os botões usam os mesmos raios e pesos tipográficos. Essa inspeção não substitui integração com backend e banco reais. diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index 42d480f..e628614 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -84,6 +84,7 @@ } .ds-card { + padding: var(--space-5); background: var(--bg-card); color: var(--text-primary); border: 1px solid var(--border-subtle); @@ -187,3 +188,23 @@ white-space: nowrap; border: 0; } + +/* Shared layouts and field spacing variants. */ +.ds-grid { display: grid; gap: var(--space-4); } +.ds-grid--one { grid-template-columns: minmax(0, 1fr); } +.ds-grid--two { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.ds-grid--three { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.ds-field--spaced { margin-bottom: var(--space-4); } +.ds-field > label { font-weight: 600; font-size: 0.88rem; color: var(--text-primary); } +@media (max-width: 820px) { + .ds-grid--two, .ds-grid--three { grid-template-columns: minmax(0, 1fr); } +} + +.ds-card--glass { + padding: 0; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + box-shadow: var(--shadow-md); + transition: border-color var(--transition-normal); +} +.ds-card--glass:hover { border-color: var(--border-active); } diff --git a/frontend/src/assets/styles/garakis-prototype.css b/frontend/src/styles/layout.css similarity index 60% rename from frontend/src/assets/styles/garakis-prototype.css rename to frontend/src/styles/layout.css index fa11fc4..26f47af 100644 --- a/frontend/src/assets/styles/garakis-prototype.css +++ b/frontend/src/styles/layout.css @@ -1,28 +1,4 @@ -/* Estilos adaptados do Protótipo Garakis (figma-import/06-prototipo-navegavel-garakis.html) */ -:root { - --bg: #0b0d10; - --surface: #111827; - --card: #171a21; - --line: rgba(196, 199, 204, .18); - --strong: rgba(196, 199, 204, .3); - --text: #e5e7eb; - --muted: #a6aab3; - --dim: #8b93a1; - --orange: #ed6a32; - --orange-bg: #4a200f; - --green: #22b458; - --red: #f0635d; - --blue: #4097ff; - --r: 10px; -} - -body { - margin: 0; - background: var(--bg); - color: var(--text); - font: 14px/1.5 'Inter', -apple-system, BlinkMacSystemFont, Arial, sans-serif; -} - +/* Application shell and page layouts. Primitives live in components.css. */ button, input, select, textarea { font: inherit; } @@ -48,7 +24,7 @@ h2 { } .muted, .help { - color: var(--muted); + color: var(--text-secondary); } .help { @@ -59,7 +35,7 @@ h2 { font-size: 11px; font-weight: 800; letter-spacing: .13em; - color: var(--orange); + color: var(--brand-primary); text-transform: uppercase; } @@ -73,7 +49,7 @@ h2 { align-items: center; gap: 30px; padding: 0 36px; - border-bottom: 1px solid var(--line); + border-bottom: 1px solid var(--border-subtle); background: rgba(11, 13, 16, .96); backdrop-filter: blur(12px); } @@ -91,7 +67,7 @@ h2 { } .brand b { - color: var(--orange); + color: var(--brand-primary); } .nav-links { @@ -103,7 +79,7 @@ h2 { .nav-links button, .nav-links a { padding: 9px 14px; border-radius: 8px; - color: var(--muted); + color: var(--text-secondary); text-decoration: none; background: transparent; border: 0; @@ -114,13 +90,13 @@ h2 { .nav-links button:hover, .nav-links button.active, .nav-links a:hover, .nav-links a.active { - background: var(--orange-bg); + background: var(--brand-surface); color: #fff; } .user-menu-btn, .user-menu-label { - color: var(--muted); + color: var(--text-secondary); border: 0; background: transparent; white-space: nowrap; @@ -138,7 +114,7 @@ h2 { } .user-menu-btn:hover { - background: var(--card); + background: var(--bg-tertiary); color: #fff; } @@ -157,133 +133,20 @@ h2 { margin-bottom: 26px; } -/* Botões do Design System Garakis */ -.btn-garakis { - min-height: 40px; - padding: 0 16px; - border: 1px solid transparent; - border-radius: var(--r); - font-weight: 700; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - transition: opacity 0.15s ease, transform 0.1s ease; -} - -.btn-garakis:hover { - opacity: 0.92; -} - -.btn-garakis.primary { - background: linear-gradient(135deg, #f1885b, var(--orange)); - color: #17191c; -} - -.btn-garakis.secondary { - background: var(--card); - border-color: var(--strong); - color: #fff; -} - -.btn-garakis.ghost { - padding-left: 0; - background: none; - border: 0; - color: var(--muted); -} - -.btn-garakis.ghost:hover { - color: var(--orange); -} - -/* Cards e Grids */ -.card-garakis { - padding: 20px; - border: 1px solid var(--line); - border-radius: var(--r); - background: var(--surface); -} - -.grid-garakis { - display: grid; - gap: 16px; -} - -.grid-garakis.three { - grid-template-columns: repeat(3, 1fr); -} - -.grid-garakis.two { - grid-template-columns: repeat(2, 1fr); -} - -/* Badges */ -.badge-garakis { - display: inline-block; - padding: 4px 9px; - border-radius: 99px; - background: var(--orange-bg); - color: #f4aa88; - font-size: 10px; - font-weight: 800; - letter-spacing: .06em; - text-transform: uppercase; -} - -.badge-garakis.green { - background: #0d371a; - color: var(--green); -} - -.badge-garakis.info { - background: #132e50; - color: #b8d9ff; -} - -/* Inputs & Form Fields */ -.input-garakis { - width: 100%; - height: 43px; - padding: 0 12px; - border: 1px solid var(--strong); - border-radius: var(--r); - background: var(--bg); - color: var(--text); - box-sizing: border-box; -} - -.input-garakis:focus { - outline: 2px solid var(--orange); - border-color: transparent; -} - -.field-garakis { - display: grid; - gap: 7px; - margin-bottom: 15px; -} - -.field-garakis label { - font-weight: 600; - font-size: 0.88rem; - color: var(--text); -} - /* Breadcrumbs */ .crumb-bar { display: flex; align-items: center; gap: 7px; margin-bottom: 15px; - color: var(--muted); + color: var(--text-secondary); font-size: 12px; } .crumb-bar button { border: 0; background: none; - color: var(--muted); + color: var(--text-secondary); padding: 0; cursor: pointer; } @@ -298,34 +161,34 @@ h2 { } /* Abas de Projeto */ -.project-tabs-garakis { +.project-tabs { display: flex; gap: 4px; margin: 25px 0; - border-bottom: 1px solid var(--line); + border-bottom: 1px solid var(--border-subtle); } -.project-tabs-garakis button { +.project-tabs button { padding: 11px 16px; border: 0; border-bottom: 2px solid transparent; background: none; - color: var(--muted); + color: var(--text-secondary); font-weight: 600; cursor: pointer; transition: all 0.15s ease; } -.project-tabs-garakis button.active { +.project-tabs button.active { color: #fff; - border-bottom-color: var(--orange); + border-bottom-color: var(--brand-primary); } /* Caixas de Aviso */ .notice-box { padding: 13px 15px; border: 1px solid rgba(64, 151, 255, .42); - border-radius: var(--r); + border-radius: var(--radius-md); background: #132e50; color: #dbeafe; font-size: 0.9rem; @@ -339,13 +202,13 @@ h2 { margin-bottom: 24px; } -.tree-grid .card-garakis { +.tree-grid .ds-card { min-height: 120px; } /* Card de Qualidade da Especificação */ .quality-card { - border-left: 3px solid var(--green); + border-left: 3px solid var(--status-success); } /* Documentos & Upload */ @@ -354,7 +217,7 @@ h2 { gap: 13px; align-items: center; padding: 14px 0; - border-bottom: 1px solid var(--line); + border-bottom: 1px solid var(--border-subtle); } .file-row:last-child { @@ -366,50 +229,19 @@ h2 { height: 42px; display: grid; place-items: center; - border: 1px solid var(--strong); + border: 1px solid var(--border-strong); border-radius: 6px; - color: var(--orange); + color: var(--brand-primary); font-size: 11px; font-weight: 800; } -.progress-bar-garakis { - height: 6px; - margin-top: 7px; - border-radius: 99px; - background: var(--bg); - overflow: hidden; -} - -.progress-bar-garakis i { - display: block; - height: 100%; - background: var(--orange); -} - -.dropzone-garakis { - display: grid; - place-items: center; - min-height: 160px; - padding: 24px; - text-align: center; - border: 1px dashed var(--orange); - border-radius: var(--r); - background: var(--card); - cursor: pointer; - transition: background 0.15s ease; -} - -.dropzone-garakis:hover { - background: var(--orange-bg); -} - /* Resultados de Busca (Conhecimento) */ .search-result-row { display: flex; gap: 14px; padding: 16px 0; - border-bottom: 1px solid var(--line); + border-bottom: 1px solid var(--border-subtle); } .search-result-row:last-child { @@ -418,7 +250,7 @@ h2 { .result-type-tag { min-width: 70px; - color: var(--orange); + color: var(--brand-primary); font-size: 11px; font-weight: 800; text-transform: uppercase; @@ -435,13 +267,13 @@ h2 { .chat-history-sidebar { padding-top: 8px; - border-right: 1px solid var(--line); + border-right: 1px solid var(--border-subtle); padding-right: 16px; } .chat-history-sidebar h3 { font-size: 13px; - color: var(--muted); + color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 12px; @@ -455,21 +287,21 @@ h2 { border: 0; border-radius: 8px; background: transparent; - color: var(--muted); + color: var(--text-secondary); text-align: left; cursor: pointer; transition: all 0.15s ease; } .history-item:hover, .history-item.active { - background: var(--card); + background: var(--bg-tertiary); color: #fff; } .history-item small { display: block; margin-top: 3px; - color: var(--dim); + color: var(--text-muted); font-size: 11px; } @@ -494,22 +326,22 @@ h2 { gap: 12px; margin: 0 0 16px; padding: 13px 15px; - border: 1px solid var(--line); + border: 1px solid var(--border-subtle); border-radius: 14px; background: rgba(23, 26, 33, .6); - color: var(--muted); + color: var(--text-secondary); } .chat-banner b { - color: var(--text); + color: var(--text-primary); } .composer-box { margin-top: 20px; padding: 12px 16px; - border: 1px solid var(--strong); + border: 1px solid var(--border-strong); border-radius: 16px; - background: var(--surface); + background: var(--bg-secondary); box-shadow: 0 10px 35px rgba(0,0,0,.3); } @@ -541,20 +373,20 @@ h2 { border: 1px solid transparent; border-radius: 7px; background: transparent; - color: var(--muted); + color: var(--text-secondary); font-size: 12px; cursor: pointer; } .mode-btn.active { - border-color: var(--strong); + border-color: var(--border-strong); background: #373737; color: #fff; } .chat-message-row { padding: 18px 0; - border-bottom: 1px solid var(--line); + border-bottom: 1px solid var(--border-subtle); line-height: 1.65; } @@ -571,16 +403,16 @@ h2 { .citation-source-card { margin-top: 12px; padding: 12px 14px; - border: 1px solid var(--line); + border: 1px solid var(--border-subtle); border-radius: 10px; - background: var(--surface); + background: var(--bg-secondary); font-size: 12px; - color: var(--muted); + color: var(--text-secondary); line-height: 1.6; } .citation-source-card b { - color: var(--orange); + color: var(--brand-primary); } @media (max-width: 820px) { @@ -597,7 +429,7 @@ h2 { .page-container { padding: 24px 16px; } - .grid-garakis.three, .grid-garakis.two, .tree-grid { + .tree-grid { grid-template-columns: 1fr; } .chat-layout { @@ -605,7 +437,7 @@ h2 { } .chat-history-sidebar { border-right: 0; - border-bottom: 1px solid var(--line); + border-bottom: 1px solid var(--border-subtle); padding-bottom: 16px; } } @@ -644,13 +476,18 @@ h2 { text-overflow: ellipsis; } - .project-tabs-garakis { + .project-tabs { overflow-x: auto; scrollbar-width: thin; } - .project-tabs-garakis button { + .project-tabs button { flex: 0 0 auto; white-space: nowrap; } } + +.app-shell { min-height: 100vh; display: flex; flex-direction: column; background: var(--surface-base); color: var(--text-primary); } +.app-user-actions { display: flex; align-items: center; gap: var(--space-3); } +.app-sign-out { min-height: 34px; padding: 0 var(--space-3); font-size: .8rem; } +.app-content { flex: 1; } diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index beb0284..f0da270 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -1,13 +1,13 @@ :root { - /* 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); + /* Canonical tokens for all application components and layouts. */ + --surface-base: #0b0d10; + --brand-primary: #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); + --text-secondary: #a6aab3; + --text-muted: #8b93a1; + --brand-surface: #4a200f; + --status-info: #4097ff; --on-brand: #17191c; --brand-gradient: linear-gradient(135deg, #f1885b, var(--brand-primary)); @@ -26,23 +26,23 @@ --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); + --bg-secondary: #111827; + --bg-tertiary: #171a21; + --bg-card: color-mix(in srgb, var(--bg-secondary) 94%, transparent); + --bg-card-hover: color-mix(in srgb, var(--bg-tertiary) 82%, #fff); - --border-subtle: var(--line, rgba(196, 199, 204, 0.18)); - --border-strong: var(--strong, rgba(196, 199, 204, 0.3)); + --border-subtle: rgba(196, 199, 204, 0.18); + --border-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-success: #22b458; --status-warning: #fbbf24; - --status-danger: var(--red, #f0635d); - --status-danger-strong: var(--red, #f0635d); + --status-danger: #f0635d; + --status-danger-strong: var(--status-danger); --accent-emerald: var(--status-success); --accent-amber: var(--status-warning); diff --git a/frontend/src/viewmodels/useAuthViewModel.tsx b/frontend/src/viewmodels/useAuthViewModel.tsx index 849e177..b5856f1 100644 --- a/frontend/src/viewmodels/useAuthViewModel.tsx +++ b/frontend/src/viewmodels/useAuthViewModel.tsx @@ -20,18 +20,7 @@ export interface AuthContextType { export const AuthContext = createContext(null); export function AuthProvider({ children }: { children: ReactNode }) { - const [session, setSessionState] = useState(() => { - try { - const cached = localStorage.getItem(USER_STORAGE_KEY); - if (cached) { - const user = JSON.parse(cached); - return { status: "authenticated", user }; - } - } catch { - // Ignora erro de leitura no storage - } - return { status: "loading" }; - }); + const [session, setSessionState] = useState({ status: "loading" }); const sessionStatus = useRef(session.status); @@ -107,16 +96,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { useEffect(() => { const revalidate = () => { if (sessionStatus.current === "authenticated") { - void restore(true); + void restore(); } }; window.addEventListener("focus", revalidate); - window.addEventListener("popstate", revalidate); return () => { window.removeEventListener("focus", revalidate); - window.removeEventListener("popstate", revalidate); }; }, [restore]); @@ -130,7 +117,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { setSession({ status: "authenticated", user }); } - async function register(nome: string, email: string, password: string, role: "admin" | "po" | "dev" = "po") { + async function register(nome: string, email: string, password: string, role: "admin" | "po" | "dev" = "dev") { const current = ++revision.current; const user = await readUser(await apiRequest("/auth/register", { method: "POST", diff --git a/frontend/src/views/admin/AdminView.test.tsx b/frontend/src/views/admin/AdminView.test.tsx new file mode 100644 index 0000000..b59b0e6 --- /dev/null +++ b/frontend/src/views/admin/AdminView.test.tsx @@ -0,0 +1,48 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { AdminView } from "./AdminView"; + +const project = { id: "a0000000-0000-4000-8000-000000000001", nome: "Projeto escolhido", cliente: "Teste", descricao: "", status: "ativo" }; +const json = (body: unknown, status = 200) => Promise.resolve(new Response(JSON.stringify(body), { status })); +afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); + +it("exige escolha explícita, envia o projeto e informa carga idempotente sem prometer reindexação", async () => { + let loads = 0; + const request = vi.fn((url: string, init?: RequestInit) => { + if (url.includes("/projects?")) return json({ items: [project, { ...project, id: "archived", nome: "Arquivado", status: "arquivado" }], total: 2, limit: 50, offset: 0 }); + if (url.endsWith("/admin/demo-seed")) { + expect(JSON.parse(init!.body as string)).toEqual({ projeto_id: project.id }); + loads++; + return json({ inseridos: loads === 1 ? 2 : 0, message: loads === 1 ? "Dados demonstrativos carregados." : "Este projeto já possui a carga demonstrativa." }); + } + return json({ projetos: 1, epicos: 0, features: 0, pbis: 0, documentos: 0, chunksIndexados: 0, statusSistema: "operacional" }); + }); + vi.stubGlobal("fetch", request); + render(); + const button = screen.getByRole("button", { name: "Carregar demonstração" }); + expect(button).toBeDisabled(); + await screen.findByRole("option", { name: project.nome }); + expect(screen.queryByRole("option", { name: "Arquivado" })).toBeNull(); + expect(button).toBeDisabled(); + fireEvent.change(screen.getByLabelText("Projeto de destino"), { target: { value: project.id } }); + fireEvent.click(button); + await screen.findByText("Dados demonstrativos carregados."); + await waitFor(() => expect(button).toBeEnabled()); + fireEvent.click(button); + await screen.findByText("Este projeto já possui a carga demonstrativa."); + expect(loads).toBe(2); + expect(screen.getByText(/Esta operação não indexa documentos nem gera vetores/)).toBeInTheDocument(); +}); + +it("exibe o erro do backend quando o projeto foi arquivado após a seleção", async () => { + vi.stubGlobal("fetch", vi.fn((url: string) => { + if (url.includes("/projects?")) return json({ items: [project], total: 1, limit: 50, offset: 0 }); + if (url.endsWith("/admin/demo-seed")) return json({ error: "Projeto arquivado é somente leitura." }, 409); + return json({}); + })); + render(); + await screen.findByRole("option", { name: project.nome }); + fireEvent.change(screen.getByLabelText("Projeto de destino"), { target: { value: project.id } }); + fireEvent.click(screen.getByRole("button", { name: "Carregar demonstração" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Projeto arquivado é somente leitura."); +}); diff --git a/frontend/src/views/admin/AdminView.tsx b/frontend/src/views/admin/AdminView.tsx index cb5a7d8..63e96a2 100644 --- a/frontend/src/views/admin/AdminView.tsx +++ b/frontend/src/views/admin/AdminView.tsx @@ -1,6 +1,7 @@ +import { Button } from "../common/ui"; import React, { useState, useEffect } from "react"; -import { apiRequest } from "../../api/api_auth"; -import "../../assets/styles/garakis-prototype.css"; +import { listProjects, type Project } from "../../api/api_projects"; +import { ApiError, apiRequest } from "../../api/api_auth"; interface Stats { projetos: number; @@ -13,6 +14,9 @@ interface Stats { } export const AdminView: React.FC = () => { + const [projects, setProjects] = useState([]); + const [projectId, setProjectId] = useState(""); + const [projectsLoading, setProjectsLoading] = useState(true); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); const [ingesting, setIngesting] = useState(false); @@ -35,23 +39,44 @@ export const AdminView: React.FC = () => { useEffect(() => { void loadStats(); + const controller = new AbortController(); + async function loadProjects() { + try { + const items: Project[] = []; + let offset = 0; + while (!controller.signal.aborted) { + const page = await listProjects(controller.signal, offset); + items.push(...page.projects.filter(project => project.status !== "arquivado")); + offset += page.limit; + if (offset >= page.total) break; + } + if (!controller.signal.aborted) setProjects(items); + } catch { + if (!controller.signal.aborted) setError("Não foi possível carregar os projetos de destino. Atualize a página para tentar novamente."); + } finally { + if (!controller.signal.aborted) setProjectsLoading(false); + } + } + void loadProjects(); + return () => controller.abort(); }, []); - const handleIngestSeed = async () => { + const handleLoadDemo = async () => { + if (!projectId || ingesting) return; setIngesting(true); - setMessage("Disparando reindexação de acervo no backend..."); + setMessage("Carregando exemplos no projeto selecionado..."); setError(""); try { - const res = await apiRequest("/admin/ingest-seed", { method: "POST" }); + const res = await apiRequest("/admin/demo-seed", { method: "POST", body: JSON.stringify({ projeto_id: projectId }) }); const data = await res.json(); - if (!res.ok) throw new Error(data.error || "Falha na reindexação"); - - setMessage(data.message || "Acervo reindexado com sucesso!"); + setMessage(data.message || "Dados demonstrativos carregados."); void loadStats(); } catch (err: unknown) { - setError(err instanceof Error ? err.message : "Erro ao reindexar acervo."); + const details = err instanceof ApiError ? err.details as { error?: string } | undefined : undefined; + setError(details?.error ?? "Não foi possível carregar os dados demonstrativos."); + setMessage(""); } finally { setIngesting(false); } @@ -61,37 +86,37 @@ export const AdminView: React.FC = () => {
-
OPERACIONALE & GOVERNANÇA
+
OPERAÇÃO & GOVERNANÇA

Administração & Carga Inicial do Acervo

- Monitore contadores reais de entidades persistidas no PostgreSQL e reindexe o acervo vetorial do repositório. + Acompanhe os dados do sistema e carregue exemplos em um projeto escolhido.

{message && ( -
+
{message}
)} {error && ( -
+
{error}
)} {/* Grid de Estatísticas */} -
-
+
+
Projetos no Banco -

+

{loading ? "..." : stats?.projetos ?? 0}

Espaços de trabalho cadastrados

-
+
Requisitos & PBIs

{loading ? "..." : stats?.pbis ?? 0} @@ -99,29 +124,37 @@ export const AdminView: React.FC = () => {

PBIs com critérios em BDD

-
- Trechos Indexados (RAG) +
+ Trechos com vetor

{loading ? "..." : stats?.chunksIndexados ?? 0}

-

Vetores bge-m3 no pgvector

+

Somente trechos com embedding armazenado

{/* Painel de Ações Administrativas */} -
+

Operações de Acervo

- Execute a carga inicial dos documentos de referência e reindexação de vetores do ecossistema. + Adicione dois exemplos identificados como demonstração ao projeto escolhido. Repetir a ação não duplica os exemplos. Esta operação não indexa documentos nem gera vetores.

+
+ + + {!projectsLoading && projects.length === 0 &&

Crie um projeto ativo para receber os exemplos.

} +
- - + +
diff --git a/frontend/src/auth/Auth.test.tsx b/frontend/src/views/auth/Auth.test.tsx similarity index 77% rename from frontend/src/auth/Auth.test.tsx rename to frontend/src/views/auth/Auth.test.tsx index 9ab5af7..fe98080 100644 --- a/frontend/src/auth/Auth.test.tsx +++ b/frontend/src/views/auth/Auth.test.tsx @@ -1,9 +1,10 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { AuthGate, AuthProvider, useAuth } from "./Auth"; -import { navigate, safeDestination } from "./navigation"; -import { apiRequest, readUser } from "../api/api_auth"; +import { AuthGate } from "./AuthView"; +import { AuthProvider, useAuth } from "../../viewmodels/useAuthViewModel"; +import { navigate, safeDestination } from "../../models/navigation"; +import { apiRequest, readUser } from "../../api/api_auth"; import { useLayoutEffect, useRef } from "react"; const user = { id: "1", nome: "Pessoa", role: "po", email: "pessoa@example.com" }; @@ -129,7 +130,7 @@ it.each([ expect(screen.queryByText("Conteúdo interno")).toBeNull(); }); -it.each(["focus", "navigation", "history"])("informa expiração detectada por %s e preserva o destino", async trigger => { +it.each(["focus"])("informa expiração detectada por %s e preserva o destino", async trigger => { const fetchMock = vi.fn().mockImplementationOnce(() => reply(200, { user })).mockImplementationOnce(() => reply(401)); vi.stubGlobal("fetch", fetchMock); mount(); @@ -146,14 +147,16 @@ it.each(["focus", "navigation", "history"])("informa expiração detectada por % expect(fetchMock.mock.calls[1][0]).toBe("/api/v1/auth/me"); }); -it("oculta conteúdo até a revalidação da navegação terminar", async () => { +it("mantém conteúdo e estado durante a revalidação em segundo plano", async () => { let resolve!: (response: Response) => void; vi.stubGlobal("fetch", vi.fn().mockImplementationOnce(() => reply(200, { user })) .mockImplementationOnce(() => new Promise(done => { resolve = done; }))); mount(); - fireEvent.click(await screen.findByText("Requisitos")); - expect(screen.queryByText("Conteúdo interno")).toBeNull(); - expect(screen.getByRole("status").textContent).toBe("Verificando sessão…"); + await screen.findByText("Conteúdo interno"); + const content = screen.getByText("Conteúdo interno"); + fireEvent.focus(window); + expect(screen.getByText("Conteúdo interno")).toBe(content); + expect(screen.queryByRole("status")).toBeNull(); resolve(new Response(JSON.stringify({ user }))); expect(await screen.findByText("Conteúdo interno")).toBeTruthy(); }); @@ -165,7 +168,7 @@ it("não mostra aviso de expiração no primeiro acesso anônimo", async () => { expect(screen.queryByText("Sua sessão expirou. Entre novamente para continuar.")).toBeNull(); }); -it("revalida histórico imediatamente após restaurar a sessão, antes dos efeitos passivos", async () => { +it("navegação pelo histórico não dispara nova verificação de sessão", async () => { const fetchMock = vi.fn().mockImplementationOnce(() => reply(200, { user })).mockImplementationOnce(() => reply(401)); vi.stubGlobal("fetch", fetchMock); function ImmediateNavigation() { @@ -179,7 +182,37 @@ it("revalida histórico imediatamente após restaurar a sessão, antes dos efeit return ; } render(); - expect(await screen.findByText("Sua sessão expirou. Entre novamente para continuar.")).toBeTruthy(); - expect(fetchMock).toHaveBeenCalledTimes(2); + expect(await screen.findByText("Conteúdo interno")).toBeTruthy(); + fireEvent.click(screen.getByText("Requisitos")); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("status")).toBeNull(); +}); + + +it("cadastro público envia apenas perfil dev", async () => { + window.history.replaceState(null, "", "/register"); + const fetchMock = vi.fn().mockImplementationOnce(() => reply(401)).mockImplementation(() => reply(200, {user: {...user, role: "dev"}})); + vi.stubGlobal("fetch", fetchMock); + mount(); + await screen.findByText("Criar nova conta"); + expect(screen.queryByRole("combobox")).toBeNull(); + fireEvent.change(screen.getByLabelText("Nome Completo"), {target: {value: "Pessoa"}}); + fireEvent.change(screen.getByLabelText("E-mail Corporativo"), {target: {value: "pessoa@example.com"}}); + fireEvent.change(screen.getByLabelText("Senha (mínimo 6 caracteres)"), {target: {value: "Senha123!"}}); + fireEvent.change(screen.getByLabelText("Confirmar Senha"), {target: {value: "Senha123!"}}); + fireEvent.click(screen.getByText("Cadastrar e Entrar")); + await waitFor(() => expect(fetchMock.mock.calls.some(([url, init]) => url === "/api/v1/auth/register" && JSON.parse(init.body).role === "dev")).toBe(true)); +}); + + +it("cache não autentica antes da resposta inicial e falha de rede bloqueia a abertura", async () => { + localStorage.setItem("app_auth_user",JSON.stringify(user)); + let reject!: (reason: Error) => void; + vi.stubGlobal("fetch",vi.fn(()=>new Promise((_resolve,fail)=>{reject=fail}))); + mount(); + expect(screen.queryByText("Conteúdo interno")).toBeNull(); + expect(screen.getByRole("status")).toHaveProperty("textContent","Verificando sessão…"); + reject(new Error("offline")); + expect(await screen.findByText("Não foi possível verificar a sessão")).toBeTruthy(); expect(screen.queryByText("Conteúdo interno")).toBeNull(); }); diff --git a/frontend/src/views/auth/AuthView.tsx b/frontend/src/views/auth/AuthView.tsx index 119e5b1..39b2c2c 100644 --- a/frontend/src/views/auth/AuthView.tsx +++ b/frontend/src/views/auth/AuthView.tsx @@ -1,3 +1,4 @@ +import { Button } from "../common/ui"; import { useEffect, useState, type ReactNode } from "react"; import { ApiError } from "../../api/api_auth"; import { useAuth } from "../../viewmodels/useAuthViewModel"; @@ -26,11 +27,11 @@ export function AuthGate({ children }: { children: ReactNode }) { if (session.status === "loading") return
Verificando sessão…
; - if (session.status === "error") return
+ if (session.status === "error") return

Não foi possível verificar a sessão

{notice || "Verifique sua conexão e tente novamente."}

- - + +
; if (session.status === "anonymous") return isAuthRoute ? : null; @@ -51,28 +52,28 @@ export function AuthScreen({ initialMode }: { initialMode: "login" | "register" return (
-
+
{mode === "register" && ( - + )} {mode === "login" && ( - + )}
@@ -124,9 +125,9 @@ export function LoginOnCard() { setPassword(e.target.value)} disabled={busy} placeholder="••••••••" /> {error &&

{error}

} - + ); @@ -138,7 +139,6 @@ export function RegisterOnCard({ onSwitch }: { onSwitch: () => void }) { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); - const [role, setRole] = useState<"po" | "dev">("po"); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); @@ -165,7 +165,7 @@ export function RegisterOnCard({ onSwitch }: { onSwitch: () => void }) { setBusy(true); setError(""); try { - await register(nome.trim(), email.trim(), password, role); + await register(nome.trim(), email.trim(), password, "dev"); } catch (failure) { setError( failure instanceof ApiError && failure.status === 409 @@ -184,11 +184,7 @@ export function RegisterOnCard({ onSwitch }: { onSwitch: () => void }) { setEmail(e.target.value)} disabled={busy} placeholder="seu.email@empresa.com" /> - - +

Novas contas recebem o perfil Desenvolvedor. O acesso de PO é concedido por um administrador.

setPassword(e.target.value)} disabled={busy} placeholder="••••••••" /> @@ -197,7 +193,7 @@ export function RegisterOnCard({ onSwitch }: { onSwitch: () => void }) { setConfirmPassword(e.target.value)} disabled={busy} placeholder="••••••••" /> {error &&

{error}

} - +
diff --git a/frontend/src/backlog/Backlog.test.tsx b/frontend/src/views/backlog/Backlog.test.tsx similarity index 98% rename from frontend/src/backlog/Backlog.test.tsx rename to frontend/src/views/backlog/Backlog.test.tsx index 62b3dcb..3182f46 100644 --- a/frontend/src/backlog/Backlog.test.tsx +++ b/frontend/src/views/backlog/Backlog.test.tsx @@ -7,8 +7,8 @@ import { screen, waitFor, } from "@testing-library/react"; -import { Projects as ProjectsPage } from "../projects/Projects"; -import { parseBacklogRoute } from "./navigation"; +import { ProjectsView as ProjectsPage } from "../projects/ProjectsView"; +import { parseBacklogRoute } from "../../models/navigation"; const response = (body: unknown, status = 200) => Promise.resolve( diff --git a/frontend/src/views/backlog/BacklogSearchResults.tsx b/frontend/src/views/backlog/BacklogSearchResults.tsx index ac5d10e..41e73c2 100644 --- a/frontend/src/views/backlog/BacklogSearchResults.tsx +++ b/frontend/src/views/backlog/BacklogSearchResults.tsx @@ -1,9 +1,9 @@ import { useEffect, useState } from "react"; import { describeSearchError, searchBacklog, type BacklogSearchResult } from "../../api/api_backlog_search"; +import "../../assets/styles/backlog-search.css"; import { TYPE_LABEL, highlightParts, nodeHref, summarize } from "../../models/backlogSearch"; import { navigate } from "../../models/navigation"; import { Alert, Badge, Button, EmptyState } from "../common/ui"; -import "../../assets/styles/backlog-search.css"; type State = | { state: "loading" } @@ -50,12 +50,12 @@ export function BacklogSearchResults({ }, [projectId, query, status, technologyId, attempt]); if (state.state === "loading") { - return
Buscando no backlog…
; + return
Buscando no backlog…
; } if (state.state === "error") { return ( -
+

{state.message}

@@ -66,7 +66,7 @@ export function BacklogSearchResults({ if (result.items.length === 0) { return ( -
+
+
  • {TYPE_LABEL[item.tipo]} {item.codigo && {item.codigo}} diff --git a/frontend/src/backlog/BacklogTechnologySelector.test.tsx b/frontend/src/views/backlog/BacklogTechnologySelector.test.tsx similarity index 95% rename from frontend/src/backlog/BacklogTechnologySelector.test.tsx rename to frontend/src/views/backlog/BacklogTechnologySelector.test.tsx index 41bebf3..3e42615 100644 --- a/frontend/src/backlog/BacklogTechnologySelector.test.tsx +++ b/frontend/src/views/backlog/BacklogTechnologySelector.test.tsx @@ -2,7 +2,7 @@ import { afterEach, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { BacklogTechnologySelector } from "../views/backlog/BacklogTechnologySelector"; +import { BacklogTechnologySelector } from "./BacklogTechnologySelector"; afterEach(() => { cleanup(); diff --git a/frontend/src/views/backlog/BacklogTechnologySelector.tsx b/frontend/src/views/backlog/BacklogTechnologySelector.tsx index a068779..a811bd0 100644 --- a/frontend/src/views/backlog/BacklogTechnologySelector.tsx +++ b/frontend/src/views/backlog/BacklogTechnologySelector.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { listTechnologies, type TechnologyOption } from "../../api/api_backlog"; +import { Button } from "../common/ui"; type LoadState = | { state: "loading" } @@ -46,9 +47,9 @@ export function BacklogTechnologySelector({ {result.state === "error" && (

    Não foi possível carregar o catálogo de tecnologias.

    - +
    )} {result.state === "ready" && result.items.length === 0 && ( diff --git a/frontend/src/views/backlog/BacklogTreeNodes.tsx b/frontend/src/views/backlog/BacklogTreeNodes.tsx new file mode 100644 index 0000000..f204253 --- /dev/null +++ b/frontend/src/views/backlog/BacklogTreeNodes.tsx @@ -0,0 +1,341 @@ +import { + type BacklogEpicNode, + type BacklogFeatureNode, + type BacklogPbiNode +} from "../../api/api_backlog_tree"; +import { + navigate, +} from "../../models/navigation"; + +const statusLabels: + Record = { + rascunho: "Rascunho", + ativo: "Ativo", + pronto: "Pronto", + concluido: "Concluído", + arquivado: "Arquivado", +}; + +function Technologies({ + values, +}: { + values: + Array<{ + id: string; + nome: string; + }>; +}) { + if (values.length === 0) { + return null; + } + + return ( + + {values.map( + (item) => ( + + {item.nome} + + ), + )} + + ); +} + +function ItemMeta({ + item, +}: { + item: { + status: string; + + tecnologias: + Array<{ + id: string; + nome: string; + }>; + }; +}) { + return ( + + + {statusLabels[item.status] + ?? item.status} + + + + + ); +} + +function TreePbi({ + projectId, + epicId, + featureId, + pbi, +}: { + projectId: string; + epicId: string; + featureId: string; + pbi: BacklogPbiNode; +}) { + const path = + `/projects/${projectId}` + + `/epics/${epicId}` + + `/features/${featureId}` + + `/pbis/${pbi.id}`; + + return ( +
  • + + + + + +
  • + ); +} + +function TreeFeature({ + projectId, + epicId, + feature, + open, + forcedOpen, + onToggle, +}: { + projectId: string; + epicId: string; + feature: BacklogFeatureNode; + open: boolean; + forcedOpen: boolean; + onToggle: () => void; +}) { + const isOpen = + forcedOpen || open; + + const path = + `/projects/${projectId}` + + `/epics/${epicId}` + + `/features/${feature.id}`; + + return ( +
  • +
    + + + + + + + +
    + + {isOpen && ( + feature.pbis.length > 0 + ? ( +
      + {feature.pbis.map( + (pbi) => ( + + ), + )} +
    + ) + : ( +

    + Nenhum PBI corresponde + nesta feature. +

    + ) + )} +
  • + ); +} + +export function TreeEpic({ + projectId, + epic, + open, + forcedOpen, + expandedFeatures, + onToggle, + onToggleFeature, +}: { + projectId: string; + epic: BacklogEpicNode; + open: boolean; + forcedOpen: boolean; + expandedFeatures: Set; + onToggle: () => void; + onToggleFeature: + (id: string) => void; +}) { + const isOpen = + forcedOpen || open; + + return ( +
  • +
    + + + + + + + +
    + + {isOpen && ( + epic.features.length > 0 + ? ( +
      + {epic.features.map( + (feature) => ( + + onToggleFeature( + feature.id, + ) + } + /> + ), + )} +
    + ) + : ( +

    + Nenhuma feature + corresponde neste épico. +

    + ) + )} +
  • + ); +} diff --git a/frontend/src/views/backlog/BacklogTreeView.tsx b/frontend/src/views/backlog/BacklogTreeView.tsx index d685096..1e47301 100644 --- a/frontend/src/views/backlog/BacklogTreeView.tsx +++ b/frontend/src/views/backlog/BacklogTreeView.tsx @@ -3,553 +3,37 @@ import { useMemo, useState, } from "react"; - import { ApiError, } from "../../api/api_auth"; - +import { MIN_QUERY_LENGTH } from "../../api/api_backlog_search"; import { getProjectBacklogTree, - type BacklogEpicNode, - type BacklogFeatureNode, - type BacklogPbiNode, - type ProjectBacklogTree, + type ProjectBacklogTree } from "../../api/api_backlog_tree"; - +import { isSearchQuery } from "../../models/backlogSearch"; import { navigate, } from "../../models/navigation"; - -import { MIN_QUERY_LENGTH } from "../../api/api_backlog_search"; -import { isSearchQuery } from "../../models/backlogSearch"; +import { Button } from "../common/ui"; import { BacklogSearchResults } from "./BacklogSearchResults"; - -export interface BacklogFilters { - status: string; - technologyId: string; - query: string; -} - -const MAX_QUERY_LENGTH = 100; - -type TreeFilters = Pick; +import { TreeEpic } from "./BacklogTreeNodes"; +import { EMPTY_FILTERS, MAX_QUERY_LENGTH, STATUS_OPTIONS, filterBacklogTree, readFilters, storageKey, type BacklogFilters } from "./backlogTreeFilters"; +export { filterBacklogTree } from "./backlogTreeFilters"; +export type { BacklogFilters } from "./backlogTreeFilters"; type Result = | { - state: "loading"; - } - | { - state: "error"; - message: string; - } - | { - state: "ready"; - tree: ProjectBacklogTree; - }; - -const EMPTY_FILTERS: - BacklogFilters = { - status: "", - technologyId: "", - query: "", - }; - -const STATUS_OPTIONS = [ - { value: "rascunho", label: "Rascunho" }, - { value: "ativo", label: "Ativo" }, - { value: "concluido", label: "Concluído" }, - { value: "arquivado", label: "Arquivado" }, -] as const; -const VALID_STATUSES = new Set([ - "", - ...STATUS_OPTIONS.map(({ value }) => value), -]); - -function storageKey( - projectId: string, -) { - return ( - "sinapse.backlog.filters." - + projectId - ); -} - -function readFilters( - projectId: string, -): BacklogFilters { - try { - const stored = - window.sessionStorage - .getItem( - storageKey(projectId), - ); - - if (!stored) { - return EMPTY_FILTERS; - } - - const value = - JSON.parse( - stored, - ) as Partial; - - return { - status: - typeof value.status - === "string" - && VALID_STATUSES.has( - value.status, - ) - ? value.status - : "", - - technologyId: - typeof value.technologyId - === "string" - ? value.technologyId - : "", - - query: - typeof value.query - === "string" - ? value.query.slice(0, MAX_QUERY_LENGTH) - : "", - }; - } catch { - return EMPTY_FILTERS; - } -} - -function matches( - item: { - status: string; - - tecnologias: - Array<{ - id: string; - }>; - }, - - filters: TreeFilters, -) { - return ( - ( - !filters.status - || item.status - === filters.status - ) - && ( - !filters.technologyId - || item.tecnologias.some( - (technology) => - technology.id - === filters.technologyId, - ) - ) - ); -} - -export function filterBacklogTree( - tree: ProjectBacklogTree, - filters: TreeFilters, -): ProjectBacklogTree { - if ( - !filters.status - && !filters.technologyId - ) { - return tree; + state: "loading"; } - - const epics = - tree.epics.flatMap( - (epic) => { - const features = - epic.features.flatMap( - (feature) => { - const pbis = - feature.pbis.filter( - (pbi) => - matches( - pbi, - filters, - ), - ); - - return ( - matches( - feature, - filters, - ) - || pbis.length > 0 - ) - ? [ - { - ...feature, - pbis, - }, - ] - : []; - }, - ); - - return ( - matches( - epic, - filters, - ) - || features.length > 0 - ) - ? [ - { - ...epic, - features, - }, - ] - : []; - }, - ); - - return { - ...tree, - epics, - }; -} - -const statusLabels: - Record = { - rascunho: "Rascunho", - ativo: "Ativo", - pronto: "Pronto", - concluido: "Concluído", - arquivado: "Arquivado", - }; - -function Technologies({ - values, -}: { - values: - Array<{ - id: string; - nome: string; - }>; -}) { - if (values.length === 0) { - return null; + | { + state: "error"; + message: string; } - - return ( - - {values.map( - (item) => ( - - {item.nome} - - ), - )} - - ); -} - -function ItemMeta({ - item, -}: { - item: { - status: string; - - tecnologias: - Array<{ - id: string; - nome: string; - }>; + | { + state: "ready"; + tree: ProjectBacklogTree; }; -}) { - return ( - - - {statusLabels[item.status] - ?? item.status} - - - - - ); -} - -function TreePbi({ - projectId, - epicId, - featureId, - pbi, -}: { - projectId: string; - epicId: string; - featureId: string; - pbi: BacklogPbiNode; -}) { - const path = - `/projects/${projectId}` - + `/epics/${epicId}` - + `/features/${featureId}` - + `/pbis/${pbi.id}`; - - return ( -
  • - - - - - -
  • - ); -} - -function TreeFeature({ - projectId, - epicId, - feature, - open, - forcedOpen, - onToggle, -}: { - projectId: string; - epicId: string; - feature: BacklogFeatureNode; - open: boolean; - forcedOpen: boolean; - onToggle: () => void; -}) { - const isOpen = - forcedOpen || open; - - const path = - `/projects/${projectId}` - + `/epics/${epicId}` - + `/features/${feature.id}`; - - return ( -
  • -
    - - - - - - - -
    - - {isOpen && ( - feature.pbis.length > 0 - ? ( -
      - {feature.pbis.map( - (pbi) => ( - - ), - )} -
    - ) - : ( -

    - Nenhum PBI corresponde - nesta feature. -

    - ) - )} -
  • - ); -} - -function TreeEpic({ - projectId, - epic, - open, - forcedOpen, - expandedFeatures, - onToggle, - onToggleFeature, -}: { - projectId: string; - epic: BacklogEpicNode; - open: boolean; - forcedOpen: boolean; - expandedFeatures: Set; - onToggle: () => void; - onToggleFeature: - (id: string) => void; -}) { - const isOpen = - forcedOpen || open; - - return ( -
  • -
    - - - - - - - -
    - - {isOpen && ( - epic.features.length > 0 - ? ( -
      - {epic.features.map( - (feature) => ( - - onToggleFeature( - feature.id, - ) - } - /> - ), - )} -
    - ) - : ( -

    - Nenhuma feature - corresponde neste épico. -

    - ) - )} -
  • - ); -} export function BacklogTreeView({ projectId, @@ -625,7 +109,7 @@ export function BacklogTreeView({ message: error instanceof ApiError - && error.status === 404 + && error.status === 404 ? "Projeto não encontrado." : "Não foi possível carregar a árvore do backlog.", }); @@ -687,9 +171,9 @@ export function BacklogTreeView({ () => result.state === "ready" ? filterBacklogTree( - result.tree, - filters, - ) + result.tree, + filters, + ) : null, [ result, @@ -778,8 +262,8 @@ export function BacklogTreeView({
    {canCreate && ( - + )}
    @@ -828,7 +312,7 @@ export function BacklogTreeView({ {filters.query.trim().length > 0 && !searchMode && filters.query.trim().length - < MIN_QUERY_LENGTH + < MIN_QUERY_LENGTH ? `Digite ao menos ${MIN_QUERY_LENGTH} caracteres.` : "Busca apenas neste projeto, combinada com os filtros."} @@ -917,15 +401,15 @@ export function BacklogTreeView({ {filtersActive && ( - + )}
    )} @@ -933,7 +417,7 @@ export function BacklogTreeView({ {result.state === "loading" && (
    Carregando árvore @@ -943,13 +427,13 @@ export function BacklogTreeView({ {result.state === "error" && ( -
    +

    {result.message}

    - +
    )} @@ -996,7 +480,7 @@ export function BacklogTreeView({ && result.tree.epics .length === 0 && ( -
    +

    Este projeto ainda não possui épicos @@ -1009,8 +493,8 @@ export function BacklogTreeView({

    {canCreate && ( - + )}

    )} @@ -1031,7 +515,7 @@ export function BacklogTreeView({ && filteredTree?.epics .length === 0 && ( -
    +

    Nenhum item corresponde aos filtros @@ -1043,13 +527,13 @@ export function BacklogTreeView({ ou limpe os critérios.

    - +

    )} diff --git a/frontend/src/views/backlog/BacklogView.tsx b/frontend/src/views/backlog/BacklogView.tsx index 74a3211..21d3adb 100644 --- a/frontend/src/views/backlog/BacklogView.tsx +++ b/frontend/src/views/backlog/BacklogView.tsx @@ -1,8 +1,11 @@ import type { BacklogRoute } from "../../models/navigation"; import { navigate } from "../../models/navigation"; -import { EpicForm, EpicDetail } from "./EpicsView"; -import { FeatureList, FeatureForm, FeatureDetail } from "./FeaturesView"; -import { PbiList, PbiForm, PbiDetail } from "./PbisView"; +import { Button } from "../common/ui"; +import { EpicDetail, EpicForm } from "./EpicsView"; +import { FeatureDetail, FeatureForm, FeatureList } from "./FeaturesView"; +import { PbiDetail } from "./PbiDetail"; +import { PbiForm } from "./PbiForm"; +import { PbiList } from "./PbiList"; export function BacklogScreen({ route, canCreate }: { route: Exclude; canCreate: boolean }) { if (route.screen === "epic-new") { @@ -45,7 +48,7 @@ function SomenteLeitura({ voltar }: { voltar: string }) {

    Acesso de leitura

    Seu perfil não permite criar itens do backlog.

    - +
    ); } diff --git a/frontend/src/views/backlog/CriteriaView.tsx b/frontend/src/views/backlog/CriteriaView.tsx index 18c31b8..b217498 100644 --- a/frontend/src/views/backlog/CriteriaView.tsx +++ b/frontend/src/views/backlog/CriteriaView.tsx @@ -1,14 +1,15 @@ import { useEffect, useRef, useState } from "react"; import { ApiError } from "../../api/api_auth"; import { - listCriteria, createCriterion, deleteCriterion, + listCriteria, moveCriterion, type Criterion, type CriterionEntityType, } from "../../api/api_backlog"; import "../../assets/styles/projects.css"; +import { Button } from "../common/ui"; type Result = | { state: "loading" } @@ -95,7 +96,7 @@ export function CriteriaEditor({ state: "error", message: error instanceof ApiError && - error.status === 401 + error.status === 401 ? "É necessário entrar para ver os critérios." : "Não foi possível carregar os critérios.", }); @@ -146,22 +147,22 @@ export function CriteriaEditor({ const criado = await createCriterion( isCenario ? { - entidade_tipo: "pbi", - entidade_id: entidadeId, - ...cenarioForm, - ...(justificativaObrigatoria - ? { justificativa: justificativa.trim() } - : {}), - } + entidade_tipo: "pbi", + entidade_id: entidadeId, + ...cenarioForm, + ...(justificativaObrigatoria + ? { justificativa: justificativa.trim() } + : {}), + } : { - entidade_tipo: - entidadeTipo as "epico" | "feature", - entidade_id: entidadeId, - ...textoForm, - ...(justificativaObrigatoria - ? { justificativa: justificativa.trim() } - : {}), - }, + entidade_tipo: + entidadeTipo as "epico" | "feature", + entidade_id: entidadeId, + ...textoForm, + ...(justificativaObrigatoria + ? { justificativa: justificativa.trim() } + : {}), + }, ); versaoRef.current += 1; @@ -297,19 +298,19 @@ export function CriteriaEditor({
    {canEdit && !formOpen && ( - + )}
    @@ -338,7 +339,7 @@ export function CriteriaEditor({ {result.state === "loading" && (
    Carregando… @@ -346,21 +347,21 @@ export function CriteriaEditor({ )} {result.state === "error" && ( -
    +

    {result.message}

    - +
    )} {result.state === "ready" && (result.items.length === 0 && !formOpen ? ( -
    +

    Nenhum{" "} {isCenario @@ -391,7 +392,7 @@ export function CriteriaEditor({ ? -1 : undefined } - className="glass-panel project-card" + className="ds-card ds-card--glass project-card" key={item.id} > {isCenario ? ( @@ -415,8 +416,8 @@ export function CriteriaEditor({ {canEdit && (

    - + - + - +
    )} @@ -463,7 +464,7 @@ export function CriteriaEditor({ {canEdit && formOpen && (
    @@ -579,19 +580,19 @@ export function CriteriaEditor({ )}
    - + - +
    )} diff --git a/frontend/src/views/backlog/DecisionsPanel.tsx b/frontend/src/views/backlog/DecisionsPanel.tsx index a0be54c..56e29e3 100644 --- a/frontend/src/views/backlog/DecisionsPanel.tsx +++ b/frontend/src/views/backlog/DecisionsPanel.tsx @@ -8,8 +8,8 @@ import { type DecisionKind, type DecisionList, } from "../../api/api_decisions"; -import { Alert, Badge, Button, EmptyState } from "../common/ui"; import "../../assets/styles/decisions.css"; +import { Alert, Badge, Button, EmptyState } from "../common/ui"; const LEVEL_LABEL: Record = { projeto: "Projeto", @@ -159,7 +159,7 @@ export function DecisionsPanel({ const inherited = state.state === "ready" ? state.data.decisoes.filter((item) => item.origem.herdada) : []; return ( -
    +

    Decisões

    @@ -187,13 +187,13 @@ export function DecisionsPanel({ ["alternativas", "Alternativas consideradas e descartadas (opcional)", "textarea"], ] as const ).map(([field, label, control]) => ( -
    +
    {control === "input" ? ( Promise.resolve(new Response(JSON.stringify(body), { status })); const Projects = ({ pathname }: { pathname: string }) => ; diff --git a/frontend/src/backlog/Epics.test.tsx b/frontend/src/views/backlog/Epics.test.tsx similarity index 97% rename from frontend/src/backlog/Epics.test.tsx rename to frontend/src/views/backlog/Epics.test.tsx index 2954c90..07825da 100644 --- a/frontend/src/backlog/Epics.test.tsx +++ b/frontend/src/views/backlog/Epics.test.tsx @@ -1,7 +1,7 @@ import { afterEach, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { EpicDetail, EpicForm, EpicList } from "./Epics"; -import { FeatureList } from "./Features"; +import { EpicDetail, EpicForm, EpicList } from "./EpicsView"; +import { FeatureList } from "./FeaturesView"; const epic = { id: "epic-1", projeto_id: "project-1", titulo: "Organizar requisitos", status: "rascunho", descricao: "d", objetivo: "o", escopo_macro: "e", resultado_esperado: "r", criterios_count: 1 }; diff --git a/frontend/src/views/backlog/EpicsView.tsx b/frontend/src/views/backlog/EpicsView.tsx index 8ca417f..22bec10 100644 --- a/frontend/src/views/backlog/EpicsView.tsx +++ b/frontend/src/views/backlog/EpicsView.tsx @@ -1,16 +1,17 @@ -import { ReadOnlyContext } from "../../models/ReadOnlyContext"; -import { ItemArchiveView } from "./ItemArchiveView"; import { useContext, useEffect, useRef, useState, type ReactNode } from "react"; import { ApiError } from "../../api/api_auth"; -import { navigate } from "../../models/navigation"; -import { createEpic, completeEpic, updateEpic, getEpic, listEpics, camposFaltantesDe, type Epic, type EpicInput } from "../../api/api_backlog"; +import { camposFaltantesDe, completeEpic, createEpic, getEpic, listEpics, updateEpic, type Epic, type EpicInput } from "../../api/api_backlog"; +import "../../assets/styles/projects.css"; import { descreverCamposFaltantes } from "../../models/fields"; -import { useUnsavedChangesGuard } from "../../viewmodels/useUnsavedChangesGuard"; +import { navigate } from "../../models/navigation"; +import { ReadOnlyContext } from "../../models/ReadOnlyContext"; import { usePbiQualityConfiguration } from "../../viewmodels/usePbiQualityConfiguration"; -import { CriteriaEditor } from "./CriteriaView"; +import { useUnsavedChangesGuard } from "../../viewmodels/useUnsavedChangesGuard"; +import { Button } from "../common/ui"; import { BacklogBreadcrumb } from "./BacklogBreadcrumb"; import { BacklogTechnologySelector } from "./BacklogTechnologySelector"; -import "../../assets/styles/projects.css"; +import { CriteriaEditor } from "./CriteriaView"; +import { ItemArchiveView } from "./ItemArchiveView"; type ListResult = { state: "loading" } | { state: "error"; message: string } | { state: "ready"; epics: Epic[] }; const emptyInput: EpicInput = { projeto_id: "", titulo: "", descricao: "", objetivo: "", escopo_macro: "", resultado_esperado: "", tecnologias_ids: [] }; @@ -38,21 +39,21 @@ export function EpicList({ projetoId, canCreate: allowedToCreate }: { projetoId:

    Épicos do projeto

    Épicos

    - {canCreate && } + {canCreate && }
    - {result.state === "loading" &&
    Carregando épicos…
    } - {result.state === "error" &&

    {result.message}

    -
    } + {result.state === "loading" &&
    Carregando épicos…
    } + {result.state === "error" &&

    {result.message}

    +
    } {result.state === "ready" && (result.epics.length === 0 - ?

    Nenhum épico cadastrado

    Crie o primeiro épico para começar a especificar este projeto.

    - {canCreate && }
    + ?

    Nenhum épico cadastrado

    Crie o primeiro épico para começar a especificar este projeto.

    + {canCreate && }
    :
    {result.epics.map((epic) => ( -
    - {epic.status} +
    + {epic.status}

    {epic.titulo}

    {epic.objetivo || "Sem objetivo registrado."}

    - +
    ))}
    )}
    @@ -73,7 +74,7 @@ export function EpicForm({ projetoId }: { projetoId: string }) {

    Épicos / Novo épico

    Criar épico

    Apenas o título é obrigatório para salvar como rascunho. Os demais campos do guia são exigidos para concluir.

    -
    { + { event.preventDefault(); if (submitting.current) return; if (!values.titulo.trim()) { setMessage("Informe o título do épico."); return; } @@ -107,16 +108,16 @@ export function EpicForm({ projetoId }: { projetoId: string }) { /> {message &&

    {message}

    }
    - - + +
    ); } -import { ItemHistoryView } from "./ItemHistoryView"; import { DecisionsPanel } from "./DecisionsPanel"; +import { ItemHistoryView } from "./ItemHistoryView"; type EpicFields = Pick & { justificativa?: string }; @@ -153,9 +154,9 @@ export function EpicDetail({ projectId, epicId, canEdit, children }: { projectId const isDirty = editing && epic !== null && formValues !== null && JSON.stringify(formValues) !== JSON.stringify(toFields(epic)); const { confirmLeave } = useUnsavedChangesGuard(isDirty); - if (result.state === "loading") return
    Carregando épico…
    ; - if (result.state === "error") return

    {result.message}

    -
    ; + if (result.state === "loading") return
    Carregando épico…
    ; + if (result.state === "error") return

    {result.message}

    +
    ; if (!epic) return null; const epicoArquivado = epic.status === "arquivado"; @@ -185,10 +186,10 @@ export function EpicDetail({ projectId, epicId, canEdit, children }: { projectId />

    Épico

    {epic.titulo}

    - +
    {readOnly && ( -
    +

    {epicoArquivado ? "Este épico está arquivado e está disponível apenas para leitura." @@ -196,8 +197,8 @@ export function EpicDetail({ projectId, epicId, canEdit, children }: { projectId

    )} -
    - {epic.status} +
    + {epic.status} {editing && formValues ? ( <> {([ @@ -231,7 +232,7 @@ export function EpicDetail({ projectId, epicId, canEdit, children }: { projectId )} {editMessage &&

    {editMessage}

    }
    - - + }}>{saving ? "Salvando…" : "Salvar alterações"} +
    ) : ( @@ -270,9 +271,9 @@ export function EpicDetail({ projectId, epicId, canEdit, children }: { projectId {canWrite && (
    - + {(epic.status === "rascunho" || epic.status === "ativo") && ( - + }}>{completing ? "Concluindo…" : "Marcar como concluído"} )}
    )} diff --git a/frontend/src/views/backlog/FeaturesView.tsx b/frontend/src/views/backlog/FeaturesView.tsx index 5cce7dc..8b58d88 100644 --- a/frontend/src/views/backlog/FeaturesView.tsx +++ b/frontend/src/views/backlog/FeaturesView.tsx @@ -1,16 +1,17 @@ -import { ReadOnlyContext } from "../../models/ReadOnlyContext"; -import { ItemArchiveView } from "./ItemArchiveView"; import { useContext, useEffect, useRef, useState, type ReactNode } from "react"; import { ApiError } from "../../api/api_auth"; -import { navigate } from "../../models/navigation"; -import { createFeature, completeFeature, updateFeature, getFeature, listFeatures, camposFaltantesDe, type Feature, type FeatureInput } from "../../api/api_backlog"; +import { camposFaltantesDe, completeFeature, createFeature, getFeature, listFeatures, updateFeature, type Feature, type FeatureInput } from "../../api/api_backlog"; +import "../../assets/styles/projects.css"; import { descreverCamposFaltantes } from "../../models/fields"; -import { useUnsavedChangesGuard } from "../../viewmodels/useUnsavedChangesGuard"; +import { navigate } from "../../models/navigation"; +import { ReadOnlyContext } from "../../models/ReadOnlyContext"; import { usePbiQualityConfiguration } from "../../viewmodels/usePbiQualityConfiguration"; -import { CriteriaEditor } from "./CriteriaView"; +import { useUnsavedChangesGuard } from "../../viewmodels/useUnsavedChangesGuard"; +import { Button } from "../common/ui"; import { BacklogBreadcrumb } from "./BacklogBreadcrumb"; import { BacklogTechnologySelector } from "./BacklogTechnologySelector"; -import "../../assets/styles/projects.css"; +import { CriteriaEditor } from "./CriteriaView"; +import { ItemArchiveView } from "./ItemArchiveView"; type ListResult = { state: "loading" } | { state: "error"; message: string } | { state: "ready"; features: Feature[] }; const emptyInput: FeatureInput = { epico_id: "", titulo: "", descricao: "", objetivo: "", tecnologias_ids: [] }; @@ -38,21 +39,21 @@ export function FeatureList({ projectId, epicoId, canCreate: allowedToCreate }:

    Features do épico

    Features

    - {canCreate && } + {canCreate && }
    - {result.state === "loading" &&
    Carregando features…
    } - {result.state === "error" &&

    {result.message}

    -
    } + {result.state === "loading" &&
    Carregando features…
    } + {result.state === "error" &&

    {result.message}

    +
    } {result.state === "ready" && (result.features.length === 0 - ?

    Nenhuma feature cadastrada

    Crie a primeira feature para detalhar este épico.

    - {canCreate && }
    + ?

    Nenhuma feature cadastrada

    Crie a primeira feature para detalhar este épico.

    + {canCreate && }
    :
    {result.features.map((feature) => ( -
    - {feature.status} +
    + {feature.status}

    {feature.titulo}

    {feature.objetivo || "Sem objetivo registrado."}

    - +
    ))}
    )}
    @@ -73,7 +74,7 @@ export function FeatureForm({ projectId, epicoId }: { projectId: string; epicoId

    Features / Nova feature

    Criar feature

    Apenas o título é obrigatório para salvar como rascunho.

    -
    { + { event.preventDefault(); if (submitting.current) return; if (!values.titulo.trim()) { setMessage("Informe o título da feature."); return; } @@ -104,16 +105,16 @@ export function FeatureForm({ projectId, epicoId }: { projectId: string; epicoId /> {message &&

    {message}

    }
    - - + +
    ); } -import { ItemHistoryView } from "./ItemHistoryView"; import { DecisionsPanel } from "./DecisionsPanel"; +import { ItemHistoryView } from "./ItemHistoryView"; type FeatureFields = Pick & { justificativa?: string }; @@ -150,9 +151,9 @@ export function FeatureDetail({ projectId, epicoId, featureId, canEdit, children const isDirty = editing && feature !== null && formValues !== null && JSON.stringify(formValues) !== JSON.stringify(toFields(feature)); const { confirmLeave } = useUnsavedChangesGuard(isDirty); - if (result.state === "loading") return
    Carregando feature…
    ; - if (result.state === "error") return

    {result.message}

    -
    ; + if (result.state === "loading") return
    Carregando feature…
    ; + if (result.state === "error") return

    {result.message}

    +
    ; const readOnly = feature!.status === "arquivado" || feature!.projeto_status === "arquivado"; const justificationRequired = feature!.status === "concluido" @@ -182,11 +183,11 @@ export function FeatureDetail({ projectId, epicoId, featureId, canEdit, children />

    Épico: {feature!.epico_titulo}

    {feature!.titulo}

    - +
    - {readOnly &&

    Esta feature pertence a um projeto arquivado e está disponível apenas para leitura.

    } -
    - {feature!.status} + {readOnly &&

    Esta feature pertence a um projeto arquivado e está disponível apenas para leitura.

    } +
    + {feature!.status} {editing && formValues ? ( <> {([["titulo", "Título", "input"], ["objetivo", "Objetivo", "textarea"], ["descricao", "Descrição", "textarea"]] as const).map(([field, label, kind]) => ( @@ -217,7 +218,7 @@ export function FeatureDetail({ projectId, epicoId, featureId, canEdit, children )} {editMessage &&

    {editMessage}

    }
    - - + }}>{saving ? "Salvando…" : "Salvar alterações"} +
    ) : ( @@ -254,9 +255,9 @@ export function FeatureDetail({ projectId, epicoId, featureId, canEdit, children {!readOnly && canEdit && (
    - + {feature!.status === "rascunho" && ( - + }}>{completing ? "Concluindo…" : "Marcar como concluída"} )}
    )} diff --git a/frontend/src/backlog/Hierarchy.test.tsx b/frontend/src/views/backlog/Hierarchy.test.tsx similarity index 98% rename from frontend/src/backlog/Hierarchy.test.tsx rename to frontend/src/views/backlog/Hierarchy.test.tsx index eb2cbb8..559725d 100644 --- a/frontend/src/backlog/Hierarchy.test.tsx +++ b/frontend/src/views/backlog/Hierarchy.test.tsx @@ -18,16 +18,16 @@ import { import { BacklogBreadcrumb, -} from "../views/backlog/BacklogBreadcrumb"; +} from "./BacklogBreadcrumb"; import { BacklogTreeView, filterBacklogTree, -} from "../views/backlog/BacklogTreeView"; +} from "./BacklogTreeView"; import type { ProjectBacklogTree, -} from "../api/api_backlog_tree"; +} from "../../api/api_backlog_tree"; const tree: ProjectBacklogTree = { project: { diff --git a/frontend/src/backlog/ItemArchive.test.tsx b/frontend/src/views/backlog/ItemArchive.test.tsx similarity index 98% rename from frontend/src/backlog/ItemArchive.test.tsx rename to frontend/src/views/backlog/ItemArchive.test.tsx index 943fa85..2130791 100644 --- a/frontend/src/backlog/ItemArchive.test.tsx +++ b/frontend/src/views/backlog/ItemArchive.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { ItemArchive } from "./ItemArchive"; +import { ItemArchiveView as ItemArchive } from "./ItemArchiveView"; const project = { id: "project-1", titulo: "Sinapse", cliente: "Cliente", descricao: "Contexto", status: "ativo" as const }; diff --git a/frontend/src/views/backlog/ItemArchiveView.tsx b/frontend/src/views/backlog/ItemArchiveView.tsx index 7148dc8..ec4314e 100644 --- a/frontend/src/views/backlog/ItemArchiveView.tsx +++ b/frontend/src/views/backlog/ItemArchiveView.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from "react"; -import { apiRequest, ApiError } from "../../api/api_auth"; +import { ApiError, apiRequest } from "../../api/api_auth"; +import { Button } from "../common/ui"; type ArchiveImpact = { epicos: number; features: number; pbis: number }; type Item = { id: string; titulo: string; status: string }; @@ -36,15 +37,15 @@ export function ItemArchiveView({ project, kind, canWrite, onArchived }: { proje } finally { pending.current = false; if (mounted.current) setBusy(false); } } return
    - + {error &&

    {error}

    } { if (busy) event.preventDefault(); else setImpact(null); }}>

    Arquivar {project.titulo}?

    {impact && <>

    Serão arquivados {impact.epicos} épico(s), {impact.features} feature(s) e {impact.pbis} PBI(s).

    Os registros serão preservados para consulta. Os itens já arquivados manterão suas datas.

    } {busy &&

    Arquivando…

    } -
    -
    +
    +
    ; } diff --git a/frontend/src/views/backlog/ItemHistoryView.tsx b/frontend/src/views/backlog/ItemHistoryView.tsx index 3e64f51..008bfe8 100644 --- a/frontend/src/views/backlog/ItemHistoryView.tsx +++ b/frontend/src/views/backlog/ItemHistoryView.tsx @@ -1,6 +1,7 @@ -import { useState, useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { getItemHistory, type AuditHistoryItem } from "../../api/api_backlog"; import { describeAuditChanges } from "../../models/auditChanges"; +import { Button } from "../common/ui"; export interface ItemHistoryViewProps { entidadeTipo: "epico" | "feature" | "pbi"; @@ -100,25 +101,25 @@ export function ItemHistoryView({ } if (loading) { - return
    Carregando histórico de auditoria…
    ; + return
    Carregando histórico de auditoria…
    ; } if (error) { return ( -
    +

    {error}

    - +
    ); } return ( -
    +
    Rastreabilidade e auditoria @@ -136,72 +137,72 @@ export function ItemHistoryView({

    Nenhum registro de alteração encontrado para este item.

    ) : (
      - {history.map((item) => { - const changes = describeAuditChanges({ - ...item.dados_json, - ...(item.pbi_snapshot ? { pbi_snapshot: item.pbi_snapshot } : {}), - }); - return ( -
    • -
      - {item.usuario_nome || "Usuário não identificado"} - - {new Date(item.created_at).toLocaleString("pt-BR")} - -
      -
      - {item.acao} - {item.pbi_versao !== null && item.pbi_versao !== undefined && ( - Versão {item.pbi_versao} - )} -
      - {changes.length > 0 && ( -
      - {changes.map((change) => ( -
      -
      {change.label}
      -
      - {change.before !== undefined - ? `${change.before} → ${change.after ?? "Não informado"}` - : change.after} -
      -
      - ))} -
      - )} - {item.justificativa ? ( -
      - Justificativa da alteração: {item.justificativa} + {history.map((item) => { + const changes = describeAuditChanges({ + ...item.dados_json, + ...(item.pbi_snapshot ? { pbi_snapshot: item.pbi_snapshot } : {}), + }); + return ( +
    • +
      + {item.usuario_nome || "Usuário não identificado"} + + {new Date(item.created_at).toLocaleString("pt-BR")} +
      - ) : ( -
      - Sem justificativa registrada (alteração em rascunho ou sem exigência). +
      + {item.acao} + {item.pbi_versao !== null && item.pbi_versao !== undefined && ( + Versão {item.pbi_versao} + )}
      - )} -
    • - ); - })} + {changes.length > 0 && ( +
      + {changes.map((change) => ( +
      +
      {change.label}
      +
      + {change.before !== undefined + ? `${change.before} → ${change.after ?? "Não informado"}` + : change.after} +
      +
      + ))} +
      + )} + {item.justificativa ? ( +
      + Justificativa da alteração: {item.justificativa} +
      + ) : ( +
      + Sem justificativa registrada (alteração em rascunho ou sem exigência). +
      + )} + + ); + })}
    )} {moreError &&

    {moreError}

    } {nextCursor && ( - + )}
    diff --git a/frontend/src/backlog/Justificativa.test.tsx b/frontend/src/views/backlog/Justificativa.test.tsx similarity index 97% rename from frontend/src/backlog/Justificativa.test.tsx rename to frontend/src/views/backlog/Justificativa.test.tsx index e6b0201..d66a893 100644 --- a/frontend/src/backlog/Justificativa.test.tsx +++ b/frontend/src/views/backlog/Justificativa.test.tsx @@ -1,10 +1,10 @@ // @vitest-environment jsdom import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { ItemHistoryView } from "../views/backlog/ItemHistoryView"; -import { EpicDetail } from "../views/backlog/EpicsView"; -import { FeatureDetail } from "../views/backlog/FeaturesView"; -import { CriteriaEditor } from "../views/backlog/CriteriaView"; +import { ItemHistoryView } from "./ItemHistoryView"; +import { EpicDetail } from "./EpicsView"; +import { FeatureDetail } from "./FeaturesView"; +import { CriteriaEditor } from "./CriteriaView"; const epicConcluido = { id: "epic-c1", diff --git a/frontend/src/views/backlog/PbiDetail.tsx b/frontend/src/views/backlog/PbiDetail.tsx new file mode 100644 index 0000000..40ed282 --- /dev/null +++ b/frontend/src/views/backlog/PbiDetail.tsx @@ -0,0 +1,784 @@ +import { useEffect, useState } from "react"; +import { ApiError } from "../../api/api_auth"; +import { + camposFaltantesDe, + completePbi, + getPbi, + hasCompletudeIndicator, + updatePbi, + type Criterion, + type Pbi, + type PbiInput +} from "../../api/api_backlog"; +import "../../assets/styles/projects.css"; +import { descreverCamposFaltantes } from "../../models/fields"; +import { navigate } from "../../models/navigation"; +import { evaluatePbiRealtime } from "../../models/qualityEngine"; +import { usePbiQualityConfiguration } from "../../viewmodels/usePbiQualityConfiguration"; +import { useUnsavedChangesGuard } from "../../viewmodels/useUnsavedChangesGuard"; +import { Button } from "../common/ui"; +import { BacklogBreadcrumb } from "./BacklogBreadcrumb"; +import { BacklogTechnologySelector } from "./BacklogTechnologySelector"; +import { CriteriaEditor } from "./CriteriaView"; +import { DecisionsPanel } from "./DecisionsPanel"; +import { ItemHistoryView } from "./ItemHistoryView"; +import { QualityPanelView as QualityPanel } from "./QualityPanelView"; + +type PbiFields = Pick< + PbiInput, + | "titulo" + | "historia_como_um" + | "historia_eu_quero" + | "historia_para_que" + | "requer_interface" + | "tecnologias_ids" +> & { justificativa?: string }; + +function toFields( + pbi: Pbi, +): PbiFields { + return { + titulo: pbi.titulo, + historia_como_um: + pbi.historia_como_um, + historia_eu_quero: + pbi.historia_eu_quero, + historia_para_que: + pbi.historia_para_que, + requer_interface: + pbi.requer_interface, + tecnologias_ids: [...(pbi.tecnologias_ids ?? [])], + justificativa: "", + }; +} + +export function PbiDetail({ + projectId, + epicoId, + featureId, + pbiId, + canEdit, +}: { + projectId: string; + epicoId: string; + featureId: string; + pbiId: string; + canEdit: boolean; +}) { + const [result, setResult] = useState< + | { state: "loading" } + | { state: "error"; message: string } + | { state: "ready"; pbi: Pbi } + >({ + state: "loading", + }); + + const [completing, setCompleting] = + useState(false); + + const [ + completionMessage, + setCompletionMessage, + ] = useState(""); + + const [attempt, setAttempt] = useState(0); + const [editing, setEditing] = useState(false); + + const [formValues, setFormValues] = + useState(null); + + const [saving, setSaving] = useState(false); + + const [editMessage, setEditMessage] = + useState(""); + + const [scenarios, setScenarios] = + useState(null); + + const qualityConfiguration = + usePbiQualityConfiguration(); + + useEffect(() => { + setScenarios(null); + }, [pbiId]); + + useEffect(() => { + const controller = + new AbortController(); + + setResult({ + state: "loading", + }); + + getPbi( + pbiId, + controller.signal, + ) + .then((pbi) => { + if (!controller.signal.aborted) { + setResult({ + state: "ready", + pbi, + }); + } + }) + .catch((error) => { + if (controller.signal.aborted) { + return; + } + + setResult({ + state: "error", + message: + error instanceof ApiError + && error.status === 404 + ? "PBI não encontrado." + : "Não foi possível carregar o PBI.", + }); + }); + + return () => controller.abort(); + }, [pbiId, attempt]); + + const pbi = + result.state === "ready" + ? result.pbi + : null; + const justificationRequired = pbi?.status === "concluido" + && (qualityConfiguration.result.state !== "ready" + || qualityConfiguration.result.config.exigir_justificativa_item_concluido); + + const isDirty = + editing + && pbi !== null + && formValues !== null + && JSON.stringify(formValues) + !== JSON.stringify(toFields(pbi)); + + const { confirmLeave } = + useUnsavedChangesGuard(isDirty); + + const qualityValues = pbi + ? ( + editing && formValues + ? formValues + : toFields(pbi) + ) + : null; + + const qualityReport = + qualityValues + && scenarios + && qualityConfiguration.result.state + === "ready" + ? evaluatePbiRealtime( + { + ...qualityValues, + prototipo_vinculado: + pbi?.prototipo_vinculado, + }, + scenarios, + qualityConfiguration.result.config, + editing ? "edit-" : "", + ) + : null; + + if (result.state === "loading") { + return ( +
    + Carregando PBI… +
    + ); + } + + if (result.state === "error") { + return ( +
    +

    + {result.message} +

    + + +
    + ); + } + + const readOnly = + pbi!.projeto_status === "arquivado"; + + return ( +
    + { + if (confirmLeave()) navigate(path); + }} + /> +
    +
    +

    + Feature: {pbi!.feature_titulo} +

    + +

    + {pbi!.codigo} + {" — "} + {pbi!.titulo} +

    +
    + + +
    + + {readOnly && ( +
    +

    + Este PBI pertence a um projeto + arquivado e está disponível apenas + para leitura. +

    +
    + )} + + {( + qualityConfiguration.result.state + === "loading" + || ( + qualityConfiguration.result.state + === "ready" + && scenarios === null + ) + ) && ( +
    + Carregando o checklist de qualidade… +
    + )} + + {qualityConfiguration.result.state + === "error" && ( +
    +

    + Não foi possível carregar a configuração + de qualidade vigente. O checklist não + será exibido com regras presumidas. +

    + + +
    + )} + + {qualityReport && ( + + )} + +
    +
    + + {pbi!.status} + + + {hasCompletudeIndicator( + pbi!.score_completude, + ) && ( + = 80 + ? "ds-badge--success" + : pbi!.score_completude! >= 50 + ? "ds-badge--warning" + : "ds-badge--danger" + }` + } + > + {pbi!.score_completude}% + {" "} + completo + + )} +
    + + {editing && formValues + ? ( + <> +
    + + + + setFormValues( + (value) => + value && { + ...value, + titulo: + event.target.value, + }, + ) + } + /> +
    + +
    + + + + setFormValues( + (value) => + value && { + ...value, + historia_como_um: + event.target.value, + }, + ) + } + /> +
    + +
    + + + + setFormValues( + (value) => + value && { + ...value, + historia_eu_quero: + event.target.value, + }, + ) + } + /> +
    + +
    + + + + setFormValues( + (value) => + value && { + ...value, + historia_para_que: + event.target.value, + }, + ) + } + /> +
    + +
    + +
    + + setFormValues((current) => current && { ...current, tecnologias_ids })} + disabled={saving} + /> + + {justificationRequired && ( +
    + +