From e53041eb59e7aa3a4084fa86116165f17f660984 Mon Sep 17 00:00:00 2001 From: vitorpdim Date: Fri, 25 Sep 2026 11:33:03 -0300 Subject: [PATCH 1/4] feat(S1-17): busca textual relacional no backlog do projeto - GET /projects/:id/backlog-search: titulo, descricao/historia e codigo de epicos, features e PBIs, sem diferenciar acento ou maiuscula, com AND entre termos e intersecao com status e tecnologia; sempre no escopo do projeto - Resposta com tipo, trecho com destaques e caminho Epico > Feature > PBI - Curingas LIKE tratados como texto literal; limites e validacoes de entrada - Frontend: campo de busca junto aos filtros da S1-16, resultados com trecho, caminho navegavel, estado vazio com limpeza de criterios e persistencia na sessao - OpenAPI e testes (unitarios, rotas, PostgreSQL e componente) --- .github/workflows/ci.yml | 2 +- backend/src/index.ts | 4 + .../backlog-search/backlog-search.db.test.ts | 77 +++++++ .../backlog-search.repository.ts | 59 +++++ .../backlog-search.routes.test.ts | 65 ++++++ .../backlog-search/backlog-search.routes.ts | 49 ++++ .../backlog-search.service.test.ts | 108 +++++++++ .../backlog-search/backlog-search.service.ts | 96 ++++++++ .../backlog-search.text.test.ts | 49 ++++ .../backlog-search/backlog-search.text.ts | 92 ++++++++ .../backlog-search/backlog-search.types.ts | 66 ++++++ docs/api/openapi.yaml | 101 +++++++++ frontend/src/api/api_backlog_search.ts | 96 ++++++++ frontend/src/assets/styles/backlog-search.css | 21 ++ frontend/src/backlog/Hierarchy.test.tsx | 1 + frontend/src/models/backlogSearch.test.ts | 36 +++ frontend/src/models/backlogSearch.ts | 45 ++++ .../src/views/backlog/BacklogSearch.test.tsx | 209 ++++++++++++++++++ .../views/backlog/BacklogSearchResults.tsx | 141 ++++++++++++ .../src/views/backlog/BacklogTreeView.tsx | 144 +++++++++++- 20 files changed, 1456 insertions(+), 5 deletions(-) create mode 100644 backend/src/modules/backlog-search/backlog-search.db.test.ts create mode 100644 backend/src/modules/backlog-search/backlog-search.repository.ts create mode 100644 backend/src/modules/backlog-search/backlog-search.routes.test.ts create mode 100644 backend/src/modules/backlog-search/backlog-search.routes.ts create mode 100644 backend/src/modules/backlog-search/backlog-search.service.test.ts create mode 100644 backend/src/modules/backlog-search/backlog-search.service.ts create mode 100644 backend/src/modules/backlog-search/backlog-search.text.test.ts create mode 100644 backend/src/modules/backlog-search/backlog-search.text.ts create mode 100644 backend/src/modules/backlog-search/backlog-search.types.ts create mode 100644 frontend/src/api/api_backlog_search.ts create mode 100644 frontend/src/assets/styles/backlog-search.css create mode 100644 frontend/src/models/backlogSearch.test.ts create mode 100644 frontend/src/models/backlogSearch.ts create mode 100644 frontend/src/views/backlog/BacklogSearch.test.tsx create mode 100644 frontend/src/views/backlog/BacklogSearchResults.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1fd4e77..2e824d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,7 @@ jobs: - run: npm run test:seed working-directory: backend - name: Test archive cascade on PostgreSQL - run: node --import tsx --test src/modules/projects/projects.archive.db.test.ts src/modules/projects/hierarchy-archive.db.test.ts src/modules/projects/projects.backlog-tree.db.test.ts src/database/migration-010.db.test.ts src/database/migration-011.db.test.ts src/database/migration-012.db.test.ts src/modules/documents/documents.repository.db.test.ts src/modules/chat/chat.repository.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 working-directory: backend env: ARCHIVE_TEST_DATABASE_URL: postgresql://seed_test:seed_test_only@localhost:5432/sinapse_seed_test diff --git a/backend/src/index.ts b/backend/src/index.ts index b1fb973..fd6bf46 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -12,6 +12,7 @@ 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 { 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"; @@ -80,6 +81,9 @@ app.use("/api/v1/admin", adminRouter); // Repo analyzer app.use('/api/v1/projects/:projectId/repo-analyses', repoAnalysesRouter); +// 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); diff --git a/backend/src/modules/backlog-search/backlog-search.db.test.ts b/backend/src/modules/backlog-search/backlog-search.db.test.ts new file mode 100644 index 0000000..8b1deb5 --- /dev/null +++ b/backend/src/modules/backlog-search/backlog-search.db.test.ts @@ -0,0 +1,77 @@ +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 { ProjectsRepository } from "../projects/projects.repository.js"; +import { BacklogSearchRepository } from "./backlog-search.repository.js"; +import { BacklogSearchService } from "./backlog-search.service.js"; + +test("S1-17: busca relacional isolada por projeto, com filtros combinados no PostgreSQL", { skip: !process.env.BACKLOG_TREE_TEST_DATABASE_URL }, async () => { + const pool = new Pool({ connectionString: validateTarget(process.env.BACKLOG_TREE_TEST_DATABASE_URL, "test") }); + const service = new BacklogSearchService(new BacklogSearchRepository(pool), new ProjectsRepository(pool)); + const [projectA, projectB, epicA, featureA, pbiTitle, pbiDesc, pbiDone, epicB, featureB, pbiB, techJava, techNode] = + Array.from({ length: 12 }, () => randomUUID()); + const tag = randomUUID().slice(0, 8); + try { + await pool.query("INSERT INTO projeto (id,nome,cliente,status) VALUES ($1::uuid,$1::text,'T','ativo'),($2::uuid,$2::text,'T','ativo')", [projectA, projectB]); + await pool.query("INSERT INTO epico (id,projeto_id,titulo,descricao,status) VALUES ($1,$2,$3,'Objetivo de navegação e busca','rascunho'),($4,$5,$6,'Descrição da autenticação do outro projeto','rascunho')", + [epicA, projectA, `Épico Autenticação ${tag}`, epicB, projectB, `Épico Autenticação ${tag}`]); + await pool.query("INSERT INTO feature (id,epico_id,titulo,descricao,status) VALUES ($1,$2,$3,'Sessão segura com expiração','rascunho'),($4,$5,$6,'igual','rascunho')", + [featureA, epicA, `Feature Sessão ${tag}`, featureB, epicB, `Feature Sessão ${tag}`]); + await pool.query(`INSERT INTO pbi (id,feature_id,codigo,titulo,historia_como_um,historia_eu_quero,historia_para_que,regras_observacoes,status) VALUES + ($1,$2,'PBI-T.1',$3,'PO','entrar','acessar','','rascunho'), + ($4,$2,'PBI-T.2',$5,'PO','recuperar a senha por e-mail','continuar','Regra: 100% dos links expiram','arquivado'), + ($6,$2,'PBI-T.3',$7,'PO','sair','encerrar','','concluido'), + ($8,$9,'PBI-T.9',$3,'PO','entrar','acessar','','rascunho')`, + [pbiTitle, featureA, `Validar Autenticação ${tag}`, pbiDesc, `Enviar link ${tag}`, pbiDone, `Encerrar sessão ${tag}`, pbiB, featureB]); + await pool.query("INSERT INTO tecnologia (id,nome) VALUES ($1,$2),($3,$4)", [techJava, `Java-${tag}`, techNode, `Node-${tag}`]); + await pool.query("INSERT INTO entidade_tecnologia (id,entidade_tipo,entidade_id,tecnologia_id) VALUES ($1,'pbi',$2,$3),($4,'pbi',$5,$6),($7,'epico',$8,$3)", + [randomUUID(), pbiTitle, techJava, randomUUID(), pbiDone, techNode, randomUUID(), epicA]); + + const byTitle = await service.search({ projetoId: projectA, q: `autenticacao ${tag}` }); + assert.equal(byTitle.total, 2, "título sem acento encontra épico e PBI do projeto A"); + assert.ok(byTitle.items.every((item) => item.trecho.destaques.length > 0)); + assert.ok(!byTitle.items.some((item) => item.id === pbiB || item.id === epicB), "nenhum item do projeto B"); + const pbiHit = byTitle.items.find((item) => item.id === pbiTitle); + assert.equal(pbiHit?.campo, "titulo"); + assert.deepEqual(pbiHit?.caminho.map((node) => node.titulo), [`Épico Autenticação ${tag}`, `Feature Sessão ${tag}`, `Validar Autenticação ${tag}`]); + assert.equal(pbiHit?.caminho[2].codigo, "PBI-T.1"); + + const byDescription = await service.search({ projetoId: projectA, q: "recuperar senha" }); + assert.deepEqual(byDescription.items.map((item) => item.id), [pbiDesc]); + assert.equal(byDescription.items[0].campo, "descricao"); + const [from, to] = byDescription.items[0].trecho.destaques[0]; + assert.equal(byDescription.items[0].trecho.texto.slice(from, to).toLowerCase(), "recuperar"); + + const byEpicDescription = await service.search({ projetoId: projectA, q: "NAVEGAÇÃO" }); + assert.deepEqual(byEpicDescription.items.map((item) => item.id), [epicA]); + assert.deepEqual(byEpicDescription.items[0].caminho.map((node) => node.tipo), ["epico"]); + + const byCode = await service.search({ projetoId: projectA, q: "pbi-t.3" }); + assert.deepEqual(byCode.items.map((item) => item.id), [pbiDone]); + + const combined = await service.search({ projetoId: projectA, q: `sessão ${tag}`, status: "concluido", tecnologiaId: techNode }); + assert.deepEqual(combined.items.map((item) => item.id), [pbiDone]); + assert.deepEqual((await service.search({ projetoId: projectA, q: `sessão ${tag}`, status: "concluido", tecnologiaId: techJava })).items, []); + assert.deepEqual((await service.search({ projetoId: projectA, q: `autenticação ${tag}`, tecnologiaId: techJava })).items.map((item) => item.id).sort(), [epicA, pbiTitle].sort()); + assert.deepEqual((await service.search({ projetoId: projectA, q: `link ${tag}`, status: "arquivado" })).items.map((item) => item.id), [pbiDesc]); + assert.deepEqual((await service.search({ projetoId: projectA, q: `link ${tag}`, status: "rascunho" })).items, []); + + assert.equal((await service.search({ projetoId: projectA, q: "100%" })).items.length, 1, "% é literal"); + assert.equal((await service.search({ projetoId: projectA, q: "e_mail" })).items.length, 0, "_ é literal"); + assert.equal((await service.search({ projetoId: projectA, q: "termo-que-nao-existe-xyz" })).total, 0); + + const limited = await service.search({ projetoId: projectA, q: tag, limit: "2" }); + assert.equal(limited.items.length, 2); + assert.ok(limited.total > 2); + + const other = await service.search({ projetoId: projectB, q: `autenticação ${tag}` }); + assert.deepEqual(other.items.map((item) => item.id).sort(), [epicB, pbiB].sort()); + } finally { + await pool.query("DELETE FROM entidade_tecnologia WHERE tecnologia_id = ANY($1::uuid[]) OR entidade_id = ANY($2::uuid[])", [[techJava, techNode], [epicA]]); + await pool.query("DELETE FROM tecnologia WHERE id = ANY($1::uuid[])", [[techJava, techNode]]); + await pool.query("DELETE FROM projeto WHERE id = ANY($1::uuid[])", [[projectA, projectB]]); + await pool.end(); + } +}); diff --git a/backend/src/modules/backlog-search/backlog-search.repository.ts b/backend/src/modules/backlog-search/backlog-search.repository.ts new file mode 100644 index 0000000..392af12 --- /dev/null +++ b/backend/src/modules/backlog-search/backlog-search.repository.ts @@ -0,0 +1,59 @@ +import { Pool } from "pg"; +import { pool } from "../../database/db.js"; +import type { BacklogSearchFilters, BacklogSearchRow } from "./backlog-search.types.js"; + +const ACCENTED = "ÁÀÂÃÄÉÈÊËÍÌÎÏÓÒÔÕÖÚÙÛÜÇÑáàâãäéèêëíìîïóòôõöúùûüçñ"; +const PLAIN = "AAAAAEEEEIIIIOOOOOUUUUCNaaaaaeeeeiiiiooooouuuucn"; + +const norm = (expression: string) => `lower(translate(${expression}, '${ACCENTED}', '${PLAIN}'))`; + +export class BacklogSearchRepository { + private readonly pool: Pool; + + constructor(customPool?: Pool) { + this.pool = customPool ?? pool; + } + + async search(projetoId: string, patterns: string[], filters: BacklogSearchFilters, limit: number): Promise { + const result = await this.pool.query( + `WITH items AS ( + SELECT 'epico'::text AS tipo, e.id, e.titulo, + concat_ws(E'\\n', e.descricao, e.objetivo, e.escopo_macro, e.resultado_esperado) AS descricao, + e.status, NULL::text AS codigo, e.id AS epico_id, e.titulo AS epico_titulo, + NULL::uuid AS feature_id, NULL::text AS feature_titulo, e.created_at, 1 AS nivel + FROM epico e WHERE e.projeto_id = $1 + UNION ALL + SELECT 'feature', f.id, f.titulo, concat_ws(E'\\n', f.descricao, f.objetivo), + f.status, NULL, e.id, e.titulo, f.id, f.titulo, f.created_at, 2 + FROM feature f JOIN epico e ON e.id = f.epico_id WHERE e.projeto_id = $1 + UNION ALL + SELECT 'pbi', p.id, p.titulo, + concat_ws(E'\\n', p.historia_como_um, p.historia_eu_quero, p.historia_para_que, p.regras_observacoes), + p.status, p.codigo, e.id, e.titulo, f.id, f.titulo, p.created_at, 3 + 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 + ), matched AS ( + SELECT i.*, + ${norm("concat_ws(' ', i.titulo, i.codigo)")} LIKE ALL ($2::text[]) AS titulo_match + FROM items i + WHERE ${norm("concat_ws(' ', i.titulo, i.codigo, i.descricao)")} LIKE ALL ($2::text[]) + AND ($3::text IS NULL OR i.status = $3) + AND ($4::uuid IS NULL OR EXISTS ( + SELECT 1 FROM entidade_tecnologia et + WHERE et.entidade_id = i.id AND et.entidade_tipo = i.tipo AND et.tecnologia_id = $4)) + ) + SELECT m.tipo, m.id, m.titulo, m.descricao, m.status, m.codigo, m.epico_id, m.epico_titulo, + m.feature_id, m.feature_titulo, m.titulo_match, + COALESCE(( + SELECT jsonb_agg(jsonb_build_object('id', t.id, 'nome', t.nome) ORDER BY t.nome) + FROM entidade_tecnologia et JOIN tecnologia t ON t.id = et.tecnologia_id + WHERE et.entidade_id = m.id AND et.entidade_tipo = m.tipo + ), '[]'::jsonb) AS tecnologias, + COUNT(*) OVER () AS total + FROM matched m + ORDER BY m.titulo_match DESC, m.nivel, m.created_at, m.id + LIMIT $5`, + [projetoId, patterns, filters.status ?? null, filters.tecnologiaId ?? null, limit], + ); + return result.rows; + } +} diff --git a/backend/src/modules/backlog-search/backlog-search.routes.test.ts b/backend/src/modules/backlog-search/backlog-search.routes.test.ts new file mode 100644 index 0000000..58ea4c9 --- /dev/null +++ b/backend/src/modules/backlog-search/backlog-search.routes.test.ts @@ -0,0 +1,65 @@ +import test, { after, before } from "node:test"; +import assert from "node:assert/strict"; +import express from "express"; +import { AddressInfo } from "node:net"; +import { Server } from "node:http"; +import { errorHandler } from "../../middleware/errorHandler.js"; +import { BacklogSearchRepository } from "./backlog-search.repository.js"; +import { createBacklogSearchRouter } from "./backlog-search.routes.js"; +import { BacklogSearchService } from "./backlog-search.service.js"; + +const PROJECT = "a0000000-0000-4000-8000-000000000001"; + +class FakeRepository extends BacklogSearchRepository { + public last: { projetoId: string; patterns: string[] } | null = null; + constructor() { super(); } + async search(projetoId: string, patterns: string[]) { + this.last = { projetoId, patterns }; + return []; + } +} + +const repository = new FakeRepository(); +let server: Server; +let baseUrl: string; + +before(() => { + const app = express(); + const service = new BacklogSearchService(repository, { async findById(id: string) { return id === PROJECT ? { id, status: "ativo" } : null; } }); + app.use("/api/v1/projects/:projectId/backlog-search", createBacklogSearchRouter(service, (req, res, next) => { + if (req.headers["x-test-user"] !== "ana") { + res.status(401).json({ error: "Autenticação necessária.", code: "UNAUTHORIZED" }); + return; + } + next(); + })); + app.use(errorHandler); + server = app.listen(0); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/v1/projects`; +}); + +after(() => { + server.close(); +}); + +const get = (path: string, auth = true) => fetch(`${baseUrl}${path}`, { headers: auth ? { "x-test-user": "ana" } : {} }); + +test("exige autenticação", async () => { + assert.equal((await get(`/${PROJECT}/backlog-search?q=login`, false)).status, 401); +}); + +test("busca no projeto da rota e devolve o contrato", async () => { + const response = await get(`/${PROJECT}/backlog-search?q=Autentica%C3%A7%C3%A3o`); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { projeto_id: PROJECT, termo: "Autenticação", total: 0, limite: 50, items: [] }); + assert.deepEqual(repository.last, { projetoId: PROJECT, patterns: ["%autenticacao%"] }); +}); + +test("valida consulta, filtros e projeto", async () => { + assert.equal((await get(`/${PROJECT}/backlog-search`)).status, 400); + assert.equal((await get(`/${PROJECT}/backlog-search?q=a`)).status, 400); + assert.equal((await get(`/${PROJECT}/backlog-search?q=ab&status=xx`)).status, 400); + assert.equal((await get(`/${PROJECT}/backlog-search?q=ab&tecnologia=xx`)).status, 400); + assert.equal((await get(`/nao-uuid/backlog-search?q=ab`)).status, 400); + assert.equal((await get(`/a0000000-0000-4000-8000-0000000000ff/backlog-search?q=ab`)).status, 404); +}); diff --git a/backend/src/modules/backlog-search/backlog-search.routes.ts b/backend/src/modules/backlog-search/backlog-search.routes.ts new file mode 100644 index 0000000..9bfd878 --- /dev/null +++ b/backend/src/modules/backlog-search/backlog-search.routes.ts @@ -0,0 +1,49 @@ +import { Router, type NextFunction, type Request, type RequestHandler, type Response } from "express"; +import { requireAuth } from "../../middleware/requireAuth.js"; +import { BacklogSearchService, backlogSearchService } from "./backlog-search.service.js"; + +function text(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +export function createBacklogSearchRouter( + service: BacklogSearchService = backlogSearchService, + authentication: RequestHandler = requireAuth, +): Router { + const router = Router({ mergeParams: true }); + router.use(authentication); + + /** + * @swagger + * /api/v1/projects/{projectId}/backlog-search: + * get: + * summary: Busca textual relacional no backlog do projeto (S1-17) + * tags: [Backlog] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Itens do projeto com trecho, destaques e caminho hierárquico + * 400: + * description: Consulta, status, tecnologia ou limite inválidos + * 404: + * description: Projeto não encontrado + */ + router.get("/", async (req: Request, res: Response, next: NextFunction) => { + try { + res.json(await service.search({ + projetoId: String(req.params.projectId), + q: text(req.query.q), + status: text(req.query.status), + tecnologiaId: text(req.query.tecnologia), + limit: text(req.query.limit), + })); + } catch (error) { + next(error); + } + }); + + return router; +} + +export const backlogSearchRouter = createBacklogSearchRouter(); diff --git a/backend/src/modules/backlog-search/backlog-search.service.test.ts b/backend/src/modules/backlog-search/backlog-search.service.test.ts new file mode 100644 index 0000000..042aba2 --- /dev/null +++ b/backend/src/modules/backlog-search/backlog-search.service.test.ts @@ -0,0 +1,108 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { NotFoundError, ValidationError } from "../../shared/errors.js"; +import { BacklogSearchRepository } from "./backlog-search.repository.js"; +import { BacklogSearchService } from "./backlog-search.service.js"; +import type { BacklogSearchFilters, BacklogSearchRow } from "./backlog-search.types.js"; + +const PROJECT = "a0000000-0000-4000-8000-000000000001"; +const TECH = "f0000000-0000-4000-8000-000000000001"; + +class FakeRepository extends BacklogSearchRepository { + public calls: Array<{ projetoId: string; patterns: string[]; filters: BacklogSearchFilters; limit: number }> = []; + public rows: BacklogSearchRow[] = []; + + constructor() { + super(); + } + + async search(projetoId: string, patterns: string[], filters: BacklogSearchFilters, limit: number): Promise { + this.calls.push({ projetoId, patterns, filters, limit }); + return this.rows; + } +} + +const projects = { async findById(id: string) { return id === PROJECT ? { id, status: "ativo" } : null; } }; + +function row(overrides: Partial): BacklogSearchRow { + return { + tipo: "pbi", id: "p1", titulo: "Filtrar backlog por status", descricao: "Como um PO\nEu quero filtrar itens\nPara localizar rápido", + status: "rascunho", codigo: "PBI-01.4.1", epico_id: "e1", epico_titulo: "Navegação", feature_id: "f1", feature_titulo: "Filtros", + tecnologias: [], titulo_match: true, total: "1", ...overrides, + }; +} + +function setup() { + const repository = new FakeRepository(); + return { repository, service: new BacklogSearchService(repository, projects) }; +} + +test("monta tipo, trecho, destaques e caminho Projeto → Épico → Feature → PBI", async () => { + const { service, repository } = setup(); + repository.rows = [row({})]; + const result = await service.search({ projetoId: PROJECT, q: " FILTRAR Backlog " }); + + assert.equal(result.termo, "FILTRAR Backlog"); + assert.equal(result.total, 1); + const [item] = result.items; + assert.equal(item.tipo, "pbi"); + assert.equal(item.campo, "titulo"); + assert.equal(item.trecho.texto, "Filtrar backlog por status"); + assert.deepEqual(item.trecho.destaques, [[0, 7], [8, 15]]); + assert.deepEqual(item.caminho.map((node) => [node.tipo, node.titulo]), [["epico", "Navegação"], ["feature", "Filtros"], ["pbi", "Filtrar backlog por status"]]); + assert.equal(item.caminho[2].codigo, "PBI-01.4.1"); + assert.deepEqual(repository.calls[0].patterns, ["%filtrar%", "%backlog%"]); +}); + +test("quando só a descrição casa, o trecho vem da descrição e o campo é descricao", async () => { + const { service, repository } = setup(); + repository.rows = [row({ titulo_match: false })]; + const [item] = (await service.search({ projetoId: PROJECT, q: "localizar" })).items; + assert.equal(item.campo, "descricao"); + assert.match(item.trecho.texto, /localizar rápido/); + assert.equal(item.trecho.texto.slice(item.trecho.destaques[0][0], item.trecho.destaques[0][1]), "localizar"); +}); + +test("épico e feature têm caminho com o nível aplicável", async () => { + const { service, repository } = setup(); + repository.rows = [ + row({ tipo: "epico", id: "e1", titulo: "Navegação", codigo: null, feature_id: null, feature_titulo: null, total: "2" }), + row({ tipo: "feature", id: "f1", titulo: "Filtros", codigo: null, total: "2" }), + ]; + const result = await service.search({ projetoId: PROJECT, q: "na" }); + assert.deepEqual(result.items[0].caminho.map((node) => node.tipo), ["epico"]); + assert.deepEqual(result.items[1].caminho.map((node) => node.tipo), ["epico", "feature"]); + assert.equal(result.total, 2); +}); + +test("repassa status, tecnologia e limite validados ao repositório", async () => { + const { service, repository } = setup(); + await service.search({ projetoId: PROJECT, q: "login", status: "concluido", tecnologiaId: TECH, limit: "10" }); + assert.deepEqual(repository.calls[0].filters, { status: "concluido", tecnologiaId: TECH }); + assert.equal(repository.calls[0].limit, 10); + assert.equal(repository.calls[0].projetoId, PROJECT); +}); + +test("rejeita consulta curta/longa, status, tecnologia e limite inválidos", async () => { + const { service, repository } = setup(); + await assert.rejects(service.search({ projetoId: PROJECT, q: "a" }), /ao menos 2/); + await assert.rejects(service.search({ projetoId: PROJECT }), ValidationError); + await assert.rejects(service.search({ projetoId: PROJECT, q: "x".repeat(101) }), /no máximo 100/); + await assert.rejects(service.search({ projetoId: PROJECT, q: "ab", status: "inventado" }), /Status inválido/); + await assert.rejects(service.search({ projetoId: PROJECT, q: "ab", tecnologiaId: "nao-uuid" }), ValidationError); + await assert.rejects(service.search({ projetoId: PROJECT, q: "ab", limit: "0" }), ValidationError); + await assert.rejects(service.search({ projetoId: PROJECT, q: "ab", limit: "101" }), ValidationError); + await assert.rejects(service.search({ projetoId: "nao-uuid", q: "ab" }), ValidationError); + assert.equal(repository.calls.length, 0); +}); + +test("projeto inexistente retorna 404 antes de consultar o banco de itens", async () => { + const { service, repository } = setup(); + await assert.rejects(service.search({ projetoId: "a0000000-0000-4000-8000-0000000000ff", q: "login" }), NotFoundError); + assert.equal(repository.calls.length, 0); +}); + +test("sem resultados devolve total zero e lista vazia", async () => { + const { service } = setup(); + assert.deepEqual(await service.search({ projetoId: PROJECT, q: "inexistente" }), { projeto_id: PROJECT, termo: "inexistente", total: 0, limite: 50, items: [] }); +}); diff --git a/backend/src/modules/backlog-search/backlog-search.service.ts b/backend/src/modules/backlog-search/backlog-search.service.ts new file mode 100644 index 0000000..3efb428 --- /dev/null +++ b/backend/src/modules/backlog-search/backlog-search.service.ts @@ -0,0 +1,96 @@ +import { NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; +import { ProjectsRepository } from "../projects/projects.repository.js"; +import { BacklogSearchRepository } from "./backlog-search.repository.js"; +import { likePatterns, makeSnippet, parseTerms } from "./backlog-search.text.js"; +import { + BACKLOG_SEARCH_STATUSES, + DEFAULT_LIMIT, + MAX_LIMIT, + MAX_QUERY_LENGTH, + MIN_QUERY_LENGTH, + type BacklogSearchItem, + type BacklogSearchResponse, + type BacklogSearchRow, + type SearchPathNode, +} from "./backlog-search.types.js"; + +export interface ProjectLookup { + findById(id: string): Promise<{ id: string; status: string } | null>; +} + +export interface BacklogSearchInput { + projetoId: string; + q?: string; + status?: string; + tecnologiaId?: string; + limit?: string; +} + +function buildPath(row: BacklogSearchRow): SearchPathNode[] { + const path: SearchPathNode[] = [{ tipo: "epico", id: row.epico_id, titulo: row.epico_titulo, codigo: null }]; + if (row.feature_id && row.feature_titulo !== null) { + path.push({ tipo: "feature", id: row.feature_id, titulo: row.feature_titulo, codigo: null }); + } + if (row.tipo === "pbi") path.push({ tipo: "pbi", id: row.id, titulo: row.titulo, codigo: row.codigo }); + return path; +} + +export class BacklogSearchService { + constructor( + private readonly repository: BacklogSearchRepository = new BacklogSearchRepository(), + private readonly projects: ProjectLookup = new ProjectsRepository(), + ) {} + + async search(input: BacklogSearchInput): Promise { + validateUuid(input.projetoId, "ID do projeto"); + const query = (input.q ?? "").replace(/\s+/g, " ").trim(); + if (query.length < MIN_QUERY_LENGTH) throw new ValidationError(`Digite ao menos ${MIN_QUERY_LENGTH} caracteres para buscar.`); + if (query.length > MAX_QUERY_LENGTH) throw new ValidationError(`A busca pode ter no máximo ${MAX_QUERY_LENGTH} caracteres.`); + const terms = parseTerms(query); + if (terms.length === 0) throw new ValidationError(`Digite ao menos ${MIN_QUERY_LENGTH} caracteres para buscar.`); + + if (input.status && !(BACKLOG_SEARCH_STATUSES as readonly string[]).includes(input.status)) { + throw new ValidationError("Status inválido para o filtro da busca."); + } + if (input.tecnologiaId) validateUuid(input.tecnologiaId, "ID da tecnologia"); + const limit = input.limit === undefined ? DEFAULT_LIMIT : Number(input.limit); + if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) { + throw new ValidationError(`O limite deve ser um inteiro entre 1 e ${MAX_LIMIT}.`); + } + + if (!(await this.projects.findById(input.projetoId))) throw new NotFoundError("Projeto não encontrado."); + + const rows = await this.repository.search( + input.projetoId, + likePatterns(terms), + { status: input.status || undefined, tecnologiaId: input.tecnologiaId || undefined }, + limit, + ); + + const items: BacklogSearchItem[] = rows.map((row) => { + const campo = row.titulo_match ? "titulo" : "descricao"; + const source = row.titulo_match ? row.titulo : row.descricao ?? ""; + return { + tipo: row.tipo, + id: row.id, + titulo: row.titulo, + codigo: row.codigo, + status: row.status, + campo, + trecho: makeSnippet(source, terms, campo === "titulo"), + caminho: buildPath(row), + tecnologias: row.tecnologias, + }; + }); + + return { + projeto_id: input.projetoId, + termo: query, + total: rows.length > 0 ? Number(rows[0].total) : 0, + limite: limit, + items, + }; + } +} + +export const backlogSearchService = new BacklogSearchService(); diff --git a/backend/src/modules/backlog-search/backlog-search.text.test.ts b/backend/src/modules/backlog-search/backlog-search.text.test.ts new file mode 100644 index 0000000..711d057 --- /dev/null +++ b/backend/src/modules/backlog-search/backlog-search.text.test.ts @@ -0,0 +1,49 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { escapeLike, fold, likePatterns, makeSnippet, parseTerms } from "./backlog-search.text.js"; + +test("fold ignora acentos e maiúsculas sem alterar o tamanho do texto", () => { + const text = "AÇÃO Rápida do Usuário Ñandú"; + assert.equal(fold(text), "acao rapida do usuario nandu"); + assert.equal(fold(text).length, text.length); +}); + +test("parseTerms separa palavras, remove duplicadas e termos de 1 caractere, limitando a 6", () => { + assert.deepEqual(parseTerms(" Autenticação LOGIN login "), ["autenticacao", "login"]); + assert.deepEqual(parseTerms("a b c"), []); + assert.deepEqual(parseTerms("x login"), ["login"]); + assert.equal(parseTerms("aa bb cc dd ee ff gg hh").length, 6); +}); + +test("curingas do usuário são tratados como texto literal", () => { + assert.equal(escapeLike("100%_ok\\"), "100\\%\\_ok\\\\"); + assert.deepEqual(likePatterns(["50%"]), ["%50\\%%"]); +}); + +test("trecho de título devolve o texto inteiro com todos os destaques", () => { + const snippet = makeSnippet("Cadastrar usuário no sistema", ["usuario", "sist"], true); + assert.equal(snippet.texto, "Cadastrar usuário no sistema"); + assert.deepEqual(snippet.destaques, [[10, 17], [21, 25]]); + assert.equal(snippet.texto.slice(10, 17), "usuário"); +}); + +test("trecho de descrição curta não recebe reticências e junta quebras de linha", () => { + const snippet = makeSnippet("Como um PO\nEu quero filtrar\nPara localizar", ["filtrar"], false); + assert.equal(snippet.texto, "Como um PO Eu quero filtrar Para localizar"); + assert.equal(snippet.texto.slice(snippet.destaques[0][0], snippet.destaques[0][1]), "filtrar"); +}); + +test("trecho de descrição longa centraliza o termo, corta em palavras e marca reticências", () => { + const long = `${"palavra ".repeat(40)}o termo-alvo aparece aqui ${"depois ".repeat(40)}`; + const snippet = makeSnippet(long, ["termo-alvo"], false); + assert.ok(snippet.texto.startsWith("…")); + assert.ok(snippet.texto.endsWith("…")); + assert.ok(snippet.texto.length < 200); + const [from, to] = snippet.destaques[0]; + assert.equal(snippet.texto.slice(from, to), "termo-alvo"); +}); + +test("destaques sobrepostos são unidos e ocorrências repetidas são todas marcadas", () => { + const snippet = makeSnippet("login logins", ["login", "logi"], true); + assert.deepEqual(snippet.destaques, [[0, 5], [6, 11]]); +}); diff --git a/backend/src/modules/backlog-search/backlog-search.text.ts b/backend/src/modules/backlog-search/backlog-search.text.ts new file mode 100644 index 0000000..18a917e --- /dev/null +++ b/backend/src/modules/backlog-search/backlog-search.text.ts @@ -0,0 +1,92 @@ +import type { SearchSnippet } from "./backlog-search.types.js"; + +const MARKS = /[̀-ͯ]/g; +const SNIPPET_RADIUS = 70; + +export function foldChar(character: string): string { + const folded = character.normalize("NFD").replace(MARKS, "").toLowerCase(); + return folded.length === 1 ? folded : character.toLowerCase().slice(0, 1) || character; +} + +export function fold(text: string): string { + let result = ""; + for (let index = 0; index < text.length; index += 1) result += foldChar(text[index]); + return result; +} + +export function parseTerms(query: string): string[] { + const words = fold(query.normalize("NFC")) + .split(/\s+/) + .map((word) => word.trim()) + .filter(Boolean); + const unique = [...new Set(words)]; + const meaningful = unique.filter((word) => word.length >= 2); + return (meaningful.length > 0 ? meaningful : []).slice(0, 6); +} + +export function escapeLike(term: string): string { + return term.replace(/[\\%_]/g, "\\$&"); +} + +export function likePatterns(terms: string[]): string[] { + return terms.map((term) => `%${escapeLike(term)}%`); +} + +function collapse(text: string): string { + let result = ""; + for (let index = 0; index < text.length; index += 1) result += /\s/.test(text[index]) ? " " : text[index]; + return result; +} + +function highlights(folded: string, terms: string[], offset: number, length: number): Array<[number, number]> { + const ranges: Array<[number, number]> = []; + for (const term of terms) { + let from = offset; + while (from < offset + length) { + const index = folded.indexOf(term, from); + if (index < 0 || index >= offset + length) break; + ranges.push([index - offset, Math.min(index + term.length, offset + length) - offset]); + from = index + Math.max(term.length, 1); + } + } + ranges.sort((left, right) => left[0] - right[0] || left[1] - right[1]); + const merged: Array<[number, number]> = []; + for (const range of ranges) { + const last = merged[merged.length - 1]; + if (last && range[0] <= last[1]) last[1] = Math.max(last[1], range[1]); + else merged.push([...range]); + } + return merged; +} + +export function makeSnippet(source: string, terms: string[], wholeText: boolean): SearchSnippet { + const text = collapse(source).trim(); + const folded = fold(text); + + if (wholeText || text.length <= SNIPPET_RADIUS * 2) { + return { texto: text, destaques: highlights(folded, terms, 0, text.length) }; + } + + const first = terms + .map((term) => folded.indexOf(term)) + .filter((index) => index >= 0) + .sort((left, right) => left - right)[0] ?? 0; + + let start = Math.max(0, first - SNIPPET_RADIUS); + let end = Math.min(text.length, first + SNIPPET_RADIUS); + if (start > 0) { + const space = text.indexOf(" ", start); + if (space >= 0 && space < first) start = space + 1; + } + if (end < text.length) { + const space = text.lastIndexOf(" ", end); + if (space > first) end = space; + } + + const prefix = start > 0 ? "…" : ""; + const suffix = end < text.length ? "…" : ""; + return { + texto: `${prefix}${text.slice(start, end)}${suffix}`, + destaques: highlights(folded, terms, start, end - start).map(([from, to]) => [from + prefix.length, to + prefix.length] as [number, number]), + }; +} diff --git a/backend/src/modules/backlog-search/backlog-search.types.ts b/backend/src/modules/backlog-search/backlog-search.types.ts new file mode 100644 index 0000000..a2caa90 --- /dev/null +++ b/backend/src/modules/backlog-search/backlog-search.types.ts @@ -0,0 +1,66 @@ +export type BacklogItemType = "epico" | "feature" | "pbi"; + +export const BACKLOG_SEARCH_STATUSES = ["rascunho", "ativo", "pronto", "concluido", "arquivado"] as const; + +export interface SearchPathNode { + tipo: BacklogItemType; + id: string; + titulo: string; + codigo: string | null; +} + +export interface SearchSnippet { + texto: string; + destaques: Array<[number, number]>; +} + +export interface SearchTechnology { + id: string; + nome: string; +} + +export interface BacklogSearchItem { + tipo: BacklogItemType; + id: string; + titulo: string; + codigo: string | null; + status: string; + campo: "titulo" | "descricao"; + trecho: SearchSnippet; + caminho: SearchPathNode[]; + tecnologias: SearchTechnology[]; +} + +export interface BacklogSearchResponse { + projeto_id: string; + termo: string; + total: number; + limite: number; + items: BacklogSearchItem[]; +} + +export interface BacklogSearchRow { + tipo: BacklogItemType; + id: string; + titulo: string; + descricao: string | null; + status: string; + codigo: string | null; + epico_id: string; + epico_titulo: string; + feature_id: string | null; + feature_titulo: string | null; + tecnologias: SearchTechnology[]; + titulo_match: boolean; + total: string; +} + +export interface BacklogSearchFilters { + status?: string; + tecnologiaId?: string; +} + +export const MIN_QUERY_LENGTH = 2; +export const MAX_QUERY_LENGTH = 100; +export const DEFAULT_LIMIT = 50; +export const MAX_LIMIT = 100; diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 20c3434..7d49729 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1146,6 +1146,55 @@ paths: '404': description: Critério não encontrado + /api/v1/projects/{projectId}/backlog-search: + get: + summary: Busca textual relacional no backlog do projeto (S1-17) + description: > + Pesquisa título, descrição (épico/feature), história e regras (PBI) e o + código do PBI, sem diferenciar acentos ou maiúsculas. Todos os termos + precisam aparecer (interseção) e a busca combina por interseção com + `status` e `tecnologia`. Nunca retorna itens de outro projeto. Não é + busca semântica/RAG. + operationId: searchProjectBacklog + security: + - cookieAuth: [] + parameters: + - name: projectId + in: path + required: true + schema: {type: string, format: uuid} + - name: q + in: query + required: true + description: Termo(s) com 2 a 100 caracteres + schema: {type: string, minLength: 2, maxLength: 100} + - name: status + in: query + required: false + schema: {type: string, enum: [rascunho, ativo, pronto, concluido, arquivado]} + - name: tecnologia + in: query + required: false + description: ID da tecnologia associada ao próprio item + schema: {type: string, format: uuid} + - name: limit + in: query + required: false + schema: {type: integer, minimum: 1, maximum: 100, default: 50} + responses: + '200': + description: Resultados ordenados (título antes de descrição, depois hierarquia) + content: + application/json: + schema: + $ref: '#/components/schemas/BacklogSearchResponse' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: Projeto não encontrado + /api/v1/projects/{projectId}/documents: parameters: - name: projectId @@ -2368,6 +2417,58 @@ components: id: { type: string, format: uuid } nome: { type: string } + BacklogSearchResponse: + type: object + required: [projeto_id, termo, total, limite, items] + properties: + projeto_id: {type: string, format: uuid} + termo: {type: string} + total: {type: integer, minimum: 0, description: Total de correspondências (pode exceder items)} + limite: {type: integer} + items: + type: array + items: + type: object + required: [tipo, id, titulo, status, campo, trecho, caminho, tecnologias] + properties: + tipo: {type: string, enum: [epico, feature, pbi]} + id: {type: string, format: uuid} + titulo: {type: string} + codigo: {type: string, nullable: true} + status: {type: string} + campo: {type: string, enum: [titulo, descricao]} + trecho: + type: object + required: [texto, destaques] + properties: + texto: {type: string} + destaques: + type: array + description: Faixas [inicio, fim) em unidades UTF-16 de `texto` + items: + type: array + minItems: 2 + maxItems: 2 + items: {type: integer} + caminho: + type: array + description: Épico → Feature → PBI até o nível do item + items: + type: object + required: [tipo, id, titulo] + properties: + tipo: {type: string, enum: [epico, feature, pbi]} + id: {type: string, format: uuid} + titulo: {type: string} + codigo: {type: string, nullable: true} + tecnologias: + type: array + items: + type: object + properties: + id: {type: string, format: uuid} + nome: {type: string} + ProjectDocument: type: object required: [id, projeto_id, nome, status_processamento, created_at] diff --git a/frontend/src/api/api_backlog_search.ts b/frontend/src/api/api_backlog_search.ts new file mode 100644 index 0000000..00bfc75 --- /dev/null +++ b/frontend/src/api/api_backlog_search.ts @@ -0,0 +1,96 @@ +import { ApiError, apiRequest } from "./api_auth"; +import { serverMessage } from "./api_errors"; + +export type BacklogItemType = "epico" | "feature" | "pbi"; + +export interface SearchPathNode { + tipo: BacklogItemType; + id: string; + titulo: string; + codigo: string | null; +} + +export interface BacklogSearchHit { + tipo: BacklogItemType; + id: string; + titulo: string; + codigo: string | null; + status: string; + campo: "titulo" | "descricao"; + trecho: { texto: string; destaques: Array<[number, number]> }; + caminho: SearchPathNode[]; + tecnologias: Array<{ id: string; nome: string }>; +} + +export interface BacklogSearchResult { + projeto_id: string; + termo: string; + total: number; + limite: number; + items: BacklogSearchHit[]; +} + +export const MIN_QUERY_LENGTH = 2; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function parseHit(value: unknown): BacklogSearchHit { + if (!isRecord(value) || typeof value.id !== "string" || typeof value.titulo !== "string" + || !["epico", "feature", "pbi"].includes(String(value.tipo)) + || !isRecord(value.trecho) || typeof value.trecho.texto !== "string" || !Array.isArray(value.trecho.destaques) + || !Array.isArray(value.caminho)) { + throw new Error("Resultado de busca inválido"); + } + return { + tipo: value.tipo as BacklogItemType, + id: value.id, + titulo: value.titulo, + codigo: typeof value.codigo === "string" ? value.codigo : null, + status: typeof value.status === "string" ? value.status : "", + campo: value.campo === "descricao" ? "descricao" : "titulo", + trecho: { + texto: value.trecho.texto, + destaques: (value.trecho.destaques as unknown[]).flatMap((range) => + Array.isArray(range) && typeof range[0] === "number" && typeof range[1] === "number" ? [[range[0], range[1]] as [number, number]] : []), + }, + caminho: value.caminho.flatMap((node) => + isRecord(node) && typeof node.id === "string" && typeof node.titulo === "string" && ["epico", "feature", "pbi"].includes(String(node.tipo)) + ? [{ tipo: node.tipo as BacklogItemType, id: node.id, titulo: node.titulo, codigo: typeof node.codigo === "string" ? node.codigo : null }] + : []), + tecnologias: Array.isArray(value.tecnologias) + ? value.tecnologias.flatMap((tech) => (isRecord(tech) && typeof tech.id === "string" && typeof tech.nome === "string" ? [{ id: tech.id, nome: tech.nome }] : [])) + : [], + }; +} + +export async function searchBacklog( + projectId: string, + criteria: { q: string; status?: string; tecnologiaId?: string; limit?: number }, + signal?: AbortSignal, +): Promise { + const params = new URLSearchParams({ q: criteria.q }); + if (criteria.status) params.set("status", criteria.status); + if (criteria.tecnologiaId) params.set("tecnologia", criteria.tecnologiaId); + if (criteria.limit) params.set("limit", String(criteria.limit)); + const response = await apiRequest(`/projects/${encodeURIComponent(projectId)}/backlog-search?${params.toString()}`, { signal }); + const data: unknown = await response.json(); + if (!isRecord(data) || !Array.isArray(data.items) || typeof data.total !== "number") throw new Error("Resposta de busca inválida"); + return { + projeto_id: String(data.projeto_id ?? projectId), + termo: String(data.termo ?? criteria.q), + total: data.total, + limite: typeof data.limite === "number" ? data.limite : data.items.length, + items: data.items.map(parseHit), + }; +} + +export function describeSearchError(error: unknown): string { + if (error instanceof ApiError) { + if (error.status === 404) return "Projeto não encontrado."; + if (error.status === 400) return serverMessage(error) ?? "Revise os critérios da busca."; + if (error.status === 401) return "Sua sessão expirou. Entre novamente para buscar."; + } + return "Não foi possível buscar no backlog. Tente novamente."; +} diff --git a/frontend/src/assets/styles/backlog-search.css b/frontend/src/assets/styles/backlog-search.css new file mode 100644 index 0000000..a02f883 --- /dev/null +++ b/frontend/src/assets/styles/backlog-search.css @@ -0,0 +1,21 @@ +.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-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; } +.backlog-search-item { padding: 16px 18px; display: grid; gap: 10px; } +.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-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-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 [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); } +.backlog-search-empty .ds-empty { border: 0; } diff --git a/frontend/src/backlog/Hierarchy.test.tsx b/frontend/src/backlog/Hierarchy.test.tsx index 5178b7d..eb2cbb8 100644 --- a/frontend/src/backlog/Hierarchy.test.tsx +++ b/frontend/src/backlog/Hierarchy.test.tsx @@ -288,6 +288,7 @@ describe( ).toEqual({ status: "rascunho", technologyId: "react", + query: "", }); view.unmount(); diff --git a/frontend/src/models/backlogSearch.test.ts b/frontend/src/models/backlogSearch.test.ts new file mode 100644 index 0000000..1dde7e2 --- /dev/null +++ b/frontend/src/models/backlogSearch.test.ts @@ -0,0 +1,36 @@ +import { expect, it } from "vitest"; +import { highlightParts, isSearchQuery, nodeHref, summarize } from "./backlogSearch"; + +const path = [ + { tipo: "epico" as const, id: "e1", titulo: "Épico", codigo: null }, + { tipo: "feature" as const, id: "f1", titulo: "Feature", codigo: null }, + { tipo: "pbi" as const, id: "p1", titulo: "PBI", codigo: "PBI-1" }, +]; + +it("divide o texto em trechos com e sem destaque", () => { + expect(highlightParts("Cadastrar usuário", [[10, 17]])).toEqual([ + { text: "Cadastrar ", match: false }, + { text: "usuário", match: true }, + ]); + expect(highlightParts("abc", [])).toEqual([{ text: "abc", match: false }]); + expect(highlightParts("abcdef", [[0, 2], [4, 6]]).map((part) => part.text)).toEqual(["ab", "cd", "ef"]); +}); + +it("ignora faixas inválidas, sobrepostas ou fora do texto", () => { + expect(highlightParts("abc", [[2, 2], [5, 9]])).toEqual([{ text: "abc", match: false }]); + expect(highlightParts("abcd", [[0, 3], [1, 4]]).map((part) => [part.text, part.match])).toEqual([["abc", true], ["d", true]]); +}); + +it("monta os endereços navegáveis de cada segmento do caminho", () => { + expect(nodeHref("p", path, 0)).toBe("/projects/p/epics/e1"); + expect(nodeHref("p", path, 1)).toBe("/projects/p/epics/e1/features/f1"); + expect(nodeHref("p", path, 2)).toBe("/projects/p/epics/e1/features/f1/pbis/p1"); +}); + +it("resume a quantidade de resultados e valida o tamanho mínimo da busca", () => { + expect(summarize(0, 0, "x")).toBe("Nenhum resultado para “x”."); + expect(summarize(1, 1, "login")).toBe("1 resultado para “login”."); + expect(summarize(80, 50, "login")).toBe("Mostrando 50 de 80 resultados para “login”."); + expect(isSearchQuery(" a ", 2)).toBe(false); + expect(isSearchQuery("ab", 2)).toBe(true); +}); diff --git a/frontend/src/models/backlogSearch.ts b/frontend/src/models/backlogSearch.ts new file mode 100644 index 0000000..96d153b --- /dev/null +++ b/frontend/src/models/backlogSearch.ts @@ -0,0 +1,45 @@ +import type { BacklogItemType, SearchPathNode } from "../api/api_backlog_search"; + +export const TYPE_LABEL: Record = { + epico: "Épico", + feature: "Feature", + pbi: "PBI", +}; + +export interface TextPart { + text: string; + match: boolean; +} + +export function highlightParts(text: string, ranges: Array<[number, number]>): TextPart[] { + const parts: TextPart[] = []; + let cursor = 0; + for (const [from, to] of [...ranges].sort((left, right) => left[0] - right[0])) { + const start = Math.max(from, cursor); + const end = Math.min(to, text.length); + if (start >= end) continue; + if (start > cursor) parts.push({ text: text.slice(cursor, start), match: false }); + parts.push({ text: text.slice(start, end), match: true }); + cursor = end; + } + if (cursor < text.length) parts.push({ text: text.slice(cursor), match: false }); + return parts.length > 0 ? parts : [{ text, match: false }]; +} + +export function nodeHref(projectId: string, path: SearchPathNode[], index: number): string { + const segments = [`/projects/${projectId}`]; + for (const node of path.slice(0, index + 1)) { + segments.push(node.tipo === "epico" ? `epics/${node.id}` : node.tipo === "feature" ? `features/${node.id}` : `pbis/${node.id}`); + } + return segments.join("/"); +} + +export function isSearchQuery(query: string, minimum: number): boolean { + return query.trim().length >= minimum; +} + +export function summarize(total: number, shown: number, query: string): string { + if (total === 0) return `Nenhum resultado para “${query}”.`; + const noun = total === 1 ? "resultado" : "resultados"; + return shown < total ? `Mostrando ${shown} de ${total} ${noun} para “${query}”.` : `${total} ${noun} para “${query}”.`; +} diff --git a/frontend/src/views/backlog/BacklogSearch.test.tsx b/frontend/src/views/backlog/BacklogSearch.test.tsx new file mode 100644 index 0000000..c47669d --- /dev/null +++ b/frontend/src/views/backlog/BacklogSearch.test.tsx @@ -0,0 +1,209 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { BacklogTreeView } from "./BacklogTreeView"; +import type { ProjectBacklogTree } from "../../api/api_backlog_tree"; + +const tree: ProjectBacklogTree = { + project: { id: "project-1", nome: "Sinapse", status: "ativo" }, + technologies: [{ id: "react", nome: "React" }, { id: "node", nome: "Node.js" }], + epics: [{ + id: "epic-1", titulo: "Organizar requisitos", status: "ativo", tecnologias: [], + features: [{ + id: "feature-1", titulo: "Navegação do backlog", status: "concluido", tecnologias: [{ id: "react", nome: "React" }], + pbis: [{ id: "pbi-1", codigo: "PBI-01.4.1", titulo: "Expandir árvore", status: "rascunho", tecnologias: [{ id: "react", nome: "React" }] }], + }], + }], +}; + +const hit = (overrides: Record = {}) => ({ + tipo: "pbi", id: "pbi-1", titulo: "Expandir árvore", codigo: "PBI-01.4.1", status: "rascunho", campo: "titulo", + trecho: { texto: "Expandir árvore", destaques: [[0, 8]] }, + caminho: [ + { tipo: "epico", id: "epic-1", titulo: "Organizar requisitos", codigo: null }, + { tipo: "feature", id: "feature-1", titulo: "Navegação do backlog", codigo: null }, + { tipo: "pbi", id: "pbi-1", titulo: "Expandir árvore", codigo: "PBI-01.4.1" }, + ], + tecnologias: [{ id: "react", nome: "React" }], + ...overrides, +}); + +const search = (items: unknown[], total = items.length, termo = "expandir") => ({ projeto_id: "project-1", termo, total, limite: 50, items }); +const json = (body: unknown, status = 200) => Promise.resolve(new Response(JSON.stringify(body), { status })); + +function stub(handler: (url: URL) => Promise) { + const request = vi.fn((input: RequestInfo | URL) => { + const url = new URL(String(input), "http://localhost"); + if (url.pathname === "/api/v1/projects/project-1/backlog-tree") return json(tree); + return handler(url); + }); + vi.stubGlobal("fetch", request); + return request; +} + +const searchCalls = (request: ReturnType) => + request.mock.calls.map((call) => new URL(String(call[0]), "http://localhost")).filter((url) => url.pathname.endsWith("/backlog-search")); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + window.sessionStorage.clear(); + window.history.replaceState(null, "", "/"); +}); + +const type = async (value: string) => { + fireEvent.change(await screen.findByLabelText(/Buscar no backlog/), { target: { value } }); +}; + +it("mostra o campo de busca junto aos filtros e orienta o mínimo de caracteres sem chamar a API", async () => { + const request = stub(() => json(search([]))); + render(); + + await type("a"); + expect(screen.getByText("Digite ao menos 2 caracteres.")).toBeInTheDocument(); + await new Promise((resolve) => setTimeout(resolve, 400)); + expect(searchCalls(request)).toHaveLength(0); + expect(screen.getByRole("list", { name: "Hierarquia do backlog" })).toBeInTheDocument(); + expect(screen.getByText("1 filtro ativo")).toBeInTheDocument(); +}); + +it("título encontrado retorna o item correto com trecho destacado, tipo e caminho navegável", async () => { + const request = stub(() => json(search([hit()]))); + render(); + + await type("expandir"); + const results = await screen.findByRole("region", { name: "Resultados da busca no backlog" }); + expect(searchCalls(request)[0].searchParams.get("q")).toBe("expandir"); + expect(within(results).getByText("PBI")).toBeInTheDocument(); + expect(within(results).getByText("PBI-01.4.1")).toBeInTheDocument(); + expect(within(results).getByText("Expandir", { selector: "mark" })).toBeInTheDocument(); + expect(within(results).getByRole("status")).toHaveTextContent("1 resultado para “expandir”."); + expect(screen.queryByRole("list", { name: "Hierarquia do backlog" })).toBeNull(); + + const path = within(results).getByRole("navigation", { name: "Caminho de Expandir árvore" }); + const items = within(path).getAllByRole("listitem").map((item) => item.textContent); + expect(items).toEqual(["Sinapse", "Organizar requisitos", "Navegação do backlog", "PBI-01.4.1 · Expandir árvore"]); + expect(within(path).getByText(/Expandir árvore/)).toHaveAttribute("aria-current", "page"); + + fireEvent.click(within(path).getByRole("button", { name: "Navegação do backlog" })); + expect(window.location.pathname).toBe("/projects/project-1/epics/epic-1/features/feature-1"); + fireEvent.click(within(results).getByRole("button", { name: "Expandir árvore" })); + expect(window.location.pathname).toBe("/projects/project-1/epics/epic-1/features/feature-1/pbis/pbi-1"); + fireEvent.click(within(path).getByRole("button", { name: "Organizar requisitos" })); + expect(window.location.pathname).toBe("/projects/project-1/epics/epic-1"); +}); + +it("descrição encontrada mostra o trecho da descrição e indica o campo", async () => { + stub(() => json(search([hit({ campo: "descricao", trecho: { texto: "…Eu quero recuperar a senha por e-mail…", destaques: [[10, 19]] } })], 1, "recuperar"))); + render(); + + await type("recuperar"); + const snippet = await screen.findByText("recuperar", { selector: "mark" }); + expect(snippet.closest("p")).toHaveAttribute("data-field", "descricao"); + expect(screen.getByText("Trecho da descrição:")).toBeInTheDocument(); +}); + +it("combina texto, status e tecnologia e conta os filtros ativos", async () => { + const request = stub(() => json(search([hit()]))); + render(); + + await type("expandir"); + await screen.findByRole("region", { name: "Resultados da busca no backlog" }); + fireEvent.change(screen.getByLabelText("Status"), { target: { value: "rascunho" } }); + fireEvent.change(screen.getByLabelText("Tecnologia"), { target: { value: "react" } }); + + await waitFor(() => { + const calls = searchCalls(request); + const last = calls[calls.length - 1]; + expect(last.searchParams.get("q")).toBe("expandir"); + expect(last.searchParams.get("status")).toBe("rascunho"); + expect(last.searchParams.get("tecnologia")).toBe("react"); + }); + expect(screen.getByText("3 filtros ativos")).toBeInTheDocument(); +}); + +it("a busca é sempre enviada para o projeto aberto", async () => { + const request = stub(() => json(search([]))); + render(); + await type("login"); + await waitFor(() => expect(searchCalls(request)).toHaveLength(1)); + expect(searchCalls(request)[0].pathname).toBe("/api/v1/projects/project-1/backlog-search"); +}); + +it("estado vazio permite limpar os critérios e voltar à árvore", async () => { + stub(() => json(search([], 0, "inexistente"))); + render(); + + fireEvent.change(await screen.findByLabelText("Status"), { target: { value: "rascunho" } }); + await type("inexistente"); + expect(await screen.findByText("Nenhum item encontrado")).toBeInTheDocument(); + expect(screen.getByText(/com os filtros atuais/)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Limpar só a busca" })); + expect(await screen.findByRole("list", { name: "Hierarquia do backlog" })).toBeInTheDocument(); + expect((screen.getByLabelText(/Buscar no backlog/) as HTMLInputElement).value).toBe(""); + expect(screen.getByLabelText("Status")).toHaveValue("rascunho"); + + await type("inexistente"); + fireEvent.click(await screen.findByRole("button", { name: "Limpar critérios" })); + expect(await screen.findByRole("list", { name: "Hierarquia do backlog" })).toBeInTheDocument(); + expect(screen.getByLabelText("Status")).toHaveValue(""); + expect(screen.getByText("0 filtros ativos")).toBeInTheDocument(); +}); + +it("texto e filtros persistem na sessão ao sair e voltar", async () => { + stub(() => json(search([hit()]))); + const first = render(); + await type("expandir"); + fireEvent.change(screen.getByLabelText("Status"), { target: { value: "rascunho" } }); + await screen.findByRole("region", { name: "Resultados da busca no backlog" }); + fireEvent.click(screen.getByRole("button", { name: "Expandir árvore" })); + first.unmount(); + + expect(JSON.parse(window.sessionStorage.getItem("sinapse.backlog.filters.project-1")!)).toEqual({ status: "rascunho", technologyId: "", query: "expandir" }); + render(); + expect(await screen.findByRole("region", { name: "Resultados da busca no backlog" })).toBeInTheDocument(); + expect((screen.getByLabelText(/Buscar no backlog/) as HTMLInputElement).value).toBe("expandir"); + expect(screen.getByLabelText("Status")).toHaveValue("rascunho"); +}); + +it("filtros salvos por versões anteriores (sem texto) continuam válidos", async () => { + window.sessionStorage.setItem("sinapse.backlog.filters.project-1", JSON.stringify({ status: "concluido", technologyId: "" })); + stub(() => json(search([]))); + render(); + expect(await screen.findByLabelText("Status")).toHaveValue("concluido"); + expect((screen.getByLabelText(/Buscar no backlog/) as HTMLInputElement).value).toBe(""); +}); + +it("erro da busca oferece nova tentativa e mensagens do servidor são legíveis", async () => { + let calls = 0; + stub(() => (++calls === 1 ? json({ error: "x" }, 500) : json(search([hit()])))); + render(); + + await type("expandir"); + expect((await screen.findByRole("alert")).textContent).toBe("Não foi possível buscar no backlog. Tente novamente."); + fireEvent.click(screen.getByRole("button", { name: "Tentar novamente" })); + expect(await screen.findByRole("region", { name: "Resultados da busca no backlog" })).toBeInTheDocument(); +}); + +it("resultados acima do limite avisam que a lista foi truncada", async () => { + stub(() => json(search([hit()], 80))); + render(); + await type("expandir"); + expect(await screen.findByText("Mostrando 1 de 80 resultados para “expandir”.")).toBeInTheDocument(); + expect(screen.getByText(/Há mais resultados do que o limite exibido/)).toBeInTheDocument(); +}); + +it("digitar rapidamente dispara uma única busca depois do atraso", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const request = stub(() => json(search([hit()]))); + render(); + const input = await screen.findByLabelText(/Buscar no backlog/); + + for (const value of ["ex", "exp", "expa", "expan"]) fireEvent.change(input, { target: { value } }); + expect(searchCalls(request)).toHaveLength(0); + await act(async () => { await vi.advanceTimersByTimeAsync(350); }); + await waitFor(() => expect(searchCalls(request)).toHaveLength(1)); + expect(searchCalls(request)[0].searchParams.get("q")).toBe("expan"); +}); diff --git a/frontend/src/views/backlog/BacklogSearchResults.tsx b/frontend/src/views/backlog/BacklogSearchResults.tsx new file mode 100644 index 0000000..ac5d10e --- /dev/null +++ b/frontend/src/views/backlog/BacklogSearchResults.tsx @@ -0,0 +1,141 @@ +import { useEffect, useState } from "react"; +import { describeSearchError, searchBacklog, type BacklogSearchResult } from "../../api/api_backlog_search"; +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" } + | { state: "error"; message: string } + | { state: "ready"; result: BacklogSearchResult }; + +const STATUS_LABEL: Record = { + rascunho: "Rascunho", + ativo: "Ativo", + pronto: "Pronto", + concluido: "Concluído", + arquivado: "Arquivado", +}; + +export function BacklogSearchResults({ + projectId, + projectName, + query, + status, + technologyId, + filtersActive, + onClearSearch, + onClearAll, +}: { + projectId: string; + projectName?: string; + query: string; + status: string; + technologyId: string; + filtersActive: boolean; + onClearSearch: () => void; + onClearAll: () => void; +}) { + const [state, setState] = useState({ state: "loading" }); + const [attempt, setAttempt] = useState(0); + + useEffect(() => { + const controller = new AbortController(); + setState({ state: "loading" }); + searchBacklog(projectId, { q: query, status, tecnologiaId: technologyId }, controller.signal) + .then((result) => { if (!controller.signal.aborted) setState({ state: "ready", result }); }) + .catch((error) => { if (!controller.signal.aborted) setState({ state: "error", message: describeSearchError(error) }); }); + return () => controller.abort(); + }, [projectId, query, status, technologyId, attempt]); + + if (state.state === "loading") { + return
Buscando no backlog…
; + } + + if (state.state === "error") { + return ( +
+

{state.message}

+ +
+ ); + } + + const { result } = state; + + if (result.items.length === 0) { + return ( +
+ +
+ + {filtersActive && } +
+
+
+ ); + } + + return ( +
+

{summarize(result.total, result.items.length, result.termo)}

+
    + {result.items.map((item) => { + const parts = highlightParts(item.trecho.texto, item.trecho.destaques); + const last = item.caminho.length - 1; + return ( +
  • +
    + {TYPE_LABEL[item.tipo]} + {item.codigo && {item.codigo}} + + {item.status && {STATUS_LABEL[item.status] ?? item.status}} +
    + +

    + {item.campo === "titulo" ? "Título: " : "Trecho da descrição: "} + {parts.map((part, index) => (part.match ? {part.text} : {part.text}))} +

    + + + + {item.tecnologias.length > 0 && ( +
      + {item.tecnologias.map((tech) =>
    • {tech.nome}
    • )} +
    + )} +
  • + ); + })} +
+ {result.total > result.items.length && ( + Há mais resultados do que o limite exibido. Refine o termo ou use os filtros para reduzir a lista. + )} +
+ ); +} diff --git a/frontend/src/views/backlog/BacklogTreeView.tsx b/frontend/src/views/backlog/BacklogTreeView.tsx index e76da36..d685096 100644 --- a/frontend/src/views/backlog/BacklogTreeView.tsx +++ b/frontend/src/views/backlog/BacklogTreeView.tsx @@ -20,11 +20,20 @@ import { navigate, } from "../../models/navigation"; +import { MIN_QUERY_LENGTH } from "../../api/api_backlog_search"; +import { isSearchQuery } from "../../models/backlogSearch"; +import { BacklogSearchResults } from "./BacklogSearchResults"; + export interface BacklogFilters { status: string; technologyId: string; + query: string; } +const MAX_QUERY_LENGTH = 100; + +type TreeFilters = Pick; + type Result = | { state: "loading"; @@ -42,6 +51,7 @@ const EMPTY_FILTERS: BacklogFilters = { status: "", technologyId: "", + query: "", }; const STATUS_OPTIONS = [ @@ -98,6 +108,12 @@ function readFilters( === "string" ? value.technologyId : "", + + query: + typeof value.query + === "string" + ? value.query.slice(0, MAX_QUERY_LENGTH) + : "", }; } catch { return EMPTY_FILTERS; @@ -114,7 +130,7 @@ function matches( }>; }, - filters: BacklogFilters, + filters: TreeFilters, ) { return ( ( @@ -135,7 +151,7 @@ function matches( export function filterBacklogTree( tree: ProjectBacklogTree, - filters: BacklogFilters, + filters: TreeFilters, ): ProjectBacklogTree { if ( !filters.status @@ -639,6 +655,33 @@ export function BacklogTreeView({ projectId, ]); + const [ + debouncedQuery, + setDebouncedQuery, + ] = useState( + () => filters.query.trim(), + ); + + useEffect(() => { + const timer = + window.setTimeout( + () => + setDebouncedQuery( + filters.query.trim(), + ), + 300, + ); + + return () => + window.clearTimeout(timer); + }, [filters.query]); + + const searchMode = + isSearchQuery( + debouncedQuery, + MIN_QUERY_LENGTH, + ); + const filteredTree = useMemo( () => @@ -662,6 +705,17 @@ export function BacklogTreeView({ Boolean( filters.technologyId, ), + ) + + Number( + Boolean( + filters.query.trim(), + ), + ); + + const treeFiltersActive = + Boolean(filters.status) + || Boolean( + filters.technologyId, ); const filtersActive = @@ -683,11 +737,25 @@ export function BacklogTreeView({ return next; }; - const clearFilters = () => + const clearFilters = () => { setFilters( EMPTY_FILTERS, ); + setDebouncedQuery(""); + }; + + const clearSearch = () => { + setFilters( + (current) => ({ + ...current, + query: "", + }), + ); + + setDebouncedQuery(""); + }; + return (
+ +