diff --git a/backend/src/app.ts b/backend/src/app.ts index ebfd997..bc153a1 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -14,6 +14,7 @@ import qualityRouter from "./modules/quality/quality.routes.js"; import { epicsCompatRouter } from "./modules/epics/epics.compat.routes.js"; import { repoAnalysesRouter } from './modules/repo-analyses/repo-analyses.routes'; import { epicDecisionsRouter, featureDecisionsRouter, pbiDecisionsRouter, projectDecisionsRouter } from "./modules/decisions/decisions.routes.js"; +import { epicSuggestionsRouter, featureSuggestionsRouter, pbiSuggestionsRouter } from "./modules/suggestions/suggestions.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"; @@ -93,6 +94,11 @@ app.use("/api/v1/epics/:entityId/decisions", epicDecisionsRouter); app.use("/api/v1/features/:entityId/decisions", featureDecisionsRouter); app.use("/api/v1/pbis/:entityId/decisions", pbiDecisionsRouter); +// Ciclo humano de sugestões da IA e proveniência por campo (S2-13) +app.use("/api/v1/epics/:entityId/suggestions", epicSuggestionsRouter); +app.use("/api/v1/features/:entityId/suggestions", featureSuggestionsRouter); +app.use("/api/v1/pbis/:entityId/suggestions", pbiSuggestionsRouter); + // Busca textual no backlog (S1-17) app.use("/api/v1/projects/:projectId/backlog-search", backlogSearchRouter); @@ -118,6 +124,7 @@ app.get("/api/v1", (_req: Request, res: Response) => { { name: "pbis", status: "ready" }, { name: "criteria", status: "ready" }, { name: "decisions", status: "in_development" }, + { name: "suggestions", status: "ready" }, { name: "ai-bridge", status: "ready" }, ], }); diff --git a/backend/src/database/migration-014.db.test.ts b/backend/src/database/migration-014.db.test.ts new file mode 100644 index 0000000..2d7151a --- /dev/null +++ b/backend/src/database/migration-014.db.test.ts @@ -0,0 +1,72 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { validateTarget } from "./seed-lib.js"; +import { applyUntil, loadMigration, migrationFiles, withDatabase } from "./migration-test-utils.js"; + +const SUGGESTIONS_MIGRATION = "014_suggestions_and_field_provenance.sql"; + +test("migration 014 vem depois da 013 e é a única com esse número", { skip: !process.env.ARCHIVE_TEST_DATABASE_URL }, async () => { + const files = await migrationFiles(); + assert.ok(files.indexOf(SUGGESTIONS_MIGRATION) > files.indexOf("013_decision_records.sql")); + assert.equal(files.filter((file) => file.startsWith("014_")).length, 1); +}); + +test("migration 014 cria provenance_json e sugestao_ia, preserva dados legados e é idempotente", { skip: !process.env.ARCHIVE_TEST_DATABASE_URL }, async () => { + await withDatabase(validateTarget(process.env.ARCHIVE_TEST_DATABASE_URL, "test"), async (client, vectorAvailable) => { + await applyUntil(client, vectorAvailable, (file) => file === SUGGESTIONS_MIGRATION); + + const project = randomUUID(); + const epic = randomUUID(); + await client.query("INSERT INTO projeto (id,nome,cliente,status) VALUES ($1,$2,'T','ativo')", [project, project]); + await client.query("INSERT INTO epico (id,projeto_id,titulo) VALUES ($1,$2,'Épico legado')", [epic, project]); + + await applyUntil(client, vectorAvailable, () => false); + + const epicRow = (await client.query("SELECT titulo, provenance_json FROM epico WHERE id=$1", [epic])).rows[0]; + assert.equal(epicRow.titulo, "Épico legado", "linhas legadas são preservadas"); + assert.deepEqual(epicRow.provenance_json, {}, "proveniência default é vazia, implicando human-authored"); + + const columns = (await client.query( + "SELECT table_name, column_name FROM information_schema.columns WHERE table_name IN ('epico','feature','pbi') AND column_name='provenance_json'", + )).rows.map((row) => row.table_name); + assert.deepEqual(columns.sort(), ["epico", "feature", "pbi"]); + + const columnDefault = (await client.query( + "SELECT is_nullable, column_default FROM information_schema.columns WHERE table_name='pbi' AND column_name='provenance_json'", + )).rows[0]; + assert.equal(columnDefault.is_nullable, "NO"); + + const suggestion = randomUUID(); + await client.query( + `INSERT INTO sugestao_ia (id, entidade_tipo, entidade_id, campo, valor_sugerido) VALUES ($1,'epico',$2,'titulo','Novo título sugerido')`, + [suggestion, epic], + ); + await assert.rejects( + client.query(`INSERT INTO sugestao_ia (entidade_tipo, entidade_id, campo, valor_sugerido) VALUES ('documento',$1,'titulo','x')`, [randomUUID()]), + /ck_sugestao_ia_entidade_tipo/, + ); + await assert.rejects( + client.query(`INSERT INTO sugestao_ia (entidade_tipo, entidade_id, campo, valor_sugerido, status) VALUES ('epico',$1,'titulo','x','aceita')`, [randomUUID()]), + /ck_sugestao_ia_resolucao/, + "status resolvido exige resolvido_em/resolvido_por preenchidos", + ); + await assert.rejects( + client.query( + `INSERT INTO sugestao_ia (entidade_tipo, entidade_id, campo, valor_sugerido) VALUES ('epico',$1,'titulo','duplicada')`, + [epic], + ), + /uq_sugestao_ia_pendente/, + "duas propostas pendentes para o mesmo campo violam o índice único parcial", + ); + + const migration = await loadMigration(SUGGESTIONS_MIGRATION, vectorAvailable); + await client.query(migration); + await client.query(migration); + + assert.equal((await client.query("SELECT count(*)::int AS n FROM sugestao_ia")).rows[0].n, 1, "reaplicar a migração não duplica ou remove linhas"); + const indexes = (await client.query("SELECT indexname FROM pg_indexes WHERE tablename='sugestao_ia'")).rows.map((row) => row.indexname); + assert.ok(indexes.includes("uq_sugestao_ia_pendente")); + assert.ok(indexes.includes("idx_sugestao_ia_entidade")); + }); +}); diff --git a/backend/src/modules/epics/epics.repository.ts b/backend/src/modules/epics/epics.repository.ts index 740d239..f5078a9 100644 --- a/backend/src/modules/epics/epics.repository.ts +++ b/backend/src/modules/epics/epics.repository.ts @@ -8,6 +8,7 @@ import { auditService } from "../audit/audit.service.js"; import { assertWritable, lockHierarchy } from "../projects/hierarchy-archive.js"; import { assertJustificationForCompletedItem } from "../quality/completed-item-policy.js"; import { getEntityTechnologyIds, replaceEntityTechnologies } from "../technologies/entity-technologies.js"; +import { markFieldsHumanAuthored } from "../provenance/provenance.js"; import { EPIC_REQUIRED_FIELDS, CreateEpicDTO, Epic, EpicQueryDTO, EpicWithStats, PaginatedEpics, UpdateEpicDTO } from "./epics.types.js"; export class EpicsRepository { @@ -144,13 +145,14 @@ export class EpicsRepository { const updates: string[] = []; const values: unknown[] = []; + const changedFields: string[] = []; let valIndex = 1; - if (data.titulo !== undefined) { updates.push(`titulo = $${valIndex}`); values.push(data.titulo.trim()); valIndex++; } - if (data.descricao !== undefined) { updates.push(`descricao = $${valIndex}`); values.push(data.descricao?.trim() ?? null); valIndex++; } - if (data.objetivo !== undefined) { updates.push(`objetivo = $${valIndex}`); values.push(data.objetivo?.trim() ?? null); valIndex++; } - if (data.escopo_macro !== undefined) { updates.push(`escopo_macro = $${valIndex}`); values.push(data.escopo_macro?.trim() ?? null); valIndex++; } - if (data.resultado_esperado !== undefined) { updates.push(`resultado_esperado = $${valIndex}`); values.push(data.resultado_esperado?.trim() ?? null); valIndex++; } + if (data.titulo !== undefined) { updates.push(`titulo = $${valIndex}`); values.push(data.titulo.trim()); valIndex++; changedFields.push("titulo"); } + if (data.descricao !== undefined) { updates.push(`descricao = $${valIndex}`); values.push(data.descricao?.trim() ?? null); valIndex++; changedFields.push("descricao"); } + if (data.objetivo !== undefined) { updates.push(`objetivo = $${valIndex}`); values.push(data.objetivo?.trim() ?? null); valIndex++; changedFields.push("objetivo"); } + if (data.escopo_macro !== undefined) { updates.push(`escopo_macro = $${valIndex}`); values.push(data.escopo_macro?.trim() ?? null); valIndex++; changedFields.push("escopo_macro"); } + if (data.resultado_esperado !== undefined) { updates.push(`resultado_esperado = $${valIndex}`); values.push(data.resultado_esperado?.trim() ?? null); valIndex++; changedFields.push("resultado_esperado"); } if (data.prioridade !== undefined) { updates.push(`prioridade = $${valIndex}`); values.push(data.prioridade); valIndex++; } updates.push(`updated_at = CURRENT_TIMESTAMP`); @@ -161,6 +163,10 @@ export class EpicsRepository { values, ); const updated = result.rows[0]; + // A human submitted this edit through the ordinary update endpoint: any + // changed free-text field is explicitly human-authored from now on, + // overriding whatever AI provenance it may have had before (S2-13). + await markFieldsHumanAuthored(client, "epico", id, changedFields); await replaceEntityTechnologies(client, "epico", id, data.tecnologias_ids); const updatedWithTechnologies = { ...updated, diff --git a/backend/src/modules/epics/epics.types.ts b/backend/src/modules/epics/epics.types.ts index 6a02c25..e21b584 100644 --- a/backend/src/modules/epics/epics.types.ts +++ b/backend/src/modules/epics/epics.types.ts @@ -63,6 +63,7 @@ export interface Epic { tecnologias_ids?: string[]; prioridade: EpicPriority; status: EpicStatus; + provenance_json?: Record; created_at: Date | string; updated_at: Date | string; } diff --git a/backend/src/modules/features/features.repository.ts b/backend/src/modules/features/features.repository.ts index 6342f97..67a6536 100644 --- a/backend/src/modules/features/features.repository.ts +++ b/backend/src/modules/features/features.repository.ts @@ -8,6 +8,7 @@ import { auditService } from "../audit/audit.service.js"; import { assertWritable, lockHierarchy } from "../projects/hierarchy-archive.js"; import { assertJustificationForCompletedItem } from "../quality/completed-item-policy.js"; import { getEntityTechnologyIds, replaceEntityTechnologies } from "../technologies/entity-technologies.js"; +import { markFieldsHumanAuthored } from "../provenance/provenance.js"; import { FEATURE_REQUIRED_FIELDS, CreateFeatureDTO, Feature, FeatureQueryDTO, FeatureWithStats, PaginatedFeatures, UpdateFeatureDTO } from "./features.types.js"; const SELECT_WITH_STATS = ` @@ -139,11 +140,12 @@ export class FeaturesRepository { const updates: string[] = []; const values: unknown[] = []; + const changedFields: string[] = []; let valIndex = 1; - if (data.titulo !== undefined) { updates.push(`titulo = $${valIndex}`); values.push(data.titulo.trim()); valIndex++; } - if (data.descricao !== undefined) { updates.push(`descricao = $${valIndex}`); values.push(data.descricao?.trim() ?? null); valIndex++; } - if (data.objetivo !== undefined) { updates.push(`objetivo = $${valIndex}`); values.push(data.objetivo?.trim() ?? null); valIndex++; } + if (data.titulo !== undefined) { updates.push(`titulo = $${valIndex}`); values.push(data.titulo.trim()); valIndex++; changedFields.push("titulo"); } + if (data.descricao !== undefined) { updates.push(`descricao = $${valIndex}`); values.push(data.descricao?.trim() ?? null); valIndex++; changedFields.push("descricao"); } + if (data.objetivo !== undefined) { updates.push(`objetivo = $${valIndex}`); values.push(data.objetivo?.trim() ?? null); valIndex++; changedFields.push("objetivo"); } if (data.prioridade !== undefined) { updates.push(`prioridade = $${valIndex}`); values.push(data.prioridade); valIndex++; } updates.push(`updated_at = CURRENT_TIMESTAMP`); @@ -154,6 +156,10 @@ export class FeaturesRepository { values, ); const updated = result.rows[0]; + // A human submitted this edit through the ordinary update endpoint: any + // changed free-text field is explicitly human-authored from now on, + // overriding whatever AI provenance it may have had before (S2-13). + await markFieldsHumanAuthored(client, "feature", id, changedFields); await replaceEntityTechnologies(client, "feature", id, data.tecnologias_ids); const updatedWithTechnologies = { ...updated, diff --git a/backend/src/modules/features/features.types.ts b/backend/src/modules/features/features.types.ts index bf4676c..d2e83a5 100644 --- a/backend/src/modules/features/features.types.ts +++ b/backend/src/modules/features/features.types.ts @@ -52,6 +52,7 @@ export interface Feature { tecnologias_ids?: string[]; prioridade: FeaturePriority; status: FeatureStatus; + provenance_json?: Record; created_at: Date | string; updated_at: Date | string; } diff --git a/backend/src/modules/pbis/pbis.repository.ts b/backend/src/modules/pbis/pbis.repository.ts index af66776..f568de2 100644 --- a/backend/src/modules/pbis/pbis.repository.ts +++ b/backend/src/modules/pbis/pbis.repository.ts @@ -7,6 +7,7 @@ import { auditService } from "../audit/audit.service.js"; import { assertWritable, lockHierarchy } from "../projects/hierarchy-archive.js"; import { assertJustificationForCompletedItem, normalizeJustification } from "../quality/completed-item-policy.js"; import { getEntityTechnologyIds, replaceEntityTechnologies } from "../technologies/entity-technologies.js"; +import { markFieldsHumanAuthored } from "../provenance/provenance.js"; import { CreatePbiDTO, PaginatedPbis, Pbi, PbiQueryDTO, PbiWithContext, UpdatePbiDTO } from "./pbis.types.js"; const SELECT_WITH_CONTEXT = ` @@ -148,13 +149,14 @@ export class PbisRepository { const updates: string[] = []; const values: unknown[] = []; + const changedFields: string[] = []; let valIndex = 1; - if (data.titulo !== undefined) { updates.push(`titulo = $${valIndex}`); values.push(data.titulo.trim()); valIndex++; } - if (data.historia_como_um !== undefined) { updates.push(`historia_como_um = $${valIndex}`); values.push(data.historia_como_um.trim()); valIndex++; } - if (data.historia_eu_quero !== undefined) { updates.push(`historia_eu_quero = $${valIndex}`); values.push(data.historia_eu_quero.trim()); valIndex++; } - if (data.historia_para_que !== undefined) { updates.push(`historia_para_que = $${valIndex}`); values.push(data.historia_para_que.trim()); valIndex++; } - if (data.regras_observacoes !== undefined) { updates.push(`regras_observacoes = $${valIndex}`); values.push(data.regras_observacoes?.trim() ?? null); valIndex++; } + if (data.titulo !== undefined) { updates.push(`titulo = $${valIndex}`); values.push(data.titulo.trim()); valIndex++; changedFields.push("titulo"); } + if (data.historia_como_um !== undefined) { updates.push(`historia_como_um = $${valIndex}`); values.push(data.historia_como_um.trim()); valIndex++; changedFields.push("historia_como_um"); } + if (data.historia_eu_quero !== undefined) { updates.push(`historia_eu_quero = $${valIndex}`); values.push(data.historia_eu_quero.trim()); valIndex++; changedFields.push("historia_eu_quero"); } + if (data.historia_para_que !== undefined) { updates.push(`historia_para_que = $${valIndex}`); values.push(data.historia_para_que.trim()); valIndex++; changedFields.push("historia_para_que"); } + if (data.regras_observacoes !== undefined) { updates.push(`regras_observacoes = $${valIndex}`); values.push(data.regras_observacoes?.trim() ?? null); valIndex++; changedFields.push("regras_observacoes"); } if (data.tipo !== undefined) { updates.push(`tipo = $${valIndex}`); values.push(data.tipo); valIndex++; } if (data.prioridade !== undefined) { updates.push(`prioridade = $${valIndex}`); values.push(data.prioridade); valIndex++; } if (data.requer_interface !== undefined) { updates.push(`requer_interface = $${valIndex}`); values.push(data.requer_interface); valIndex++; } @@ -167,6 +169,10 @@ export class PbisRepository { values, ); const updated = result.rows[0]; + // A human submitted this edit through the ordinary update endpoint: any + // changed free-text field is explicitly human-authored from now on, + // overriding whatever AI provenance it may have had before (S2-13). + await markFieldsHumanAuthored(client, "pbi", id, changedFields); await replaceEntityTechnologies(client, "pbi", id, data.tecnologias_ids); const updatedWithTechnologies = { ...updated, diff --git a/backend/src/modules/pbis/pbis.types.ts b/backend/src/modules/pbis/pbis.types.ts index 03d1b31..df26fb9 100644 --- a/backend/src/modules/pbis/pbis.types.ts +++ b/backend/src/modules/pbis/pbis.types.ts @@ -75,6 +75,7 @@ export interface Pbi { status: PbiStatus; score_completude: number | null; provenance: string; + provenance_json?: Record; created_at: Date | string; updated_at: Date | string; } diff --git a/backend/src/modules/provenance/provenance.ts b/backend/src/modules/provenance/provenance.ts new file mode 100644 index 0000000..abb87dd --- /dev/null +++ b/backend/src/modules/provenance/provenance.ts @@ -0,0 +1,55 @@ +import type { PoolClient } from "pg"; + +// S2-13: proveniência por campo. "human-authored" é o padrão implícito de +// qualquer campo ausente do mapa — nunca precisa ser escrito explicitamente +// por uma edição manual comum, apenas quando uma sugestão de IA é resolvida. +export type ProvenanceValue = "human-authored" | "ai-accepted" | "ai-edited"; + +export type SuggestibleEntityType = "epico" | "feature" | "pbi"; + +export const SUGGESTIBLE_ENTITY_TYPES: readonly SuggestibleEntityType[] = ["epico", "feature", "pbi"]; + +// Somente campos de texto livre do guia de especificação aceitam sugestão; +// enums (status, prioridade, tipo), listas (tecnologias_ids) e campos +// derivados (score, contadores) ficam fora do ciclo de sugestão da IA. +export const SUGGESTIBLE_FIELDS: Record = { + epico: ["titulo", "descricao", "objetivo", "escopo_macro", "resultado_esperado"], + feature: ["titulo", "descricao", "objetivo"], + pbi: ["titulo", "historia_como_um", "historia_eu_quero", "historia_para_que", "regras_observacoes"], +}; + +const MAX_FIELD_LENGTH: Record = { + titulo: 255, +}; +const DEFAULT_MAX_LENGTH = 5000; + +export function maxLengthFor(field: string): number { + return MAX_FIELD_LENGTH[field] ?? DEFAULT_MAX_LENGTH; +} + +export function isSuggestibleField(kind: SuggestibleEntityType, field: string): boolean { + return SUGGESTIBLE_FIELDS[kind].includes(field); +} + +/** + * Marks the given fields as human-authored on the entity's provenance map. + * Called from the ordinary update() of epics/features/pbis, inside the same + * transaction, right after the field values themselves are written — a plain + * edit by a person always overrides any earlier AI provenance for that field. + * Fields outside the suggestible allow-list (status, prioridade, etc.) are + * silently ignored: provenance only tracks free-text specification fields. + */ +export async function markFieldsHumanAuthored( + client: PoolClient, + kind: SuggestibleEntityType, + id: string, + fields: readonly string[], +): Promise { + const relevant = fields.filter((field) => isSuggestibleField(kind, field)); + if (relevant.length === 0) return; + const patch: Record = Object.fromEntries(relevant.map((field) => [field, "human-authored"])); + await client.query( + `UPDATE ${kind} SET provenance_json = COALESCE(provenance_json, '{}'::jsonb) || $2::jsonb WHERE id = $1`, + [id, JSON.stringify(patch)], + ); +} diff --git a/backend/src/modules/suggestions/suggestions.db.test.ts b/backend/src/modules/suggestions/suggestions.db.test.ts new file mode 100644 index 0000000..1c10aec --- /dev/null +++ b/backend/src/modules/suggestions/suggestions.db.test.ts @@ -0,0 +1,165 @@ +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 { EpicsRepository } from "../epics/epics.repository.js"; +import { PbisRepository } from "../pbis/pbis.repository.js"; +import { SuggestionsRepository } from "./suggestions.repository.js"; +import { SuggestionsService } from "./suggestions.service.js"; + +test("S2-13: ciclo humano de sugestões, proveniência por campo e fallback manual 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 service = new SuggestionsService(new SuggestionsRepository(pool)); + const [user, project, epic, feature, pbi, archivedPbi, concludedPbi] = Array.from({ length: 7 }, () => randomUUID()); + const configId = "00000000-0000-4000-8000-000000000001"; + let originalConfiguration: unknown; + + try { + await pool.query("INSERT INTO usuario (id,nome,email,senha_hash,role) VALUES ($1,'Ana PO',$2,'h','po')", [user, `${user}@sugestoes.test`]); + await pool.query("INSERT INTO projeto (id,nome,cliente,status) VALUES ($1::uuid,$1::text,'T','ativo')", [project]); + await pool.query("INSERT INTO epico (id,projeto_id,titulo) VALUES ($1,$2,'Épico')", [epic, project]); + await pool.query("INSERT INTO feature (id,epico_id,titulo) VALUES ($1,$2,'Feature')", [feature, epic]); + await pool.query(`INSERT INTO pbi (id,feature_id,codigo,titulo,historia_como_um,historia_eu_quero,historia_para_que) VALUES + ($1,$2,'PBI-S.1','PBI','PO','x','y'),($3,$2,'PBI-S.2','PBI arquivado','PO','x','y'),($4,$2,'PBI-S.3','PBI concluído','PO','x','y')`, + [pbi, feature, archivedPbi, concludedPbi]); + + await t.test("propõe, aceita e grava proveniência por campo no item real", async () => { + const created = await service.create("pbi", pbi, user, { campo: "titulo", valor_sugerido: "Validar login com e-mail" }); + assert.equal(created.status, "pendente"); + const accepted = await service.accept("pbi", pbi, created.id, user, {}); + assert.equal(accepted.status, "aceita"); + assert.equal(accepted.valor_resolvido, "Validar login com e-mail"); + + const row = (await pool.query("SELECT titulo, provenance_json FROM pbi WHERE id=$1", [pbi])).rows[0]; + assert.equal(row.titulo, "Validar login com e-mail"); + assert.equal(row.provenance_json.titulo, "ai-accepted"); + + const versao = (await pool.query("SELECT versao, snapshot_json FROM pbi_versao WHERE pbi_id=$1 ORDER BY versao DESC LIMIT 1", [pbi])).rows[0]; + assert.equal(versao.versao, 1); + assert.equal(versao.snapshot_json.titulo, "Validar login com e-mail"); + + const audit = (await pool.query("SELECT acao, dados_json FROM auditoria WHERE entidade_id=$1 AND acao='ACEITAR_SUGESTAO_IA'", [pbi])).rows[0]; + assert.equal(audit.dados_json.campo, "titulo"); + }); + + await t.test("repetir a aceitação é idempotente (200) e editar depois conflita (409)", async () => { + const created = await service.create("epico", epic, user, { campo: "objetivo", valor_sugerido: "Organizar backlog" }); + const accepted = await service.accept("epico", epic, created.id, user, {}); + const repeated = await service.accept("epico", epic, created.id, user, {}); + assert.deepEqual(repeated, accepted); + await assert.rejects(service.edit("epico", epic, created.id, user, { valor: "Outro objetivo" }), /já foi aceita/); + + const row = (await pool.query("SELECT provenance_json FROM epico WHERE id=$1", [epic])).rows[0]; + assert.equal(row.provenance_json.objetivo, "ai-accepted"); + }); + + await t.test("editar aplica o novo valor com proveniência ai-edited; nova proposta pendente não duplica (idempotência de criação)", async () => { + const first = await service.create("feature", feature, user, { campo: "titulo", valor_sugerido: "Sessão do usuário" }); + const second = await service.create("feature", feature, user, { campo: "titulo", valor_sugerido: "Sessão e login do usuário" }); + assert.equal(first.id, second.id, "a proposta pendente anterior é substituída, não duplicada"); + + const edited = await service.edit("feature", feature, second.id, user, { valor: "Sessão, login e logout" }); + assert.equal(edited.status, "editada"); + assert.equal(edited.valor_sugerido, "Sessão e login do usuário", "preserva o texto originalmente sugerido"); + + const row = (await pool.query("SELECT titulo, provenance_json FROM feature WHERE id=$1", [feature])).rows[0]; + assert.equal(row.titulo, "Sessão, login e logout"); + assert.equal(row.provenance_json.titulo, "ai-edited"); + }); + + await t.test("descartar nunca grava na tabela de negócio e é idempotente", async () => { + const created = await service.create("pbi", pbi, user, { campo: "historia_como_um", valor_sugerido: "usuário autenticado" }); + const discarded = await service.discard("pbi", pbi, created.id, user); + assert.equal(discarded.status, "descartada"); + assert.equal(discarded.valor_resolvido, null); + assert.deepEqual(await service.discard("pbi", pbi, created.id, user), discarded); + + const row = (await pool.query("SELECT historia_como_um, provenance_json FROM pbi WHERE id=$1", [pbi])).rows[0]; + assert.equal(row.historia_como_um, "PO"); + assert.equal(row.provenance_json.historia_como_um, undefined); + }); + + await t.test("item arquivado recusa aceitar/editar com 409, corrida com o arquivamento resulta em conflito, mas descartar continua permitido", async () => { + const created = await service.create("pbi", archivedPbi, user, { campo: "titulo", valor_sugerido: "Novo título" }); + + const archiver = await pool.connect(); + try { + await archiver.query("BEGIN"); + await lockHierarchy(archiver, "pbi", archivedPbi); + await archiver.query("UPDATE pbi SET status='arquivado' WHERE id=$1", [archivedPbi]); + const attempt = service.accept("pbi", archivedPbi, created.id, user, {}); + await new Promise((resolve) => setTimeout(resolve, 150)); + await archiver.query("COMMIT"); + await assert.rejects(attempt, ArchiveConflict); + } finally { + archiver.release(); + } + + await assert.rejects(service.accept("pbi", archivedPbi, created.id, user, {}), ArchiveConflict); + await assert.rejects(service.edit("pbi", archivedPbi, created.id, user, { valor: "x" }), ArchiveConflict); + const discarded = await service.discard("pbi", archivedPbi, created.id, user); + assert.equal(discarded.status, "descartada"); + }); + + await t.test("item concluído exige justificativa para aceitar ou editar, mas não para descartar", async () => { + const savedConfiguration = await pool.query<{ configuration: unknown }>( + "SELECT configuration FROM quality_configuration WHERE id = $1 FOR UPDATE", + [configId], + ); + assert.ok(savedConfiguration.rows[0], "o banco de teste deve estar migrado"); + originalConfiguration = savedConfiguration.rows[0].configuration; + await pool.query( + `UPDATE quality_configuration + SET configuration = jsonb_set(configuration, '{exigir_justificativa_item_concluido}', 'true'::jsonb, true) + WHERE id = $1`, + [configId], + ); + await pool.query("UPDATE pbi SET status='concluido' WHERE id=$1", [concludedPbi]); + + const created = await service.create("pbi", concludedPbi, user, { campo: "titulo", valor_sugerido: "PBI revisado" }); + await assert.rejects(service.accept("pbi", concludedPbi, created.id, user, {}), /justificativa é obrigatória/); + const accepted = await service.accept("pbi", concludedPbi, created.id, user, { justificativa: "Correção aprovada em revisão" }); + assert.equal(accepted.status, "aceita"); + + const other = await service.create("pbi", concludedPbi, user, { campo: "historia_eu_quero", valor_sugerido: "ver meu histórico" }); + const discarded = await service.discard("pbi", concludedPbi, other.id, user); + assert.equal(discarded.status, "descartada"); + }); + + await t.test("isolamento: sugestão de outro item ou outro tipo não é encontrada", async () => { + const created = await service.create("pbi", pbi, user, { campo: "titulo", valor_sugerido: "x" }); + await assert.rejects(service.accept("epico", epic, created.id, user, {}), /não encontrada/); + await assert.rejects(service.accept("pbi", feature, created.id, user, {}), /não encontrada/); + }); + + await t.test("fallback manual: operar epics/features/pbis sem IA grava proveniência humana e não perde dados", async () => { + const epics = new EpicsRepository(pool); + const pbis = new PbisRepository(pool); + + await epics.update(epic, { titulo: "Épico revisado manualmente", objetivo: "Organizar backlog" }); + const epicRow = (await pool.query("SELECT titulo, objetivo, provenance_json FROM epico WHERE id=$1", [epic])).rows[0]; + assert.equal(epicRow.titulo, "Épico revisado manualmente"); + assert.equal(epicRow.provenance_json.titulo, "human-authored"); + assert.equal(epicRow.provenance_json.objetivo, "human-authored", "edição manual sobrescreve proveniência ai-accepted anterior"); + + await pbis.update(pbi, { historia_para_que: "ter acesso rápido ao sistema" }); + const pbiRow = (await pool.query("SELECT historia_para_que, provenance_json FROM pbi WHERE id=$1", [pbi])).rows[0]; + assert.equal(pbiRow.historia_para_que, "ter acesso rápido ao sistema"); + assert.equal(pbiRow.provenance_json.historia_para_que, "human-authored"); + }); + } finally { + const entityIds = [project, epic, feature, pbi, archivedPbi, concludedPbi]; + 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[])", [[pbi, archivedPbi, concludedPbi]]); + await pool.query("DELETE FROM auditoria WHERE usuario_id=$1", [user]); + await pool.query("DELETE FROM projeto WHERE id=$1", [project]); + await pool.query("DELETE FROM usuario WHERE id=$1", [user]); + if (originalConfiguration !== undefined) { + await pool.query("UPDATE quality_configuration SET configuration = $2 WHERE id = $1", [configId, originalConfiguration]); + } + await pool.end(); + } +}); diff --git a/backend/src/modules/suggestions/suggestions.fakes.ts b/backend/src/modules/suggestions/suggestions.fakes.ts new file mode 100644 index 0000000..6cf1146 --- /dev/null +++ b/backend/src/modules/suggestions/suggestions.fakes.ts @@ -0,0 +1,99 @@ +import { ArchiveConflict } from "../projects/archive.types.js"; +import { ConflictError, NotFoundError, ValidationError } from "../../shared/errors.js"; +import { SuggestionsRepository } from "./suggestions.repository.js"; +import type { CreateSuggestionInput, ResolveSuggestionInput, SuggestionRow } from "./suggestions.types.js"; + +export const EPIC = "a0000000-0000-4000-8000-000000000002"; +export const FEATURE = "a0000000-0000-4000-8000-000000000003"; +export const PBI = "a0000000-0000-4000-8000-000000000004"; +export const UNKNOWN_ENTITY = "a0000000-0000-4000-8000-00000000ffff"; +export const USER = "b0000000-0000-4000-8000-000000000001"; +export const OTHER_USER = "b0000000-0000-4000-8000-000000000002"; + +export class FakeSuggestionsRepository extends SuggestionsRepository { + public entities = new Set([EPIC, FEATURE, PBI]); + public archived = new Set(); + public requiresJustification = new Set(); + public rows: SuggestionRow[] = []; + private sequence = 0; + + constructor() { + super(); + } + + private nextId(): string { + this.sequence += 1; + return `c0000000-0000-4000-8000-${String(this.sequence).padStart(12, "0")}`; + } + + async entityExists(_kind: CreateSuggestionInput["entidadeTipo"], entityId: string): Promise { + return this.entities.has(entityId); + } + + async listForEntity(kind: CreateSuggestionInput["entidadeTipo"], entityId: string): Promise { + return this.rows + .filter((row) => row.entidade_tipo === kind && row.entidade_id === entityId) + .sort((left, right) => Number(right.status === "pendente") - Number(left.status === "pendente") || right.created_at.localeCompare(left.created_at)); + } + + async findOne(kind: CreateSuggestionInput["entidadeTipo"], entityId: string, suggestionId: string): Promise { + return this.rows.find((row) => row.id === suggestionId && row.entidade_tipo === kind && row.entidade_id === entityId) ?? null; + } + + async create(input: CreateSuggestionInput): Promise { + if (!this.entities.has(input.entidadeId)) throw new NotFoundError(); + const existingPending = this.rows.find((row) => row.status === "pendente" && row.entidade_tipo === input.entidadeTipo && row.entidade_id === input.entidadeId && row.campo === input.campo); + if (existingPending) { + existingPending.valor_sugerido = input.valorSugerido; + existingPending.origem = input.origem; + existingPending.criado_por = input.usuarioId; + existingPending.updated_at = new Date().toISOString(); + return existingPending; + } + const row: SuggestionRow = { + id: this.nextId(), + entidade_tipo: input.entidadeTipo, + entidade_id: input.entidadeId, + campo: input.campo, + valor_sugerido: input.valorSugerido, + valor_resolvido: null, + origem: input.origem, + 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, 8, 25, 10, this.sequence)).toISOString(), + updated_at: new Date(Date.UTC(2026, 8, 25, 10, this.sequence)).toISOString(), + }; + this.rows.push(row); + return row; + } + + async resolve(input: ResolveSuggestionInput): Promise { + const row = this.rows.find((item) => item.id === input.suggestionId && item.entidade_tipo === input.entidadeTipo && item.entidade_id === input.entidadeId); + if (!row) throw new NotFoundError("Sugestão não encontrada."); + + if (row.status !== "pendente") { + const sameOutcome = row.status === input.resolution && (input.resolution === "descartada" || row.valor_resolvido === input.valor); + if (sameOutcome) return row; + throw new ConflictError(`Esta sugestão já foi ${row.status} anteriormente; nenhuma nova ação foi aplicada.`); + } + + if (input.resolution !== "descartada") { + if (this.archived.has(input.entidadeId)) throw new ArchiveConflict("Item ou ancestral arquivado está disponível apenas para leitura."); + if (this.requiresJustification.has(input.entidadeId) && !input.justificativa?.trim()) { + throw new ValidationError("A justificativa é obrigatória ao alterar um item concluído."); + } + } + + row.status = input.resolution; + row.valor_resolvido = input.resolution === "descartada" ? null : input.valor; + row.resolvido_por = input.usuarioId; + row.resolvido_por_nome = "Ana PO"; + row.resolvido_em = new Date().toISOString(); + row.updated_at = row.resolvido_em; + return row; + } +} diff --git a/backend/src/modules/suggestions/suggestions.repository.ts b/backend/src/modules/suggestions/suggestions.repository.ts new file mode 100644 index 0000000..52cd1b7 --- /dev/null +++ b/backend/src/modules/suggestions/suggestions.repository.ts @@ -0,0 +1,171 @@ +import { Pool } from "pg"; +import { pool } from "../../database/db.js"; +import { auditService } from "../audit/audit.service.js"; +import { assertJustificationForCompletedItem } from "../quality/completed-item-policy.js"; +import { assertWritable, lockHierarchy } from "../projects/hierarchy-archive.js"; +import { ConflictError, NotFoundError } from "../../shared/errors.js"; +import type { CreateSuggestionInput, ResolveSuggestionInput, SuggestionRow } from "./suggestions.types.js"; + +const SELECT_COLUMNS = ` + s.id, s.entidade_tipo, s.entidade_id, s.campo, s.valor_sugerido, s.valor_resolvido, s.origem, s.status, + s.criado_por, criador.nome AS criado_por_nome, + s.resolvido_por, resolvedor.nome AS resolvido_por_nome, + s.resolvido_em::text AS resolvido_em, s.created_at::text AS created_at, s.updated_at::text AS updated_at +`; +const FROM_CLAUSE = ` + FROM sugestao_ia s + LEFT JOIN usuario criador ON criador.id = s.criado_por + LEFT JOIN usuario resolvedor ON resolvedor.id = s.resolvido_por +`; + +export class SuggestionsRepository { + private readonly pool: Pool; + + constructor(customPool?: Pool) { + this.pool = customPool ?? pool; + } + + async entityExists(kind: CreateSuggestionInput["entidadeTipo"], entityId: string): Promise { + const result = await this.pool.query(`SELECT 1 FROM ${kind} WHERE id = $1`, [entityId]); + return (result.rowCount ?? 0) > 0; + } + + async listForEntity(kind: CreateSuggestionInput["entidadeTipo"], entityId: string): Promise { + const result = await this.pool.query( + `SELECT ${SELECT_COLUMNS} ${FROM_CLAUSE} WHERE s.entidade_tipo = $1 AND s.entidade_id = $2 ORDER BY s.status = 'pendente' DESC, s.created_at DESC, s.id`, + [kind, entityId], + ); + return result.rows; + } + + async findOne(kind: CreateSuggestionInput["entidadeTipo"], entityId: string, suggestionId: string): Promise { + const result = await this.pool.query( + `SELECT ${SELECT_COLUMNS} ${FROM_CLAUSE} WHERE s.id = $1 AND s.entidade_tipo = $2 AND s.entidade_id = $3`, + [suggestionId, kind, entityId], + ); + return result.rows[0] ?? null; + } + + /** + * Idempotent proposal: a repeated run of the same AI pipeline for the same + * field replaces the still-pending proposal instead of piling up duplicates. + * Enforced by the partial unique index on (entidade_tipo, entidade_id, campo) + * WHERE status = 'pendente'; this never touches the business table. + */ + async create(input: CreateSuggestionInput): Promise { + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + const exists = await client.query(`SELECT 1 FROM ${input.entidadeTipo} WHERE id = $1`, [input.entidadeId]); + if (!exists.rowCount) throw new NotFoundError(); + 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.entidadeTipo, input.entidadeId, input.campo, input.valorSugerido, input.origem, input.usuarioId], + ); + await client.query("COMMIT"); + const created = await this.findOne(input.entidadeTipo, input.entidadeId, inserted.rows[0].id); + if (!created) throw new NotFoundError(); + return created; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + + /** + * Resolves a suggestion into acceptance, edit, or discard. Re-reads the row + * under FOR UPDATE so concurrent/duplicate resolve calls serialize safely: + * repeating the exact same resolution is a no-op (idempotent); requesting a + * different outcome on an already-resolved suggestion raises a conflict + * instead of silently overwriting history. Only "aceita"/"editada" ever + * write to the entity table, and only after the usual archive and + * completed-item-justification guards pass. + */ + async resolve(input: ResolveSuggestionInput): Promise { + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + await lockHierarchy(client, input.entidadeTipo, input.entidadeId); + const current = await client.query( + `SELECT ${SELECT_COLUMNS} ${FROM_CLAUSE} WHERE s.id = $1 AND s.entidade_tipo = $2 AND s.entidade_id = $3 FOR UPDATE OF s`, + [input.suggestionId, input.entidadeTipo, input.entidadeId], + ); + const suggestion = current.rows[0]; + if (!suggestion) throw new NotFoundError("Sugestão não encontrada."); + + if (suggestion.status !== "pendente") { + const sameOutcome = suggestion.status === input.resolution + && (input.resolution === "descartada" || suggestion.valor_resolvido === input.valor); + if (sameOutcome) { + await client.query("COMMIT"); + return suggestion; + } + throw new ConflictError(`Esta sugestão já foi ${suggestion.status === "aceita" ? "aceita" : suggestion.status === "editada" ? "editada" : "descartada"} anteriormente; nenhuma nova ação foi aplicada.`); + } + + const valorResolvido = input.resolution === "descartada" ? null : input.valor; + + if (input.resolution !== "descartada") { + await assertWritable(client, input.entidadeTipo, input.entidadeId); + await assertJustificationForCompletedItem(client, input.entidadeTipo, input.entidadeId, input.justificativa); + + const patch = JSON.stringify({ [suggestion.campo]: input.resolution === "aceita" ? "ai-accepted" : "ai-edited" }); + const updated = await client.query( + `UPDATE ${input.entidadeTipo} SET ${suggestion.campo} = $1, + provenance_json = COALESCE(provenance_json, '{}'::jsonb) || $2::jsonb, + updated_at = CURRENT_TIMESTAMP + WHERE id = $3 RETURNING *`, + [valorResolvido, patch, input.entidadeId], + ); + + if (input.entidadeTipo === "pbi") { + await client.query( + `INSERT INTO pbi_versao (pbi_id, versao, snapshot_json, justificativa, autor_id, created_at) + VALUES ($1, (SELECT COALESCE(MAX(versao), 0) + 1 FROM pbi_versao WHERE pbi_id = $1), $2, $3, $4, CURRENT_TIMESTAMP)`, + [input.entidadeId, JSON.stringify(updated.rows[0]), input.justificativa?.trim() || "", input.usuarioId], + ); + } + + await auditService.record({ + usuario_id: input.usuarioId, + entidade_tipo: input.entidadeTipo, + entidade_id: input.entidadeId, + acao: input.resolution === "aceita" ? "ACEITAR_SUGESTAO_IA" : "EDITAR_SUGESTAO_IA", + justificativa: input.justificativa ?? null, + dados_json: { sugestao_id: suggestion.id, campo: suggestion.campo, valor_sugerido: suggestion.valor_sugerido, valor_aplicado: valorResolvido }, + }, client); + } else { + await auditService.record({ + usuario_id: input.usuarioId, + entidade_tipo: input.entidadeTipo, + entidade_id: input.entidadeId, + acao: "DESCARTAR_SUGESTAO_IA", + dados_json: { sugestao_id: suggestion.id, campo: suggestion.campo, valor_sugerido: suggestion.valor_sugerido }, + }, client); + } + + await client.query( + `UPDATE sugestao_ia SET status = $1, valor_resolvido = $2, resolvido_por = $3, resolvido_em = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE id = $4`, + [input.resolution, valorResolvido, input.usuarioId, suggestion.id], + ); + + await client.query("COMMIT"); + const refreshed = await this.findOne(input.entidadeTipo, input.entidadeId, suggestion.id); + if (!refreshed) throw new NotFoundError("Sugestão não encontrada."); + return refreshed; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } +} diff --git a/backend/src/modules/suggestions/suggestions.routes.test.ts b/backend/src/modules/suggestions/suggestions.routes.test.ts new file mode 100644 index 0000000..7c9a405 --- /dev/null +++ b/backend/src/modules/suggestions/suggestions.routes.test.ts @@ -0,0 +1,119 @@ +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 { createSuggestionsRouter } from "./suggestions.routes.js"; +import { SuggestionsService } from "./suggestions.service.js"; +import { EPIC, FakeSuggestionsRepository, PBI, USER } from "./suggestions.fakes.js"; + +const repository = new FakeSuggestionsRepository(); +let server: Server; +let baseUrl: string; +let role: UserRole | null = "po"; + +before(() => { + const service = new SuggestionsService(repository); + 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/pbis/:entityId/suggestions", createSuggestionsRouter("pbi", service, authentication, requireRole("admin", "po"))); + app.use("/api/v1/epics/:entityId/suggestions", createSuggestionsRouter("epico", 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, init: { method?: string; json?: unknown } = {}) => + fetch(`${baseUrl}${path}`, { + method: init.method ?? "GET", + headers: { "Content-Type": "application/json" }, + body: init.json === undefined ? undefined : JSON.stringify(init.json), + }); + +test("exige autenticação em todas as sub-rotas", async () => { + role = null; + assert.equal((await call(`/pbis/${PBI}/suggestions`)).status, 401); + assert.equal((await call(`/pbis/${PBI}/suggestions`, { method: "POST", json: { campo: "titulo", valor_sugerido: "x" } })).status, 401); + assert.equal((await call(`/pbis/${PBI}/suggestions/x/accept`, { method: "POST" })).status, 401); + role = "po"; +}); + +test("PO propõe (201) e todos os perfis consultam; dev não propõe nem resolve (403)", async () => { + const created = await call(`/pbis/${PBI}/suggestions`, { method: "POST", json: { campo: "titulo", valor_sugerido: "Validar login" } }); + assert.equal(created.status, 201); + const body = await created.json() as { id: string; status: string }; + assert.equal(body.status, "pendente"); + + role = "dev"; + assert.equal((await call(`/pbis/${PBI}/suggestions`, { method: "POST", json: { campo: "titulo", valor_sugerido: "x" } })).status, 403); + assert.equal((await call(`/pbis/${PBI}/suggestions/${body.id}/accept`, { method: "POST" })).status, 403); + const list = await call(`/pbis/${PBI}/suggestions`); + assert.equal(list.status, 200); + assert.equal(((await list.json()) as { items: unknown[] }).items.length, 1); + role = "po"; +}); + +test("valida o corpo da proposta e o identificador do item", async () => { + assert.equal((await call(`/pbis/${PBI}/suggestions`, { method: "POST", json: { campo: "status", valor_sugerido: "concluido" } })).status, 400); + assert.equal((await call(`/pbis/${PBI}/suggestions`, { method: "POST", json: {} })).status, 400); + assert.equal((await call(`/pbis/nao-uuid/suggestions`)).status, 400); + assert.equal((await call(`/pbis/a0000000-0000-4000-8000-0000000000ff/suggestions`)).status, 404); +}); + +test("ciclo completo: aceitar aplica o valor e repetir é idempotente (200)", async () => { + const created = await (await call(`/epics/${EPIC}/suggestions`, { method: "POST", json: { campo: "objetivo", valor_sugerido: "Organizar backlog" } })).json() as { id: string }; + const accept = await call(`/epics/${EPIC}/suggestions/${created.id}/accept`, { method: "POST", json: {} }); + assert.equal(accept.status, 200); + const acceptedBody = await accept.json() as { status: string; valor_resolvido: string }; + assert.equal(acceptedBody.status, "aceita"); + assert.equal(acceptedBody.valor_resolvido, "Organizar backlog"); + + const repeat = await call(`/epics/${EPIC}/suggestions/${created.id}/accept`, { method: "POST", json: {} }); + assert.equal(repeat.status, 200); + assert.deepEqual(await repeat.json(), acceptedBody); +}); + +test("editar depois de aceito com valor diferente retorna 409", async () => { + const created = await (await call(`/epics/${EPIC}/suggestions`, { method: "POST", json: { campo: "descricao", valor_sugerido: "Texto original" } })).json() as { id: string }; + await call(`/epics/${EPIC}/suggestions/${created.id}/accept`, { method: "POST", json: {} }); + const conflict = await call(`/epics/${EPIC}/suggestions/${created.id}/edit`, { method: "POST", json: { valor: "Outro texto" } }); + assert.equal(conflict.status, 409); +}); + +test("descartar nunca aplica valor e é idempotente", async () => { + const created = await (await call(`/epics/${EPIC}/suggestions`, { method: "POST", json: { campo: "titulo", valor_sugerido: "Descartar isso" } })).json() as { id: string }; + const discard = await call(`/epics/${EPIC}/suggestions/${created.id}/discard`, { method: "POST" }); + assert.equal(discard.status, 200); + const body = await discard.json() as { status: string; valor_resolvido: string | null }; + assert.equal(body.status, "descartada"); + assert.equal(body.valor_resolvido, null); + assert.equal((await call(`/epics/${EPIC}/suggestions/${created.id}/discard`, { method: "POST" })).status, 200); +}); + +test("item ou ancestral arquivado recusa aceitar/editar com 409", async () => { + const created = await (await call(`/pbis/${PBI}/suggestions`, { method: "POST", json: { campo: "historia_como_um", valor_sugerido: "usuário cadastrado" } })).json() as { id: string }; + repository.archived.add(PBI); + assert.equal((await call(`/pbis/${PBI}/suggestions/${created.id}/accept`, { method: "POST", json: {} })).status, 409); + repository.archived.delete(PBI); +}); + +test("sugestão inexistente ou de outro item retorna 404", async () => { + assert.equal((await call(`/pbis/${PBI}/suggestions/a0000000-0000-4000-8000-0000000000ff/accept`, { method: "POST", json: {} })).status, 404); + const created = await (await call(`/pbis/${PBI}/suggestions`, { method: "POST", json: { campo: "titulo", valor_sugerido: "x" } })).json() as { id: string }; + assert.equal((await call(`/epics/${EPIC}/suggestions/${created.id}/accept`, { method: "POST", json: {} })).status, 404); +}); diff --git a/backend/src/modules/suggestions/suggestions.routes.ts b/backend/src/modules/suggestions/suggestions.routes.ts new file mode 100644 index 0000000..be8ee2e --- /dev/null +++ b/backend/src/modules/suggestions/suggestions.routes.ts @@ -0,0 +1,169 @@ +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 { SuggestionsService, suggestionsService } from "./suggestions.service.js"; +import type { SuggestibleEntityType } from "./suggestions.types.js"; + +export function createSuggestionsRouter( + tipo: SuggestibleEntityType, + service: SuggestionsService = suggestionsService, + authentication: RequestHandler = requireAuth, + canWrite: RequestHandler = requireRole("admin", "po"), +): Router { + const router = Router({ mergeParams: true }); + router.use(authentication); + + function requireUser(req: Request): string { + if (!req.auth?.id) throw new ValidationError("Autenticação necessária."); + return req.auth.id; + } + + /** + * @swagger + * /api/v1/{nivel}/{id}/suggestions: + * get: + * summary: Lista as sugestões da IA para o item (S2-13) + * tags: [Suggestions] + * security: + * - cookieAuth: [] + * responses: + * 200: + * description: Sugestões do item, pendentes primeiro + * 404: + * description: Item não encontrado + */ + router.get("/", async (req: Request, res: Response, next: NextFunction) => { + try { + res.json({ items: await service.list(tipo, String(req.params.entityId)) }); + } catch (error) { + next(error); + } + }); + + /** + * @swagger + * /api/v1/{nivel}/{id}/suggestions: + * post: + * summary: Propõe uma sugestão para um campo do item (S2-13) + * description: > + * Staging da IA: nunca altera o item sozinha. Uma nova proposta para o + * mesmo campo substitui a sugestão pendente existente (idempotente). + * tags: [Suggestions] + * security: + * - cookieAuth: [] + * responses: + * 201: + * description: Sugestão pendente criada ou atualizada + * 400: + * description: Campo fora do permitido ou valor inválido + * 403: + * description: Perfil sem permissão de escrita + * 404: + * description: Item não encontrado + */ + router.post("/", canWrite, async (req: Request, res: Response, next: NextFunction) => { + try { + res.status(201).json(await service.create(tipo, String(req.params.entityId), requireUser(req), req.body)); + } catch (error) { + next(error); + } + }); + + /** + * @swagger + * /api/v1/{nivel}/{id}/suggestions/{suggestionId}/accept: + * post: + * summary: Aceita a sugestão e aplica o valor sugerido ao item (S2-13) + * description: > + * Repetir a mesma aceitação é idempotente (200, sem duplicar). Já + * resolvida de outra forma retorna 409. Marca o campo como + * ai-accepted na proveniência. + * tags: [Suggestions] + * security: + * - cookieAuth: [] + * responses: + * 200: + * description: Sugestão aceita e campo atualizado + * 400: + * description: Justificativa obrigatória em item concluído + * 403: + * description: Perfil sem permissão de escrita + * 404: + * description: Item ou sugestão não encontrados + * 409: + * description: Item arquivado ou sugestão já resolvida de outra forma + */ + router.post("/:suggestionId/accept", canWrite, async (req: Request, res: Response, next: NextFunction) => { + try { + res.json(await service.accept(tipo, String(req.params.entityId), String(req.params.suggestionId), requireUser(req), req.body)); + } catch (error) { + next(error); + } + }); + + /** + * @swagger + * /api/v1/{nivel}/{id}/suggestions/{suggestionId}/edit: + * post: + * summary: Edita a sugestão antes de aplicá-la ao item (S2-13) + * description: > + * Repetir a mesma edição é idempotente (200). Marca o campo como + * ai-edited na proveniência, preservando o texto originalmente + * sugerido para referência. + * tags: [Suggestions] + * security: + * - cookieAuth: [] + * responses: + * 200: + * description: Sugestão editada e campo atualizado com o novo valor + * 400: + * description: Valor editado vazio, longo demais ou justificativa obrigatória + * 403: + * description: Perfil sem permissão de escrita + * 404: + * description: Item ou sugestão não encontrados + * 409: + * description: Item arquivado ou sugestão já resolvida de outra forma + */ + router.post("/:suggestionId/edit", canWrite, async (req: Request, res: Response, next: NextFunction) => { + try { + res.json(await service.edit(tipo, String(req.params.entityId), String(req.params.suggestionId), requireUser(req), req.body)); + } catch (error) { + next(error); + } + }); + + /** + * @swagger + * /api/v1/{nivel}/{id}/suggestions/{suggestionId}/discard: + * post: + * summary: Descarta a sugestão sem alterar o item (S2-13) + * description: Repetir o descarte é idempotente (200). Nunca escreve no item. + * tags: [Suggestions] + * security: + * - cookieAuth: [] + * responses: + * 200: + * description: Sugestão descartada + * 403: + * description: Perfil sem permissão de escrita + * 404: + * description: Item ou sugestão não encontrados + * 409: + * description: Sugestão já resolvida de outra forma + */ + router.post("/:suggestionId/discard", canWrite, async (req: Request, res: Response, next: NextFunction) => { + try { + res.json(await service.discard(tipo, String(req.params.entityId), String(req.params.suggestionId), requireUser(req))); + } catch (error) { + next(error); + } + }); + + return router; +} + +export const epicSuggestionsRouter = createSuggestionsRouter("epico"); +export const featureSuggestionsRouter = createSuggestionsRouter("feature"); +export const pbiSuggestionsRouter = createSuggestionsRouter("pbi"); diff --git a/backend/src/modules/suggestions/suggestions.service.test.ts b/backend/src/modules/suggestions/suggestions.service.test.ts new file mode 100644 index 0000000..9dd8cdf --- /dev/null +++ b/backend/src/modules/suggestions/suggestions.service.test.ts @@ -0,0 +1,144 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { ArchiveConflict } from "../projects/archive.types.js"; +import { ConflictError, NotFoundError, ValidationError } from "../../shared/errors.js"; +import { SuggestionsService } from "./suggestions.service.js"; +import { EPIC, FakeSuggestionsRepository, FEATURE, PBI, UNKNOWN_ENTITY, USER } from "./suggestions.fakes.js"; + +function setup() { + const repository = new FakeSuggestionsRepository(); + return { repository, service: new SuggestionsService(repository) }; +} + +test("propõe uma sugestão pendente para um campo permitido do item", async () => { + const { service } = setup(); + const created = await service.create("pbi", PBI, USER, { campo: "titulo", valor_sugerido: "Validar login com e-mail" }); + assert.equal(created.status, "pendente"); + assert.equal(created.campo, "titulo"); + assert.equal(created.valor_sugerido, "Validar login com e-mail"); + assert.equal(created.origem, "manual"); + assert.equal(created.valor_resolvido, null); +}); + +test("aceita origem customizada e normaliza espaços no valor sugerido", async () => { + const { service } = setup(); + const created = await service.create("epico", EPIC, USER, { campo: "objetivo", valor_sugerido: " Reduzir retrabalho ", origem: "harness-pro4tech" }); + assert.equal(created.valor_sugerido, "Reduzir retrabalho"); + assert.equal(created.origem, "harness-pro4tech"); +}); + +test("recusa campo fora do permitido para o tipo de entidade, valor vazio ou longo demais", async () => { + const { service, repository } = setup(); + await assert.rejects(service.create("pbi", PBI, USER, { campo: "status", valor_sugerido: "concluido" }), ValidationError); + await assert.rejects(service.create("pbi", PBI, USER, { campo: "escopo_macro", valor_sugerido: "x" }), ValidationError, "campo exclusivo de épico não vale para pbi"); + await assert.rejects(service.create("pbi", PBI, USER, { campo: "titulo", valor_sugerido: " " }), ValidationError); + await assert.rejects(service.create("pbi", PBI, USER, { campo: "titulo", valor_sugerido: "x".repeat(256) }), /máximo 255/); + await assert.rejects(service.create("epico", EPIC, USER, { campo: "descricao", valor_sugerido: "x".repeat(5001) }), /máximo 5000/); + assert.equal(repository.rows.length, 0); +}); + +test("item inexistente ou identificador malformado", async () => { + const { service } = setup(); + await assert.rejects(service.create("pbi", UNKNOWN_ENTITY, USER, { campo: "titulo", valor_sugerido: "x" }), NotFoundError); + await assert.rejects(service.list("pbi", "nao-uuid"), ValidationError); + await assert.rejects(service.accept("pbi", "nao-uuid", "nao-uuid", USER, {}), ValidationError); +}); + +test("uma nova proposta para o mesmo campo substitui a pendente existente (idempotência de criação)", async () => { + const { service, repository } = setup(); + const first = await service.create("pbi", PBI, USER, { campo: "titulo", valor_sugerido: "Primeira versão" }); + const second = await service.create("pbi", PBI, USER, { campo: "titulo", valor_sugerido: "Segunda versão" }); + assert.equal(first.id, second.id); + assert.equal(second.valor_sugerido, "Segunda versão"); + assert.equal(repository.rows.length, 1); +}); + +test("aceitar aplica o valor sugerido; repetir a mesma aceitação é idempotente", async () => { + const { service } = setup(); + const created = await service.create("pbi", PBI, USER, { campo: "titulo", valor_sugerido: "Validar login" }); + const accepted = await service.accept("pbi", PBI, created.id, USER, {}); + assert.equal(accepted.status, "aceita"); + assert.equal(accepted.valor_resolvido, "Validar login"); + assert.equal(accepted.resolvido_por, USER); + assert.ok(accepted.resolvido_em); + + const repeated = await service.accept("pbi", PBI, created.id, USER, {}); + assert.deepEqual(repeated, accepted); +}); + +test("editar aplica o novo valor e preserva o texto originalmente sugerido; repetir a mesma edição é idempotente", async () => { + const { service } = setup(); + const created = await service.create("epico", EPIC, USER, { campo: "objetivo", valor_sugerido: "Organizar o backlog" }); + const edited = await service.edit("epico", EPIC, created.id, USER, { valor: "Organizar e priorizar o backlog" }); + assert.equal(edited.status, "editada"); + assert.equal(edited.valor_resolvido, "Organizar e priorizar o backlog"); + assert.equal(edited.valor_sugerido, "Organizar o backlog"); + + const repeated = await service.edit("epico", EPIC, created.id, USER, { valor: "Organizar e priorizar o backlog" }); + assert.deepEqual(repeated, edited); +}); + +test("editar com um valor diferente do já resolvido conflita (409) e não altera o estado", async () => { + const { service } = setup(); + const created = await service.create("feature", FEATURE, USER, { campo: "titulo", valor_sugerido: "Sessão do usuário" }); + const accepted = await service.accept("feature", FEATURE, created.id, USER, {}); + await assert.rejects(service.edit("feature", FEATURE, created.id, USER, { valor: "Outro texto" }), ConflictError); + const stillAccepted = (await service.list("feature", FEATURE))[0]; + assert.deepEqual(stillAccepted, accepted); +}); + +test("descartar nunca altera o item e é idempotente; discordar do descarte depois de aceitar conflita", async () => { + const { service } = setup(); + const created = await service.create("pbi", PBI, USER, { campo: "titulo", valor_sugerido: "Validar login" }); + const discarded = await service.discard("pbi", PBI, created.id, USER); + assert.equal(discarded.status, "descartada"); + assert.equal(discarded.valor_resolvido, null); + assert.deepEqual(await service.discard("pbi", PBI, created.id, USER), discarded); + + const other = await service.create("pbi", PBI, USER, { campo: "historia_como_um", valor_sugerido: "usuário cadastrado" }); + await service.accept("pbi", PBI, other.id, USER, {}); + await assert.rejects(service.discard("pbi", PBI, other.id, USER), ConflictError); +}); + +test("sugestão editada exige valor não vazio e respeita o limite do campo alvo", async () => { + const { service } = setup(); + const created = await service.create("pbi", PBI, USER, { campo: "titulo", valor_sugerido: "Validar login" }); + await assert.rejects(service.edit("pbi", PBI, created.id, USER, { valor: " " }), ValidationError); + await assert.rejects(service.edit("pbi", PBI, created.id, USER, { valor: "x".repeat(256) }), /máximo 255/); +}); + +test("item ou ancestral arquivado recusa aceitar/editar (409) mas ainda permite descartar", async () => { + const { service, repository } = setup(); + const created = await service.create("pbi", PBI, USER, { campo: "titulo", valor_sugerido: "Validar login" }); + repository.archived.add(PBI); + await assert.rejects(service.accept("pbi", PBI, created.id, USER, {}), ArchiveConflict); + await assert.rejects(service.edit("pbi", PBI, created.id, USER, { valor: "x" }), ArchiveConflict); + const discarded = await service.discard("pbi", PBI, created.id, USER); + assert.equal(discarded.status, "descartada"); +}); + +test("item concluído exige justificativa para aceitar ou editar, mas não para descartar", async () => { + const { service, repository } = setup(); + repository.requiresJustification.add(PBI); + const created = await service.create("pbi", PBI, USER, { campo: "titulo", valor_sugerido: "Validar login" }); + await assert.rejects(service.accept("pbi", PBI, created.id, USER, {}), ValidationError); + const accepted = await service.accept("pbi", PBI, created.id, USER, { justificativa: "Ajuste de clareza aprovado em revisão." }); + assert.equal(accepted.status, "aceita"); +}); + +test("sugestão de outra entidade ou tipo não é encontrada (isolamento)", async () => { + const { service } = setup(); + const created = await service.create("pbi", PBI, USER, { campo: "titulo", valor_sugerido: "Validar login" }); + await assert.rejects(service.accept("epico", EPIC, created.id, USER, {}), NotFoundError); + await assert.rejects(service.accept("pbi", EPIC, created.id, USER, {}), NotFoundError); +}); + +test("lista mostra pendentes primeiro", async () => { + const { service } = setup(); + const a = await service.create("pbi", PBI, USER, { campo: "titulo", valor_sugerido: "a" }); + await service.discard("pbi", PBI, a.id, USER); + const b = await service.create("pbi", PBI, USER, { campo: "historia_como_um", valor_sugerido: "b" }); + const list = await service.list("pbi", PBI); + assert.equal(list[0].id, b.id); + assert.equal(list[0].status, "pendente"); +}); diff --git a/backend/src/modules/suggestions/suggestions.service.ts b/backend/src/modules/suggestions/suggestions.service.ts new file mode 100644 index 0000000..0624aec --- /dev/null +++ b/backend/src/modules/suggestions/suggestions.service.ts @@ -0,0 +1,83 @@ +import { NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; +import { SuggestionsRepository } from "./suggestions.repository.js"; +import { + createSuggestionSchema, + resolveAcceptSchema, + resolveEditSchema, + type ResolveSuggestionInput, + type SuggestibleEntityType, + type SuggestionRow, +} from "./suggestions.types.js"; + +const LABELS: Record = { epico: "épico", feature: "feature", pbi: "PBI" }; + +function zodMessage(error: { issues: Array<{ message: string }> }): string { + return error.issues[0]?.message ?? "Dados inválidos."; +} + +export class SuggestionsService { + constructor(private readonly repository: SuggestionsRepository = new SuggestionsRepository()) {} + + async list(kind: SuggestibleEntityType, entityId: string): Promise { + validateUuid(entityId, `ID do ${LABELS[kind]}`); + if (!(await this.repository.entityExists(kind, entityId))) { + throw new NotFoundError(`${LABELS[kind][0].toUpperCase()}${LABELS[kind].slice(1)} não encontrado.`); + } + return this.repository.listForEntity(kind, entityId); + } + + async create(kind: SuggestibleEntityType, entityId: string, usuarioId: string, body: unknown): Promise { + validateUuid(entityId, `ID do ${LABELS[kind]}`); + const parsed = createSuggestionSchema(kind).safeParse(body); + if (!parsed.success) throw new ValidationError(zodMessage(parsed.error), parsed.error.flatten().fieldErrors); + return this.repository.create({ + entidadeTipo: kind, + entidadeId: entityId, + campo: parsed.data.campo, + valorSugerido: parsed.data.valor_sugerido, + origem: parsed.data.origem?.trim() || "manual", + usuarioId, + }); + } + + private async requireSuggestion(kind: SuggestibleEntityType, entityId: string, suggestionId: string): Promise { + validateUuid(entityId, `ID do ${LABELS[kind]}`); + validateUuid(suggestionId, "ID da sugestão"); + const suggestion = await this.repository.findOne(kind, entityId, suggestionId); + if (!suggestion) throw new NotFoundError("Sugestão não encontrada."); + return suggestion; + } + + async accept(kind: SuggestibleEntityType, entityId: string, suggestionId: string, usuarioId: string, body: unknown): Promise { + const suggestion = await this.requireSuggestion(kind, entityId, suggestionId); + const parsed = resolveAcceptSchema.safeParse(body ?? {}); + if (!parsed.success) throw new ValidationError(zodMessage(parsed.error), parsed.error.flatten().fieldErrors); + return this.resolve(kind, entityId, suggestionId, usuarioId, "aceita", suggestion.valor_sugerido, parsed.data.justificativa ?? null); + } + + async edit(kind: SuggestibleEntityType, entityId: string, suggestionId: string, usuarioId: string, body: unknown): Promise { + const suggestion = await this.requireSuggestion(kind, entityId, suggestionId); + const parsed = resolveEditSchema(suggestion.campo).safeParse(body); + if (!parsed.success) throw new ValidationError(zodMessage(parsed.error), parsed.error.flatten().fieldErrors); + return this.resolve(kind, entityId, suggestionId, usuarioId, "editada", parsed.data.valor, parsed.data.justificativa ?? null); + } + + async discard(kind: SuggestibleEntityType, entityId: string, suggestionId: string, usuarioId: string): Promise { + await this.requireSuggestion(kind, entityId, suggestionId); + return this.resolve(kind, entityId, suggestionId, usuarioId, "descartada", null, null); + } + + private async resolve( + kind: SuggestibleEntityType, + entityId: string, + suggestionId: string, + usuarioId: string, + resolution: ResolveSuggestionInput["resolution"], + valor: string | null, + justificativa: string | null, + ): Promise { + return this.repository.resolve({ entidadeTipo: kind, entidadeId: entityId, suggestionId, resolution, valor, justificativa, usuarioId }); + } +} + +export const suggestionsService = new SuggestionsService(); diff --git a/backend/src/modules/suggestions/suggestions.types.ts b/backend/src/modules/suggestions/suggestions.types.ts new file mode 100644 index 0000000..029e33f --- /dev/null +++ b/backend/src/modules/suggestions/suggestions.types.ts @@ -0,0 +1,91 @@ +import { z } from "zod"; +import { maxLengthFor, SUGGESTIBLE_FIELDS, type SuggestibleEntityType } from "../provenance/provenance.js"; + +export type { SuggestibleEntityType } from "../provenance/provenance.js"; + +export const SUGGESTION_STATUSES = ["pendente", "aceita", "editada", "descartada"] as const; +export type SuggestionStatus = (typeof SUGGESTION_STATUSES)[number]; + +// Target of a resolve call; "descartada" never writes to the entity table. +export type SuggestionResolution = "aceita" | "editada" | "descartada"; + +export interface SuggestionRow { + id: string; + entidade_tipo: SuggestibleEntityType; + entidade_id: string; + campo: string; + valor_sugerido: string; + valor_resolvido: string | null; + origem: string; + status: SuggestionStatus; + criado_por: string | null; + criado_por_nome: string | null; + resolvido_por: string | null; + resolvido_por_nome: string | null; + resolvido_em: string | null; + created_at: string; + updated_at: string; +} + +export interface CreateSuggestionInput { + entidadeTipo: SuggestibleEntityType; + entidadeId: string; + campo: string; + valorSugerido: string; + origem: string; + usuarioId: string | null; +} + +export interface ResolveSuggestionInput { + entidadeTipo: SuggestibleEntityType; + entidadeId: string; + suggestionId: string; + resolution: SuggestionResolution; + valor: string | null; + justificativa: string | null; + usuarioId: string | null; +} + +const LABELS: Record = { epico: "épico", feature: "feature", pbi: "PBI" }; + +export function campoSchema(kind: SuggestibleEntityType) { + const fields = SUGGESTIBLE_FIELDS[kind]; + return z.enum(fields as [string, ...string[]], { + required_error: "O campo da sugestão é obrigatório.", + invalid_type_error: `Campo inválido para sugestão em ${LABELS[kind]}. Use um de: ${fields.join(", ")}.`, + }); +} + +export function createSuggestionSchema(kind: SuggestibleEntityType) { + return z.object({ + campo: campoSchema(kind), + valor_sugerido: z + .string({ required_error: "O valor sugerido é obrigatório.", invalid_type_error: "O valor sugerido deve ser texto." }) + .trim() + .min(1, "O valor sugerido é obrigatório."), + origem: z.string().trim().max(100).optional(), + }).strict().superRefine((data, ctx) => { + const max = maxLengthFor(data.campo); + if (data.valor_sugerido.length > max) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["valor_sugerido"], message: `O valor sugerido pode ter no máximo ${max} caracteres.` }); + } + }); +} + +export const resolveDiscardSchema = z.object({}).strict(); + +export const resolveAcceptSchema = z.object({ + justificativa: z.string().trim().max(2000).optional().nullable(), +}).strict(); + +export function resolveEditSchema(campo: string) { + const max = maxLengthFor(campo); + return z.object({ + valor: z + .string({ required_error: "Informe o valor editado.", invalid_type_error: "O valor editado deve ser texto." }) + .trim() + .min(1, "Informe o valor editado.") + .max(max, `O valor editado pode ter no máximo ${max} caracteres.`), + justificativa: z.string().trim().max(2000).optional().nullable(), + }).strict(); +} diff --git a/database/migrations/014_suggestions_and_field_provenance.sql b/database/migrations/014_suggestions_and_field_provenance.sql new file mode 100644 index 0000000..2b5f8aa --- /dev/null +++ b/database/migrations/014_suggestions_and_field_provenance.sql @@ -0,0 +1,42 @@ +-- S2-13: ciclo humano de sugestoes da IA, proveniencia por campo e fallback manual. +-- A proveniencia e guardada por campo (nao por registro inteiro) para que um +-- item possa ter alguns campos humanos e outros aceitos/editados de sugestoes. + +ALTER TABLE epico ADD COLUMN IF NOT EXISTS provenance_json JSONB NOT NULL DEFAULT '{}'::jsonb; +ALTER TABLE feature ADD COLUMN IF NOT EXISTS provenance_json JSONB NOT NULL DEFAULT '{}'::jsonb; +ALTER TABLE pbi ADD COLUMN IF NOT EXISTS provenance_json JSONB NOT NULL DEFAULT '{}'::jsonb; + +-- Tabela de propostas da IA (nunca grava diretamente nas tabelas de negocio). +-- Uma sugestao so altera epico/feature/pbi quando aceita ou editada por uma +-- pessoa autenticada; ate la permanece isolada nesta tabela de staging. +CREATE TABLE IF NOT EXISTS sugestao_ia ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + entidade_tipo VARCHAR(20) NOT NULL, + entidade_id UUID NOT NULL, + campo VARCHAR(100) NOT NULL, + valor_sugerido TEXT NOT NULL, + valor_resolvido TEXT, + origem VARCHAR(100) NOT NULL DEFAULT 'manual', + status VARCHAR(20) NOT NULL DEFAULT 'pendente', + criado_por UUID REFERENCES usuario(id) ON DELETE SET NULL, + resolvido_por UUID REFERENCES usuario(id) ON DELETE SET NULL, + resolvido_em TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT ck_sugestao_ia_entidade_tipo CHECK (entidade_tipo IN ('epico', 'feature', 'pbi')), + CONSTRAINT ck_sugestao_ia_status CHECK (status IN ('pendente', 'aceita', 'editada', 'descartada')), + CONSTRAINT ck_sugestao_ia_resolucao CHECK ( + (status = 'pendente' AND resolvido_em IS NULL AND resolvido_por IS NULL) + OR (status <> 'pendente' AND resolvido_em IS NOT NULL) + ) +); + +-- Garante no maximo uma sugestao pendente por campo: uma nova proposta para o +-- mesmo campo atualiza a pendente existente em vez de duplicar (idempotencia +-- de criacao/repeticao de execucao da IA). +CREATE UNIQUE INDEX IF NOT EXISTS uq_sugestao_ia_pendente + ON sugestao_ia (entidade_tipo, entidade_id, campo) + WHERE status = 'pendente'; + +CREATE INDEX IF NOT EXISTS idx_sugestao_ia_entidade + ON sugestao_ia (entidade_tipo, entidade_id, status, created_at); diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a597ac9..9cc1ca9 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1528,6 +1528,519 @@ paths: '409': description: Item ou ancestral arquivado (somente leitura) + /api/v1/epics/{id}/suggestions: + parameters: + - name: id + in: path + required: true + schema: {type: string, format: uuid} + get: + summary: Lista as sugestões da IA para o épico (S2-13) + description: Pendentes primeiro; nenhuma sugestão altera o item sem aceite explícito. + operationId: listEpicsSuggestions + security: + - cookieAuth: [] + responses: + '200': + description: Sugestões do item + content: + application/json: + schema: + $ref: '#/components/schemas/SuggestionList' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: Item não encontrado + post: + summary: Propõe uma sugestão para um campo do épico (S2-13) + description: > + Staging da IA: nunca grava diretamente no item. Uma nova proposta para + o mesmo campo substitui a sugestão pendente existente (idempotente). + Apenas admin e po. + operationId: createEpicsSuggestion + security: + - cookieAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SuggestionInput' + responses: + '201': + description: Sugestão pendente criada ou atualizada + content: + application/json: + schema: + $ref: '#/components/schemas/Suggestion' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Perfil sem permissão de escrita + '404': + description: Item não encontrado + + /api/v1/epics/{id}/suggestions/{suggestionId}/accept: + parameters: + - name: id + in: path + required: true + schema: {type: string, format: uuid} + - name: suggestionId + in: path + required: true + schema: {type: string, format: uuid} + post: + summary: Aceita a sugestão e aplica o valor ao épico (S2-13) + description: > + Idempotente: repetir a mesma aceitação retorna 200 sem duplicar. + Marca o campo como ai-accepted na proveniência. Apenas admin e po. + operationId: acceptEpicsSuggestion + security: + - cookieAuth: [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ResolveAcceptInput' + responses: + '200': + description: Sugestão aceita e campo atualizado + content: + application/json: + schema: + $ref: '#/components/schemas/Suggestion' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Perfil sem permissão de escrita + '404': + description: Item ou sugestão não encontrados + '409': + description: Item ou ancestral arquivado, ou sugestão já resolvida de outra forma + + /api/v1/epics/{id}/suggestions/{suggestionId}/edit: + parameters: + - name: id + in: path + required: true + schema: {type: string, format: uuid} + - name: suggestionId + in: path + required: true + schema: {type: string, format: uuid} + post: + summary: Edita a sugestão e aplica o novo valor ao épico (S2-13) + description: > + Idempotente: repetir a mesma edição retorna 200. Preserva o texto + originalmente sugerido e marca o campo como ai-edited. Apenas admin e po. + operationId: editEpicsSuggestion + security: + - cookieAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ResolveEditInput' + responses: + '200': + description: Sugestão editada e campo atualizado com o novo valor + content: + application/json: + schema: + $ref: '#/components/schemas/Suggestion' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Perfil sem permissão de escrita + '404': + description: Item ou sugestão não encontrados + '409': + description: Item ou ancestral arquivado, ou sugestão já resolvida de outra forma + + /api/v1/epics/{id}/suggestions/{suggestionId}/discard: + parameters: + - name: id + in: path + required: true + schema: {type: string, format: uuid} + - name: suggestionId + in: path + required: true + schema: {type: string, format: uuid} + post: + summary: Descarta a sugestão sem alterar o épico (S2-13) + description: Nunca escreve no item. Idempotente. Apenas admin e po. + operationId: discardEpicsSuggestion + security: + - cookieAuth: [] + responses: + '200': + description: Sugestão descartada + content: + application/json: + schema: + $ref: '#/components/schemas/Suggestion' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Perfil sem permissão de escrita + '404': + description: Item ou sugestão não encontrados + '409': + description: Sugestão já resolvida de outra forma + + /api/v1/features/{id}/suggestions: + parameters: + - name: id + in: path + required: true + schema: {type: string, format: uuid} + get: + summary: Lista as sugestões da IA para o feature (S2-13) + description: Pendentes primeiro; nenhuma sugestão altera o item sem aceite explícito. + operationId: listFeaturesSuggestions + security: + - cookieAuth: [] + responses: + '200': + description: Sugestões do item + content: + application/json: + schema: + $ref: '#/components/schemas/SuggestionList' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: Item não encontrado + post: + summary: Propõe uma sugestão para um campo do feature (S2-13) + description: > + Staging da IA: nunca grava diretamente no item. Uma nova proposta para + o mesmo campo substitui a sugestão pendente existente (idempotente). + Apenas admin e po. + operationId: createFeaturesSuggestion + security: + - cookieAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SuggestionInput' + responses: + '201': + description: Sugestão pendente criada ou atualizada + content: + application/json: + schema: + $ref: '#/components/schemas/Suggestion' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Perfil sem permissão de escrita + '404': + description: Item não encontrado + + /api/v1/features/{id}/suggestions/{suggestionId}/accept: + parameters: + - name: id + in: path + required: true + schema: {type: string, format: uuid} + - name: suggestionId + in: path + required: true + schema: {type: string, format: uuid} + post: + summary: Aceita a sugestão e aplica o valor ao feature (S2-13) + description: > + Idempotente: repetir a mesma aceitação retorna 200 sem duplicar. + Marca o campo como ai-accepted na proveniência. Apenas admin e po. + operationId: acceptFeaturesSuggestion + security: + - cookieAuth: [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ResolveAcceptInput' + responses: + '200': + description: Sugestão aceita e campo atualizado + content: + application/json: + schema: + $ref: '#/components/schemas/Suggestion' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Perfil sem permissão de escrita + '404': + description: Item ou sugestão não encontrados + '409': + description: Item ou ancestral arquivado, ou sugestão já resolvida de outra forma + + /api/v1/features/{id}/suggestions/{suggestionId}/edit: + parameters: + - name: id + in: path + required: true + schema: {type: string, format: uuid} + - name: suggestionId + in: path + required: true + schema: {type: string, format: uuid} + post: + summary: Edita a sugestão e aplica o novo valor ao feature (S2-13) + description: > + Idempotente: repetir a mesma edição retorna 200. Preserva o texto + originalmente sugerido e marca o campo como ai-edited. Apenas admin e po. + operationId: editFeaturesSuggestion + security: + - cookieAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ResolveEditInput' + responses: + '200': + description: Sugestão editada e campo atualizado com o novo valor + content: + application/json: + schema: + $ref: '#/components/schemas/Suggestion' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Perfil sem permissão de escrita + '404': + description: Item ou sugestão não encontrados + '409': + description: Item ou ancestral arquivado, ou sugestão já resolvida de outra forma + + /api/v1/features/{id}/suggestions/{suggestionId}/discard: + parameters: + - name: id + in: path + required: true + schema: {type: string, format: uuid} + - name: suggestionId + in: path + required: true + schema: {type: string, format: uuid} + post: + summary: Descarta a sugestão sem alterar o feature (S2-13) + description: Nunca escreve no item. Idempotente. Apenas admin e po. + operationId: discardFeaturesSuggestion + security: + - cookieAuth: [] + responses: + '200': + description: Sugestão descartada + content: + application/json: + schema: + $ref: '#/components/schemas/Suggestion' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Perfil sem permissão de escrita + '404': + description: Item ou sugestão não encontrados + '409': + description: Sugestão já resolvida de outra forma + + /api/v1/pbis/{id}/suggestions: + parameters: + - name: id + in: path + required: true + schema: {type: string, format: uuid} + get: + summary: Lista as sugestões da IA para o PBI (S2-13) + description: Pendentes primeiro; nenhuma sugestão altera o item sem aceite explícito. + operationId: listPbisSuggestions + security: + - cookieAuth: [] + responses: + '200': + description: Sugestões do item + content: + application/json: + schema: + $ref: '#/components/schemas/SuggestionList' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: Item não encontrado + post: + summary: Propõe uma sugestão para um campo do PBI (S2-13) + description: > + Staging da IA: nunca grava diretamente no item. Uma nova proposta para + o mesmo campo substitui a sugestão pendente existente (idempotente). + Apenas admin e po. + operationId: createPbisSuggestion + security: + - cookieAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SuggestionInput' + responses: + '201': + description: Sugestão pendente criada ou atualizada + content: + application/json: + schema: + $ref: '#/components/schemas/Suggestion' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Perfil sem permissão de escrita + '404': + description: Item não encontrado + + /api/v1/pbis/{id}/suggestions/{suggestionId}/accept: + parameters: + - name: id + in: path + required: true + schema: {type: string, format: uuid} + - name: suggestionId + in: path + required: true + schema: {type: string, format: uuid} + post: + summary: Aceita a sugestão e aplica o valor ao PBI (S2-13) + description: > + Idempotente: repetir a mesma aceitação retorna 200 sem duplicar. + Marca o campo como ai-accepted na proveniência. Apenas admin e po. + operationId: acceptPbisSuggestion + security: + - cookieAuth: [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ResolveAcceptInput' + responses: + '200': + description: Sugestão aceita e campo atualizado + content: + application/json: + schema: + $ref: '#/components/schemas/Suggestion' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Perfil sem permissão de escrita + '404': + description: Item ou sugestão não encontrados + '409': + description: Item ou ancestral arquivado, ou sugestão já resolvida de outra forma + + /api/v1/pbis/{id}/suggestions/{suggestionId}/edit: + parameters: + - name: id + in: path + required: true + schema: {type: string, format: uuid} + - name: suggestionId + in: path + required: true + schema: {type: string, format: uuid} + post: + summary: Edita a sugestão e aplica o novo valor ao PBI (S2-13) + description: > + Idempotente: repetir a mesma edição retorna 200. Preserva o texto + originalmente sugerido e marca o campo como ai-edited. Apenas admin e po. + operationId: editPbisSuggestion + security: + - cookieAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ResolveEditInput' + responses: + '200': + description: Sugestão editada e campo atualizado com o novo valor + content: + application/json: + schema: + $ref: '#/components/schemas/Suggestion' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Perfil sem permissão de escrita + '404': + description: Item ou sugestão não encontrados + '409': + description: Item ou ancestral arquivado, ou sugestão já resolvida de outra forma + + /api/v1/pbis/{id}/suggestions/{suggestionId}/discard: + parameters: + - name: id + in: path + required: true + schema: {type: string, format: uuid} + - name: suggestionId + in: path + required: true + schema: {type: string, format: uuid} + post: + summary: Descarta a sugestão sem alterar o PBI (S2-13) + description: Nunca escreve no item. Idempotente. Apenas admin e po. + operationId: discardPbisSuggestion + security: + - cookieAuth: [] + responses: + '200': + description: Sugestão descartada + content: + application/json: + schema: + $ref: '#/components/schemas/Suggestion' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Perfil sem permissão de escrita + '404': + description: Item ou sugestão não encontrados + '409': + description: Sugestão já resolvida de outra forma + /api/v1/auth/register: post: summary: Cria uma conta (PO ou dev); administrador exige sessão de administrador @@ -3383,6 +3896,58 @@ components: items: $ref: '#/components/schemas/Decision' + SuggestionInput: + type: object + additionalProperties: false + required: [campo, valor_sugerido] + properties: + campo: {type: string, description: Nome do campo sugerível do item (varia por tipo de entidade)} + valor_sugerido: {type: string, minLength: 1} + origem: {type: string, description: Origem da proposta (ex. harness-pro4tech); default manual} + + ResolveAcceptInput: + type: object + additionalProperties: false + properties: + justificativa: {type: string, description: Obrigatória apenas quando o item já está concluído} + + ResolveEditInput: + type: object + additionalProperties: false + required: [valor] + properties: + valor: {type: string, minLength: 1} + justificativa: {type: string, description: Obrigatória apenas quando o item já está concluído} + + Suggestion: + type: object + required: [id, entidade_tipo, entidade_id, campo, valor_sugerido, origem, status, created_at, updated_at] + properties: + id: {type: string, format: uuid} + entidade_tipo: {type: string, enum: [epico, feature, pbi]} + entidade_id: {type: string, format: uuid} + campo: {type: string} + valor_sugerido: {type: string} + valor_resolvido: {type: string, nullable: true} + origem: {type: string} + status: {type: string, enum: [pendente, aceita, editada, descartada]} + criado_por: {type: string, format: uuid, nullable: true} + criado_por_nome: {type: string, nullable: true} + resolvido_por: {type: string, format: uuid, nullable: true} + resolvido_por_nome: {type: string, nullable: true} + resolvido_em: {type: string, format: date-time, nullable: true} + created_at: {type: string, format: date-time} + updated_at: {type: string, format: date-time} + + SuggestionList: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: '#/components/schemas/Suggestion' + BacklogSearchResponse: type: object required: [projeto_id, termo, total, limite, items] diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8ea3317..9ddd368 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2496,9 +2496,9 @@ "license": "ISC" }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { diff --git a/frontend/src/api/api_backlog.ts b/frontend/src/api/api_backlog.ts index b675413..efcb703 100644 --- a/frontend/src/api/api_backlog.ts +++ b/frontend/src/api/api_backlog.ts @@ -38,6 +38,7 @@ export interface Epic extends EpicInput { projeto_status: string; archived_at: string | null; tecnologias_ids?: string[]; + provenance_json?: Record; } export interface FeatureInput { @@ -60,6 +61,7 @@ export interface Feature extends FeatureInput { projeto_status: string; archived_at: string | null; tecnologias_ids?: string[]; + provenance_json?: Record; } export interface PbiInput { @@ -87,6 +89,7 @@ export interface Pbi extends PbiInput { score_completude: number | null; prototipo_vinculado?: boolean; tecnologias_ids?: string[]; + provenance_json?: Record; } export interface CompletionError { @@ -164,6 +167,12 @@ function parseTechnologyIds(value: unknown): string[] { return value.filter((id): id is string => typeof id === "string"); } +function parseProvenance(value: unknown): Record { + if (!value || typeof value !== "object") return {}; + const entries = Object.entries(value as Record).filter(([, v]) => typeof v === "string") as Array<[string, string]>; + return Object.fromEntries(entries); +} + export async function listTechnologies(signal?: AbortSignal): Promise { const response = await apiRequest("/technologies", { signal }); const data = await response.json(); @@ -223,6 +232,7 @@ function parseEpic(value: unknown): Epic { typeof epic.archived_at === "string" ? epic.archived_at : null, + provenance_json: parseProvenance(epic.provenance_json), }; } @@ -279,6 +289,7 @@ function parseFeature( "string" ? feature.archived_at : null, + provenance_json: parseProvenance(feature.provenance_json), }; } @@ -339,6 +350,7 @@ function parsePbi(value: unknown): Pbi { projeto_status: asText( pbi.projeto_status, ), + provenance_json: parseProvenance(pbi.provenance_json), }; } diff --git a/frontend/src/api/api_suggestions.ts b/frontend/src/api/api_suggestions.ts new file mode 100644 index 0000000..3e64cb2 --- /dev/null +++ b/frontend/src/api/api_suggestions.ts @@ -0,0 +1,120 @@ +import { ApiError, apiRequest } from "./api_auth"; +import { serverMessage } from "./api_errors"; + +export type SuggestionKind = "epico" | "feature" | "pbi"; +export type SuggestionStatus = "pendente" | "aceita" | "editada" | "descartada"; + +export interface Suggestion { + id: string; + entidade_tipo: SuggestionKind; + entidade_id: string; + campo: string; + valor_sugerido: string; + valor_resolvido: string | null; + origem: string; + status: SuggestionStatus; + criado_por: string | null; + criado_por_nome: string | null; + resolvido_por: string | null; + resolvido_por_nome: string | null; + resolvido_em: string | null; + created_at: string; + updated_at: string; +} + +export interface CreateSuggestionInput { + campo: string; + valor_sugerido: string; + origem?: string; +} + +const PATHS: Record = { + epico: "epics", + feature: "features", + pbi: "pbis", +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function asNullableString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +export function parseSuggestion(value: unknown): Suggestion { + if (!isRecord(value) || typeof value.id !== "string" || typeof value.campo !== "string" + || typeof value.valor_sugerido !== "string" || typeof value.status !== "string" + || typeof value.created_at !== "string" || typeof value.updated_at !== "string") { + throw new Error("Sugestão inválida"); + } + return { + id: value.id, + entidade_tipo: value.entidade_tipo as SuggestionKind, + entidade_id: String(value.entidade_id), + campo: value.campo, + valor_sugerido: value.valor_sugerido, + valor_resolvido: asNullableString(value.valor_resolvido), + origem: typeof value.origem === "string" ? value.origem : "manual", + status: value.status as SuggestionStatus, + criado_por: asNullableString(value.criado_por), + criado_por_nome: asNullableString(value.criado_por_nome), + resolvido_por: asNullableString(value.resolvido_por), + resolvido_por_nome: asNullableString(value.resolvido_por_nome), + resolvido_em: asNullableString(value.resolvido_em), + created_at: value.created_at, + updated_at: value.updated_at, + }; +} + +export async function listSuggestions(kind: SuggestionKind, id: string, signal?: AbortSignal): Promise { + const data: unknown = await (await apiRequest(`/${PATHS[kind]}/${encodeURIComponent(id)}/suggestions`, { signal })).json(); + if (!isRecord(data) || !Array.isArray(data.items)) throw new Error("Lista de sugestões inválida"); + return data.items.map(parseSuggestion); +} + +export async function createSuggestion(kind: SuggestionKind, id: string, input: CreateSuggestionInput, signal?: AbortSignal): Promise { + const response = await apiRequest(`/${PATHS[kind]}/${encodeURIComponent(id)}/suggestions`, { + method: "POST", + body: JSON.stringify(input), + signal, + }); + return parseSuggestion(await response.json()); +} + +export async function acceptSuggestion(kind: SuggestionKind, id: string, suggestionId: string, justificativa?: string, signal?: AbortSignal): Promise { + const response = await apiRequest(`/${PATHS[kind]}/${encodeURIComponent(id)}/suggestions/${encodeURIComponent(suggestionId)}/accept`, { + method: "POST", + body: JSON.stringify(justificativa?.trim() ? { justificativa: justificativa.trim() } : {}), + signal, + }); + return parseSuggestion(await response.json()); +} + +export async function editSuggestion(kind: SuggestionKind, id: string, suggestionId: string, valor: string, justificativa?: string, signal?: AbortSignal): Promise { + const response = await apiRequest(`/${PATHS[kind]}/${encodeURIComponent(id)}/suggestions/${encodeURIComponent(suggestionId)}/edit`, { + method: "POST", + body: JSON.stringify(justificativa?.trim() ? { valor, justificativa: justificativa.trim() } : { valor }), + signal, + }); + return parseSuggestion(await response.json()); +} + +export async function discardSuggestion(kind: SuggestionKind, id: string, suggestionId: string, signal?: AbortSignal): Promise { + const response = await apiRequest(`/${PATHS[kind]}/${encodeURIComponent(id)}/suggestions/${encodeURIComponent(suggestionId)}/discard`, { + method: "POST", + signal, + }); + return parseSuggestion(await response.json()); +} + +export function describeSuggestionError(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 decidir sobre sugestões."; + if (error.status === 404) return "A sugestão ou o item não foram encontrados. Atualize a página."; + if (error.status === 409) return serverMessage(error) ?? "Esta sugestão já foi resolvida de outra forma."; + if (error.status === 400) return serverMessage(error) ?? "Revise o valor informado."; + } + return "Não foi possível concluir a ação. Tente novamente."; +} diff --git a/frontend/src/views/backlog/EpicsView.tsx b/frontend/src/views/backlog/EpicsView.tsx index 22bec10..1c02ff9 100644 --- a/frontend/src/views/backlog/EpicsView.tsx +++ b/frontend/src/views/backlog/EpicsView.tsx @@ -117,6 +117,8 @@ export function EpicForm({ projetoId }: { projetoId: string }) { } import { DecisionsPanel } from "./DecisionsPanel"; +import { SuggestionsPanel } from "./SuggestionsPanel"; +import { ProvenanceBadge } from "./ProvenanceBadge"; import { ItemHistoryView } from "./ItemHistoryView"; type EpicFields = Pick & { justificativa?: string }; @@ -185,7 +187,7 @@ export function EpicDetail({ projectId, epicId, canEdit, children }: { projectId }} />
-

