From 373f64d43b24c8edbe6598bac3e4bb46ba7f9315 Mon Sep 17 00:00:00 2001 From: heitors1337 Date: Wed, 21 Oct 2026 19:57:57 -0300 Subject: [PATCH 1/4] =?UTF-8?q?feat=20(backend):=20implementa=C3=A7=C3=A3o?= =?UTF-8?q?=20do=20backend=20da=20tarefa=20S2-12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Basicamente consiste em uma busca aberta a todos os perfis (devs, PO e etc.) e devolve no máximo 5 itens, facilitando caso você queira usar uma funcionalidade de projeto x em y. Além disso foi removido bugs e adicionado outros. --- backend/src/app.ts | 12 +- backend/src/config/env.ts | 2 + backend/src/index.ts | 2 - .../similar-items/similar-items.db.test.ts | 172 ++++++++++++++++ .../similar-items/similar-items.fakes.ts | 156 +++++++++++++++ .../similar-items/similar-items.repository.ts | 189 ++++++++++++++++++ .../similar-items.routes.test.ts | 104 ++++++++++ .../similar-items/similar-items.routes.ts | 51 +++++ .../similar-items.service.test.ts | 147 ++++++++++++++ .../similar-items/similar-items.service.ts | 106 ++++++++++ .../similar-items/similar-items.types.ts | 105 ++++++++++ .../migrations/020_similar_item_reuse.sql | 26 +++ 12 files changed, 1064 insertions(+), 8 deletions(-) create mode 100644 backend/src/modules/similar-items/similar-items.db.test.ts create mode 100644 backend/src/modules/similar-items/similar-items.fakes.ts create mode 100644 backend/src/modules/similar-items/similar-items.repository.ts create mode 100644 backend/src/modules/similar-items/similar-items.routes.test.ts create mode 100644 backend/src/modules/similar-items/similar-items.routes.ts create mode 100644 backend/src/modules/similar-items/similar-items.service.test.ts create mode 100644 backend/src/modules/similar-items/similar-items.service.ts create mode 100644 backend/src/modules/similar-items/similar-items.types.ts create mode 100644 database/migrations/020_similar_item_reuse.sql diff --git a/backend/src/app.ts b/backend/src/app.ts index aadcc43..67164e4 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -18,6 +18,7 @@ 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 { epicSuggestionsRouter, featureSuggestionsRouter, pbiSuggestionsRouter } from "./modules/suggestions/suggestions.routes.js"; +import { epicSimilarItemsRouter, featureSimilarItemsRouter, pbiSimilarItemsRouter, projectSimilarItemsRouter } from "./modules/similar-items/similar-items.routes.js"; import { backlogSearchRouter } from "./modules/backlog-search/backlog-search.routes.js"; import { documentsRouter } from "./modules/documents/documents.routes.js"; import { chunksRouter } from "./modules/documents/chunks.routes.js"; @@ -39,7 +40,6 @@ 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; @@ -59,15 +59,10 @@ app.get("/health", async (_req: Request, res: Response) => { 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); @@ -105,6 +100,11 @@ app.use("/api/v1/epics/:entityId/suggestions", epicSuggestionsRouter); app.use("/api/v1/features/:entityId/suggestions", featureSuggestionsRouter); app.use("/api/v1/pbis/:entityId/suggestions", pbiSuggestionsRouter); +app.use("/api/v1/projects/:projectId/similar-items", projectSimilarItemsRouter); +app.use("/api/v1/epics/:entityId/similar-items", epicSimilarItemsRouter); +app.use("/api/v1/features/:entityId/similar-items", featureSimilarItemsRouter); +app.use("/api/v1/pbis/:entityId/similar-items", pbiSimilarItemsRouter); + // Busca textual no backlog (S1-17) app.use("/api/v1/projects/:projectId/backlog-search", backlogSearchRouter); diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 492f164..f152c74 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -23,6 +23,8 @@ const envSchema = z.object({ REPO_ANALYZER_URL: z.string().default('http://localhost:8000'), SEARCH_MIN_VECTOR_SIMILARITY: z.coerce.number().min(0).max(1).default(0.55), SEARCH_MIN_TEXT_RANK: z.coerce.number().min(0).max(1).default(0.05), + // S2-12: limiar mais alto que o da busca para não forçar sugestões pouco relacionadas. + SIMILAR_MIN_VECTOR_SIMILARITY: z.coerce.number().min(0).max(1).default(0.72), // S1-01 — Autenticação e segurança AUTH_MAX_LOGIN_ATTEMPTS: z.coerce.number().int().min(1).default(5), diff --git a/backend/src/index.ts b/backend/src/index.ts index e99d10e..fb6a003 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -8,8 +8,6 @@ export { app }; const PORT = env.PORT; if (env.NODE_ENV !== "test") { - // Worker unico pos-merge S2-01: processa documentos pendentes via claim/lease - // (processPendingDocuments) + flush de eventos + reconciliacao de arquivos. startDocumentsBackgroundWorker(); startIndexingWorker(); startRepoAnalysesWorker(); diff --git a/backend/src/modules/similar-items/similar-items.db.test.ts b/backend/src/modules/similar-items/similar-items.db.test.ts new file mode 100644 index 0000000..7536922 --- /dev/null +++ b/backend/src/modules/similar-items/similar-items.db.test.ts @@ -0,0 +1,172 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { Pool } from "pg"; +import { validateTarget } from "../../database/seed-lib.js"; +import { ArchiveConflict } from "../projects/archive.types.js"; +import { lockHierarchy } from "../projects/hierarchy-archive.js"; +import { SuggestionsRepository } from "../suggestions/suggestions.repository.js"; +import { SuggestionsService } from "../suggestions/suggestions.service.js"; +import { ConflictError, NotFoundError } from "../../shared/errors.js"; +import { SimilarItemsRepository } from "./similar-items.repository.js"; +import { SimilarItemsService } from "./similar-items.service.js"; +import { FakeEmbeddings, VALID_VECTOR } from "./similar-items.fakes.js"; + +const literal = (vector: number[]) => `[${vector.join(",")}]`; +const DIFFERENT_VECTOR = VALID_VECTOR.map((_, index) => (index % 7 === 3 ? 0.9 : 0.01)); +const DRAFT = { tipo: "pbi", titulo: "Validar login do usuário", descricao: "Entrar com e-mail e senha" }; + +test("S2-12: itens semelhantes entre projetos, aproveitamento com origem e visibilidade no PostgreSQL", { skip: !process.env.ARCHIVE_TEST_DATABASE_URL }, async (t) => { + const pool = new Pool({ connectionString: validateTarget(process.env.ARCHIVE_TEST_DATABASE_URL, "test") }); + const embeddings = new FakeEmbeddings(); + const service = new SimilarItemsService(new SimilarItemsRepository(pool), embeddings, 0.72); + const suggestions = new SuggestionsService(new SuggestionsRepository(pool)); + + const [po, devA, devOther, developerA, projectA, projectB, projectArchived] = Array.from({ length: 7 }, () => randomUUID()); + const ids = { + a: { epic: randomUUID(), feature: randomUUID(), target: randomUUID(), sibling: randomUUID() }, + b: { epic: randomUUID(), feature: randomUUID(), similar: randomUUID(), unrelated: randomUUID(), archivedItem: randomUUID() }, + z: { epic: randomUUID(), feature: randomUUID(), similar: randomUUID() }, + }; + + const insertTree = async (project: string, tree: { epic: string; feature: string }) => { + await pool.query("INSERT INTO epico (id,projeto_id,titulo) VALUES ($1,$2,'Épico')", [tree.epic, project]); + await pool.query("INSERT INTO feature (id,epico_id,titulo) VALUES ($1,$2,'Feature de acesso')", [tree.feature, tree.epic]); + }; + const insertPbi = async (id: string, feature: string, codigo: string, titulo: string, status = "rascunho") => { + 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,$3,$4,'usuário cadastrado','entrar com e-mail e senha','acessar minhas informações','Bloquear após 5 tentativas',$5)`, + [id, feature, codigo, titulo, status], + ); + }; + const insertChunk = async (project: string, id: string, vector: number[], texto = "Validar login com e-mail e senha") => { + await pool.query( + "INSERT INTO chunk (projeto_id, entidade_tipo, entidade_id, texto, embedding) VALUES ($1,'pbi',$2,$3,$4::vector)", + [project, id, texto, literal(vector)], + ); + }; + + try { + await pool.query("INSERT INTO usuario (id,nome,email,senha_hash,role) VALUES ($1,'Ana PO',$2,'h','po')", [po, `${po}@similares.test`]); + await pool.query("INSERT INTO usuario (id,nome,email,senha_hash,role) VALUES ($1,'Dev A',$2,'h','dev')", [devA, `${devA}@similares.test`]); + await pool.query("INSERT INTO usuario (id,nome,email,senha_hash,role) VALUES ($1,'Dev sem alocação',$2,'h','dev')", [devOther, `${devOther}@similares.test`]); + await pool.query("INSERT INTO desenvolvedor (id,usuario_id) VALUES ($1,$2)", [developerA, devA]); + await pool.query("INSERT INTO projeto (id,nome,cliente,status) VALUES ($1,'Projeto A','T','ativo'),($2,'Portal do Cliente','T','ativo'),($3,'Projeto Antigo','T','ativo')", [projectA, projectB, projectArchived]); + await pool.query("INSERT INTO alocacao (desenvolvedor_id,projeto_id) VALUES ($1,$2)", [developerA, projectA]); + await insertTree(projectA, ids.a); + await insertTree(projectB, ids.b); + await insertTree(projectArchived, ids.z); + await insertPbi(ids.a.target, ids.a.feature, `PBI-${randomUUID().slice(0, 8)}`, "Rascunho do login"); + await insertPbi(ids.a.sibling, ids.a.feature, `PBI-${randomUUID().slice(0, 8)}`, "Login já concluído no mesmo projeto", "concluido"); + await insertPbi(ids.b.similar, ids.b.feature, `PBI-${randomUUID().slice(0, 8)}`, "Validar login com e-mail", "concluido"); + await insertPbi(ids.b.unrelated, ids.b.feature, `PBI-${randomUUID().slice(0, 8)}`, "Exportar relatório mensal", "concluido"); + await insertPbi(ids.b.archivedItem, ids.b.feature, `PBI-${randomUUID().slice(0, 8)}`, "Login arquivado", "arquivado"); + await insertPbi(ids.z.similar, ids.z.feature, `PBI-${randomUUID().slice(0, 8)}`, "Login em projeto arquivado", "concluido"); + await insertChunk(projectB, ids.b.similar, VALID_VECTOR); + await insertChunk(projectB, ids.b.similar, VALID_VECTOR, "Segundo trecho do mesmo item"); + await insertChunk(projectB, ids.b.unrelated, DIFFERENT_VECTOR, "Relatório mensal"); + await insertChunk(projectB, ids.b.archivedItem, VALID_VECTOR); + await insertChunk(projectA, ids.a.sibling, VALID_VECTOR, "Login concluído"); + await insertChunk(projectArchived, ids.z.similar, VALID_VECTOR); + await pool.query("UPDATE projeto SET status='arquivado' WHERE id=$1", [projectArchived]); + + await t.test("sugere de outro projeto, uma vez por item, sem itens arquivados, abaixo do limiar ou de projeto arquivado", async () => { + const result = await service.search(projectA, po, { ...DRAFT, excluir_id: ids.a.target }); + const found = result.items.map((item) => item.id).sort(); + assert.deepEqual(found, [ids.a.sibling, ids.b.similar].sort()); + const other = result.items.find((item) => item.id === ids.b.similar)!; + assert.equal(other.outro_projeto, true); + assert.equal(other.origem, "Portal do Cliente › Épico › Feature de acesso"); + assert.equal(other.similaridade, 1); + assert.equal(result.items.find((item) => item.id === ids.a.sibling)!.outro_projeto, false); + }); + + await t.test("item em edição nunca é sugerido a si mesmo", async () => { + const result = await service.search(projectA, po, { ...DRAFT, excluir_id: ids.a.sibling }); + assert.ok(result.items.every((item) => item.id !== ids.a.sibling)); + }); + + await t.test("respeita o acesso por projeto: dev só vê projetos em que está alocado", async () => { + const allocatedToA = await service.search(projectA, devA, DRAFT); + assert.deepEqual(allocatedToA.items.map((item) => item.id), [ids.a.sibling], "sem acesso ao projeto B"); + const noAllocation = await service.search(projectA, devOther, DRAFT); + assert.deepEqual(noAllocation.items, []); + await pool.query("INSERT INTO alocacao (desenvolvedor_id,projeto_id) VALUES ($1,$2)", [developerA, projectB]); + const allocatedToBoth = await service.search(projectA, devA, DRAFT); + assert.ok(allocatedToBoth.items.some((item) => item.id === ids.b.similar)); + await pool.query("DELETE FROM alocacao WHERE desenvolvedor_id=$1 AND projeto_id=$2", [developerA, projectB]); + }); + + await t.test("aproveitar cria propostas pendentes com origem, não altera o item e registra a relação", async () => { + const before = (await pool.query("SELECT titulo, historia_como_um, provenance_json FROM pbi WHERE id=$1", [ids.a.target])).rows[0]; + const reused = await service.reuse("pbi", ids.a.target, ids.b.similar, po, {}); + + assert.deepEqual(reused.sugestoes.map((item) => item.campo).sort(), ["historia_como_um", "historia_eu_quero", "historia_para_que", "regras_observacoes", "titulo"]); + assert.ok(reused.sugestoes.every((item) => item.status === "pendente" && item.origem === `similar:pbi:${ids.b.similar}`)); + assert.equal(reused.relacao.tipo_relacao, "reaproveitado_de"); + assert.equal(reused.relacao.origem_projeto_id, projectB); + assert.equal(reused.relacao.destino_projeto_id, projectA); + + const after = (await pool.query("SELECT titulo, historia_como_um, provenance_json FROM pbi WHERE id=$1", [ids.a.target])).rows[0]; + assert.deepEqual(after, before, "a IA nunca grava na tabela de negócio"); + + const audit = (await pool.query("SELECT dados_json FROM auditoria WHERE entidade_id=$1 AND acao='APROVEITAR_ITEM_SIMILAR'", [ids.a.target])).rows[0]; + assert.equal(audit.dados_json.origem_id, ids.b.similar); + }); + + await t.test("repetir o aproveitamento é idempotente e a busca passa a marcar ja_aproveitado", async () => { + const first = (await pool.query("SELECT id FROM sugestao_ia WHERE entidade_id=$1 AND campo='titulo' AND status='pendente'", [ids.a.target])).rows[0].id; + await service.reuse("pbi", ids.a.target, ids.b.similar, po, { campos: ["titulo"] }); + const rows = (await pool.query("SELECT id FROM sugestao_ia WHERE entidade_id=$1 AND campo='titulo'", [ids.a.target])).rows; + assert.deepEqual(rows.map((row) => row.id), [first]); + assert.equal((await pool.query("SELECT 1 FROM reaproveitamento_item WHERE destino_id=$1 AND origem_id=$2", [ids.a.target, ids.b.similar])).rowCount, 1); + + const result = await service.search(projectA, po, { ...DRAFT, excluir_id: ids.a.target }); + assert.equal(result.items.find((item) => item.id === ids.b.similar)!.ja_aproveitado, true); + }); + + await t.test("a proposta aproveitada segue o ciclo S2-13: aceitar grava com proveniência ai-accepted", async () => { + const pending = (await pool.query("SELECT id FROM sugestao_ia WHERE entidade_id=$1 AND campo='titulo' AND status='pendente'", [ids.a.target])).rows[0]; + const accepted = await suggestions.accept("pbi", ids.a.target, pending.id, po, {}); + assert.equal(accepted.status, "aceita"); + const row = (await pool.query("SELECT titulo, provenance_json FROM pbi WHERE id=$1", [ids.a.target])).rows[0]; + assert.equal(row.titulo, "Validar login com e-mail"); + assert.equal(row.provenance_json.titulo, "ai-accepted"); + }); + + await t.test("origem inacessível ao usuário, arquivada ou do próprio item é recusada", async () => { + await assert.rejects(service.reuse("pbi", ids.a.target, ids.b.similar, devOther, {}), NotFoundError); + await assert.rejects(service.reuse("pbi", ids.a.target, ids.b.archivedItem, po, {}), ConflictError); + await assert.rejects(service.reuse("pbi", ids.a.target, ids.z.similar, po, {}), ConflictError); + await assert.rejects(service.reuse("pbi", ids.a.target, randomUUID(), po, {}), NotFoundError); + }); + + await t.test("corrida com o arquivamento do item em edição resulta em conflito e não deixa relação", async () => { + const racing = randomUUID(); + await insertPbi(racing, ids.a.feature, `PBI-${randomUUID().slice(0, 8)}`, "Item que será arquivado"); + const archiver = await pool.connect(); + try { + await archiver.query("BEGIN"); + await lockHierarchy(archiver, "pbi", racing); + await archiver.query("UPDATE pbi SET status='arquivado' WHERE id=$1", [racing]); + const attempt = service.reuse("pbi", racing, ids.b.similar, po, {}); + await new Promise((resolve) => setTimeout(resolve, 150)); + await archiver.query("COMMIT"); + await assert.rejects(attempt, ArchiveConflict); + } finally { + archiver.release(); + } + assert.equal((await pool.query("SELECT 1 FROM reaproveitamento_item WHERE destino_id=$1", [racing])).rowCount, 0); + assert.equal((await pool.query("SELECT 1 FROM sugestao_ia WHERE entidade_id=$1", [racing])).rowCount, 0); + }); + } finally { + const entityIds = [...Object.values(ids.a), ...Object.values(ids.b), ...Object.values(ids.z)]; + await pool.query("DELETE FROM sugestao_ia WHERE entidade_id = ANY($1::uuid[])", [entityIds]); + await pool.query("DELETE FROM pbi_versao WHERE pbi_id = ANY($1::uuid[])", [entityIds]); + await pool.query("DELETE FROM auditoria WHERE usuario_id = ANY($1::uuid[])", [[po, devA, devOther]]); + await pool.query("DELETE FROM projeto WHERE id = ANY($1::uuid[])", [[projectA, projectB, projectArchived]]); + await pool.query("DELETE FROM usuario WHERE id = ANY($1::uuid[])", [[po, devA, devOther]]); + await pool.end(); + } +}); diff --git a/backend/src/modules/similar-items/similar-items.fakes.ts b/backend/src/modules/similar-items/similar-items.fakes.ts new file mode 100644 index 0000000..7fd5e22 --- /dev/null +++ b/backend/src/modules/similar-items/similar-items.fakes.ts @@ -0,0 +1,156 @@ +import { ArchiveConflict } from "../projects/archive.types.js"; +import { ConflictError, NotFoundError, ValidationError } from "../../shared/errors.js"; +import { SUGGESTIBLE_FIELDS, maxLengthFor } from "../provenance/provenance.js"; +import type { SuggestionRow } from "../suggestions/suggestions.types.js"; +import type { SearchEmbeddingClient } from "../search/search.service.js"; +import { SimilarItemsRepository } from "./similar-items.repository.js"; +import { + SIMILAR_RELATION_TYPE, + similarOriginLabel, + type FindSimilarInput, + type ReuseInput, + type ReuseRelation, + type ReuseResult, + type SimilarItemRow, +} from "./similar-items.types.js"; + +export const PROJECT_A = "a1000000-0000-4000-8000-000000000001"; +export const PROJECT_B = "a1000000-0000-4000-8000-000000000002"; +export const UNKNOWN_PROJECT = "a1000000-0000-4000-8000-00000000ffff"; +export const TARGET_PBI = "a2000000-0000-4000-8000-000000000001"; +export const ORIGIN_PBI = "a2000000-0000-4000-8000-000000000002"; +export const ARCHIVED_ORIGIN_PBI = "a2000000-0000-4000-8000-000000000003"; +export const UNKNOWN_PBI = "a2000000-0000-4000-8000-00000000ffff"; +export const USER = "b0000000-0000-4000-8000-000000000001"; + +export const VALID_VECTOR = Array.from({ length: 1024 }, (_, index) => (index % 7 === 0 ? 0.5 : 0.1)); + +export class FakeEmbeddings implements SearchEmbeddingClient { + public calls: string[] = []; + public failure: Error | null = null; + public vector: number[] = VALID_VECTOR; + + async embed(text: string): Promise { + this.calls.push(text); + if (this.failure) throw this.failure; + return this.vector; + } +} + +interface FakeOrigin { projeto_id: string; arquivado: boolean; dados: Record } + +export class FakeSimilarItemsRepository extends SimilarItemsRepository { + public projects = new Set([PROJECT_A, PROJECT_B]); + public candidates: SimilarItemRow[] = []; + public lastFind: FindSimilarInput | null = null; + public archivedTargets = new Set(); + public origins = new Map(); + public relations: ReuseRelation[] = []; + public suggestionRows: SuggestionRow[] = []; + private sequence = 0; + + constructor() { + super(); + } + + async projectExists(projectId: string): Promise { + return this.projects.has(projectId); + } + + async findSimilar(input: FindSimilarInput): Promise { + this.lastFind = input; + return this.candidates + .filter((row) => row.tipo === input.tipo && row.id !== input.excluirId && row.similaridade >= input.minimoSimilaridade) + .sort((left, right) => right.similaridade - left.similaridade) + .slice(0, input.limite); + } + + async reuse(input: ReuseInput): Promise { + if (this.archivedTargets.has(input.destinoId)) { + throw new ArchiveConflict("Item ou ancestral arquivado está disponível apenas para leitura."); + } + const origin = this.origins.get(input.origemId); + if (!origin) throw new NotFoundError("Item de origem não encontrado."); + if (origin.arquivado) throw new ConflictError("O item de origem está arquivado e não pode ser aproveitado."); + + const wanted = input.campos ?? [...SUGGESTIBLE_FIELDS[input.tipo]]; + const copied = wanted.flatMap((campo) => { + const raw = origin.dados[campo]; + const valor = typeof raw === "string" ? raw.trim() : ""; + return valor && valor.length <= maxLengthFor(campo) ? [{ campo, valor }] : []; + }); + if (copied.length === 0) throw new ValidationError("O item de origem não possui conteúdo aproveitável nos campos selecionados."); + + const sugestoes = copied.map(({ campo, valor }) => { + const pending = this.suggestionRows.find((row) => row.status === "pendente" && row.entidade_id === input.destinoId && row.campo === campo); + if (pending) { + pending.valor_sugerido = valor; + pending.origem = similarOriginLabel(input.tipo, input.origemId); + return pending; + } + this.sequence += 1; + const row: SuggestionRow = { + id: `c1000000-0000-4000-8000-${String(this.sequence).padStart(12, "0")}`, + entidade_tipo: input.tipo, + entidade_id: input.destinoId, + campo, + valor_sugerido: valor, + valor_resolvido: null, + origem: similarOriginLabel(input.tipo, input.origemId), + status: "pendente", + criado_por: input.usuarioId, + criado_por_nome: "Ana PO", + resolvido_por: null, + resolvido_por_nome: null, + resolvido_em: null, + created_at: new Date(Date.UTC(2026, 9, 21, 10, this.sequence)).toISOString(), + updated_at: new Date(Date.UTC(2026, 9, 21, 10, this.sequence)).toISOString(), + }; + this.suggestionRows.push(row); + return row; + }); + + const campos = copied.map((item) => item.campo); + let relacao = this.relations.find((row) => row.destino_id === input.destinoId && row.origem_id === input.origemId); + if (relacao) { + relacao.campos = [...new Set([...relacao.campos, ...campos])].sort(); + } else { + relacao = { + id: `d1000000-0000-4000-8000-${String(this.relations.length + 1).padStart(12, "0")}`, + tipo_relacao: SIMILAR_RELATION_TYPE, + destino_tipo: input.tipo, + destino_id: input.destinoId, + destino_projeto_id: PROJECT_A, + origem_tipo: input.tipo, + origem_id: input.origemId, + origem_projeto_id: origin.projeto_id, + campos: [...campos].sort(), + criado_por: input.usuarioId, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + this.relations.push(relacao); + } + return { relacao, sugestoes }; + } +} + +export function similarRow(overrides: Partial = {}): SimilarItemRow { + return { + tipo: "pbi", + id: ORIGIN_PBI, + codigo: "PBI-01.1.1", + titulo: "Validar login com e-mail", + status: "concluido", + similaridade: 0.86, + trecho: "Como um usuário, eu quero entrar com e-mail e senha", + projeto_id: PROJECT_B, + projeto_nome: "Portal do Cliente", + epico_id: "e1000000-0000-4000-8000-000000000001", + epico_titulo: "Acesso e autenticação", + feature_id: "f1000000-0000-4000-8000-000000000001", + feature_titulo: "Login", + ja_aproveitado: false, + ...overrides, + }; +} diff --git a/backend/src/modules/similar-items/similar-items.repository.ts b/backend/src/modules/similar-items/similar-items.repository.ts new file mode 100644 index 0000000..0cc0087 --- /dev/null +++ b/backend/src/modules/similar-items/similar-items.repository.ts @@ -0,0 +1,189 @@ +import { Pool } from "pg"; +import { pool } from "../../database/db.js"; +import { ConflictError, NotFoundError, ValidationError } from "../../shared/errors.js"; +import { auditService } from "../audit/audit.service.js"; +import { assertWritable, lockHierarchy } from "../projects/hierarchy-archive.js"; +import { projectAccessSql } from "../projects/project-access.js"; +import { SUGGESTIBLE_FIELDS, maxLengthFor } from "../provenance/provenance.js"; +import { SuggestionsRepository } from "../suggestions/suggestions.repository.js"; +import type { SuggestionRow } from "../suggestions/suggestions.types.js"; +import { + SIMILAR_RELATION_TYPE, + similarOriginLabel, + type FindSimilarInput, + type ReuseInput, + type ReuseRelation, + type ReuseResult, + type SimilarEntityType, + type SimilarItemRow, +} from "./similar-items.types.js"; + +const CANDIDATE_POOL = 100; +const SNIPPET_LENGTH = 240; +const ORIGIN_SQL: Record = { + epico: `SELECT to_jsonb(o) AS dados, o.projeto_id, + (o.status = 'arquivado' OR p.status = 'arquivado') AS arquivado + FROM epico o JOIN projeto p ON p.id = o.projeto_id WHERE o.id = $1`, + feature: `SELECT to_jsonb(o) AS dados, e.projeto_id, + (o.status = 'arquivado' OR e.status = 'arquivado' OR p.status = 'arquivado') AS arquivado + FROM feature o JOIN epico e ON e.id = o.epico_id JOIN projeto p ON p.id = e.projeto_id WHERE o.id = $1`, + pbi: `SELECT to_jsonb(o) AS dados, e.projeto_id, + (o.status = 'arquivado' OR f.status = 'arquivado' OR e.status = 'arquivado' OR p.status = 'arquivado') AS arquivado + FROM pbi o JOIN feature f ON f.id = o.feature_id JOIN epico e ON e.id = f.epico_id + JOIN projeto p ON p.id = e.projeto_id WHERE o.id = $1`, +}; + +export class SimilarItemsRepository { + private readonly pool: Pool; + private readonly suggestions: SuggestionsRepository; + + constructor(customPool?: Pool) { + this.pool = customPool ?? pool; + this.suggestions = new SuggestionsRepository(this.pool); + } + + async projectExists(projectId: string): Promise { + const result = await this.pool.query("SELECT 1 FROM projeto WHERE id = $1", [projectId]); + return (result.rowCount ?? 0) > 0; + } + + async findSimilar(input: FindSimilarInput): Promise { + const vector = `[${input.vector.join(",")}]`; + const result = await this.pool.query( + `WITH candidatos AS ( + SELECT c.entidade_id, c.projeto_id, c.texto, 1 - (c.embedding <=> $1::vector) AS similaridade + FROM chunk c + WHERE c.entidade_tipo = $2::text + AND c.embedding IS NOT NULL + AND ($3::uuid IS NULL OR c.entidade_id <> $3::uuid) + AND ${projectAccessSql("c.projeto_id", "$4")} + ORDER BY c.embedding <=> $1::vector, c.id + LIMIT ${CANDIDATE_POOL} + ), melhores AS ( + SELECT DISTINCT ON (entidade_id) entidade_id, projeto_id, texto, similaridade + FROM candidatos + WHERE similaridade >= $5 + ORDER BY entidade_id, similaridade DESC + ) + SELECT $2::text AS tipo, m.entidade_id AS id, b.codigo, + COALESCE(e.titulo, f.titulo, b.titulo) AS titulo, + COALESCE(e.status, f.status, b.status) AS status, + m.similaridade::float8 AS similaridade, + left(m.texto, ${SNIPPET_LENGTH}) AS trecho, + m.projeto_id, p.nome AS projeto_nome, + ep.id AS epico_id, ep.titulo AS epico_titulo, + ft.id AS feature_id, ft.titulo AS feature_titulo, + EXISTS ( + SELECT 1 FROM reaproveitamento_item r + WHERE $3::uuid IS NOT NULL AND r.destino_tipo = $2::text AND r.destino_id = $3::uuid + AND r.origem_tipo = $2::text AND r.origem_id = m.entidade_id + ) AS ja_aproveitado + FROM melhores m + JOIN projeto p ON p.id = m.projeto_id + LEFT JOIN epico e ON $2::text = 'epico' AND e.id = m.entidade_id + LEFT JOIN feature f ON $2::text = 'feature' AND f.id = m.entidade_id + LEFT JOIN pbi b ON $2::text = 'pbi' AND b.id = m.entidade_id + LEFT JOIN feature bf ON bf.id = b.feature_id + LEFT JOIN feature ft ON ft.id = COALESCE(f.id, bf.id) + LEFT JOIN epico ep ON ep.id = COALESCE(e.id, f.epico_id, bf.epico_id) + WHERE p.status <> 'arquivado' + AND COALESCE(e.status, f.status, b.status) IS NOT NULL + AND COALESCE(e.status, f.status, b.status) <> 'arquivado' + AND (ep.id IS NULL OR ep.status <> 'arquivado') + AND (ft.id IS NULL OR ft.status <> 'arquivado') + ORDER BY m.similaridade DESC, m.entidade_id + LIMIT $6`, + [vector, input.tipo, input.excluirId, input.usuarioId, input.minimoSimilaridade, input.limite], + ); + return result.rows; + } + + async reuse(input: ReuseInput): Promise { + const client = await this.pool.connect(); + let relation: ReuseRelation; + const suggestionIds: string[] = []; + try { + await client.query("BEGIN"); + await lockHierarchy(client, input.tipo, input.destinoId); + await assertWritable(client, input.tipo, input.destinoId); + + const target = await client.query<{ projeto_id: string }>(ORIGIN_SQL[input.tipo], [input.destinoId]); + if (!target.rows[0]) throw new NotFoundError(); + + const origin = await client.query<{ dados: Record; projeto_id: string; arquivado: boolean }>( + `SELECT scope.* FROM (${ORIGIN_SQL[input.tipo]}) scope WHERE ${projectAccessSql("scope.projeto_id", "$2")}`, + [input.origemId, input.usuarioId], + ); + if (!origin.rows[0]) throw new NotFoundError("Item de origem não encontrado."); + if (origin.rows[0].arquivado) throw new ConflictError("O item de origem está arquivado e não pode ser aproveitado."); + + const wanted = input.campos ?? [...SUGGESTIBLE_FIELDS[input.tipo]]; + const copied: Array<{ campo: string; valor: string }> = []; + for (const campo of wanted) { + const raw = origin.rows[0].dados[campo]; + const valor = typeof raw === "string" ? raw.trim() : ""; + if (valor && valor.length <= maxLengthFor(campo)) copied.push({ campo, valor }); + } + if (copied.length === 0) { + throw new ValidationError("O item de origem não possui conteúdo aproveitável nos campos selecionados."); + } + + const origem = similarOriginLabel(input.tipo, input.origemId); + for (const { campo, valor } of copied) { + const inserted = await client.query<{ id: string }>( + `INSERT INTO sugestao_ia (entidade_tipo, entidade_id, campo, valor_sugerido, origem, criado_por) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (entidade_tipo, entidade_id, campo) WHERE status = 'pendente' + DO UPDATE SET valor_sugerido = EXCLUDED.valor_sugerido, origem = EXCLUDED.origem, + criado_por = EXCLUDED.criado_por, updated_at = CURRENT_TIMESTAMP + RETURNING id`, + [input.tipo, input.destinoId, campo, valor, origem, input.usuarioId], + ); + suggestionIds.push(inserted.rows[0].id); + } + + const campos = copied.map((item) => item.campo); + const saved = await client.query( + `INSERT INTO reaproveitamento_item + (tipo_relacao, destino_tipo, destino_id, destino_projeto_id, origem_tipo, origem_id, origem_projeto_id, campos, criado_por) + VALUES ($1, $2, $3, $4, $2, $5, $6, $7::text[], $8) + ON CONFLICT (destino_tipo, destino_id, origem_tipo, origem_id, tipo_relacao) + DO UPDATE SET + campos = ARRAY(SELECT DISTINCT unnest(reaproveitamento_item.campos || EXCLUDED.campos) ORDER BY 1), + updated_at = CURRENT_TIMESTAMP + RETURNING id, tipo_relacao, destino_tipo, destino_id, destino_projeto_id, origem_tipo, origem_id, + origem_projeto_id, campos, criado_por, created_at::text AS created_at, updated_at::text AS updated_at`, + [SIMILAR_RELATION_TYPE, input.tipo, input.destinoId, target.rows[0].projeto_id, input.origemId, origin.rows[0].projeto_id, campos, input.usuarioId], + ); + relation = saved.rows[0]; + + await auditService.record({ + usuario_id: input.usuarioId, + entidade_tipo: input.tipo, + entidade_id: input.destinoId, + acao: "APROVEITAR_ITEM_SIMILAR", + dados_json: { + origem_tipo: input.tipo, + origem_id: input.origemId, + origem_projeto_id: origin.rows[0].projeto_id, + campos, + sugestoes_ids: suggestionIds, + }, + }, client); + + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + + const sugestoes: SuggestionRow[] = []; + for (const id of suggestionIds) { + const row = await this.suggestions.findOne(input.tipo, input.destinoId, id); + if (row) sugestoes.push(row); + } + return { relacao: relation, sugestoes }; + } +} diff --git a/backend/src/modules/similar-items/similar-items.routes.test.ts b/backend/src/modules/similar-items/similar-items.routes.test.ts new file mode 100644 index 0000000..a6f2d26 --- /dev/null +++ b/backend/src/modules/similar-items/similar-items.routes.test.ts @@ -0,0 +1,104 @@ +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 { requireRole } from "../../middleware/requireRole.js"; +import type { UserRole } from "../auth/auth.types.js"; +import { createProjectSimilarItemsRouter, createReuseSimilarItemRouter } from "./similar-items.routes.js"; +import { SimilarItemsService } from "./similar-items.service.js"; +import { FakeEmbeddings, FakeSimilarItemsRepository, ORIGIN_PBI, PROJECT_A, PROJECT_B, TARGET_PBI, UNKNOWN_PBI, USER, similarRow } from "./similar-items.fakes.js"; + +const repository = new FakeSimilarItemsRepository(); +const embeddings = new FakeEmbeddings(); +let server: Server; +let baseUrl: string; +let role: UserRole | null = "po"; + +before(() => { + repository.candidates = [similarRow()]; + repository.origins.set(ORIGIN_PBI, { projeto_id: PROJECT_B, arquivado: false, dados: { titulo: "Validar login com e-mail" } }); + const service = new SimilarItemsService(repository, embeddings, 0.7); + const app = express(); + app.use(express.json()); + const authentication = (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (!role) { + res.status(401).json({ error: "Autenticação necessária.", code: "UNAUTHORIZED" }); + return; + } + req.auth = { id: USER, nome: "Ana", email: "a@example.com", role }; + next(); + }; + app.use("/api/v1/projects/:projectId/similar-items", createProjectSimilarItemsRouter(service, authentication)); + app.use("/api/v1/pbis/:entityId/similar-items", createReuseSimilarItemRouter("pbi", service, authentication, requireRole("admin", "po"))); + app.use(errorHandler); + server = app.listen(0); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/v1`; +}); + +after(() => { + server.close(); +}); + +const call = (path: string, json?: unknown) => + fetch(`${baseUrl}${path}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: json === undefined ? undefined : JSON.stringify(json) }); + +const draft = { tipo: "pbi", titulo: "Validar login do usuário", descricao: "Entrar com e-mail e senha" }; + +test("exige autenticação na busca e no aproveitamento", async () => { + role = null; + assert.equal((await call(`/projects/${PROJECT_A}/similar-items`, draft)).status, 401); + assert.equal((await call(`/pbis/${TARGET_PBI}/similar-items/${ORIGIN_PBI}/reuse`, {})).status, 401); + role = "po"; +}); + +test("busca devolve os itens com projeto de origem e todos os perfis podem consultar", async () => { + for (const current of ["po", "dev"] as const) { + role = current; + const response = await call(`/projects/${PROJECT_A}/similar-items`, draft); + assert.equal(response.status, 200); + const body = await response.json() as { items: Array<{ outro_projeto: boolean; projeto: { nome: string } }>; degradado: boolean }; + assert.equal(body.items[0].outro_projeto, true); + assert.equal(body.items[0].projeto.nome, "Portal do Cliente"); + assert.equal(body.degradado, false); + } + role = "po"; +}); + +test("busca valida o corpo e o projeto", async () => { + assert.equal((await call(`/projects/${PROJECT_A}/similar-items`, { tipo: "projeto" })).status, 400); + assert.equal((await call(`/projects/nao-uuid/similar-items`, draft)).status, 400); + assert.equal((await call(`/projects/a1000000-0000-4000-8000-0000000000ff/similar-items`, draft)).status, 404); +}); + +test("indisponibilidade da IA não vira erro: 200 com degradado", async () => { + const { AppError } = await import("../../shared/errors.js"); + embeddings.failure = new AppError("fora", 503, "EMBEDDING_SERVICE_UNAVAILABLE"); + const response = await call(`/projects/${PROJECT_A}/similar-items`, draft); + embeddings.failure = null; + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { items: [], total: 0, degradado: true }); +}); + +test("aproveitar: po recebe 201 com proposta pendente e relação; dev recebe 403", async () => { + const created = await call(`/pbis/${TARGET_PBI}/similar-items/${ORIGIN_PBI}/reuse`, {}); + assert.equal(created.status, 201); + const body = await created.json() as { relacao: { tipo_relacao: string }; sugestoes: Array<{ status: string; origem: string }> }; + assert.equal(body.relacao.tipo_relacao, "reaproveitado_de"); + assert.equal(body.sugestoes[0].status, "pendente"); + assert.equal(body.sugestoes[0].origem, `similar:pbi:${ORIGIN_PBI}`); + + role = "dev"; + assert.equal((await call(`/pbis/${TARGET_PBI}/similar-items/${ORIGIN_PBI}/reuse`, {})).status, 403); + role = "po"; +}); + +test("aproveitar valida identificadores e corpo; origem inexistente retorna 404; arquivado 409", async () => { + assert.equal((await call(`/pbis/${TARGET_PBI}/similar-items/${TARGET_PBI}/reuse`, {})).status, 400); + assert.equal((await call(`/pbis/${TARGET_PBI}/similar-items/${ORIGIN_PBI}/reuse`, { campos: ["status"] })).status, 400); + assert.equal((await call(`/pbis/${TARGET_PBI}/similar-items/${UNKNOWN_PBI}/reuse`, {})).status, 404); + repository.archivedTargets.add(TARGET_PBI); + assert.equal((await call(`/pbis/${TARGET_PBI}/similar-items/${ORIGIN_PBI}/reuse`, {})).status, 409); + repository.archivedTargets.delete(TARGET_PBI); +}); diff --git a/backend/src/modules/similar-items/similar-items.routes.ts b/backend/src/modules/similar-items/similar-items.routes.ts new file mode 100644 index 0000000..6d858b8 --- /dev/null +++ b/backend/src/modules/similar-items/similar-items.routes.ts @@ -0,0 +1,51 @@ +import { Router, type NextFunction, type Request, type RequestHandler, type Response } from "express"; +import { requireAuth } from "../../middleware/requireAuth.js"; +import { requireRole } from "../../middleware/requireRole.js"; +import { ValidationError } from "../../shared/errors.js"; +import { SimilarItemsService, similarItemsService } from "./similar-items.service.js"; +import type { SimilarEntityType } from "./similar-items.types.js"; + +function requireUser(req: Request): string { + if (!req.auth?.id) throw new ValidationError("Autenticação necessária."); + return req.auth.id; +} + +export function createProjectSimilarItemsRouter( + service: SimilarItemsService = similarItemsService, + authentication: RequestHandler = requireAuth, +): Router { + const router = Router({ mergeParams: true }); + router.use(authentication); + // POST only to carry the draft text; the operation is read-only and open to every authenticated profile. + router.post("/", async (req: Request, res: Response, next: NextFunction) => { + try { + res.json(await service.search(String(req.params.projectId), requireUser(req), req.body)); + } catch (error) { + next(error); + } + }); + return router; +} + +export function createReuseSimilarItemRouter( + tipo: SimilarEntityType, + service: SimilarItemsService = similarItemsService, + authentication: RequestHandler = requireAuth, + canWrite: RequestHandler = requireRole("admin", "po"), +): Router { + const router = Router({ mergeParams: true }); + router.use(authentication); + router.post("/:origemId/reuse", canWrite, async (req: Request, res: Response, next: NextFunction) => { + try { + res.status(201).json(await service.reuse(tipo, String(req.params.entityId), String(req.params.origemId), requireUser(req), req.body)); + } catch (error) { + next(error); + } + }); + return router; +} + +export const projectSimilarItemsRouter = createProjectSimilarItemsRouter(); +export const epicSimilarItemsRouter = createReuseSimilarItemRouter("epico"); +export const featureSimilarItemsRouter = createReuseSimilarItemRouter("feature"); +export const pbiSimilarItemsRouter = createReuseSimilarItemRouter("pbi"); diff --git a/backend/src/modules/similar-items/similar-items.service.test.ts b/backend/src/modules/similar-items/similar-items.service.test.ts new file mode 100644 index 0000000..9a3d208 --- /dev/null +++ b/backend/src/modules/similar-items/similar-items.service.test.ts @@ -0,0 +1,147 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { ArchiveConflict } from "../projects/archive.types.js"; +import { AppError, ConflictError, NotFoundError, ValidationError } from "../../shared/errors.js"; +import { SimilarItemsService } from "./similar-items.service.js"; +import { + ARCHIVED_ORIGIN_PBI, + FakeEmbeddings, + FakeSimilarItemsRepository, + ORIGIN_PBI, + PROJECT_A, + PROJECT_B, + TARGET_PBI, + UNKNOWN_PBI, + UNKNOWN_PROJECT, + USER, + similarRow, +} from "./similar-items.fakes.js"; + +const DRAFT = { tipo: "pbi", titulo: "Validar login do usuário", descricao: "Entrar com e-mail e senha" }; + +function setup() { + const repository = new FakeSimilarItemsRepository(); + const embeddings = new FakeEmbeddings(); + repository.origins.set(ORIGIN_PBI, { + projeto_id: PROJECT_B, + arquivado: false, + dados: { + titulo: "Validar login com e-mail", + historia_como_um: "usuário cadastrado", + historia_eu_quero: "entrar com e-mail e senha", + historia_para_que: "acessar minhas informações", + regras_observacoes: " ", + }, + }); + repository.origins.set(ARCHIVED_ORIGIN_PBI, { projeto_id: PROJECT_B, arquivado: true, dados: { titulo: "Antigo" } }); + return { repository, embeddings, service: new SimilarItemsService(repository, embeddings, 0.7) }; +} + +test("sugere item de outro projeto indicando claramente a origem", async () => { + const { service, repository } = setup(); + repository.candidates = [similarRow()]; + const result = await service.search(PROJECT_A, USER, DRAFT); + assert.equal(result.degradado, false); + assert.equal(result.total, 1); + const [item] = result.items; + assert.equal(item.outro_projeto, true); + assert.deepEqual(item.projeto, { id: PROJECT_B, nome: "Portal do Cliente" }); + assert.equal(item.origem, "Portal do Cliente › Acesso e autenticação › Login"); + assert.equal(item.similaridade, 0.86); + assert.equal(repository.lastFind?.projetoId, PROJECT_A); +}); + +test("item do mesmo projeto também é sugerido, sem a marca de outro projeto", async () => { + const { service, repository } = setup(); + repository.candidates = [similarRow({ projeto_id: PROJECT_A, projeto_nome: "Projeto A" })]; + const [item] = (await service.search(PROJECT_A, USER, DRAFT)).items; + assert.equal(item.outro_projeto, false); +}); + +test("não força sugestões: nada acima do limiar resulta em lista vazia e sem erro", async () => { + const { service, repository } = setup(); + repository.candidates = [similarRow({ similaridade: 0.4 })]; + assert.deepEqual(await service.search(PROJECT_A, USER, DRAFT), { items: [], total: 0, degradado: false }); +}); + +test("texto curto não consulta a IA nem sugere nada", async () => { + const { service, embeddings, repository } = setup(); + repository.candidates = [similarRow()]; + const result = await service.search(PROJECT_A, USER, { tipo: "pbi", titulo: "Login" }); + assert.deepEqual(result, { items: [], total: 0, degradado: false }); + assert.equal(embeddings.calls.length, 0); +}); + +test("não interrompe a escrita quando o serviço de embeddings falha", async () => { + const { service, embeddings, repository } = setup(); + repository.candidates = [similarRow()]; + embeddings.failure = new AppError("indisponível", 503, "EMBEDDING_SERVICE_UNAVAILABLE"); + assert.deepEqual(await service.search(PROJECT_A, USER, DRAFT), { items: [], total: 0, degradado: true }); + embeddings.failure = null; + embeddings.vector = [1, 2, 3]; + assert.equal((await service.search(PROJECT_A, USER, DRAFT)).degradado, true); +}); + +test("falhas inesperadas (não de embeddings) não são engolidas", async () => { + const { service, embeddings } = setup(); + embeddings.failure = new TypeError("bug"); + await assert.rejects(service.search(PROJECT_A, USER, DRAFT), TypeError); +}); + +test("limita a 5 itens e respeita o item em edição (excluir_id)", async () => { + const { service, repository } = setup(); + repository.candidates = Array.from({ length: 8 }, (_, index) => similarRow({ id: `a2000000-0000-4000-8000-0000000001${index}0`.slice(0, 36), similaridade: 0.9 - index * 0.01 })); + assert.equal((await service.search(PROJECT_A, USER, DRAFT)).items.length, 5); + const excluded = repository.candidates[0].id; + const result = await service.search(PROJECT_A, USER, { ...DRAFT, excluir_id: excluded }); + assert.ok(result.items.every((item) => item.id !== excluded)); +}); + +test("valida o corpo, o projeto e os identificadores", async () => { + const { service } = setup(); + await assert.rejects(service.search("nao-uuid", USER, DRAFT), ValidationError); + await assert.rejects(service.search(PROJECT_A, USER, { tipo: "projeto" }), ValidationError); + await assert.rejects(service.search(PROJECT_A, USER, { ...DRAFT, extra: 1 }), ValidationError); + await assert.rejects(service.search(PROJECT_A, USER, { ...DRAFT, titulo: "x".repeat(256) }), /máximo 255/); + await assert.rejects(service.search(PROJECT_A, USER, { ...DRAFT, excluir_id: "xx" }), ValidationError); + await assert.rejects(service.search(UNKNOWN_PROJECT, USER, DRAFT), NotFoundError); +}); + +test("aproveitar cria propostas pendentes editáveis com a origem e registra a relação", async () => { + const { service } = setup(); + const result = await service.reuse("pbi", TARGET_PBI, ORIGIN_PBI, USER, {}); + assert.deepEqual(result.sugestoes.map((item) => item.campo).sort(), ["historia_como_um", "historia_eu_quero", "historia_para_que", "titulo"]); + assert.ok(result.sugestoes.every((item) => item.status === "pendente" && item.origem === `similar:pbi:${ORIGIN_PBI}`)); + assert.equal(result.relacao.tipo_relacao, "reaproveitado_de"); + assert.equal(result.relacao.origem_id, ORIGIN_PBI); + assert.equal(result.relacao.origem_projeto_id, PROJECT_B); + assert.ok(!result.relacao.campos.includes("regras_observacoes"), "campo vazio na origem não é copiado"); +}); + +test("aproveitar só os campos escolhidos e repetir é idempotente, unindo os campos na relação", async () => { + const { service, repository } = setup(); + const first = await service.reuse("pbi", TARGET_PBI, ORIGIN_PBI, USER, { campos: ["titulo"] }); + const again = await service.reuse("pbi", TARGET_PBI, ORIGIN_PBI, USER, { campos: ["titulo", "historia_eu_quero", "titulo"] }); + assert.equal(first.sugestoes[0].id, again.sugestoes.find((item) => item.campo === "titulo")?.id); + assert.equal(repository.relations.length, 1); + assert.deepEqual(again.relacao.campos, ["historia_eu_quero", "titulo"]); + assert.equal(repository.suggestionRows.length, 2); +}); + +test("aproveitar recusa o próprio item, campo inválido, origem inexistente ou arquivada", async () => { + const { service } = setup(); + await assert.rejects(service.reuse("pbi", TARGET_PBI, TARGET_PBI, USER, {}), /próprio item/); + await assert.rejects(service.reuse("pbi", "nao-uuid", ORIGIN_PBI, USER, {}), ValidationError); + await assert.rejects(service.reuse("pbi", TARGET_PBI, ORIGIN_PBI, USER, { campos: ["status"] }), ValidationError); + await assert.rejects(service.reuse("pbi", TARGET_PBI, ORIGIN_PBI, USER, { campos: [] }), ValidationError); + await assert.rejects(service.reuse("pbi", TARGET_PBI, UNKNOWN_PBI, USER, {}), NotFoundError); + await assert.rejects(service.reuse("pbi", TARGET_PBI, ARCHIVED_ORIGIN_PBI, USER, {}), ConflictError); + await assert.rejects(service.reuse("pbi", TARGET_PBI, ORIGIN_PBI, USER, { campos: ["regras_observacoes"] }), /aproveitável/); +}); + +test("item em edição arquivado recusa o aproveitamento (409)", async () => { + const { service, repository } = setup(); + repository.archivedTargets.add(TARGET_PBI); + await assert.rejects(service.reuse("pbi", TARGET_PBI, ORIGIN_PBI, USER, {}), ArchiveConflict); + assert.equal(repository.relations.length, 0); +}); diff --git a/backend/src/modules/similar-items/similar-items.service.ts b/backend/src/modules/similar-items/similar-items.service.ts new file mode 100644 index 0000000..c4aea95 --- /dev/null +++ b/backend/src/modules/similar-items/similar-items.service.ts @@ -0,0 +1,106 @@ +import { env } from "../../config/env.js"; +import { AppError, NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; +import { HttpSearchEmbeddingClient, type SearchEmbeddingClient } from "../search/search.service.js"; +import { SimilarItemsRepository } from "./similar-items.repository.js"; +import { + MAX_SIMILAR_RESULTS, + MIN_DRAFT_LENGTH, + draftSearchSchema, + reuseSchema, + type ReuseResult, + type SimilarEntityType, + type SimilarItem, + type SimilarItemRow, + type SimilarSearchResult, +} from "./similar-items.types.js"; + +const LABELS: Record = { epico: "épico", feature: "feature", pbi: "PBI" }; +const EMPTY: SimilarSearchResult = { items: [], total: 0, degradado: false }; + +function zodMessage(error: { issues: Array<{ message: string }> }): string { + return error.issues[0]?.message ?? "Dados inválidos."; +} + +function validVector(vector: unknown): vector is number[] { + return Array.isArray(vector) && vector.length === 1024 + && vector.every((value) => typeof value === "number" && Number.isFinite(value)) + && !vector.every((value) => value === 0); +} + +export function toSimilarItem(row: SimilarItemRow, currentProjectId: string): SimilarItem { + const caminho: SimilarItem["caminho"] = []; + if (row.epico_id && row.epico_titulo && row.tipo !== "epico") caminho.push({ tipo: "epico", id: row.epico_id, titulo: row.epico_titulo }); + if (row.feature_id && row.feature_titulo && row.tipo === "pbi") caminho.push({ tipo: "feature", id: row.feature_id, titulo: row.feature_titulo }); + return { + tipo: row.tipo, + id: row.id, + codigo: row.codigo, + titulo: row.titulo, + status: row.status, + similaridade: Math.round(Number(row.similaridade) * 100) / 100, + trecho: row.trecho, + projeto: { id: row.projeto_id, nome: row.projeto_nome }, + outro_projeto: row.projeto_id !== currentProjectId, + caminho, + origem: [row.projeto_nome, ...caminho.map((node) => node.titulo)].join(" › "), + ja_aproveitado: row.ja_aproveitado, + }; +} + +export class SimilarItemsService { + constructor( + private readonly repository: SimilarItemsRepository = new SimilarItemsRepository(), + private readonly embeddings: SearchEmbeddingClient = new HttpSearchEmbeddingClient(), + private readonly minimumSimilarity: number = env.SIMILAR_MIN_VECTOR_SIMILARITY, + ) {} + + async search(projectId: string, usuarioId: string, body: unknown): Promise { + validateUuid(projectId, "ID do projeto"); + const parsed = draftSearchSchema.safeParse(body); + if (!parsed.success) throw new ValidationError(zodMessage(parsed.error), parsed.error.flatten().fieldErrors); + const { tipo, titulo, descricao, excluir_id: excluirId } = parsed.data; + if (excluirId !== undefined) validateUuid(excluirId, `ID do ${LABELS[tipo]}`); + if (!(await this.repository.projectExists(projectId))) throw new NotFoundError("Projeto não encontrado."); + + const text = [titulo, descricao].filter((part) => part && part.length > 0).join("\n").trim(); + if (text.length < MIN_DRAFT_LENGTH) return { ...EMPTY }; + + let vector: unknown; + try { + vector = await this.embeddings.embed(text.slice(0, 2000)); + } catch (error) { + if (error instanceof AppError) return { items: [], total: 0, degradado: true }; + throw error; + } + if (!validVector(vector)) return { items: [], total: 0, degradado: true }; + + const rows = await this.repository.findSimilar({ + tipo, + vector, + usuarioId, + projetoId: projectId, + excluirId: excluirId ?? null, + minimoSimilaridade: this.minimumSimilarity, + limite: MAX_SIMILAR_RESULTS, + }); + const items = rows.map((row) => toSimilarItem(row, projectId)); + return { items, total: items.length, degradado: false }; + } + + async reuse(tipo: SimilarEntityType, entityId: string, origemId: string, usuarioId: string, body: unknown): Promise { + validateUuid(entityId, `ID do ${LABELS[tipo]}`); + validateUuid(origemId, `ID do ${LABELS[tipo]} de origem`); + if (entityId === origemId) throw new ValidationError("Não é possível aproveitar o próprio item."); + const parsed = reuseSchema(tipo).safeParse(body ?? {}); + if (!parsed.success) throw new ValidationError(zodMessage(parsed.error), parsed.error.flatten().fieldErrors); + return this.repository.reuse({ + tipo, + destinoId: entityId, + origemId, + campos: parsed.data.campos ? [...new Set(parsed.data.campos)] : null, + usuarioId, + }); + } +} + +export const similarItemsService = new SimilarItemsService(); diff --git a/backend/src/modules/similar-items/similar-items.types.ts b/backend/src/modules/similar-items/similar-items.types.ts new file mode 100644 index 0000000..70bc624 --- /dev/null +++ b/backend/src/modules/similar-items/similar-items.types.ts @@ -0,0 +1,105 @@ +import { z } from "zod"; +import { SUGGESTIBLE_FIELDS, type SuggestibleEntityType } from "../provenance/provenance.js"; +import type { SuggestionRow } from "../suggestions/suggestions.types.js"; + +export type SimilarEntityType = SuggestibleEntityType; +export const SIMILAR_ENTITY_TYPES = ["epico", "feature", "pbi"] as const; + +export const SIMILAR_RELATION_TYPE = "reaproveitado_de"; +export const MIN_DRAFT_LENGTH = 15; +export const MAX_SIMILAR_RESULTS = 5; + +export const similarOriginLabel = (tipo: SimilarEntityType, origemId: string): string => `similar:${tipo}:${origemId}`; + +export interface SimilarItemRow { + tipo: SimilarEntityType; + id: string; + codigo: string | null; + titulo: string; + status: string; + similaridade: number; + trecho: string; + projeto_id: string; + projeto_nome: string; + epico_id: string | null; + epico_titulo: string | null; + feature_id: string | null; + feature_titulo: string | null; + ja_aproveitado: boolean; +} + +export interface SimilarItem { + tipo: SimilarEntityType; + id: string; + codigo: string | null; + titulo: string; + status: string; + similaridade: number; + trecho: string; + projeto: { id: string; nome: string }; + outro_projeto: boolean; + caminho: Array<{ tipo: SimilarEntityType; id: string; titulo: string }>; + origem: string; + ja_aproveitado: boolean; +} + +export interface SimilarSearchResult { + items: SimilarItem[]; + total: number; + // true quando o serviço de embeddings não respondeu: a escrita segue normalmente + degradado: boolean; +} + +export interface FindSimilarInput { + tipo: SimilarEntityType; + vector: number[]; + usuarioId: string; + projetoId: string; + excluirId: string | null; + minimoSimilaridade: number; + limite: number; +} + +export interface ReuseInput { + tipo: SimilarEntityType; + destinoId: string; + origemId: string; + campos: string[] | null; + usuarioId: string; +} + +export interface ReuseRelation { + id: string; + tipo_relacao: string; + destino_tipo: SimilarEntityType; + destino_id: string; + destino_projeto_id: string; + origem_tipo: SimilarEntityType; + origem_id: string; + origem_projeto_id: string; + campos: string[]; + criado_por: string | null; + created_at: string; + updated_at: string; +} + +export interface ReuseResult { + relacao: ReuseRelation; + sugestoes: SuggestionRow[]; +} + +export const draftSearchSchema = z.object({ + tipo: z.enum(SIMILAR_ENTITY_TYPES, { required_error: "O tipo do item é obrigatório.", invalid_type_error: "Tipo de item inválido." }), + titulo: z.string().trim().max(255, "O título pode ter no máximo 255 caracteres.").optional(), + descricao: z.string().trim().max(5000, "A descrição pode ter no máximo 5000 caracteres.").optional(), + excluir_id: z.string().optional(), +}).strict(); + +export function reuseSchema(tipo: SimilarEntityType) { + const fields = SUGGESTIBLE_FIELDS[tipo]; + return z.object({ + campos: z.array(z.enum(fields as [string, ...string[]], { + invalid_type_error: `Campo inválido para ${tipo}. Use um de: ${fields.join(", ")}.`, + })).min(1, "Informe ao menos um campo.").max(fields.length).optional(), + }).strict(); +} diff --git a/database/migrations/020_similar_item_reuse.sql b/database/migrations/020_similar_item_reuse.sql new file mode 100644 index 0000000..3d6487c --- /dev/null +++ b/database/migrations/020_similar_item_reuse.sql @@ -0,0 +1,26 @@ +CREATE TABLE IF NOT EXISTS reaproveitamento_item ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tipo_relacao VARCHAR(30) NOT NULL DEFAULT 'reaproveitado_de', + destino_tipo VARCHAR(20) NOT NULL, + destino_id UUID NOT NULL, + destino_projeto_id UUID NOT NULL REFERENCES projeto(id) ON DELETE CASCADE, + origem_tipo VARCHAR(20) NOT NULL, + origem_id UUID NOT NULL, + origem_projeto_id UUID NOT NULL REFERENCES projeto(id) ON DELETE CASCADE, + campos TEXT[] NOT NULL, + criado_por UUID REFERENCES usuario(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT ck_reaproveitamento_destino_tipo CHECK (destino_tipo IN ('epico', 'feature', 'pbi')), + CONSTRAINT ck_reaproveitamento_origem_tipo CHECK (origem_tipo IN ('epico', 'feature', 'pbi')), + CONSTRAINT ck_reaproveitamento_mesmo_nivel CHECK (destino_tipo = origem_tipo), + CONSTRAINT ck_reaproveitamento_tipo_relacao CHECK (tipo_relacao IN ('reaproveitado_de')), + CONSTRAINT ck_reaproveitamento_nao_reflexivo CHECK (destino_id <> origem_id), + CONSTRAINT ck_reaproveitamento_campos CHECK (cardinality(campos) > 0) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_reaproveitamento_item + ON reaproveitamento_item (destino_tipo, destino_id, origem_tipo, origem_id, tipo_relacao); + +CREATE INDEX IF NOT EXISTS idx_reaproveitamento_origem + ON reaproveitamento_item (origem_tipo, origem_id); From 555ced2eaec0e179bc920044b821879ce4a174b7 Mon Sep 17 00:00:00 2001 From: heitors1337 Date: Thu, 22 Oct 2026 21:27:20 -0300 Subject: [PATCH 2/4] =?UTF-8?q?fix(backend):=20"deslimita=C3=A7=C3=A3o"=20?= =?UTF-8?q?da=20consulta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit as rotas estão documentadas também --- backend/src/app.ts | 7 + .../similar-items/similar-items.db.test.ts | 36 +++ .../similar-items.openapi.test.ts | 53 ++++ .../similar-items/similar-items.repository.ts | 25 +- .../migrations/020_similar_item_reuse.sql | 6 + docs/api/openapi.yaml | 269 ++++++++++++++++++ .../src/views/backlog/SuggestionsPanel.tsx | 13 +- 7 files changed, 397 insertions(+), 12 deletions(-) create mode 100644 backend/src/modules/similar-items/similar-items.openapi.test.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index 67164e4..d32dac3 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -40,6 +40,7 @@ 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; @@ -59,10 +60,15 @@ app.get("/health", async (_req: Request, res: Response) => { 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); @@ -100,6 +106,7 @@ app.use("/api/v1/epics/:entityId/suggestions", epicSuggestionsRouter); app.use("/api/v1/features/:entityId/suggestions", featureSuggestionsRouter); app.use("/api/v1/pbis/:entityId/suggestions", pbiSuggestionsRouter); +// Itens semelhantes entre projetos e aproveitamento com origem registrada (S2-12) app.use("/api/v1/projects/:projectId/similar-items", projectSimilarItemsRouter); app.use("/api/v1/epics/:entityId/similar-items", epicSimilarItemsRouter); app.use("/api/v1/features/:entityId/similar-items", featureSimilarItemsRouter); diff --git a/backend/src/modules/similar-items/similar-items.db.test.ts b/backend/src/modules/similar-items/similar-items.db.test.ts index 7536922..9dbd630 100644 --- a/backend/src/modules/similar-items/similar-items.db.test.ts +++ b/backend/src/modules/similar-items/similar-items.db.test.ts @@ -14,6 +14,7 @@ import { FakeEmbeddings, VALID_VECTOR } from "./similar-items.fakes.js"; const literal = (vector: number[]) => `[${vector.join(",")}]`; const DIFFERENT_VECTOR = VALID_VECTOR.map((_, index) => (index % 7 === 3 ? 0.9 : 0.01)); +const MIDDLE_VECTOR = VALID_VECTOR.map((value, index) => value * 0.7 + DIFFERENT_VECTOR[index] * 0.3); // ~0,86 de similaridade com VALID_VECTOR const DRAFT = { tipo: "pbi", titulo: "Validar login do usuário", descricao: "Entrar com e-mail e senha" }; test("S2-12: itens semelhantes entre projetos, aproveitamento com origem e visibilidade no PostgreSQL", { skip: !process.env.ARCHIVE_TEST_DATABASE_URL }, async (t) => { @@ -40,6 +41,9 @@ test("S2-12: itens semelhantes entre projetos, aproveitamento com origem e visib [id, feature, codigo, titulo, status], ); }; + const settleIndex = async (...projects: string[]) => { + await pool.query("UPDATE indice_evento SET status='processado' WHERE projeto_id = ANY($1::uuid[])", [projects]); + }; const insertChunk = async (project: string, id: string, vector: number[], texto = "Validar login com e-mail e senha") => { await pool.query( "INSERT INTO chunk (projeto_id, entidade_tipo, entidade_id, texto, embedding) VALUES ($1,'pbi',$2,$3,$4::vector)", @@ -63,6 +67,7 @@ test("S2-12: itens semelhantes entre projetos, aproveitamento com origem e visib await insertPbi(ids.b.unrelated, ids.b.feature, `PBI-${randomUUID().slice(0, 8)}`, "Exportar relatório mensal", "concluido"); await insertPbi(ids.b.archivedItem, ids.b.feature, `PBI-${randomUUID().slice(0, 8)}`, "Login arquivado", "arquivado"); await insertPbi(ids.z.similar, ids.z.feature, `PBI-${randomUUID().slice(0, 8)}`, "Login em projeto arquivado", "concluido"); + await settleIndex(projectA, projectB, projectArchived); await insertChunk(projectB, ids.b.similar, VALID_VECTOR); await insertChunk(projectB, ids.b.similar, VALID_VECTOR, "Segundo trecho do mesmo item"); await insertChunk(projectB, ids.b.unrelated, DIFFERENT_VECTOR, "Relatório mensal"); @@ -98,6 +103,37 @@ test("S2-12: itens semelhantes entre projetos, aproveitamento com origem e visib await pool.query("DELETE FROM alocacao WHERE desenvolvedor_id=$1 AND projeto_id=$2", [developerA, projectB]); }); + await t.test("muitos chunks de uma mesma entidade não escondem as demais candidatas (dedup antes do limite)", async () => { + const crowded = randomUUID(); + const others = [randomUUID(), randomUUID()]; + try { + await insertPbi(crowded, ids.b.feature, `PBI-${randomUUID().slice(0, 8)}`, "Item com muitos trechos", "concluido"); + for (const [index, other] of others.entries()) { + await insertPbi(other, ids.b.feature, `PBI-${randomUUID().slice(0, 8)}`, `Candidata ${index}`, "concluido"); + } + await settleIndex(projectB); + await pool.query( + "INSERT INTO chunk (projeto_id, entidade_tipo, entidade_id, texto, embedding) SELECT $1,'pbi',$2,'trecho '||g,$3::vector FROM generate_series(1,150) g", + [projectB, crowded, literal(VALID_VECTOR)], + ); + for (const [index, other] of others.entries()) { + await insertChunk(projectB, other, MIDDLE_VECTOR, `Trecho da candidata ${index}`); + } + + const result = await service.search(projectA, po, { ...DRAFT, excluir_id: ids.a.target }); + const found = result.items.map((item) => item.id); + assert.equal(new Set(found).size, found.length, "cada item aparece uma única vez"); + assert.equal(found.filter((id) => id === crowded).length, 1); + assert.deepEqual( + [...found].sort(), + [crowded, ids.b.similar, ids.a.sibling, ...others].sort(), + "as candidatas com um único chunk também aparecem", + ); + } finally { + await pool.query("DELETE FROM chunk WHERE entidade_id = ANY($1::uuid[])", [[crowded, ...others]]); + } + }); + await t.test("aproveitar cria propostas pendentes com origem, não altera o item e registra a relação", async () => { const before = (await pool.query("SELECT titulo, historia_como_um, provenance_json FROM pbi WHERE id=$1", [ids.a.target])).rows[0]; const reused = await service.reuse("pbi", ids.a.target, ids.b.similar, po, {}); diff --git a/backend/src/modules/similar-items/similar-items.openapi.test.ts b/backend/src/modules/similar-items/similar-items.openapi.test.ts new file mode 100644 index 0000000..f5cf934 --- /dev/null +++ b/backend/src/modules/similar-items/similar-items.openapi.test.ts @@ -0,0 +1,53 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { parse } from "yaml"; + +type Operation = { + security?: Array>; + requestBody?: { content?: Record }; + responses?: Record }>; +}; +type Document = { paths: Record }>; components: { schemas: Record } }; + +const document = parse(readFileSync(resolve(process.cwd(), "../docs/api/openapi.yaml"), "utf8")) as Document; + +const SEARCH = "/api/v1/projects/{projectId}/similar-items"; +const REUSE = ["epics", "features", "pbis"].map((level) => `/api/v1/${level}/{id}/similar-items/{origemId}/reuse`); + +function schemaName(reference: string | undefined): string { + return String(reference).replace("#/components/schemas/", ""); +} + +test("S2-12: a busca está documentada com autenticação, corpo, resposta e erros", () => { + const item = document.paths[SEARCH]; + assert.ok(item?.post, "POST de busca documentado"); + assert.deepEqual(item.post.security, [{ cookieAuth: [] }]); + assert.ok(item.parameters?.some((parameter) => parameter.name === "projectId" && parameter.in === "path" && parameter.required)); + assert.ok(item.post.requestBody?.content?.["application/json"]?.schema?.$ref); + assert.equal(schemaName(item.post.responses?.["200"]?.content?.["application/json"]?.schema?.$ref), "SimilarItemsResult"); + for (const code of ["400", "401", "404"]) assert.ok(item.post.responses?.[code], `resposta ${code} documentada`); + assert.equal(item.post.responses?.["403"], undefined, "consulta é aberta a todos os perfis autenticados"); +}); + +test("S2-12: o aproveitamento está documentado nos três níveis com autorização e conflitos", () => { + for (const path of REUSE) { + const item = document.paths[path]; + assert.ok(item?.post, `${path} documentado`); + assert.deepEqual(item.post.security, [{ cookieAuth: [] }]); + assert.ok(["id", "origemId"].every((name) => item.parameters?.some((parameter) => parameter.name === name && parameter.in === "path" && parameter.required)), `${path}: parâmetros de caminho`); + assert.equal(schemaName(item.post.responses?.["201"]?.content?.["application/json"]?.schema?.$ref), "SimilarItemsReuseResult"); + for (const code of ["400", "401", "403", "404", "409"]) assert.ok(item.post.responses?.[code], `${path}: resposta ${code} documentada`); + } +}); + +test("S2-12: todos os schemas referenciados existem em components", () => { + for (const name of ["SimilarItemsSearchInput", "SimilarItem", "SimilarItemsResult", "SimilarItemsReuseInput", "SimilarItemsReuseResult"]) { + assert.ok(document.components.schemas[name], `schema ${name}`); + } + const raw = readFileSync(resolve(process.cwd(), "../docs/api/openapi.yaml"), "utf8"); + for (const match of raw.matchAll(/\$ref: '#\/components\/schemas\/(Similar\w+)'/g)) { + assert.ok(document.components.schemas[match[1]], `$ref para ${match[1]} resolve`); + } +}); diff --git a/backend/src/modules/similar-items/similar-items.repository.ts b/backend/src/modules/similar-items/similar-items.repository.ts index 0cc0087..a2f1f80 100644 --- a/backend/src/modules/similar-items/similar-items.repository.ts +++ b/backend/src/modules/similar-items/similar-items.repository.ts @@ -18,7 +18,6 @@ import { type SimilarItemRow, } from "./similar-items.types.js"; -const CANDIDATE_POOL = 100; const SNIPPET_LENGTH = 240; const ORIGIN_SQL: Record = { epico: `SELECT to_jsonb(o) AS dados, o.projeto_id, @@ -50,20 +49,15 @@ export class SimilarItemsRepository { async findSimilar(input: FindSimilarInput): Promise { const vector = `[${input.vector.join(",")}]`; const result = await this.pool.query( - `WITH candidatos AS ( - SELECT c.entidade_id, c.projeto_id, c.texto, 1 - (c.embedding <=> $1::vector) AS similaridade + `WITH melhores AS ( + SELECT DISTINCT ON (c.entidade_id) + c.entidade_id, c.projeto_id, c.texto, 1 - (c.embedding <=> $1::vector) AS similaridade FROM chunk c WHERE c.entidade_tipo = $2::text AND c.embedding IS NOT NULL AND ($3::uuid IS NULL OR c.entidade_id <> $3::uuid) AND ${projectAccessSql("c.projeto_id", "$4")} - ORDER BY c.embedding <=> $1::vector, c.id - LIMIT ${CANDIDATE_POOL} - ), melhores AS ( - SELECT DISTINCT ON (entidade_id) entidade_id, projeto_id, texto, similaridade - FROM candidatos - WHERE similaridade >= $5 - ORDER BY entidade_id, similaridade DESC + ORDER BY c.entidade_id, c.embedding <=> $1::vector, c.id ) SELECT $2::text AS tipo, m.entidade_id AS id, b.codigo, COALESCE(e.titulo, f.titulo, b.titulo) AS titulo, @@ -86,7 +80,8 @@ export class SimilarItemsRepository { LEFT JOIN feature bf ON bf.id = b.feature_id LEFT JOIN feature ft ON ft.id = COALESCE(f.id, bf.id) LEFT JOIN epico ep ON ep.id = COALESCE(e.id, f.epico_id, bf.epico_id) - WHERE p.status <> 'arquivado' + WHERE m.similaridade >= $5 + AND p.status <> 'arquivado' AND COALESCE(e.status, f.status, b.status) IS NOT NULL AND COALESCE(e.status, f.status, b.status) <> 'arquivado' AND (ep.id IS NULL OR ep.status <> 'arquivado') @@ -98,6 +93,14 @@ export class SimilarItemsRepository { return result.rows; } + /** + * "Aproveitar": copies the origin's text fields into pending, editable + * suggestions on the item being edited (S2-13 staging — nothing is written + * to the business table until the PO accepts/edits) and records the origin + * relation. One transaction, locked like every other hierarchy writer; + * repeating it for the same origin refreshes the pending proposals and + * merges the copied fields into the existing relation. + */ async reuse(input: ReuseInput): Promise { const client = await this.pool.connect(); let relation: ReuseRelation; diff --git a/database/migrations/020_similar_item_reuse.sql b/database/migrations/020_similar_item_reuse.sql index 3d6487c..22bad4c 100644 --- a/database/migrations/020_similar_item_reuse.sql +++ b/database/migrations/020_similar_item_reuse.sql @@ -1,3 +1,8 @@ +-- S2-12: relação de origem registrada ao aproveitar um item similar de outro +-- (ou do mesmo) projeto. A sugestão em si continua isolada em sugestao_ia +-- (S2-13); esta tabela apenas guarda de onde o conteúdo veio e quais campos +-- foram copiados, para que a origem seja sempre rastreável. + CREATE TABLE IF NOT EXISTS reaproveitamento_item ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), tipo_relacao VARCHAR(30) NOT NULL DEFAULT 'reaproveitado_de', @@ -19,6 +24,7 @@ CREATE TABLE IF NOT EXISTS reaproveitamento_item ( CONSTRAINT ck_reaproveitamento_campos CHECK (cardinality(campos) > 0) ); +-- Repetir o aproveitamento do mesmo item atualiza a relação existente. CREATE UNIQUE INDEX IF NOT EXISTS uq_reaproveitamento_item ON reaproveitamento_item (destino_tipo, destino_id, origem_tipo, origem_id, tipo_relacao); diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 080a57c..fca6857 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2267,6 +2267,188 @@ paths: '409': description: Sugestão já resolvida de outra forma + /api/v1/projects/{projectId}/similar-items: + parameters: + - name: projectId + in: path + required: true + description: Projeto em que o item está sendo escrito + schema: {type: string, format: uuid} + post: + summary: Sugere itens semelhantes enquanto o item é escrito (S2-12) + description: > + Consulta somente leitura (POST apenas para transportar o texto em + edição). Diferentemente da busca, o acervo é consultado em todos os + projetos que o usuário pode acessar; cada item informa o projeto de + origem e `outro_projeto`. Só entram itens já indexados, não arquivados + e acima do limiar de similaridade, no máximo 5. Texto curto, nada + semelhante ou serviço de embeddings indisponível (`degradado: true`) + resultam em `items: []` com 200, sem interromper a escrita. Nada é + gravado. + operationId: searchSimilarItems + security: + - cookieAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SimilarItemsSearchInput' + responses: + '200': + description: Itens semelhantes, do mais para o menos similar + content: + application/json: + schema: + $ref: '#/components/schemas/SimilarItemsResult' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: Projeto não encontrado ou sem acesso + + /api/v1/epics/{id}/similar-items/{origemId}/reuse: + parameters: + - name: id + in: path + required: true + description: Item em edição que receberá a proposta + schema: {type: string, format: uuid} + - name: origemId + in: path + required: true + description: Item semelhante a aproveitar (mesmo nível) + schema: {type: string, format: uuid} + post: + summary: Aproveita um item semelhante como proposta editável no épico (S2-12) + description: > + Copia os campos de texto do item de origem para sugestões pendentes do + item em edição (origem `similar::`), que a pessoa aceita, + edita ou descarta pelo fluxo de S2-13, e registra a relação de origem. + Não altera o item. Repetir o aproveitamento atualiza as propostas + pendentes e a relação. Apenas admin e po. + operationId: reuseEpicsSimilarItem + security: + - cookieAuth: [] + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/SimilarItemsReuseInput' + responses: + '201': + description: Propostas pendentes criadas e relação registrada + content: + application/json: + schema: + $ref: '#/components/schemas/SimilarItemsReuseResult' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Perfil sem permissão de escrita + '404': + description: Item em edição ou item de origem não encontrado + '409': + description: Item em edição ou item de origem arquivado + + /api/v1/features/{id}/similar-items/{origemId}/reuse: + parameters: + - name: id + in: path + required: true + description: Item em edição que receberá a proposta + schema: {type: string, format: uuid} + - name: origemId + in: path + required: true + description: Item semelhante a aproveitar (mesmo nível) + schema: {type: string, format: uuid} + post: + summary: Aproveita um item semelhante como proposta editável no feature (S2-12) + description: > + Copia os campos de texto do item de origem para sugestões pendentes do + item em edição (origem `similar::`), que a pessoa aceita, + edita ou descarta pelo fluxo de S2-13, e registra a relação de origem. + Não altera o item. Repetir o aproveitamento atualiza as propostas + pendentes e a relação. Apenas admin e po. + operationId: reuseFeaturesSimilarItem + security: + - cookieAuth: [] + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/SimilarItemsReuseInput' + responses: + '201': + description: Propostas pendentes criadas e relação registrada + content: + application/json: + schema: + $ref: '#/components/schemas/SimilarItemsReuseResult' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Perfil sem permissão de escrita + '404': + description: Item em edição ou item de origem não encontrado + '409': + description: Item em edição ou item de origem arquivado + + /api/v1/pbis/{id}/similar-items/{origemId}/reuse: + parameters: + - name: id + in: path + required: true + description: Item em edição que receberá a proposta + schema: {type: string, format: uuid} + - name: origemId + in: path + required: true + description: Item semelhante a aproveitar (mesmo nível) + schema: {type: string, format: uuid} + post: + summary: Aproveita um item semelhante como proposta editável no PBI (S2-12) + description: > + Copia os campos de texto do item de origem para sugestões pendentes do + item em edição (origem `similar::`), que a pessoa aceita, + edita ou descarta pelo fluxo de S2-13, e registra a relação de origem. + Não altera o item. Repetir o aproveitamento atualiza as propostas + pendentes e a relação. Apenas admin e po. + operationId: reusePbisSimilarItem + security: + - cookieAuth: [] + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/SimilarItemsReuseInput' + responses: + '201': + description: Propostas pendentes criadas e relação registrada + content: + application/json: + schema: + $ref: '#/components/schemas/SimilarItemsReuseResult' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Perfil sem permissão de escrita + '404': + description: Item em edição ou item de origem não encontrado + '409': + description: Item em edição ou item de origem arquivado + /api/v1/auth/register: post: summary: Cria uma conta (PO ou dev); administrador exige sessão de administrador @@ -4227,6 +4409,93 @@ components: created_at: {type: string, format: date-time} updated_at: {type: string, format: date-time} + SimilarItemsSearchInput: + type: object + additionalProperties: false + required: [tipo] + properties: + tipo: {type: string, enum: [epico, feature, pbi]} + titulo: {type: string, maxLength: 255} + descricao: {type: string, maxLength: 5000} + excluir_id: {type: string, format: uuid, description: Item já salvo que está em edição (não é sugerido a si mesmo)} + + SimilarItem: + type: object + required: [tipo, id, titulo, status, similaridade, trecho, projeto, outro_projeto, caminho, origem, ja_aproveitado] + properties: + tipo: {type: string, enum: [epico, feature, pbi]} + id: {type: string, format: uuid} + codigo: {type: string, nullable: true} + titulo: {type: string} + status: {type: string} + similaridade: {type: number, minimum: 0, maximum: 1} + trecho: {type: string} + projeto: + type: object + required: [id, nome] + properties: + id: {type: string, format: uuid} + nome: {type: string} + outro_projeto: {type: boolean, description: true quando o item pertence a outro projeto} + caminho: + type: array + items: + type: object + required: [tipo, id, titulo] + properties: + tipo: {type: string, enum: [epico, feature, pbi]} + id: {type: string, format: uuid} + titulo: {type: string} + origem: {type: string, description: 'Rótulo legível, ex. Projeto › Épico › Feature'} + ja_aproveitado: {type: boolean} + + SimilarItemsResult: + type: object + required: [items, total, degradado] + properties: + items: + type: array + maxItems: 5 + items: + $ref: '#/components/schemas/SimilarItem' + total: {type: integer} + degradado: {type: boolean, description: true quando o serviço de embeddings estava indisponível} + + SimilarItemsReuseInput: + type: object + additionalProperties: false + properties: + campos: + type: array + minItems: 1 + items: {type: string} + description: Campos a copiar (padrão todos os campos de texto sugeríveis com conteúdo) + + SimilarItemsReuseResult: + type: object + required: [relacao, sugestoes] + properties: + relacao: + type: object + required: [id, tipo_relacao, destino_tipo, destino_id, origem_tipo, origem_id, origem_projeto_id, campos] + properties: + id: {type: string, format: uuid} + tipo_relacao: {type: string, enum: [reaproveitado_de]} + destino_tipo: {type: string, enum: [epico, feature, pbi]} + destino_id: {type: string, format: uuid} + destino_projeto_id: {type: string, format: uuid} + origem_tipo: {type: string, enum: [epico, feature, pbi]} + origem_id: {type: string, format: uuid} + origem_projeto_id: {type: string, format: uuid} + campos: {type: array, items: {type: string}} + criado_por: {type: string, format: uuid, nullable: true} + created_at: {type: string} + updated_at: {type: string} + sugestoes: + type: array + items: + $ref: '#/components/schemas/Suggestion' + SuggestionList: type: object required: [items] diff --git a/frontend/src/views/backlog/SuggestionsPanel.tsx b/frontend/src/views/backlog/SuggestionsPanel.tsx index 399ca06..64a322c 100644 --- a/frontend/src/views/backlog/SuggestionsPanel.tsx +++ b/frontend/src/views/backlog/SuggestionsPanel.tsx @@ -33,6 +33,10 @@ function fieldLabel(campo: string): string { return FIELD_LABEL[campo] ?? campo; } +function isSimilarOrigin(origem: string): boolean { + return origem.startsWith("similar:"); +} + function formatDate(value: string | null): string { if (!value) return ""; const date = new Date(value); @@ -44,6 +48,7 @@ function ResolvedSuggestionCard({ suggestion }: { suggestion: Suggestion }) { return (
  • {STATUS_LABEL[suggestion.status]} + {isSimilarOrigin(suggestion.origem) && Item semelhante}

    {fieldLabel(suggestion.campo)}

    {suggestion.valor_resolvido &&

    {suggestion.valor_resolvido}

    }

    @@ -96,6 +101,9 @@ function PendingSuggestionCard({ Campo: {fieldLabel(suggestion.campo)} {suggestion.criado_por_nome ? ` · Proposto por ${suggestion.criado_por_nome}` : ""}

    + {isSimilarOrigin(suggestion.origem) && ( +

    Aproveitado de item semelhante A origem ficou registrada no histórico deste item.

    + )} {editing ? (
    @@ -142,11 +150,14 @@ export function SuggestionsPanel({ id, canWrite, readOnlyNote, + refreshKey = 0, }: { kind: SuggestionKind; id: string; canWrite: boolean; readOnlyNote?: string; + /** Incrementar para recarregar a lista (ex.: depois de aproveitar um item semelhante). */ + refreshKey?: number; }) { const [state, setState] = useState<{ state: "loading" } | { state: "error" } | { state: "ready"; items: Suggestion[] }>({ state: "loading" }); const [attempt, setAttempt] = useState(0); @@ -164,7 +175,7 @@ export function SuggestionsPanel({ .then((items) => { if (!controller.signal.aborted) setState({ state: "ready", items }); }) .catch(() => { if (!controller.signal.aborted) setState({ state: "error" }); }); return () => controller.abort(); - }, [kind, id, attempt]); + }, [kind, id, attempt, refreshKey]); const replace = (updated: Suggestion) => { if (!mounted.current) return; From 3869c6caa9dcb5f6d1eee76af8b987e30cf71515 Mon Sep 17 00:00:00 2001 From: heitors1337 Date: Thu, 22 Oct 2026 21:30:34 -0300 Subject: [PATCH 3/4] =?UTF-8?q?feat(frontend):=20implementa=C3=A7=C3=A3o?= =?UTF-8?q?=20da=20parte=20visual=20do=20primeiro=20commit=20desta=20branc?= =?UTF-8?q?h?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit basicamente um painel discreto, com avisos, botões e sinalizando o projeto de origem vscode 🤙 --- frontend/src/api/api_similar_items.test.ts | 65 ++++++ frontend/src/api/api_similar_items.ts | 155 ++++++++++++++ frontend/src/views/backlog/EpicsView.tsx | 16 +- frontend/src/views/backlog/FeaturesView.tsx | 16 +- frontend/src/views/backlog/PbiDetail.tsx | 15 ++ frontend/src/views/backlog/PbiForm.tsx | 16 ++ .../views/backlog/SimilarItemsPanel.test.tsx | 132 ++++++++++++ .../src/views/backlog/SimilarItemsPanel.tsx | 194 ++++++++++++++++++ 8 files changed, 607 insertions(+), 2 deletions(-) create mode 100644 frontend/src/api/api_similar_items.test.ts create mode 100644 frontend/src/api/api_similar_items.ts create mode 100644 frontend/src/views/backlog/SimilarItemsPanel.test.tsx create mode 100644 frontend/src/views/backlog/SimilarItemsPanel.tsx diff --git a/frontend/src/api/api_similar_items.test.ts b/frontend/src/api/api_similar_items.test.ts new file mode 100644 index 0000000..7df7e12 --- /dev/null +++ b/frontend/src/api/api_similar_items.test.ts @@ -0,0 +1,65 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { ApiError } from "./api_auth"; +import { describeReuseError, parseSimilarItem, reuseSimilarItem, searchSimilarItems, similarItemPath } from "./api_similar_items"; + +const raw = (overrides: Record = {}) => ({ + tipo: "pbi", id: "pbi-9", codigo: "PBI-02.1.3", titulo: "Validar login", status: "pronto", similaridade: 0.83, + trecho: "Como usuário quero entrar", projeto: { id: "p2", nome: "Beta" }, outro_projeto: true, + caminho: [{ tipo: "epico", id: "e2", titulo: "Acesso" }, { tipo: "feature", id: "f2", titulo: "Login" }], + origem: "Beta › Acesso › Login", ja_aproveitado: false, + ...overrides, +}); +const json = (body: unknown, status = 200) => Promise.resolve(new Response(JSON.stringify(body), { status })); + +afterEach(() => vi.unstubAllGlobals()); + +it("consulta o acervo pelo projeto atual enviando só o rascunho", async () => { + const request = vi.fn(() => json({ items: [raw()], total: 1, degradado: false })); + vi.stubGlobal("fetch", request); + const result = await searchSimilarItems("p1", { tipo: "pbi", titulo: "Validar login", descricao: "entrar com e-mail" }); + const [url, init] = request.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe("/api/v1/projects/p1/similar-items"); + expect(init.method).toBe("POST"); + expect(JSON.parse(String(init.body))).toEqual({ tipo: "pbi", titulo: "Validar login", descricao: "entrar com e-mail" }); + expect(result.items[0]).toMatchObject({ id: "pbi-9", outro_projeto: true, origem: "Beta › Acesso › Login" }); +}); + +it("rejeita resposta malformada em vez de exibir lixo", async () => { + vi.stubGlobal("fetch", vi.fn(() => json({ items: [{ id: 1 }] }))); + await expect(searchSimilarItems("p1", { tipo: "pbi", titulo: "x" })).rejects.toThrow(); +}); + +it("monta a origem a partir do caminho quando o servidor não a envia", () => { + expect(parseSimilarItem(raw({ origem: undefined })).origem).toBe("Beta › Acesso › Login"); +}); + +it("monta o link de consulta por tipo e ignora caminho incompleto", () => { + expect(similarItemPath(parseSimilarItem(raw()))).toBe("/projects/p2/epics/e2/features/f2/pbis/pbi-9"); + expect(similarItemPath(parseSimilarItem(raw({ tipo: "epico", id: "e2", caminho: [] })))).toBe("/projects/p2/epics/e2"); + expect(similarItemPath(parseSimilarItem(raw({ caminho: [] })))).toBeNull(); +}); + +it("aproveitar chama a rota do tipo do item e devolve relação e propostas", async () => { + const suggestion = { + id: "s1", entidade_tipo: "pbi", entidade_id: "pbi-1", campo: "titulo", valor_sugerido: "Validar login", valor_resolvido: null, + origem: "similar:pbi:pbi-9", status: "pendente", criado_por: "u1", criado_por_nome: "Ana", resolvido_por: null, + resolvido_por_nome: null, resolvido_em: null, created_at: "2026-10-21T10:00:00Z", updated_at: "2026-10-21T10:00:00Z", + }; + const request = vi.fn(() => json({ + relacao: { id: "r1", tipo_relacao: "reaproveitado_de", destino_tipo: "pbi", destino_id: "pbi-1", origem_tipo: "pbi", origem_id: "pbi-9", origem_projeto_id: "p2", campos: ["titulo"] }, + sugestoes: [suggestion], + }, 201)); + vi.stubGlobal("fetch", request); + const result = await reuseSimilarItem("pbi", "pbi-1", "pbi-9"); + const [url, init] = request.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe("/api/v1/pbis/pbi-1/similar-items/pbi-9/reuse"); + expect(JSON.parse(String(init.body))).toEqual({}); + expect(result.relacao.campos).toEqual(["titulo"]); + expect(result.sugestoes[0].origem).toBe("similar:pbi:pbi-9"); +}); + +it("traduz erros de aproveitamento para mensagens claras", () => { + expect(describeReuseError(new ApiError(403, undefined))).toMatch(/perfil/); + expect(describeReuseError(new ApiError(409, { error: "O item de origem está arquivado e não pode ser aproveitado." }))).toMatch(/arquivado/); + expect(describeReuseError(new Error("x"))).toMatch(/Não foi possível/); +}); diff --git a/frontend/src/api/api_similar_items.ts b/frontend/src/api/api_similar_items.ts new file mode 100644 index 0000000..a5783bc --- /dev/null +++ b/frontend/src/api/api_similar_items.ts @@ -0,0 +1,155 @@ +import { ApiError, apiRequest } from "./api_auth"; +import { serverMessage } from "./api_errors"; +import { parseSuggestion, type Suggestion, type SuggestionKind } from "./api_suggestions"; + +export type SimilarKind = SuggestionKind; + +export interface SimilarPathNode { + tipo: SimilarKind; + id: string; + titulo: string; +} + +export interface SimilarItem { + tipo: SimilarKind; + id: string; + codigo: string | null; + titulo: string; + status: string; + similaridade: number; + trecho: string; + projeto: { id: string; nome: string }; + outro_projeto: boolean; + caminho: SimilarPathNode[]; + origem: string; + ja_aproveitado: boolean; +} + +export interface SimilarSearchResult { + items: SimilarItem[]; + total: number; + degradado: boolean; +} + +export interface SimilarDraft { + tipo: SimilarKind; + titulo?: string; + descricao?: string; + excluir_id?: string; +} + +export interface ReuseRelation { + id: string; + tipo_relacao: string; + destino_tipo: SimilarKind; + destino_id: string; + origem_tipo: SimilarKind; + origem_id: string; + origem_projeto_id: string; + campos: string[]; +} + +export interface ReuseResult { + relacao: ReuseRelation; + sugestoes: Suggestion[]; +} + +const PATHS: Record = { epico: "epics", feature: "features", pbi: "pbis" }; +const KINDS: readonly string[] = ["epico", "feature", "pbi"]; + +/** Mínimo de texto digitado (título + descrição) antes de consultar o acervo; espelha o backend. */ +export const SIMILAR_MIN_DRAFT_LENGTH = 15; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function parseNode(value: unknown): SimilarPathNode | null { + if (!isRecord(value) || typeof value.id !== "string" || typeof value.titulo !== "string" + || typeof value.tipo !== "string" || !KINDS.includes(value.tipo)) return null; + return { tipo: value.tipo as SimilarKind, id: value.id, titulo: value.titulo }; +} + +export function parseSimilarItem(value: unknown): SimilarItem { + if (!isRecord(value) || typeof value.id !== "string" || typeof value.titulo !== "string" + || typeof value.tipo !== "string" || !KINDS.includes(value.tipo) + || !isRecord(value.projeto) || typeof value.projeto.id !== "string" || typeof value.projeto.nome !== "string") { + throw new Error("Item semelhante inválido"); + } + const caminho = Array.isArray(value.caminho) + ? value.caminho.map(parseNode).filter((node): node is SimilarPathNode => node !== null) + : []; + const projeto = { id: value.projeto.id, nome: value.projeto.nome }; + return { + tipo: value.tipo as SimilarKind, + id: value.id, + codigo: typeof value.codigo === "string" ? value.codigo : null, + titulo: value.titulo, + status: typeof value.status === "string" ? value.status : "", + similaridade: Number.isFinite(Number(value.similaridade)) ? Number(value.similaridade) : 0, + trecho: typeof value.trecho === "string" ? value.trecho : "", + projeto, + outro_projeto: value.outro_projeto === true, + caminho, + origem: typeof value.origem === "string" && value.origem + ? value.origem + : [projeto.nome, ...caminho.map((node) => node.titulo)].join(" › "), + ja_aproveitado: value.ja_aproveitado === true, + }; +} + +export async function searchSimilarItems(projectId: string, draft: SimilarDraft, signal?: AbortSignal): Promise { + const response = await apiRequest(`/projects/${encodeURIComponent(projectId)}/similar-items`, { + method: "POST", + body: JSON.stringify(draft), + signal, + }); + const data: unknown = await response.json(); + if (!isRecord(data) || !Array.isArray(data.items)) throw new Error("Resposta de itens semelhantes inválida"); + const items = data.items.map(parseSimilarItem); + return { items, total: items.length, degradado: data.degradado === true }; +} + +export async function reuseSimilarItem(kind: SimilarKind, id: string, origemId: string, campos?: string[], signal?: AbortSignal): Promise { + const response = await apiRequest(`/${PATHS[kind]}/${encodeURIComponent(id)}/similar-items/${encodeURIComponent(origemId)}/reuse`, { + method: "POST", + body: JSON.stringify(campos && campos.length > 0 ? { campos } : {}), + signal, + }); + const data: unknown = await response.json(); + if (!isRecord(data) || !isRecord(data.relacao) || !Array.isArray(data.sugestoes)) throw new Error("Resposta de aproveitamento inválida"); + const relacao = data.relacao; + return { + relacao: { + id: String(relacao.id), + tipo_relacao: String(relacao.tipo_relacao), + destino_tipo: relacao.destino_tipo as SimilarKind, + destino_id: String(relacao.destino_id), + origem_tipo: relacao.origem_tipo as SimilarKind, + origem_id: String(relacao.origem_id), + origem_projeto_id: String(relacao.origem_projeto_id), + campos: Array.isArray(relacao.campos) ? relacao.campos.map(String) : [], + }, + sugestoes: data.sugestoes.map(parseSuggestion), + }; +} + +export function describeReuseError(error: unknown): string { + if (error instanceof ApiError) { + if (error.status === 401) return "Sua sessão expirou. Entre novamente para continuar."; + if (error.status === 403) return "Seu perfil não permite aproveitar itens de outros projetos."; + if (error.status === 404) return serverMessage(error) ?? "O item de origem ou de destino não foi encontrado."; + if (error.status === 409) return serverMessage(error) ?? "O item de origem não pode ser aproveitado no momento."; + if (error.status === 400) return serverMessage(error) ?? "O item de origem não possui conteúdo aproveitável."; + } + return "Não foi possível aproveitar o item. Tente novamente."; +} + +export function similarItemPath(item: SimilarItem): string | null { + const epic = item.caminho.find((node) => node.tipo === "epico"); + const feature = item.caminho.find((node) => node.tipo === "feature"); + const base = `/projects/${item.projeto.id}`; + if (item.tipo === "epico") return `${base}/epics/${item.id}`; + if (item.tipo === "feature") return epic ? `${base}/epics/${epic.id}/features/${item.id}` : null; + return epic && feature ? `${base}/epics/${epic.id}/features/${feature.id}/pbis/${item.id}` : null; +} diff --git a/frontend/src/views/backlog/EpicsView.tsx b/frontend/src/views/backlog/EpicsView.tsx index ddd8748..00a5cff 100644 --- a/frontend/src/views/backlog/EpicsView.tsx +++ b/frontend/src/views/backlog/EpicsView.tsx @@ -13,6 +13,8 @@ import { BacklogBreadcrumb } from "./BacklogBreadcrumb"; import { BacklogTechnologySelector } from "./BacklogTechnologySelector"; import { CriteriaEditor } from "./CriteriaView"; import { ItemArchiveView } from "./ItemArchiveView"; +import { reuseAfterCreate, SimilarItemsPanel, SimilarSelection } from "./SimilarItemsPanel"; +import type { SimilarItem } from "../../api/api_similar_items"; 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: [] }; @@ -63,6 +65,7 @@ export function EpicList({ projetoId, canCreate: allowedToCreate }: { projetoId: export function EpicForm({ projetoId }: { projetoId: string }) { const [values, setValues] = useState({ ...emptyInput, projeto_id: projetoId }); + const [origin, setOrigin] = useState(null); const [busy, setBusy] = useState(false); const [message, setMessage] = useState(""); const submitting = useRef(false); @@ -82,6 +85,7 @@ export function EpicForm({ projetoId }: { projetoId: string }) { submitting.current = true; setBusy(true); setMessage(""); try { const epic = await createEpic({ ...values, projeto_id: projetoId }); + await reuseAfterCreate("epico", epic.id, origin); if (mounted.current) navigate(`/projects/${projetoId}/epics/${epic.id}`); } catch (error) { if (!mounted.current) return; @@ -107,6 +111,10 @@ export function EpicForm({ projetoId }: { projetoId: string }) { onChange={(tecnologias_ids) => setValues((current) => ({ ...current, tecnologias_ids }))} disabled={busy} /> + part.trim()).join("\n")} + disabled={busy} selectedId={origin?.id ?? null} onSelect={setOrigin} /> + setOrigin(null)} /> {message &&

    {message}

    }
    @@ -138,6 +146,7 @@ export function EpicDetail({ projectId, epicId, canEdit, children }: { projectId const [formValues, setFormValues] = useState(null); const [saving, setSaving] = useState(false); const [editMessage, setEditMessage] = useState(""); + const [reuseTick, setReuseTick] = useState(0); useEffect(() => { const controller = new AbortController(); @@ -302,7 +311,12 @@ export function EpicDetail({ projectId, epicId, canEdit, children }: { projectId setAttempt(v => v + 1)} /> - + {canWrite && !editing && ( + setReuseTick((value) => value + 1)} /> + )} + {children} diff --git a/frontend/src/views/backlog/FeaturesView.tsx b/frontend/src/views/backlog/FeaturesView.tsx index 2891d68..409ca22 100644 --- a/frontend/src/views/backlog/FeaturesView.tsx +++ b/frontend/src/views/backlog/FeaturesView.tsx @@ -13,6 +13,8 @@ import { BacklogBreadcrumb } from "./BacklogBreadcrumb"; import { BacklogTechnologySelector } from "./BacklogTechnologySelector"; import { CriteriaEditor } from "./CriteriaView"; import { ItemArchiveView } from "./ItemArchiveView"; +import { reuseAfterCreate, SimilarItemsPanel, SimilarSelection } from "./SimilarItemsPanel"; +import type { SimilarItem } from "../../api/api_similar_items"; type ListResult = { state: "loading" } | { state: "error"; message: string } | { state: "ready"; features: Feature[] }; const emptyInput: FeatureInput = { epico_id: "", titulo: "", descricao: "", objetivo: "", tecnologias_ids: [] }; @@ -63,6 +65,7 @@ export function FeatureList({ projectId, epicoId, canCreate: allowedToCreate }: export function FeatureForm({ projectId, epicoId }: { projectId: string; epicoId: string }) { const [values, setValues] = useState({ ...emptyInput, epico_id: epicoId }); + const [origin, setOrigin] = useState(null); const [busy, setBusy] = useState(false); const [message, setMessage] = useState(""); const submitting = useRef(false); @@ -82,6 +85,7 @@ export function FeatureForm({ projectId, epicoId }: { projectId: string; epicoId submitting.current = true; setBusy(true); setMessage(""); try { const feature = await createFeature({ ...values, epico_id: epicoId }); + await reuseAfterCreate("feature", feature.id, origin); if (mounted.current) navigate(`/projects/${projectId}/epics/${epicoId}/features/${feature.id}`); } catch (error) { if (!mounted.current) return; @@ -104,6 +108,10 @@ export function FeatureForm({ projectId, epicoId }: { projectId: string; epicoId onChange={(tecnologias_ids) => setValues((current) => ({ ...current, tecnologias_ids }))} disabled={busy} /> + part.trim()).join("\n")} + disabled={busy} selectedId={origin?.id ?? null} onSelect={setOrigin} /> + setOrigin(null)} /> {message &&

    {message}

    }
    @@ -135,6 +143,7 @@ export function FeatureDetail({ projectId, epicoId, featureId, canEdit, children const [formValues, setFormValues] = useState(null); const [saving, setSaving] = useState(false); const [editMessage, setEditMessage] = useState(""); + const [reuseTick, setReuseTick] = useState(0); useEffect(() => { const controller = new AbortController(); @@ -286,7 +295,12 @@ export function FeatureDetail({ projectId, epicoId, featureId, canEdit, children setAttempt(v => v + 1)} /> - + {canEdit && !readOnly && !editing && ( + setReuseTick((value) => value + 1)} /> + )} + {children} diff --git a/frontend/src/views/backlog/PbiDetail.tsx b/frontend/src/views/backlog/PbiDetail.tsx index b9b512b..8aae6d9 100644 --- a/frontend/src/views/backlog/PbiDetail.tsx +++ b/frontend/src/views/backlog/PbiDetail.tsx @@ -22,6 +22,7 @@ import { BacklogBreadcrumb } from "./BacklogBreadcrumb"; import { BacklogTechnologySelector } from "./BacklogTechnologySelector"; import { CriteriaEditor, type ScenarioDraftHandler } from "./CriteriaView"; import { DecisionsPanel } from "./DecisionsPanel"; +import { SimilarItemsPanel } from "./SimilarItemsPanel"; import { SuggestionsPanel } from "./SuggestionsPanel"; import { ProvenanceBadge } from "./ProvenanceBadge"; import { ItemHistoryView } from "./ItemHistoryView"; @@ -85,6 +86,7 @@ export function PbiDetail({ ] = useState(""); const [attempt, setAttempt] = useState(0); + const [reuseTick, setReuseTick] = useState(0); const [editing, setEditing] = useState(false); const [formValues, setFormValues] = @@ -788,9 +790,22 @@ export function PbiDetail({ titulo="Critérios da feature (consulta)" /> + {canEdit && !readOnly && !editing && ( + setReuseTick((value) => value + 1)} + /> + )} + diff --git a/frontend/src/views/backlog/PbiForm.tsx b/frontend/src/views/backlog/PbiForm.tsx index b424c45..1f2f96f 100644 --- a/frontend/src/views/backlog/PbiForm.tsx +++ b/frontend/src/views/backlog/PbiForm.tsx @@ -13,6 +13,8 @@ import { useUnsavedChangesGuard } from "../../viewmodels/useUnsavedChangesGuard" import { Button } from "../common/ui"; import { BacklogTechnologySelector } from "./BacklogTechnologySelector"; import { QualityPanelView as QualityPanel } from "./QualityPanelView"; +import { reuseAfterCreate, SimilarItemsPanel, SimilarSelection } from "./SimilarItemsPanel"; +import type { SimilarItem } from "../../api/api_similar_items"; const emptyInput: PbiInput = { feature_id: "", @@ -39,6 +41,7 @@ export function PbiForm({ feature_id: featureId, }); + const [origin, setOrigin] = useState(null); const [busy, setBusy] = useState(false); const [message, setMessage] = useState(""); const submitting = useRef(false); @@ -179,6 +182,8 @@ export function PbiForm({ feature_id: featureId, }); + await reuseAfterCreate("pbi", pbi.id, origin); + if (mounted.current) { navigate( `${featurePath}/pbis/${pbi.id}`, @@ -314,6 +319,17 @@ export function PbiForm({ disabled={busy} /> + part.trim()).join(" ")} + disabled={busy} + selectedId={origin?.id ?? null} + onSelect={setOrigin} + /> + setOrigin(null)} /> + {message && ( diff --git a/frontend/src/views/backlog/SimilarItemsPanel.test.tsx b/frontend/src/views/backlog/SimilarItemsPanel.test.tsx new file mode 100644 index 0000000..8a4f4eb --- /dev/null +++ b/frontend/src/views/backlog/SimilarItemsPanel.test.tsx @@ -0,0 +1,132 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { reuseAfterCreate, SimilarItemsPanel, SimilarSelection } from "./SimilarItemsPanel"; +import { parseSimilarItem } from "../../api/api_similar_items"; + +const raw = (overrides: Record = {}) => ({ + tipo: "epico", id: "e-9", codigo: null, titulo: "Autenticação de usuários", status: "concluido", similaridade: 0.84, + trecho: "Permitir login seguro", projeto: { id: "p2", nome: "Beta" }, outro_projeto: true, + caminho: [], origem: "Beta", ja_aproveitado: false, + ...overrides, +}); +const json = (body: unknown, status = 200) => Promise.resolve(new Response(JSON.stringify(body), { status })); +const found = (items: unknown[] = [raw()]) => json({ items, total: items.length, degradado: false }); +const props = { kind: "epico" as const, projectId: "p1", titulo: "Autenticar usuários", descricao: "Login com e-mail e senha", debounceMs: 0 }; + +afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); + +it("não consulta enquanto o texto é curto demais", async () => { + const request = vi.fn(() => found()); + vi.stubGlobal("fetch", request); + render(); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(request).not.toHaveBeenCalled(); + expect(screen.queryByLabelText("Itens semelhantes no acervo")).toBeNull(); +}); + +it("mostra só um resumo recolhido e revela a origem e o aviso de outro projeto ao abrir", async () => { + const request = vi.fn(() => found()); + vi.stubGlobal("fetch", request); + render(); + + const toggle = await screen.findByRole("button", { name: /1 item semelhante no acervo \(1 de outro projeto\)/ }); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByText("Autenticação de usuários")).toBeNull(); + const [url, init] = request.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe("/api/v1/projects/p1/similar-items"); + expect(JSON.parse(String(init.body))).toMatchObject({ tipo: "epico", titulo: "Autenticar usuários" }); + + fireEvent.click(toggle); + expect(screen.getByText("Autenticação de usuários")).toBeInTheDocument(); + expect(screen.getByText("Outro projeto")).toBeInTheDocument(); + expect(screen.getByText("84% semelhante")).toBeInTheDocument(); + expect(screen.getByText("Beta")).toBeInTheDocument(); +}); + +it("não exibe nada quando não há semelhança ou quando a consulta falha", async () => { + const request = vi.fn() + .mockImplementationOnce(() => found([])) + .mockImplementationOnce(() => json({ error: "falhou" }, 500)); + vi.stubGlobal("fetch", request); + const { rerender } = render(); + await waitFor(() => expect(request).toHaveBeenCalledTimes(1)); + expect(screen.queryByRole("complementary")).toBeNull(); + rerender(); + await waitFor(() => expect(request).toHaveBeenCalledTimes(2)); + expect(screen.queryByRole("complementary")).toBeNull(); + expect(screen.queryByRole("alert")).toBeNull(); +}); + +it("no item já salvo, aproveitar chama a API, avisa o chamador e marca como aproveitado", async () => { + const request = vi.fn() + .mockImplementationOnce(() => found()) + .mockImplementationOnce(() => json({ + relacao: { id: "r1", tipo_relacao: "reaproveitado_de", destino_tipo: "epico", destino_id: "e-1", origem_tipo: "epico", origem_id: "e-9", origem_projeto_id: "p2", campos: ["titulo"] }, + sugestoes: [], + }, 201)); + vi.stubGlobal("fetch", request); + const onReused = vi.fn(); + render(); + + fireEvent.click(await screen.findByRole("button", { name: /item semelhante no acervo/ })); + fireEvent.click(screen.getByRole("button", { name: "Aproveitar" })); + + await waitFor(() => expect(onReused).toHaveBeenCalledTimes(1)); + const [url] = request.mock.calls[1] as unknown as [string]; + expect(url).toBe("/api/v1/epics/e-1/similar-items/e-9/reuse"); + expect(JSON.parse(String((request.mock.calls[0] as unknown as [string, RequestInit])[1].body))).toMatchObject({ excluir_id: "e-1" }); + expect(await screen.findByText("Já aproveitado")).toBeInTheDocument(); +}); + +it("mostra o erro do aproveitamento sem esconder a lista", async () => { + const request = vi.fn() + .mockImplementationOnce(() => found()) + .mockImplementationOnce(() => json({ error: "O item de origem está arquivado e não pode ser aproveitado." }, 409)); + vi.stubGlobal("fetch", request); + render(); + fireEvent.click(await screen.findByRole("button", { name: /item semelhante no acervo/ })); + fireEvent.click(screen.getByRole("button", { name: "Aproveitar" })); + expect(await screen.findByRole("alert")).toHaveTextContent("arquivado"); + expect(screen.getByText("Autenticação de usuários")).toBeInTheDocument(); +}); + +it("na criação, a escolha é só guardada e pode ser desfeita", async () => { + vi.stubGlobal("fetch", vi.fn(() => found())); + const onSelect = vi.fn(); + render(); + fireEvent.click(await screen.findByRole("button", { name: /item semelhante no acervo/ })); + fireEvent.click(screen.getByRole("button", { name: "Aproveitar ao criar" })); + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: "e-9", origem: "Beta" })); + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1); +}); + +it("registra a origem depois de criar e ignora falha sem travar a navegação", async () => { + const request = vi.fn(() => json({ relacao: { id: "r", tipo_relacao: "reaproveitado_de", destino_tipo: "epico", destino_id: "novo", origem_tipo: "epico", origem_id: "e-9", origem_projeto_id: "p2", campos: [] }, sugestoes: [] }, 201)); + vi.stubGlobal("fetch", request); + await reuseAfterCreate("epico", "novo", parseSimilarItem(raw())); + expect((request.mock.calls[0] as unknown as [string])[0]).toBe("/api/v1/epics/novo/similar-items/e-9/reuse"); + + const failing = vi.fn(() => json({ error: "x" }, 500)); + vi.stubGlobal("fetch", failing); + await expect(reuseAfterCreate("epico", "novo", parseSimilarItem(raw()))).resolves.toBeUndefined(); + await reuseAfterCreate("epico", "novo", null); + expect(failing).toHaveBeenCalledTimes(1); +}); + +it("a escolha feita fica visível com a origem mesmo fora da lista", () => { + const onClear = vi.fn(); + render(); + expect(screen.getByRole("status")).toHaveTextContent("Será aproveitado ao criar"); + expect(screen.getByRole("status")).toHaveTextContent("origem: Beta"); + fireEvent.click(screen.getByRole("button", { name: "Desfazer" })); + expect(onClear).toHaveBeenCalled(); +}); + +it("ocultar remove o painel", async () => { + vi.stubGlobal("fetch", vi.fn(() => found())); + render(); + await screen.findByRole("button", { name: /item semelhante no acervo/ }); + fireEvent.click(screen.getByRole("button", { name: "Ocultar itens semelhantes" })); + expect(screen.queryByLabelText("Itens semelhantes no acervo")).toBeNull(); +}); diff --git a/frontend/src/views/backlog/SimilarItemsPanel.tsx b/frontend/src/views/backlog/SimilarItemsPanel.tsx new file mode 100644 index 0000000..b652d0b --- /dev/null +++ b/frontend/src/views/backlog/SimilarItemsPanel.tsx @@ -0,0 +1,194 @@ +import { useEffect, useRef, useState } from "react"; +import { + describeReuseError, + reuseSimilarItem, + searchSimilarItems, + similarItemPath, + SIMILAR_MIN_DRAFT_LENGTH, + type SimilarItem, + type SimilarKind, +} from "../../api/api_similar_items"; +import "../../assets/styles/similar-items.css"; +import { Alert, Badge, Button } from "../common/ui"; + +const KIND_LABEL: Record = { epico: "Épico", feature: "Feature", pbi: "PBI" }; +const DEFAULT_DEBOUNCE_MS = 1200; + +export interface SimilarItemsPanelProps { + kind: SimilarKind; + projectId: string; + titulo: string; + descricao: string; + excludeId?: string; + disabled?: boolean; + canReuse?: boolean; + debounceMs?: number; + selectedId?: string | null; + onSelect?: (item: SimilarItem | null) => void; + reuseTargetId?: string; + onReused?: () => void; +} + +function percent(value: number): string { + return `${Math.round(Math.min(1, Math.max(0, value)) * 100)}%`; +} + +export function SimilarItemsPanel({ + kind, + projectId, + titulo, + descricao, + excludeId, + disabled = false, + canReuse = true, + debounceMs = DEFAULT_DEBOUNCE_MS, + selectedId = null, + onSelect, + reuseTargetId, + onReused, +}: SimilarItemsPanelProps) { + const [items, setItems] = useState([]); + const [open, setOpen] = useState(false); + const [dismissed, setDismissed] = useState(false); + const [busyId, setBusyId] = useState(null); + const [error, setError] = useState(""); + const [done, setDone] = useState>({}); + const mounted = useRef(true); + const lock = useRef(false); + + useEffect(() => { + mounted.current = true; + return () => { mounted.current = false; }; + }, []); + + const text = [titulo, descricao].map((part) => part.trim()).filter(Boolean).join("\n"); + + useEffect(() => { + if (disabled || dismissed) return; + if (text.length < SIMILAR_MIN_DRAFT_LENGTH) { + setItems([]); + return; + } + const controller = new AbortController(); + const timer = window.setTimeout(() => { + searchSimilarItems(projectId, { + tipo: kind, + titulo: titulo.trim() || undefined, + descricao: descricao.trim() || undefined, + excluir_id: excludeId, + }, controller.signal) + .then((result) => { if (!controller.signal.aborted) setItems(result.items); }) + .catch(() => { if (!controller.signal.aborted) setItems([]); }); + }, debounceMs); + return () => { window.clearTimeout(timer); controller.abort(); }; + }, [text, kind, projectId, excludeId, disabled, dismissed, debounceMs]); + + if (dismissed || items.length === 0) return null; + + const others = items.filter((item) => item.outro_projeto).length; + const summary = `${items.length} ${items.length === 1 ? "item semelhante" : "itens semelhantes"} no acervo` + + (others > 0 ? ` (${others} de ${others === 1 ? "outro projeto" : "outros projetos"})` : ""); + + const reuse = async (item: SimilarItem) => { + if (lock.current || !reuseTargetId) return; + lock.current = true; + setBusyId(item.id); + setError(""); + try { + await reuseSimilarItem(kind, reuseTargetId, item.id); + if (!mounted.current) return; + setDone((current) => ({ ...current, [item.id]: true })); + onReused?.(); + } catch (caught) { + if (mounted.current) setError(describeReuseError(caught)); + } finally { + lock.current = false; + if (mounted.current) setBusyId(null); + } + }; + + return ( + + ); +} +export function SimilarSelection({ item, onClear, disabled }: { item: SimilarItem | null; onClear: () => void; disabled?: boolean }) { + if (!item) return null; + return ( +

    + Será aproveitado ao criar: {item.titulo} — origem: {item.origem}. O conteúdo chegará como proposta editável + nas sugestões do novo item e a relação de origem ficará registrada.{" "} + +

    + ); +} + +export async function reuseAfterCreate(kind: SimilarKind, createdId: string, origin: SimilarItem | null): Promise { + if (!origin) return; + try { + await reuseSimilarItem(kind, createdId, origin.id); + } catch { + // O item já existe; a pessoa pode aproveitar de novo pela tela de detalhe. + } +} From dfe64e32ab9411333d85d78d8f60b8037898bbfd Mon Sep 17 00:00:00 2001 From: AgenciaWiks Date: Sun, 25 Oct 2026 13:24:37 -0300 Subject: [PATCH 4/4] fix(frontend): add similar items styles --- frontend/src/assets/styles/similar-items.css | 152 +++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 frontend/src/assets/styles/similar-items.css diff --git a/frontend/src/assets/styles/similar-items.css b/frontend/src/assets/styles/similar-items.css new file mode 100644 index 0000000..2234421 --- /dev/null +++ b/frontend/src/assets/styles/similar-items.css @@ -0,0 +1,152 @@ +.similar-panel { + display: grid; + gap: 12px; + margin-block: 16px; + padding: 16px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + background: var(--bg-card); +} + +.similar-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.similar-toggle { + display: inline-flex; + align-items: flex-start; + gap: 8px; + min-width: 0; + padding: 2px 0; + border: 0; + color: var(--text-primary); + background: transparent; + font: inherit; + font-weight: 600; + text-align: left; + cursor: pointer; +} + +.similar-toggle:hover { + color: var(--accent-secondary); +} + +.similar-note { + color: var(--text-secondary); + font-size: 0.875rem; + line-height: 1.5; +} + +.similar-list { + display: grid; + gap: 12px; + margin: 12px 0 0; + padding: 0; + list-style: none; +} + +.similar-item { + display: grid; + gap: 10px; + min-width: 0; + padding: 14px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + background: var(--bg-secondary); +} + +.similar-item[data-other-project="true"] { + border-inline-start: 3px solid var(--brand-primary); +} + +.similar-item-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.similar-item-head > strong { + min-width: 0; + overflow-wrap: anywhere; +} + +.similar-badges { + display: flex; + flex: 0 1 auto; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} + +.similar-origin, +.similar-excerpt { + color: var(--text-secondary); + font-size: 0.875rem; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.similar-excerpt { + padding: 10px 12px; + border-inline-start: 2px solid var(--border-strong); + background: color-mix(in srgb, var(--bg-tertiary) 72%, transparent); + white-space: pre-wrap; +} + +.similar-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.similar-actions a { + color: var(--accent-secondary); + font-size: 0.875rem; + text-underline-offset: 3px; +} + +.similar-actions a:hover { + color: var(--brand-primary); +} + +.similar-selected { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-block: 12px; + padding: 12px 14px; + border: 1px solid var(--border-active); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--brand-primary) 10%, var(--bg-card)); + color: var(--text-secondary); + overflow-wrap: anywhere; +} + +.similar-selected strong { + color: var(--text-primary); +} + +@media (max-width: 640px) { + .similar-panel { + padding: 12px; + } + + .similar-head, + .similar-item-head { + align-items: flex-start; + } + + .similar-item-head { + flex-direction: column; + } + + .similar-badges { + justify-content: flex-start; + } +}