Épico

{epic.titulo}

+

Épico

{epic.titulo}

{readOnly && ( @@ -263,10 +265,10 @@ export function EpicDetail({ projectId, epicId, canEdit, children }: { projectId ) : ( <>
-
Objetivo
{epic.objetivo || "Não informado."}
-
Descrição
{epic.descricao || "Não informada."}
-
Escopo macro
{epic.escopo_macro || "Não informado."}
-
Resultado esperado
{epic.resultado_esperado || "Não informado."}
+
Objetivo
{epic.objetivo || "Não informado."}
+
Descrição
{epic.descricao || "Não informada."}
+
Escopo macro
{epic.escopo_macro || "Não informado."}
+
Resultado esperado
{epic.resultado_esperado || "Não informado."}
Critérios de aceitação registrados
{epic.criterios_count}
{canWrite && ( @@ -297,6 +299,7 @@ export function EpicDetail({ projectId, epicId, canEdit, children }: { projectId setAttempt(v => v + 1)} /> + {children} diff --git a/frontend/src/views/backlog/FeaturesView.tsx b/frontend/src/views/backlog/FeaturesView.tsx index 8b58d88..e07fcd3 100644 --- a/frontend/src/views/backlog/FeaturesView.tsx +++ b/frontend/src/views/backlog/FeaturesView.tsx @@ -114,6 +114,8 @@ export function FeatureForm({ projectId, epicoId }: { projectId: string; epicoId } import { DecisionsPanel } from "./DecisionsPanel"; +import { SuggestionsPanel } from "./SuggestionsPanel"; +import { ProvenanceBadge } from "./ProvenanceBadge"; import { ItemHistoryView } from "./ItemHistoryView"; type FeatureFields = Pick & { justificativa?: string }; @@ -182,7 +184,7 @@ export function FeatureDetail({ projectId, epicoId, featureId, canEdit, children }} />
-

Épico: {feature!.epico_titulo}

{feature!.titulo}

+

Épico: {feature!.epico_titulo}

{feature!.titulo}

{readOnly &&

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

} @@ -249,8 +251,8 @@ export function FeatureDetail({ projectId, epicoId, featureId, canEdit, children ) : ( <>
-
Objetivo
{feature!.objetivo || "Não informado."}
-
Descrição
{feature!.descricao || "Não informada."}
+
Objetivo
{feature!.objetivo || "Não informado."}
+
Descrição
{feature!.descricao || "Não informada."}
Critérios de aceitação registrados
{feature!.criterios_count}
{!readOnly && canEdit && ( @@ -281,6 +283,7 @@ export function FeatureDetail({ projectId, epicoId, featureId, canEdit, children setAttempt(v => v + 1)} /> + {children} diff --git a/frontend/src/views/backlog/PbiDetail.tsx b/frontend/src/views/backlog/PbiDetail.tsx index 40ed282..b170777 100644 --- a/frontend/src/views/backlog/PbiDetail.tsx +++ b/frontend/src/views/backlog/PbiDetail.tsx @@ -21,6 +21,8 @@ import { BacklogBreadcrumb } from "./BacklogBreadcrumb"; import { BacklogTechnologySelector } from "./BacklogTechnologySelector"; import { CriteriaEditor } from "./CriteriaView"; import { DecisionsPanel } from "./DecisionsPanel"; +import { SuggestionsPanel } from "./SuggestionsPanel"; +import { ProvenanceBadge } from "./ProvenanceBadge"; import { ItemHistoryView } from "./ItemHistoryView"; import { QualityPanelView as QualityPanel } from "./QualityPanelView"; @@ -264,6 +266,8 @@ export function PbiDetail({ {pbi!.codigo} {" — "} {pbi!.titulo} + {" "} + @@ -626,7 +630,7 @@ export function PbiDetail({ : ( <>
-
COMO UM
+
COMO UM
-
EU QUERO
+
EU QUERO
-
PARA QUE
+
PARA QUE
+ + = { + "ai-accepted": "Sugestão aceita", + "ai-edited": "Sugestão editada", +}; + +export function ProvenanceBadge({ provenance, field }: { provenance: Record | undefined; field: string }) { + const value = provenance?.[field]; + if (value !== "ai-accepted" && value !== "ai-edited") return null; + return {LABEL[value]}; +} diff --git a/frontend/src/views/backlog/SuggestionsPanel.test.tsx b/frontend/src/views/backlog/SuggestionsPanel.test.tsx new file mode 100644 index 0000000..fc77a78 --- /dev/null +++ b/frontend/src/views/backlog/SuggestionsPanel.test.tsx @@ -0,0 +1,131 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import { SuggestionsPanel } from "./SuggestionsPanel"; + +const suggestion = (overrides: Record = {}) => ({ + id: "s-1", entidade_tipo: "pbi", entidade_id: "pbi-1", campo: "titulo", valor_sugerido: "Validar login com e-mail", + valor_resolvido: null, origem: "manual", status: "pendente", criado_por: "u-1", criado_por_nome: "Harness PRO4TECH", + resolvido_por: null, resolvido_por_nome: null, resolvido_em: null, + created_at: "2026-09-20T13:00:00Z", updated_at: "2026-09-20T13:00:00Z", + ...overrides, +}); +const json = (body: unknown, status = 200) => Promise.resolve(new Response(JSON.stringify(body), { status })); +const urlOf = (call: unknown[]) => String(call[0]); + +afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); + +it("lista sugestões pendentes e resolvidas, separadas", async () => { + const request = vi.fn(() => json({ items: [suggestion(), suggestion({ id: "s-2", status: "descartada", resolvido_por_nome: "Ana PO", resolvido_em: "2026-09-21T10:00:00Z" })] })); + vi.stubGlobal("fetch", request); + render(); + + expect(await screen.findByText("Pendentes (1)")).toBeInTheDocument(); + expect(urlOf(request.mock.calls[0])).toBe("/api/v1/pbis/pbi-1/suggestions"); + expect(screen.getByText("Validar login com e-mail")).toBeInTheDocument(); + expect(screen.getByText(/Proposto por Harness PRO4TECH/)).toBeInTheDocument(); + expect(screen.getByText("Resolvidas (1)")).toBeInTheDocument(); + expect(screen.getByText("Descartada")).toBeInTheDocument(); +}); + +it("estado vazio e mensagem de somente leitura quando não pode escrever", async () => { + vi.stubGlobal("fetch", vi.fn(() => json({ items: [] }))); + render(); + expect(await screen.findByText("Nenhuma sugestão da IA para este item")).toBeInTheDocument(); + expect(screen.getByText("Item arquivado: somente consulta.")).toBeInTheDocument(); +}); + +it("aceitar aplica o valor e remove a sugestão da lista de pendentes", async () => { + const request = vi.fn() + .mockImplementationOnce(() => json({ items: [suggestion()] })) + .mockImplementationOnce(() => json(suggestion({ status: "aceita", valor_resolvido: "Validar login com e-mail", resolvido_por_nome: "Ana PO", resolvido_em: "2026-09-21T10:00:00Z" }))); + vi.stubGlobal("fetch", request); + render(); + + fireEvent.click(await screen.findByRole("button", { name: "Aceitar" })); + expect(await screen.findByText("Resolvidas (1)")).toBeInTheDocument(); + expect(screen.getByText("Aceita")).toBeInTheDocument(); + expect(screen.queryByText("Pendentes (1)")).toBeNull(); + const [url, init] = request.mock.calls[1] as [string, RequestInit]; + expect(url).toBe("/api/v1/pbis/pbi-1/suggestions/s-1/accept"); + expect(JSON.parse(String(init.body))).toEqual({}); +}); + +it("editar abre um campo de valor e confirma com o texto alterado", async () => { + const request = vi.fn() + .mockImplementationOnce(() => json({ items: [suggestion()] })) + .mockImplementationOnce(() => json(suggestion({ status: "editada", valor_sugerido: "Validar login com e-mail", valor_resolvido: "Validar login com e-mail corporativo" }))); + vi.stubGlobal("fetch", request); + render(); + + fireEvent.click(await screen.findByRole("button", { name: "Editar antes de aceitar" })); + const textarea = screen.getByLabelText("Valor editado"); + fireEvent.change(textarea, { target: { value: "Validar login com e-mail corporativo" } }); + fireEvent.click(screen.getByRole("button", { name: "Confirmar edição" })); + + expect(await screen.findByText("Editada")).toBeInTheDocument(); + const [url, init] = request.mock.calls[1] as [string, RequestInit]; + expect(url).toBe("/api/v1/pbis/pbi-1/suggestions/s-1/edit"); + expect(JSON.parse(String(init.body))).toEqual({ valor: "Validar login com e-mail corporativo" }); +}); + +it("descartar nunca altera o item e some da lista de pendentes", async () => { + const request = vi.fn() + .mockImplementationOnce(() => json({ items: [suggestion()] })) + .mockImplementationOnce(() => json(suggestion({ status: "descartada", valor_resolvido: null }))); + vi.stubGlobal("fetch", request); + render(); + + fireEvent.click(await screen.findByRole("button", { name: "Descartar" })); + expect(await screen.findByText("Descartada")).toBeInTheDocument(); + const [url] = request.mock.calls[1] as [string, RequestInit]; + expect(url).toBe("/api/v1/pbis/pbi-1/suggestions/s-1/discard"); +}); + +it("409 ao aceitar sugestão já resolvida de outra forma explica o conflito", async () => { + const request = vi.fn() + .mockImplementationOnce(() => json({ items: [suggestion()] })) + .mockImplementationOnce(() => json({ error: "Esta sugestão já foi descartada anteriormente; nenhuma nova ação foi aplicada." }, 409)); + vi.stubGlobal("fetch", request); + render(); + + fireEvent.click(await screen.findByRole("button", { name: "Aceitar" })); + expect(await screen.findByText(/já foi descartada anteriormente/)).toBeInTheDocument(); + expect(screen.getByText("Validar login com e-mail")).toBeInTheDocument(); +}); + +it("sem permissão de escrita não mostra ações, apenas a nota de somente leitura", async () => { + vi.stubGlobal("fetch", vi.fn(() => json({ items: [suggestion()] }))); + render(); + await screen.findByText("Validar login com e-mail"); + expect(screen.queryByRole("button", { name: "Aceitar" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Editar antes de aceitar" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Descartar" })).toBeNull(); +}); + +it("erro ao carregar oferece nova tentativa", async () => { + const request = vi.fn() + .mockImplementationOnce(() => json({ error: "x" }, 500)) + .mockImplementationOnce(() => json({ items: [suggestion()] })); + vi.stubGlobal("fetch", request); + render(); + expect((await screen.findByRole("alert")).textContent).toContain("Não foi possível carregar as sugestões."); + fireEvent.click(screen.getByRole("button", { name: "Tentar novamente" })); + expect(await screen.findByText("Validar login com e-mail")).toBeInTheDocument(); + expect(urlOf(request.mock.calls[0])).toBe("/api/v1/features/f-1/suggestions"); +}); + +it("justificativa preenchida é enviada ao aceitar", async () => { + const request = vi.fn() + .mockImplementationOnce(() => json({ items: [suggestion()] })) + .mockImplementationOnce(() => json(suggestion({ status: "aceita", valor_resolvido: "Validar login com e-mail" }))); + vi.stubGlobal("fetch", request); + render(); + + const card = (await screen.findByText("Validar login com e-mail")).closest("section")!; + fireEvent.change(within(card).getByLabelText(/Justificativa/), { target: { value: "Correção aprovada em revisão" } }); + fireEvent.click(within(card).getByRole("button", { name: "Aceitar" })); + + const [, init] = request.mock.calls[1] as [string, RequestInit]; + expect(JSON.parse(String(init.body))).toEqual({ justificativa: "Correção aprovada em revisão" }); +}); diff --git a/frontend/src/views/backlog/SuggestionsPanel.tsx b/frontend/src/views/backlog/SuggestionsPanel.tsx new file mode 100644 index 0000000..399ca06 --- /dev/null +++ b/frontend/src/views/backlog/SuggestionsPanel.tsx @@ -0,0 +1,230 @@ +import { useEffect, useRef, useState } from "react"; +import { + acceptSuggestion, + describeSuggestionError, + discardSuggestion, + editSuggestion, + listSuggestions, + type Suggestion, + type SuggestionKind, +} from "../../api/api_suggestions"; +import { AISuggestion, Alert, Badge, Button, EmptyState } from "../common/ui"; + +const FIELD_LABEL: Record = { + titulo: "Título", + descricao: "Descrição", + objetivo: "Objetivo", + escopo_macro: "Escopo macro", + resultado_esperado: "Resultado esperado", + historia_como_um: "Como um", + historia_eu_quero: "Eu quero", + historia_para_que: "Para que", + regras_observacoes: "Regras e observações", +}; + +const STATUS_LABEL: Record = { + pendente: "Pendente", + aceita: "Aceita", + editada: "Editada", + descartada: "Descartada", +}; + +function fieldLabel(campo: string): string { + return FIELD_LABEL[campo] ?? campo; +} + +function formatDate(value: string | null): string { + if (!value) return ""; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? "" : date.toLocaleString("pt-BR", { dateStyle: "short", timeStyle: "short" }); +} + +function ResolvedSuggestionCard({ suggestion }: { suggestion: Suggestion }) { + const tone = suggestion.status === "descartada" ? "danger" : "success"; + return ( +
  • + {STATUS_LABEL[suggestion.status]} +

    {fieldLabel(suggestion.campo)}

    + {suggestion.valor_resolvido &&

    {suggestion.valor_resolvido}

    } +

    + {suggestion.resolvido_por_nome ?? "Autor não informado"} + {suggestion.resolvido_em ? ` · ${formatDate(suggestion.resolvido_em)}` : ""} +

    +
  • + ); +} + +function PendingSuggestionCard({ + suggestion, + canWrite, + onResolved, +}: { + suggestion: Suggestion; + canWrite: boolean; + onResolved: (updated: Suggestion) => void; +}) { + const [editing, setEditing] = useState(false); + const [valor, setValor] = useState(suggestion.valor_sugerido); + const [justificativa, setJustificativa] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const lock = useRef(false); + + const run = async (action: () => Promise) => { + if (lock.current) return; + lock.current = true; + setBusy(true); + setError(""); + try { + const updated = await action(); + onResolved(updated); + } catch (caught) { + setError(describeSuggestionError(caught)); + } finally { + lock.current = false; + setBusy(false); + } + }; + + return ( + run(() => acceptSuggestion(suggestion.entidade_tipo, suggestion.entidade_id, suggestion.id, justificativa)) : undefined} + onEdit={canWrite ? () => setEditing((value) => !value) : undefined} + onDiscard={canWrite ? () => run(() => discardSuggestion(suggestion.entidade_tipo, suggestion.entidade_id, suggestion.id)) : undefined} + > +

    + Campo: {fieldLabel(suggestion.campo)} + {suggestion.criado_por_nome ? ` · Proposto por ${suggestion.criado_por_nome}` : ""} +

    + {editing ? ( +
    + +